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.
Latest build from main, as unsigned pre-release assets on the nightly release:
- Linux — AppImage (recommended) or the raw binary
- macOS — universal
.app - Windows — portable
.exe
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).
- Install Go 1.25+ and Node.js.
- Install the Wails CLI:
go install github.com/wailsapp/wails/v2/cmd/wails@latest. wails devfor live development, orwails buildfor a release binary inbuild/bin.
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:
- Register an application at the Twitch Developer Console.
- Set OAuth Redirect URL to exactly
http://localhost:43385/callback. - Set Client Type to Public.
- Take the generated Client ID and either set it as the
SAIL_TWITCH_CLIENT_IDenvironment variable (no rebuild needed), or replace the value ininternal/twitchapi/client_id.goand rebuild.
Use the Commands, Redeems, 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. -
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 to0, so a numeric param still gets a number when chat omits the argument.
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.
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.
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.args—trigger.messagesplit into words.
(A hook binding's trigger carries the hook's fields instead — trigger.itemId, trigger.sceneId, ….)
and calls any of:
sail.action(name, params?, options?)— start an action.paramsmatches the action's schema ({ level: 3 });optionsis{ duration, lifetime },durationin 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 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"?}—paramsis keyed by the action's own param names (seeaction.list).duration/expiresAfterare frame counts (20/sec); omittingdurationuses the action's default, omittingexpiresAftergives the game 30s to become ready.lifetimeis"session"(default) or"save"(dropped when a save loads). A timed action reportsapplied, then later an unsolicited{"id", "type": "action.ended", "outcome"}with the sameidwhen it finishes or is cancelled. - Cancel an action
{"id", "type": "action.remove", "name"}— stops every running instance;cancelledreports 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"}— repliesready/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.hookIdFilternarrows 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.outcomeis one word:ok,applied,finished/cancelled,expired,impossible, orinvalid(message was malformed — seereason).
internal/sail implements this protocol standalone, with no dependency on the rest of the app — a reasonable
starting point for another Sail-speaking integration.