Skip to content

Repository files navigation

Sail

Note

Disclaimer: This is a mostly an AI-generated project, written largely by Claude Opus 4.8. The code may contain bugs, security vulnerabilities, or other issues. This project was deemed low-stakes and suitable for this. Please use AI responsibly - ProxySaw

Sail is a crowd-control desktop app for 2 Ship 2 Harkinian: viewers trigger in-game effects with Twitch chat commands and channel point redemptions. It's a Wails app (Go backend, React/TypeScript frontend) that authenticates with Twitch, lets you map chat commands (!kick 2) and redeems to game actions through a UI, and hosts the TCP server the game connects to.

The Sail protocol the game speaks is general-purpose: it lets any external program trigger in-game actions and react to game events. Twitch viewer interaction is the obvious use case — and what this app implements — but the same protocol could drive the game from anything. That protocol (below) is defined in 2ship2harkinian's own mm/2s2h/Network/Sail/Sail.cpp, which is under active development — check that file before assuming a shape here is still current.

Downloads

Latest build from main, as unsigned pre-release assets on the nightly release:

The Linux builds link WebKitGTK 4.1, so the host needs webkit2gtk-4.1 and gtk3 installed — e.g. sudo pacman -S webkit2gtk-4.1 gtk3 on Arch, or sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 on Debian/Ubuntu. The AppImage additionally needs FUSE to self-mount (fuse2 on Arch); if it's missing, run it with ./Sail-x86_64.AppImage --appimage-extract-and-run.

Because the builds are unsigned, macOS quarantines the app (right-click → Open, or xattr -dr com.apple.quarantine Sail.app) and Windows SmartScreen will warn (More info → Run anyway).

Development setup

  • Install Go 1.25+ and Node.js.
  • Install the Wails CLI: go install github.com/wailsapp/wails/v2/cmd/wails@latest.
  • wails dev for live development, or wails build for a release binary in build/bin.

Using your own Twitch application (optional)

The built-in application is shared by everyone running Sail. To point at your own instead — for isolated rate limits, or if you're forking Sail — register one and override the default:

  1. Register an application at the Twitch Developer Console.
  2. Set OAuth Redirect URL to exactly http://localhost:43385/callback.
  3. Set Client Type to Public.
  4. Take the generated Client ID and either set it as the SAIL_TWITCH_CLIENT_ID environment variable (no rebuild needed), or replace the value in internal/twitchapi/client_id.go and rebuild.

Commands, redeems, events, and hooks

