| name | osc-controllers |
| description | Use when working on the OSC controller framework - Open Stage Control layouts/custom modules, Node-RED OSC bridge flows, tablet touch controllers, or Playwright E2E tests that drive Open Stage Control widgets. Also use when debugging OSC message routing, widget feedback loops, or O-S-C deployment in Docker. |
| version | "2026-07-04T00:00:00.000Z" |
| related_skills | ["starwards-tdd (E2E-first workflow)","starwards-verification (evidence before claiming done)"] |
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-control fails — no such package. Deploy from the Framagit release asset open-stage-control-[version]-node.zip (pure Node, no Electron/xvfb) and run node /path/to/open-stage-control --no-gui .... There is no --headless flag; the flag is --no-gui.
- The user-defined widget
id is 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 via el._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 with loadJSON(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 calls receive('/SESSION/OPEN', <path>, {clientId}).
- Inbound OSC does not loop by default. A message matching a widget (by
address + preArgs only — sender host:port is ignored) updates the display without re-emitting. /SET and user interaction DO emit. bypass: true stops 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
label property. A "label" key in a fader's session JSON is silently ignored (runtime-verified on v1.30.4). Label sliders with sibling text widgets (e.g. a vertical panel: fader expand: true + text below).
- The feedback rate limit must be per-topic (
delay node in queue mode). rate mode with drop: true is 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. queue mode releases the latest message per msg.topic at 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 → osc decode → ship-write (JSON Pointer admission enforces safety — no new server surface).
- Feedback path:
ship-read → per-topic rate limit → osc encode → udp out (dedicated outport) → O-S-C (matches widgets by address, no loop).
- Subscription: O-S-C custom module walks session on load → synthetic subscribe messages →
ship-read dynamic 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/xy widgets (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.