Skip to content

Building Your Own

There are four ways to put the feed behind your own display or integration. They differ in where your credentials live, who can see the page, and how much code you write.

Approach You write Credentials live Good for
Agent + your own page HTML, CSS and JavaScript only In the agent, on your computer OBS overlays and venue displays driven from one computer
Node.js service A Node.js program using @scorbit/feed In your service's environment Integrations, bots, data into your own systems
Public website A server that holds the feed and relays updates to visitors On your server only Live scores on a public website or app
Browser page A server that opens the feed, and a page that attaches to it API key on your server; feed token in the page Your own kiosk or venue display, or a staff-only page

All four use the same feed and the same update data.

Agent + Your Own Page

The simplest approach, and the right one for OBS. Run the scorbit-feed agent, and write a page that reads the feed from it on localhost. Your page never handles a credential.

SCORBIT_API_KEY=sb_live_... npx @scorbit/feed --static ./my-display

The agent serves your folder at http://127.0.0.1:8787/ and the live feed alongside it:

  • GET /state returns the latest state of every machine.
  • GET /events streams changes as Server-Sent Events.

A minimal page that renders each update:

<div id="scores"></div>
<script>
  const events = new EventSource("/events");
  events.addEventListener("state", (e) => {
    const { machines } = JSON.parse(e.data);
    const list = document.getElementById("scores");
    list.replaceChildren(
      ...machines.map((m) => {
        const p = document.createElement("p");
        p.textContent = `${m.game_name ?? "Machine"}: ${m.scores.map((s) => s.score).join(" / ")}`;
        return p;
      }),
    );
  });
</script>

Start from the starter overlay for a complete example, and see The Agent for every route and option.

Node.js Service

Use the @scorbit/feed library in a Node.js 22+ program to open a feed and react to updates.

npm install @scorbit/feed

See what your key covers

import { listMachines } from "@scorbit/feed";

const { scope_type, machines } = await listMachines({ apiKey: process.env.SCORBIT_API_KEY! });
for (const m of machines) console.log(m.uuid, m.game_name, m.venue.name);

Open a feed and handle updates

import { openFeed } from "@scorbit/feed";

const { feed, created } = await openFeed({
  apiKey: process.env.SCORBIT_API_KEY!,
  machines: ["<venue-machine-uuid>", "<venue-machine-uuid>"], // optional: omit for the key's whole scope
  transport: "sdk", // or "sse"
});

feed.on("machines", ({ added, removed }) => console.log("joined", added, "left", removed));
feed.on("update", (update) => {
  for (const machine of update.payload.machines) {
    console.log(machine.game_name, machine.scores.map((s) => s.score));
  }
});
feed.on("status", (status) => console.log("status:", status));
feed.on("ended", ({ reason, error }) => console.log("ended:", reason, error?.code));
feed.start(); // returns at once; updates arrive through the listeners above

// On shutdown (Ctrl-C, or a service manager such as systemd, Docker or Kubernetes),
// disconnect and delete the feed so it doesn't keep holding a live-feed slot.
for (const signal of ["SIGINT", "SIGTERM"]) {
  process.once(signal, async () => {
    await feed.stop().catch(() => {});
    process.exit(0);
  });
}

created includes the feed token (feed_token). Keep it out of logs.

Public Website: Relay Through Your Server

For a page anyone on the internet can open, the most reliable setup keeps every credential on your server. Your server holds the feed and relays each update to visitors over its own connection, so one feed serves every visitor and nothing a visitor does can interrupt it.

import { createServer, type ServerResponse } from "node:http";
import { openFeed, type Feed } from "@scorbit/feed";

let latest: string | null = null;           // the last update, sent to each new visitor at once
const visitors = new Set<ServerResponse>(); // open connections from visitors' browsers
let current: Feed | undefined;              // the feed this server holds
let shuttingDown = false;

function broadcast(payload: string) {
  latest = payload;
  for (const res of visitors) {
    // Drop a visitor that isn't keeping up; their browser reconnects and gets the latest state.
    if (res.writableLength > 1_000_000) {
      res.destroy();
      visitors.delete(res);
      continue;
    }
    res.write(`data: ${payload}\n\n`);
  }
}