Use the Commands, Redeems, Events, and Hooks pages to map triggers to steps:

  • A command fires on a chat message starting with its trigger (e.g. !kick); a redeem fires on its channel point reward.

  • Channel point rewards can be created, edited, switched on and off, and deleted from the Redeems page — no round trip to the Twitch dashboard. Twitch only lets the application that created a reward change it, so rewards Sail made are fully editable here while ones made elsewhere stay bindable but read-only (Sail says which is which). Creating rewards at all needs an affiliate or partner channel, since that's who has channel points.

  • A command can require bits: set a minimum and it only fires when the message cheers at least that many (Cheer100 !kick). Below it — or with no cheer at all — nothing happens, silently, like a command on cooldown. The cheer can go before or after the trigger word.

  • Any action param or raw command can reference variables from the trigger, resolved when the binding fires:

    • {{message}} — everything the viewer typed (chat after the trigger word, or a redeem's text input).
    • {{user}} — who triggered it.
    • {{0}}, {{1}}, … — the words of that message. An out-of-range {{n}} resolves to 0, so a numeric param still gets a number when chat omits the argument.
    • {{bits}} — bits cheered with the message, 0 if none.

    A number/bool param whose editor can't hold {{…}} has a { } toggle that swaps in a text field. A templated value that resolves to a number is sent as a number, otherwise as text.

  • Each binding runs one or more steps, immediately and in order: start an action (with its params, and for timed actions a duration and session/save lifetime), cancel an action, run a raw console command, or post a chat message. A timed action's own duration ends it; use a "cancel action" step to end one early.

  • The action picker is populated live from the connected game (action.list), so it's empty until a game has connected at least once.

  • An optional cooldown rate-limits how often a binding can fire.

Twitch events

The Events page fires bindings on what happens to the channel, with nothing typed in chat:

Event Fires when Its variables
New follower someone follows {{user}}
New subscription a first-time sub, or someone receiving a gifted one {{user}}, {{tier}}, {{gifted}}
Resubscribe a returning subscriber shares their resub in chat {{user}}, {{tier}}, {{months}}, {{streak}}, {{message}}
Gifted subs someone gifts subs (each recipient also fires a new subscription) {{user}}, {{tier}}, {{count}}
Cheer someone cheers bits, command or not {{user}}, {{bits}}, {{message}}

A cheer binding can require a bits amount, matched either at least or exactly, and one event can carry several bindings. At-least stacks them — a 1000-bit cheer also fires the 100 rung — while exact keeps each rung to itself, so a ladder of amounts can each do their own thing. {{tier}} is 1, 2 or 3 (Prime counts as 1), and an anonymous gift or cheer shows up as Anonymous.

These need Twitch permissions added after the first release, so a login from before then can't receive them — the Dashboard says so, and logging out and back in grants them.

Game hooks

The Hooks page fires bindings on in-game events (item pickup, scene load, flag change) rather than Twitch, over the same protocol (hook.list/subscribe below). Pick a hook, optionally filter by id, and give it steps or a script; its fields ({{itemId}}, {{sceneId}}, …) are available as variables and on trigger. A live feed streams a hook's firings so you can read off ids. Sail subscribes only to hooks you've bound or are watching.

Scripting (advanced)

For logic the steps and {{…}} variables can't express, a binding's Script section takes JavaScript that produces the steps itself. When it holds any code it runs instead of the step list, once per fire. It gets:

  • trigger.user — who triggered it.
  • trigger.message — the text they typed.
  • trigger.argstrigger.message split into words.
  • trigger.bits — bits cheered with it, 0 if none.

(A hook binding's trigger carries the hook's fields instead — trigger.itemId, trigger.sceneId, …. A Twitch event binding's carries trigger.event plus that event's own fields — trigger.tier, trigger.months, trigger.streak, trigger.count, trigger.gifted.)

and calls any of:

  • sail.action(name, params?, options?) — start an action. params matches the action's schema ({ level: 3 }); options is { duration, lifetime }, duration in seconds, lifetime "session" (default) or "save".
  • sail.command(string) — run a raw console command.
  • sail.remove(name) — cancel a running action.
  • sail.chat(string) — post a message to your chat.
// !gravity heavy  →  strong, timed gravity; anything else  →  a gentle nudge
if (trigger.args[0] === "heavy") {
  sail.action("gravity", { level: 3 }, { duration: 30 });
} else {
  sail.action("gravity", { level: 1 });
}

The runtime is sandboxed (no file, network, or timer access) and a script that runs too long is interrupted. Syntax errors are caught on save; runtime errors show up in the Dashboard's activity feed when the binding fires.

Configuration is stored as JSON in the OS user config directory (e.g. ~/Library/Application Support/sail on macOS), separate from the Twitch session in the keychain.

The Sail protocol

The game connects over TCP (port 43384 by default) and exchanges NUL-delimited JSON packets. Every outgoing packet carries an id; the game's result reply echoes it back.

  • Command {"id", "type": "command", "command": "..."} — run a raw console command. Prefer the action form of anything that has one.
  • Start an action {"id", "type": "action.apply", "name", "params": {...}, "duration"?, "expiresAfter"?, "lifetime"?}params is keyed by the action's own param names (see action.list). duration/expiresAfter are frame counts (20/sec); omitting duration uses the action's default, omitting expiresAfter gives the game 30s to become ready. lifetime is "session" (default) or "save" (dropped when a save loads). A timed action reports applied, then later an unsolicited {"id", "type": "action.ended", "outcome"} with the same id when it finishes or is cancelled.
  • Cancel an action {"id", "type": "action.remove", "name"} — stops every running instance; cancelled reports how many.
  • List actions {"id", "type": "action.list"} — the game's live, self-describing action catalog (name, display name, timed, default duration, stacking, valence, and a param schema per action). The only source of truth, and it needs an active connection.
  • Check readiness {"id", "type": "action.status"} — replies ready/pending/active.
  • List hooks {"id", "type": "hook.list"} — the game's live hook catalog (name + whether it supports a per-id filter).
  • Subscribe / unsubscribe {"id", "type": "subscribe"|"unsubscribe", "hookName", "hookIdFilter"?} — hooks are opt-in and forgotten on disconnect, so Sail re-subscribes on each connection. hookIdFilter narrows delivery to one scene/item/actor id.
  • Hook {"id", "type": "hook", "hook": {...}} — an unsolicited game event (scene transition, item pickup, flag set, …), sent once subscribed.
  • Result {"id", "type": "result", "outcome", "reason"?, ...} — the game's answer. outcome is one word: ok, applied, finished/cancelled, expired, impossible, or invalid (message was malformed — see reason).

internal/sail implements this protocol standalone, with no dependency on the rest of the app — a reasonable starting point for another Sail-speaking integration.

About

A networking protocol designed to facilitate remote control of the Ship of Harkinian client.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages