OBS tally to VRChat OSC

Camera operators keep publishing from their browsers. A Node script discovers room streams and labels with the SDK, reads OBS tally through the updated alpha API, and sends the selected stream's program/preview state to OSC.

Tested with actual OBS Browser Sources against VDO.Ninja alpha. Includes browser camera and screen publishing, late joins, disconnect cleanup, and UDP output. VRChat avatar rendering itself was not tested.

Choose a method

MethodUse it forConfiguration
SDK discovery + API tallyList browser publishers and select their camera or screen by ID or label.Hybrid JSON
API onlyRead streams known to the reporting OBS or publisher pages, without native WebRTC.API-only JSON
SDK data-only markerTrack a marker Browser Source placed in an OBS scene, without Node capturing media.Marker JSON

Playing someone else's feed with the SDK does not subscribe to the tally that OBS sends to their publishing page. Read that publisher's &api, or the actual OBS viewing page's &api. The marker method tracks the marker itself.

Run the sample

Use Node 22 and this repository's SDK source, which includes the peer-label fix. The published npm 1.6.0 package alone does not include that fix. Clone the repository or download its ZIP, then run commands from the repository root:

git clone https://github.com/steveseguin/ninjasdk.git
cd ninjasdk
npm install
npm install --no-save @roamhq/wrtc@0.10.0

The camera operator opens this alpha URL and joins with camera or screen sharing. Replace the uppercase values consistently throughout:

https://vdo.ninja/alpha/?push=CAMERA_ID&room=ROOM_ID&password=false&label=CameraOne

Add an OBS Browser Source with this URL:

https://vdo.ninja/alpha/?scene=0&room=ROOM_ID&view=CAMERA_ID&password=false&api=OBS_API_ID

Include scene=0 in this room-source example. Without scene mode, the room/view combination may open a guest join page. For an existing director-generated scene link, keep its scene parameters and add a unique &api=OBS_API_ID.

Disable OBS's Shutdown source when not visible and Refresh browser when scene becomes active. Default read access to OBS status information is sufficient.

Give each reporting page its own API ID; multiple listeners can share that ID. Keep IDs private because they also permit remote API commands. Generate one with:

node -e "console.log(require('crypto').randomBytes(18).toString('hex'))"

Copy demos/tally-osc/config.example.json to tally-config.json. Fill in your room, reporting API IDs, and selected stream:

{
  "room": "ROOM_ID",
  "password": false,
  "apiIds": ["OBS_API_ID"],
  "streamID": "CAMERA_ID",
  "osc": { "host": "127.0.0.1", "port": 9000 }
}

The example disables the room password. For a protected room, use the same password in the config and links, URL-encoding the link value. Start the bridge:

node demos/tally-osc/bridge.cjs tally-config.json

In its terminal, use list, select CAMERA_ID, select CameraOne, or quit. Put your chosen stream ID in the config to preserve selection between runs.

Stream IDs, labels, and screen sharing

SDK listing and videoaddedtoroom events discover streams. The sample views them with audio/video disabled so peerInfo can provide each remote label. It never captures the operator's screen itself.

Use stream IDs as keys and labels for display. Names can repeat; selecting a duplicate label is rejected. The list remembers previously seen streams, including ones that have left.

A standalone screen publisher has its own stream ID. An attached screen can use a separate ID ending in :s; select that exact ID from API details. The sample does not pass colon IDs through SDK view(), which sanitizes them. Attached-screen layouts have not been live-tested by this harness.

Tally state and late joins

Before a show, put each source in Program once, let the transition finish, then put it in Preview. OBS can initially report sourceActive:null until an activity change. Unknown state stays unknown; it is not assumed to be Preview.

StateProgramPreviewKnown
Programtruefalsetrue
Preview onlyfalsetruetrue
Hidden / excluded from VDO scenefalsefalsetrue
Disconnected / unknownfalsefalsefalse

Once OBS has reported its state, a late API listener requests a snapshot and gets the current tally without another switch. The bridge polls every 3 seconds, expires nonresponsive reporting pages after 15 seconds, and reconnects the API socket. Program takes priority if several sources show the selected stream. Program means OBS source activity, not that OBS is broadcasting to the internet.

API-only monitoring and event details

