/** * The arena: this game's one live match, served the way `engine/arena.ts` * serves any match — ticking only while somebody is connected, one playing * seat an address, a positionless view for whoever has no seat, and every * snapshot built for one viewer from what they can see. * * What is this game's own is here: taking a seat replaces a bot in the * emptiest team (same body, same credits, same position, so joining * mid-round hands you a live player rather than a lobby), the commands a * client may send, the shape of the snapshot, and the kill feed read off the * match a tick at a time. */ import { agentById, sightBlocked } from "./abilities.ts"; import { runBots } from "./bots.ts"; import { recordResult } from "./db.ts"; import { makeArena, type Command, type Game, type Why } from "./engine/arena.ts"; import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; import type { Point } from "./map.ts"; import { wallBetween } from "./map.ts"; import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, ROSTER, setInput, speedOf, tick, visible } from "./match.ts"; import type { MatchState, Player, Zone } from "./state.ts"; import { isWeaponId, WEAPONS } from "./weapons.ts"; export { TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; /** * How many ticks apart this game's snapshots go out, given the seats filled: * every tick, for every room this match can hold. * * The rule used to halve the rate at the third seat. The browser's render * delay follows the rate it measures — 1.3 × the interval, floored at 45ms * (`static/engine/interp.js`) — so that third seat moved every other body * everybody saw from 45ms in the past to 87ms. In a game about peeking a * corner first that was the wrong thing to save, and what it saved is small * enough to write down. * * Measured 2026-09 on a 6-vCPU EPYC 9555P under bun 1.3: medians of seven * 20-second runs of a seeded match with idle humans in the seats, the * never-sending loop subtracted to leave the snapshots alone. * * one world tick 27µs * one snapshot, vision filter and all 6µs * * seats loop, every tick of which snapshots bytes out: live round / whole match * 4 1.3ms a second 0.8ms 210 kB/s / 340 kB/s * 8 2.0ms a second 1.7ms 385 kB/s / 645 kB/s * 10 2.1ms a second 2.0ms 465 kB/s / 790 kB/s * * That is 0.2% of one core with the room full; a box five times slower is * at 1%. The wire is the larger number — 790 kB/s (6 Mbit/s) out of the box * at ten seats, 80 kB/s to each of them — which a small droplet's port * carries without noticing and a phone on 4G has ten times the headroom * for. A live-round snapshot is ~1.6 KiB a seat; a buy phase is larger, * which is the gap between the two bytes columns. * * The threshold is the room: ten seats is every seat there is, so the every * tick case is the only one a person ever sits in. Past the measured room * the old cadence stays because it is unmeasured, not because it is right. */ export const broadcastEvery = (filled: number): number => (filled <= ROSTER * 2 ? 1 : 2); const round2 = (n: number): number => Math.round(n * 10) / 10; /** Sit in the emptiest team, on a bot's seat, keeping its body as it stands. */ function sit(state: MatchState): Player | null { const humans = { red: 0, blue: 0 }; for (const player of state.players) if (!player.bot) humans[player.team] += 1; const wanted = humans.red <= humans.blue ? "red" : "blue"; const seat = state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); if (!seat) return null; seat.bot = false; seat.think = 0; seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; return seat; } /** The seat goes back to a bot, mid-round and all. */ function stand(_state: MatchState, player: Player): void { player.bot = true; player.think = 0; player.input.fire = false; player.input.use = false; } const num = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined; /** Everything a client may ask for, validated here and nowhere else. */ function runCommand(state: MatchState, player: Player, message: Command): void { switch (message.t) { case "i": setInput(player, { mx: num(message.mx), my: num(message.my), angle: num(message.a), fire: typeof message.f === "boolean" ? message.f : undefined, use: typeof message.u === "boolean" ? message.u : undefined, reload: typeof message.r === "boolean" ? message.r : undefined, cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined, }); break; case "buy": { const what = String(message.what ?? ""); if (isWeaponId(what)) buyWeapon(state, player, what); else if (what === "light" || what === "heavy") buyShield(state, player, what); else if (what === "c" || what === "q") buyAbility(state, player, what); break; } case "agent": if (typeof message.id === "string") pickAgent(state, player, message.id); break; } } /** What this game is to the arena: one match, and the answers `makeArena` asks for. */ export function valGame( state: MatchState, record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult, cadence: (filled: number) => number = broadcastEvery, ): Game { let lastResult: string | null = null; // The kill feed is read off the match a tick at a time; the rules keep no // list of their own, and this is a view, not a rule. let marks: Mark[] = marksOf(state.players); let feed: Fall[] = []; // Names and a gun, never a position: the feed says what happened, not // where, so it can never be read as a call-out. const feedFor = (state: MatchState) => feed.map((f) => ({ k: f.killer, v: f.victim, w: f.weapon ? WEAPONS[f.weapon].name : null, h: f.headshot, t: round2(Math.max(0, state.now - f.at)), })); /** The public state everyone already sees on the page itself. */ function spectatorShape(state: MatchState, _why: Why) { return { phase: state.phase, clock: round2(Math.max(0, state.clock)), round: state.round, score: state.score, attackers: state.attackers, result: state.result, last: state.last, events: state.events.slice(0, 5).map((e) => e.text), feed: feedFor(state), }; } /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { const agent = agentById(me.agent); const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); // A zone is known to the viewer if they are standing in it (so your own // smoke never vanishes from under you) or, failing that, if no wall sits // between the two — the wall test only, not `sightBlocked`, which also // counts zones and would let a smoke hide the wall behind it. const canSeeZone = (z: Zone): boolean => Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); return { phase: state.phase, clock: round2(Math.max(0, state.clock)), round: state.round, score: state.score, attackers: state.attackers, result: state.result, last: state.last, me: { id: me.id, side: me.side, team: me.team, agent: me.agent, alive: me.alive, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), hp: Math.round(me.hp), armour: Math.round(me.armour), credits: me.credits, weapon: me.weapon, ammo: me.ammo, reloading: me.reloadIn > 0, charges: me.charges, cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, ult: me.ult, ultNeeds: agent.abilities.x.points, blind: round2(me.blind), spike: me.spike, planting: round2(me.planting), // How fast the server is about to move you, in units a second, and not // the reasons behind it: the browser's prediction runs at this number // and re-derives nothing. A rule that holds you still — planting, // defusing, a decided round — arrives as a zero. To a tenth, like every // other number here: rounded to a whole unit instead, a hasted walk // (337.5) is drawn a half-unit a second fast and clipped back by the // cap every frame, which measured 0.2 points of per-frame spread. A // tenth is a no-op for feel and costs at most two bytes. speed: round2(speedOf(state, me)), bot: me.bot, }, players: seen.map((p) => ({ id: p.id, name: p.name, agent: p.agent, x: round2(p.x), y: round2(p.y), angle: round2(p.angle), hp: Math.round(p.hp), armour: Math.round(p.armour), alive: p.alive, mate: p.side === me.side, spike: p.spike, blind: p.blind > 0, revealed: p.revealed > 0, })), zones: state.zones .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), spike: { planted: state.spike.planted, carried: state.spike.carried !== null, fuse: round2(state.spike.fuse), defuse: round2(state.spike.defuse), checkpoint: state.spike.checkpoint, // A loose spike is the attackers' business until it is armed. x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, }, orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), // The scoreboard — ten names, agents and tallies — was almost half of // every snapshot and changes a few times a round, so the arena sends it // only when it differs from what the seat last received. board: state.players.map((p) => ({ id: p.id, name: p.name, team: p.team, side: p.side, agent: p.agent, bot: p.bot, alive: p.alive, kills: p.kills, deaths: p.deaths, damage: Math.round(p.damageDone), credits: p.team === me.team ? p.credits : null, ult: p.ult, ultNeeds: agentById(p.agent).abilities.x.points, })), events: state.events.slice(0, 5).map((e) => e.text), feed: feedFor(state), }; } /** * Who fell on this tick. A shot that landed this tick and started where the * killer stands is what tells a gun kill from an ability kill — the position * is used here and goes no further. */ function readFalls(state: MatchState): void { const after = marksOf(state.players); const shooters = new Set(); for (const shot of state.shots) { if (!shot.hit || shot.age > 0) continue; for (const player of state.players) if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); } const falls = fallsBetween(marks, after, { at: state.now, events: state.events.slice(0, 6).map((e) => e.text), shooters, }); marks = after; if (falls.length) feed = [...feed, ...falls]; feed = trimFeed(feed, state.now); } /** One tick of this game: the bots, the rules, the feed, and the record. */ function step(state: MatchState, dt: number): void { runBots(state, dt); tick(state, dt); readFalls(state); if (state.result && state.result !== lastResult) { lastResult = state.result; record(state.result, state.score.red, state.score.blue, state.round); } if (state.result === null) lastResult = null; } return { state, players: (s) => s.players, step, sit, stand, visible, snapshot: snapshotShape, sticky: "board", cadence, spectatorSnapshot: spectatorShape, command: runCommand, }; } /** A served match of this game, on a fresh world unless one is handed over. */ export const makeValArena = ( state: MatchState = newMatch(), record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult, cadence: (filled: number) => number = broadcastEvery, ) => makeArena(valGame(state, record, cadence)); // The one live match the server serves. const arena = makeValArena(); export const { match, findPlayer, addressCapped, takeSeat, attach, release, releaseAddress, seatCount, watch, unwatch, spectatorSnapshot, command, snapshot, snapshotForSeat, attendance, beat, advance, } = arena; export const startArena = arena.start; export const stopArena = arena.stop;