OSC Controllers (Open Stage Control ⇄ Node-RED ⇄ Starwards)
Touch/MIDI control surfaces via Open Stage Control (O-S-C), bridged to the game by Node-RED. Convention: widget OSC address = admitted JSON Pointer (e.g. fader address /reactor/power). All facts verified against O-S-C v1.30.3 primary sources (Framagit source + official docs); provenance in docs/reference/open-stage-control-reference.md.
Critical facts (agents get these wrong from memory)
- O-S-C is NOT on npm.
npm install open-stage-controlfails — no such package. Deploy from the Framagit release assetopen-stage-control-[version]-node.zip(pure Node, no Electron/xvfb) and runnode /path/to/open-stage-control --no-gui .... There is no--headlessflag; the flag is--no-gui. - The user-defined widget
idis NOT in the DOM. Widget containers are<div class="widget {type}-container" id="{hash}" data-widget="{hash}">where{hash}is an internal uuid. Resolve widgets in tests viael._widget_instance(see reference/playwright-testing.md). - A custom module cannot read the widget tree directly. Enumerate widgets by listening to
app.on('sessionSetPath')/app.on('sessionOpened')(payload has the session file path) and walking the file withloadJSON(path). - Per-client sessions are NOT selected by URL. No
?session=/?load=query param exists. One instance serves per-station sessions via custom module: client connects with?id=<station>, module callsreceive('/SESSION/OPEN', <path>, {clientId}). - Inbound OSC does not loop by default. A message matching a widget (by
address+preArgsonly — sender host:port is ignored) updates the display without re-emitting./SETand user interaction DO emit.bypass: truestops a widget's own emissions. - Canvas widgets (fader/knob/xy) never show their value in the DOM. Read values via the widget instance, not DOM scraping.
- Faders have no
labelproperty. A"label"key in a fader's session JSON is silently ignored (runtime-verified on v1.30.4). Label sliders with siblingtextwidgets (e.g. a vertical panel: faderexpand: true+ text below). - The feedback rate limit must be per-topic (
delaynode inqueuemode).ratemode withdrop: trueis a global limiter: the session-load burst of immediate emits (one per subscribed address, same instant) gets all but one message dropped, leaving faders uninitialized until their value next changes.queuemode releases the latest message permsg.topicat the configured rate.
Quick reference
| Task | Where |
|---|---|
| Custom module globals, app events, widget enumeration, per-client sessions | reference/open-stage-control.md |
| Inbound matching, feedback/loop rules, reconnect/state behavior | reference/open-stage-control.md |
| Session file JSON format, deployment (-node.zip, --no-gui, cache/config dirs) | reference/open-stage-control.md |
| Locating/driving/reading widgets in Playwright | reference/playwright-testing.md |
| OSC message encoding, type tags, bundles, UDP vs TCP/SLIP | reference/osc-protocol.md |
| node-red-contrib-osc msg shapes, type casting, udp wiring | reference/node-red-osc.md |
Architecture (this repo)
- Write path: O-S-C widget → UDP → Node-RED
udp in→oscdecode →ship-write(JSON Pointer admission enforces safety — no new server surface). - Feedback path:
ship-read→ per-topic rate limit →oscencode →udp out(dedicatedoutport) → O-S-C (matches widgets by address, no loop). - Subscription: O-S-C custom module walks session on load → synthetic subscribe messages →
ship-readdynamic patterns (see SPEC-0002 in the design repo). - Accepted design: feedback is broadcast to one UDP target and subscriptions register once, never unsubscribing — the deployment is a handful of identical stations, so per-client fan-out and unsubscribe add complexity for no benefit.
Common mistakes
| Mistake | Reality |
|---|---|
| npm install -g open-stage-control | Package doesn't exist; use Framagit -node.zip release asset |
| --headless flag | It's --no-gui |
| page.locator('#my_widget_id') | DOM id is the internal hash; use _widget_instance.getProp('id') |
| Expecting ?session=x.json per tablet | Use ?id=<station> + custom module /SESSION/OPEN with {clientId} |
| Fearing feedback loops from udp out → O-S-C | Plain inbound match doesn't re-emit; only /SET/interaction do |
| Assuming widget targets constrain inbound matching | Matching is address + preArgs only (targets matter for MIDI and outbound) |
| "label": "Power" on a fader | Faders have no label prop; add a text widget next to the fader |
| delay node in rate mode + drop for feedback | Global limiter drops the subscribe-burst initial emits; use queue mode (latest per msg.topic) |
| Session → Open dialog shows an empty/home dir in Docker | File browser roots at the server's working dir; pass --remote-root /sessions |
Verify hands-on (source-verified, not yet runtime-verified)
- Inner DOM of
toggle/push/xywidgets (base structure confirmed; per-type internals not read from source).
Runtime-verified on v1.30.4 (live browser session, 2026-07-12): the _widget_instance recipe (getProp('id')/getValue()), session files carrying a "version" field, and w.setValue(v, {send: true, sync: true}) emitting like a real interaction.