Use the API-only config and the same bridge command; omit room and sdkPublishID. No native WebRTC dependency is needed for this mode. Labels and streams come from reporting pages' details rather than SDK room discovery.

You can put &api=UNIQUE_ID on the OBS scene/view page or the camera operator's alpha publishing page. The OBS side reports its own state and viewed streams. The publishing side reports OBS state received for its own outgoing feed. A director publishing media follows that same rule; it is not automatically a room-wide tally monitor.

const WebSocket = require('ws');
const ws = new WebSocket('wss://api.vdo.ninja');
ws.on('open', () => {
  ws.send(JSON.stringify({ join: 'YOUR_API_ID' }));
  ws.send(JSON.stringify({ action: 'getDetails', cib: 'tally-snapshot' }));
});
ws.on('message', raw => {
  const message = JSON.parse(raw);
  if (message.update?.action === 'obs-state') {
    console.log('Live tally:', message.update.value);
  }
  if (message.callback?.cib === 'tally-snapshot') {
    console.log('Snapshot:', message.callback.result);
  }
});

Updates include source, UUID, streamID, connected, obsState, and sceneDisplay. Local entries describe the OBS page; remote entries describe an OBS viewer seen by a publisher. UUID identifies a connection. Null stream IDs represent page-level state and must not be assigned to every participant.

getDetails returns stream records with tally arrays. Replace each reporting endpoint's snapshot to remove vanished peers. A connected API socket alone does not mean its reporting page is online. The iframe getStreamInfo response also includes tally snapshots; iframe tally events use the full entry, while legacy obs-state events remain sparse.

This documented remote-control API is separate from the SDK handshake server. Use the SDK for signaling.

SDK-only data marker

Use the marker config with a unique sdkPublishID and password:false. The same bridge calls announce() to publish a data-only marker and listens for obsState. Add an enabled Browser Source in the OBS scene:

https://vdo.ninja/alpha/?view=YOUR_UNIQUE_MARKER_STREAM_ID&password=false

No camera, screen capture, or &api is needed. It tracks that marker's presence in OBS, not another camera's inclusion within a VDO scene. For independent markers, use distinct IDs and bridge configurations.

SDK disconnect() emits disconnected once after cleanup. Unexpected socket loss emits it immediately with phase: 'socket'. Await sdk.disconnect() or teardownComplete for final cleanup.

VRCTally for each camera operator

Use the Happyrobot33/VRCTally avatar project with the VRCTally example config. Set label to your exact VDO.Ninja display label and apiIds to a private ID on your own publishing page. Your page receives its OBS viewers' tally; operators do not need a shared OBS or director API ID.

This ID is not an account API key and requires no registration. It does allow control of its page, so keep it private and share viewer links without it. This is not a read-only tally endpoint.

The profile sends integer Program/Preview values to both the VRCTally_* and VRCLLime_*_Active paths, plus Standby, Error, and a heartbeat toggling every 500 ms. Missing, duplicate, disconnected, or stale label matches produce Error. Label binding follows a camera's new stream ID on reconnect.

See the complete per-operator setup and OSC paths. VRChat avatar rendering and OSCQuery discovery are not covered by this sample.

VRChat and testing OSC

Enable OSC > Enabled in VRChat's Action Menu. Its default receiving port is UDP 9000. Configure avatar boolean parameters TallyProgram, TallyPreview, and TallyKnown to drive your lights. Default addresses are /avatar/parameters/NAME; all three are configurable in the bridge. See the official OSC overview and avatar parameter guide.

Run the script on the VRChat machine or set osc.host to its reachable address. Boolean packets are sent on changes and every 3 seconds. Quitting clears all three parameters.

To verify packets without VRChat, temporarily set osc.port to 9002 and start the included receiver in another terminal:

node demos/tally-osc/udp-monitor.cjs 9002

Switch OBS scenes and watch the booleans. Restore port 9000 for VRChat.

Source, tests, and limits

Validated September 5, 2026: OBS 32.2.2, OBS WebSocket 5.7.4, Node 22.14.0, and @roamhq/wrtc 0.10.0. Browser camera and getDisplayMedia runs used Chromium's synthetic capture devices and actual OBS activity events. Actual UDP output was verified; avatar rendering, attached :s screen layouts, and every director layout were not. This sample does not implement vMix tally.