async function startFeed() {
  try {
    const { feed } = await openFeed({ apiKey: process.env.SCORBIT_API_KEY! });
    current = feed;
    feed.on("update", (update) => broadcast(JSON.stringify(update.payload)));
    feed.on("ended", () => {
      broadcast(JSON.stringify({ machines: [] })); // stop showing scores from an ended feed
      if (!shuttingDown) setTimeout(startFeed, 30_000); // open a new feed after a pause
    });
    feed.start();
  } catch (err) {
    console.error("could not open the feed:", err);
    if (!shuttingDown) setTimeout(startFeed, 30_000);
  }
}
await startFeed();

// Visitors connect to /live and receive updates as Server-Sent Events.
createServer((req, res) => {
  if (req.url !== "/live") return res.writeHead(404).end();
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  if (latest) res.write(`data: ${latest}\n\n`);
  visitors.add(res);
  req.on("close", () => visitors.delete(res));
}).listen(3000);

for (const signal of ["SIGINT", "SIGTERM"]) {
  process.once(signal, async () => {
    shuttingDown = true;
    await current?.stop().catch(() => {});
    process.exit(0);
  });
}

Serve your page from the same server, and read the relay with an EventSource:

const live = new EventSource("/live");
live.onmessage = (e) => render(JSON.parse(e.data).machines);

One feed serves any number of visitors this way, so you stay well inside your two live feeds.

  • When the feed ends, the relay clears what visitors see, so nobody is left looking at old scores, and opens a new feed 30 seconds later. The pause keeps a persistent problem, such as a revoked key, from hitting the rate limits.
  • A visitor whose connection can't keep up is dropped; their browser reconnects by itself and gets the latest state.

Browser Page: Attach in the Browser

The browser can also attach to the feed directly. Your server opens the feed with the API key and hands the page only its feed_id and feed token (sbf_...), in a response body, never in the page's URL. See Credentials and Security.

Best for your own displays

A feed token can also end its feed. Deleting a feed loses nothing: your server can simply open a new one. But on a page the public can open, a visitor could interrupt the live display for everyone until it does. Attaching in the browser is ideal for your own kiosk, venue display or a staff-only page; for a public website, the relay keeps the display running regardless.

On the page, attach with the feed token:

import { attachFeed } from "@scorbit/feed";

const feed = attachFeed({ feedId, feedToken }); // from your server's response body
feed.on("update", render);
feed.start();

Without a bundler, load the browser build from a CDN. It exposes attachFeed as the global ScorbitFeed. The browser build leaves out createFeed and openFeed on purpose, because they need the API key.

<script src="https://unpkg.com/@scorbit/feed@0.1.0/dist/scorbit-feed.iife.js"></script>
<script>
  const feed = ScorbitFeed.attachFeed({ feedId, feedToken }); // from your server's response body
  feed.on("update", render);
  feed.start();
</script>

Library Reference

Function Returns Options
listMachines(options) Promise<MachineScope> { apiKey, baseUrl?, fetch?, dangerouslyAllowBrowser? }
createFeed(options) Promise<CreatedFeed> { apiKey, machines?, transport?, baseUrl?, fetch?, dangerouslyAllowBrowser? }
attachFeed(options) Feed { feedId, feedToken, baseUrl?, transport?, initialTokens?, fetch?, websocket? }
openFeed(options) Promise<{ feed, created }> createFeed, then attachFeed with the create response
feed.start() void Connect and keep the feed's access renewed
feed.stop({ deleteFeed = true }) Promise<void> Disconnect and, by default, delete the feed
feed.on(event, listener) unsubscribe function See the events below

createFeed, openFeed and listMachines need the API key and refuse to run in a browser. attachFeed needs a feed token and refuses anything else, including an API key.

Events

Event Payload When
update the update message Every change to any machine in the feed
machines { added, removed, machines } (machine uuids) The set of machines in the feed changed, just before the update that carries it
status idle, connecting, live, reconnecting or ended The connection state changed
ended { reason, error } The feed ended. See Why a feed ends
error an Error A problem the library recovers from by itself, such as a dropped connection. Never contains a credential

feed.machines always holds the current set of machine uuids.

Transports

Both transports carry the same messages; sdk is the default.

  • sdk: a WebSocket connection with delta compression, using the official centrifuge client.
  • sse: Server-Sent Events over plain fetch. Use it where WebSockets are blocked.

Rendering Tips

  • Key everything by machine_uuid, never by position. On a feed that follows a venue, machines join and leave while it runs.
  • Handle an empty feed. A venue feed can briefly cover no machines.
  • Show final scores when game_ended is true: the last game finished and its scores are final.
  • Expect missing values. ball can be null, and player is null for an unclaimed slot.
  • Show the Scorbit attribution on every screen that shows feed data. See Branding and Media Kit.