Skip to content

Room recipes

Record every match

Save each match as a replay file, including the one in progress when the room closes.

This room records every match from kickoff to the final whistle and writes each recording to disk with a small JSON summary next to it.

host.mjs
import { mkdir, writeFile } from 'node:fs/promises';
import { createRoom, readReplay } from 'ball2d/node';

const directory = 'recordings';
await mkdir(directory, { recursive: true });

const room = await createRoom({
  apiKey: process.env.BALL2D_API_KEY,
  roomName: 'Recorded matches',
  noPlayer: true,
  public: true,
});

async function save(blob, reason) {
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  const name = `${directory}/${stamp}`;
  await writeFile(
    `${name}.ball2drep`,
    Buffer.from(await blob.arrayBuffer()),
  );
  const replay = await readReplay(blob);
  const summary = {
    reason,
    seconds: (replay.end - replay.initial.tick) / 60,
    players: [
      ...new Set(
        replay.roster
          .map((entry) => entry.name)
          .filter(Boolean),
      ),
    ],
  };
  await writeFile(
    `${name}.json`,
    `${JSON.stringify(summary, null, 2)}\n`,
  );
  console.log('Saved', name);
}

room.onGameStart = () => room.startRecording();

room.onGameStop = () => {
  const blob = room.stopRecording();
  if (blob)
    void save(blob, 'Match ended').catch((error) =>
      console.error(error),
    );
};

// The room finishes recordings on its own at a limit,
// on a stadium change and on close.
room.onRecordingComplete = (blob, reason) => {
  void save(blob, reason).catch((error) =>
    console.error(error),
  );
};

room.onError = (message) => console.error(message);
console.log(room.roomLink);

for (const signal of ['SIGINT', 'SIGTERM']) {
  process.once(signal, async () => {
    // Delivers the running recording to onRecordingComplete.
    room.close();
    await room.closed;
    // Let the last write finish.
    setTimeout(() => process.exit(0), 1000);
  });
}

How it works

  • Start on kickoff, stop on the whistle. onGameStop fires both when a match is stopped and when a finished match returns to the lobby, so every match ends up in save().
  • Nothing is lost. Recordings the room ends itself, for the one-hour limit, a stadium change or close(), arrive in onRecordingComplete. Recordings you stop yourself don't, so each file is saved exactly once.
  • Summaries come from the file. readReplay() verifies each recording as it is saved; a corrupt file fails here, not months later.
  • Writes stay off the tick. Saving is asynchronous and never blocks the simulation.

Recordings can be read only by the engine build that made them. Store the SDK version with your recordings if you plan to read them after upgrading.