/** * The shape of a served match: one world, always running while somebody is * connected, and the seats people sit in. * * Nothing here knows what the game is. The game hands over its state and a * handful of answers — how to step it, who is in it, what one viewer may see * and what a snapshot looks like — and this module keeps the rules every * served game needs and none should re-derive: * * - The idle gate: nobody watching, nothing happens. The world freezes when * the last connection goes and carries on when someone arrives. * - A playing seat is rationed by address, not by cookie: a cookie costs one * HTTP request, an address is the one thing a machine cannot mint two of. * Both claims go in synchronously, before a socket is ever offered a seat. * - A connection with no seat watches: it is sent the public state and no * position in it. * - Every snapshot is built for one viewer, from the bodies the game says * that viewer can see. The client never learns a position it has no line * of sight to. */ export const TICK = 1 / 30; /** * How many ticks apart the snapshots go out. * * The simulation runs at 30Hz. A small room gets every tick: it is the case * that matters (one or two people playing each other), the payload is a * couple of kilobytes of JSON a seat, and the arena only runs at all while * somebody is connected. Above that the cost multiplies by seats and the * older cadence — every other tick — comes back. */ export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void; /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */ board: string; }; /** * Why this connection is watching. The client says so in words, and the two * have different answers — open the tab that is playing, or let the page keep * its cookie — so which one it is travels with the snapshot. */ export type Why = "address" | "cookie" | "unsaid"; export type Command = { t: string; [key: string]: unknown }; /** What the arena needs of a body: a way to find it, address it, and tell a bot from a person. */ export type Body = { id: string; name: string; bot: boolean }; /** The game, as the arena sees it. */ export type Game = { /** The one world. Mutated in place; the arena never replaces it. */ state: S; players: (state: S) => P[]; /** One fixed step of the world, bots and rules and all. */ step: (state: S, dt: number) => void; /** Hand a bot's seat to a person: the same body, made human. Null when there is none. */ sit: (state: S) => P | null; /** Give a seat back to a bot. */ stand: (state: S, player: P) => void; /** May this viewer be sent this body? The server's answer, never the client's. */ visible: (state: S, viewer: P, other: P) => boolean; /** The snapshot for one viewer, given exactly the bodies they may see. */ snapshot: (state: S, viewer: P, seen: P[]) => Record; /** * A field of the snapshot resent only when it differs from what the seat * last received; the client keeps the one it has. Null sends everything. */ sticky: string | null; /** The public view for a connection with no seat. Must carry no position. */ spectatorSnapshot: (state: S, why: Why) => Record; /** Everything a client may ask for, validated by the game and nowhere else. */ command: (state: S, player: P, message: Command) => void; }; export function makeArena(game: Game) { const state = game.state; const seats = new Map(); // A session is a cookie, and a cookie costs one HTTP request — free for // anyone to mint as many of as they like. The address is the one thing here // a single machine cannot hand itself two of, so it is what a playing seat // is actually rationed by; the session on top of it is only what lets a // reload find its way back to the same seat rather than a fresh one. const addressSeats = new Map(); let ticks = 0; const match = (): S => state; const findPlayer = (id: string): P | undefined => game.players(state).find((p) => p.id === id); /** * A name nobody else in this match is using. Two players under one name * make a feed line that reads as a team kill; a person who picks a taken * name gets a number after it, inside the sixteen characters a name is * allowed. */ function freeName(want: string, exceptId: string): string { const taken = new Set( game .players(state) .filter((p) => p.id !== exceptId) .map((p) => p.name), ); if (!taken.has(want)) return want; for (let n = 2; n < 100; n++) { const tried = `${want.slice(0, 13)} ${n}`; if (!taken.has(tried)) return tried; } return want; } /** * Whether this address already plays under a different session. Checked * before a socket is even offered a seat, so the caller can hand it a * spectator view instead of upgrading it into a second side's vision. */ function addressCapped(address: string, session: string): boolean { const holder = addressSeats.get(address); return holder !== undefined && holder !== session; } /** * Sit somebody down, replacing a bot where it stands. * * Both claims go in right here, synchronously, before the caller ever opens * a socket. A websocket upgrade completes on a later tick, so two requests * arriving close together both used to read the maps before either had * written to them — one client, two seats, opposite sides, the vision of * both. Claiming inline closes that window regardless of how the two * requests were related. */ function takeSeat(address: string, session: string, name: string): P | null { const existing = seats.get(session); const held = existing ? findPlayer(existing.playerId) : undefined; if (held) { held.name = freeName(name, held.id); return held; } if (addressCapped(address, session)) return null; const seat = game.sit(state); if (!seat) return null; seat.name = freeName(name, seat.id); addressSeats.set(address, session); seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" }); return seat; } /** The socket that actually opened fills in how to reach it and its token. */ function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { seats.set(session, { playerId, token, address, send, board: "" }); } const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId); /** * A person leaving gives the seat back to a bot, mid-round and all — but only * if this is still the socket the session is on. A reload opens the new * socket before the old one's close event arrives; without the token, that * late close would delete the reconnect's seat entry and hand a live human's * seat to a bot mid-round. */ function release(session: string, token: string): void { const seat = seats.get(session); if (!seat || seat.token !== token) return; seats.delete(session); if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address); const player = findPlayer(seat.playerId); if (player && !stillHeld(seat.playerId)) game.stand(state, player); } /** * Test-only hygiene: give back whatever this address currently holds. The * arena is one match shared by the whole test run, over real HTTP, from one * loopback address — this is how a test that needs that address free gets it * free, rather than depending on every other test in the file to clean up * after itself. */ function releaseAddress(address: string): void { const session = addressSeats.get(address); if (session !== undefined) release(session, seats.get(session)?.token ?? ""); } const seatCount = (): number => seats.size; // ------------------------------------------------------------ spectators // A connection that never carried a session the page minted gets no side and // no seat: only the public state everyone already sees on the page itself. const spectators = new Map void; why: Why }>(); const spectatorSnapshot = (why: Why = "unsaid"): string => JSON.stringify({ t: "s", spectator: true, why, ...game.spectatorSnapshot(state, why) }); function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void { spectators.set(token, { send, why }); send(spectatorSnapshot(why)); } function unwatch(token: string): void { spectators.delete(token); } // ----------------------------------------------------------- the commands function command(playerId: string, message: Command): void { const player = findPlayer(playerId); if (!player || player.bot) return; game.command(state, player, message); } // ----------------------------------------------------------- the snapshot function snapshotFor(viewerId: string): Record { const players = game.players(state); const me = findPlayer(viewerId) ?? players[0]!; const seen = players.filter((p) => game.visible(state, me, p)); return { t: "s", ...game.snapshot(state, me, seen) }; } /** The full snapshot for one viewer, as one string. */ const snapshot = (viewerId: string): string => JSON.stringify(snapshotFor(viewerId)); /** * The snapshot a seat is actually sent on this tick: everything, every time, * except the sticky field, which goes out only when it differs from what * this seat last received. */ function snapshotForSeat(seat: Seat): string { const full = snapshotFor(seat.playerId); if (game.sticky !== null) { const part = JSON.stringify(full[game.sticky]); if (part === seat.board) delete full[game.sticky]; else seat.board = part; } return JSON.stringify(full); } // --------------------------------------------------------------- the loop /** * Nobody watching, nothing happens. * * An arena that ticks around the clock plays out rounds for an empty room — * burning a share of a small box's CPU and filling the record with matches * no one saw. The world freezes when the last connection goes and carries on * from exactly where it stopped when someone arrives, so a visitor never * finds a score that ran up while they were away. * * A spectator counts as somebody: watching is watching. */ const attended = (): boolean => seats.size > 0 || spectators.size > 0; /** How many connections the arena is playing for: seats plus watchers. */ const attendance = (): number => seats.size + spectators.size; function step(): void { game.step(state, TICK); ticks += 1; if (ticks % broadcastEvery(seats.size) !== 0) return; for (const seat of seats.values()) { try { seat.send(snapshotForSeat(seat)); } catch { // A socket that will not take a frame is closed already; the close // handler gives the seat back. } } for (const spectator of spectators.values()) { try { spectator.send(spectatorSnapshot(spectator.why)); } catch { // Same story: the close handler drops it from the set. } } } let timer: ReturnType | null = null; /** * One beat of real time. The gate lives here rather than inside `step` so the * tests can still drive the simulation directly with `advance` — a test is an * attendance of its own, and making it prove otherwise would only teach the * suite to work around the rule. */ function beat(): void { if (attended()) step(); } function start(): void { if (timer) return; timer = setInterval(beat, TICK * 1000); // The clock must never hold the process open on its own. (timer as unknown as { unref?: () => void }).unref?.(); } function stop(): void { if (timer) clearInterval(timer); timer = null; } /** Used by the tests: run the arena forward without waiting for real time. */ function advance(seconds: number): void { for (let i = 0; i < Math.round(seconds / TICK); i++) step(); } return { match, findPlayer, addressCapped, takeSeat, attach, release, releaseAddress, seatCount, watch, unwatch, spectatorSnapshot, command, snapshot, snapshotForSeat, attendance, beat, start, stop, advance, }; }