# Osc Controllers

> 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.

- Skill: `starwards/osc-controllers` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add starwards/osc-controllers`
- Raw SKILL.md: https://api.skillmd.com/api/skills/starwards/osc-controllers/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: starwards (https://skillmd.com/u/starwards)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/starwards/osc-controllers

---


# 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)

1. **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`.
2. **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](reference/playwright-testing.md)).
3. **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)`.
4. **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})`.
5. **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.
6. **Canvas widgets (fader/knob/xy) never show their value in the DOM.** Read values via the widget instance, not DOM scraping.
7. **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).
8. **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](reference/open-stage-control.md) |
| Inbound matching, feedback/loop rules, reconnect/state behavior | [reference/open-stage-control.md](reference/open-stage-control.md) |
| Session file JSON format, deployment (`-node.zip`, `--no-gui`, cache/config dirs) | [reference/open-stage-control.md](reference/open-stage-control.md) |
| Locating/driving/reading widgets in Playwright | [reference/playwright-testing.md](reference/playwright-testing.md) |
| OSC message encoding, type tags, bundles, UDP vs TCP/SLIP | [reference/osc-protocol.md](reference/osc-protocol.md) |
| node-red-contrib-osc msg shapes, type casting, udp wiring | [reference/node-red-osc.md](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.

