quill/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files main
1
/**
2
 * The arena: this game's one live match, served the way `engine/arena.ts`
3
 * serves any match — ticking only while somebody is connected, one playing
4
 * seat an address, a positionless view for whoever has no seat, and every
5
 * snapshot built for one viewer from what they can see.
6
 *
7
 * What is this game's own is here: taking a seat replaces a bot in the
8
 * emptiest team (same body, same credits, same position, so joining
9
 * mid-round hands you a live player rather than a lobby), the commands a
10
 * client may send, the shape of the snapshot, and the kill feed read off the
11
 * match a tick at a time.
12
 */
13
14
import { agentById, sightBlocked } from "./abilities.ts";
15
import { runBots } from "./bots.ts";
16
import { recordResult } from "./db.ts";
17
import { makeArena, type Command, type Game, type Why } from "./engine/arena.ts";
18
import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
19
import type { Point } from "./map.ts";
20
import { wallBetween } from "./map.ts";
21
import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, ROSTER, setInput, speedOf, tick, visible } from "./match.ts";
22
import type { MatchState, Player, Zone } from "./state.ts";
23
import { isWeaponId, WEAPONS } from "./weapons.ts";
24
25
export { TICK, type Command, type Seat, type Why } from "./engine/arena.ts";
26
27
/**
28
 * How many ticks apart this game's snapshots go out, given the seats filled:
29
 * every tick, for every room this match can hold.
30
 *
31
 * The rule used to halve the rate at the third seat. The browser's render
32
 * delay follows the rate it measures — 1.3 × the interval, floored at 45ms
33
 * (`static/engine/interp.js`) — so that third seat moved every other body
34
 * everybody saw from 45ms in the past to 87ms. In a game about peeking a
35
 * corner first that was the wrong thing to save, and what it saved is small
36
 * enough to write down.
37
 *
38
 * Measured 2026-09 on a 6-vCPU EPYC 9555P under bun 1.3: medians of seven
39
 * 20-second runs of a seeded match with idle humans in the seats, the
40
 * never-sending loop subtracted to leave the snapshots alone.
41
 *
42
 *   one world tick                        27µs
43
 *   one snapshot, vision filter and all    6µs
44
 *
45
 *   seats   loop, every tick   of which snapshots   bytes out: live round / whole match
46
 *     4       1.3ms a second        0.8ms               210 kB/s  /  340 kB/s
47
 *     8       2.0ms a second        1.7ms               385 kB/s  /  645 kB/s
48
 *    10       2.1ms a second        2.0ms               465 kB/s  /  790 kB/s
49
 *
50
 * That is 0.2% of one core with the room full; a box five times slower is
51
 * at 1%. The wire is the larger number — 790 kB/s (6 Mbit/s) out of the box
52
 * at ten seats, 80 kB/s to each of them — which a small droplet's port
53
 * carries without noticing and a phone on 4G has ten times the headroom
54
 * for. A live-round snapshot is ~1.6 KiB a seat; a buy phase is larger,
55
 * which is the gap between the two bytes columns.
56
 *
57
 * The threshold is the room: ten seats is every seat there is, so the every
58
 * tick case is the only one a person ever sits in. Past the measured room
59
 * the old cadence stays because it is unmeasured, not because it is right.
60
 */
61
export const broadcastEvery = (filled: number): number => (filled <= ROSTER * 2 ? 1 : 2);
62
63
const round2 = (n: number): number => Math.round(n * 10) / 10;
64
65
/** Sit in the emptiest team, on a bot's seat, keeping its body as it stands. */
66
function sit(state: MatchState): Player | null {
67
  const humans = { red: 0, blue: 0 };
68
  for (const player of state.players) if (!player.bot) humans[player.team] += 1;
69
  const wanted = humans.red <= humans.blue ? "red" : "blue";
70
  const seat = state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
71
  if (!seat) return null;
72
  seat.bot = false;
73
  seat.think = 0;
74
  seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
75
  return seat;
76
}
77
78
/** The seat goes back to a bot, mid-round and all. */
79
function stand(_state: MatchState, player: Player): void {
80
  player.bot = true;
81
  player.think = 0;
82
  player.input.fire = false;
83
  player.input.use = false;
84
}
85
86
const num = (value: unknown): number | undefined =>
87
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
88
89
/** Everything a client may ask for, validated here and nowhere else. */
90
function runCommand(state: MatchState, player: Player, message: Command): void {
91
  switch (message.t) {
92
    case "i":
93
      setInput(player, {
94
        mx: num(message.mx),
95
        my: num(message.my),
96
        angle: num(message.a),
97
        fire: typeof message.f === "boolean" ? message.f : undefined,
98
        use: typeof message.u === "boolean" ? message.u : undefined,
99
        reload: typeof message.r === "boolean" ? message.r : undefined,
100
        cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined,
101
      });
102
      break;
103
    case "buy": {
104
      const what = String(message.what ?? "");
105
      if (isWeaponId(what)) buyWeapon(state, player, what);
106
      else if (what === "light" || what === "heavy") buyShield(state, player, what);
107
      else if (what === "c" || what === "q") buyAbility(state, player, what);
108
      break;
109
    }
110
    case "agent":
111
      if (typeof message.id === "string") pickAgent(state, player, message.id);
112
      break;
113
  }
114
}
115
116
/** What this game is to the arena: one match, and the answers `makeArena` asks for. */
117
export function valGame(
118
  state: MatchState,
119
  record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult,
120
  cadence: (filled: number) => number = broadcastEvery,
121
): Game<MatchState, Player> {
122
  let lastResult: string | null = null;
123
  // The kill feed is read off the match a tick at a time; the rules keep no
124
  // list of their own, and this is a view, not a rule.
125
  let marks: Mark[] = marksOf(state.players);
126
  let feed: Fall[] = [];
127
128
  // Names and a gun, never a position: the feed says what happened, not
129
  // where, so it can never be read as a call-out.
130
  const feedFor = (state: MatchState) =>
131
    feed.map((f) => ({
132
      k: f.killer,
133
      v: f.victim,
134
      w: f.weapon ? WEAPONS[f.weapon].name : null,
135
      h: f.headshot,
136
      t: round2(Math.max(0, state.now - f.at)),
137
    }));
138
139
  /** The public state everyone already sees on the page itself. */
140
  function spectatorShape(state: MatchState, _why: Why) {
141
    return {
142
      phase: state.phase,
143
      clock: round2(Math.max(0, state.clock)),
144
      round: state.round,
145
      score: state.score,
146
      attackers: state.attackers,
147
      result: state.result,
148
      last: state.last,
149
      events: state.events.slice(0, 5).map((e) => e.text),
150
      feed: feedFor(state),
151
    };
152
  }
153
154
  /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */
155
  function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
156
    const agent = agentById(me.agent);
157
    const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
158
    // A zone is known to the viewer if they are standing in it (so your own
159
    // smoke never vanishes from under you) or, failing that, if no wall sits
160
    // between the two — the wall test only, not `sightBlocked`, which also
161
    // counts zones and would let a smoke hide the wall behind it.
162
    const canSeeZone = (z: Zone): boolean =>
163
      Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z);
164
165
    return {
166
      phase: state.phase,
167
      clock: round2(Math.max(0, state.clock)),
168
      round: state.round,
169
      score: state.score,
170
      attackers: state.attackers,
171
      result: state.result,
172
      last: state.last,
173
      me: {
174
        id: me.id,
175
        side: me.side,
176
        team: me.team,
177
        agent: me.agent,
178
        alive: me.alive,
179
        x: round2(me.x),
180
        y: round2(me.y),
181
        angle: round2(me.angle),
182
        hp: Math.round(me.hp),
183
        armour: Math.round(me.armour),
184
        credits: me.credits,
185
        weapon: me.weapon,
186
        ammo: me.ammo,
187
        reloading: me.reloadIn > 0,
188
        charges: me.charges,
189
        cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 },
190
        ult: me.ult,
191
        ultNeeds: agent.abilities.x.points,
192
        blind: round2(me.blind),
193
        spike: me.spike,
194
        planting: round2(me.planting),
195
        // How fast the server is about to move you, in units a second, and not
196
        // the reasons behind it: the browser's prediction runs at this number
197
        // and re-derives nothing. A rule that holds you still — planting,
198
        // defusing, a decided round — arrives as a zero. To a tenth, like every
199
        // other number here: rounded to a whole unit instead, a hasted walk
200
        // (337.5) is drawn a half-unit a second fast and clipped back by the
201
        // cap every frame, which measured 0.2 points of per-frame spread. A
202
        // tenth is a no-op for feel and costs at most two bytes.
203
        speed: round2(speedOf(state, me)),
204
        bot: me.bot,
205
      },
206
      players: seen.map((p) => ({
207
        id: p.id,
208
        name: p.name,
209
        agent: p.agent,
210
        x: round2(p.x),
211
        y: round2(p.y),
212
        angle: round2(p.angle),
213
        hp: Math.round(p.hp),
214
        armour: Math.round(p.armour),
215
        alive: p.alive,
216
        mate: p.side === me.side,
217
        spike: p.spike,
218
        blind: p.blind > 0,
219
        revealed: p.revealed > 0,
220
      })),
221
      zones: state.zones
222
        .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z)))
223
        .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
224
      spike: {
225
        planted: state.spike.planted,
226
        carried: state.spike.carried !== null,
227
        fuse: round2(state.spike.fuse),
228
        defuse: round2(state.spike.defuse),
229
        checkpoint: state.spike.checkpoint,
230
        // A loose spike is the attackers' business until it is armed.
231
        x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
232
        y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
233
      },
234
      orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
235
      shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
236
      // The scoreboard — ten names, agents and tallies — was almost half of
237
      // every snapshot and changes a few times a round, so the arena sends it
238
      // only when it differs from what the seat last received.
239
      board: state.players.map((p) => ({
240
        id: p.id,
241
        name: p.name,
242
        team: p.team,
243
        side: p.side,
244
        agent: p.agent,
245
        bot: p.bot,
246
        alive: p.alive,
247
        kills: p.kills,
248
        deaths: p.deaths,
249
        damage: Math.round(p.damageDone),
250
        credits: p.team === me.team ? p.credits : null,
251
        ult: p.ult,
252
        ultNeeds: agentById(p.agent).abilities.x.points,
253
      })),
254
      events: state.events.slice(0, 5).map((e) => e.text),
255
      feed: feedFor(state),
256
    };
257
  }
258
259
  /**
260
   * Who fell on this tick. A shot that landed this tick and started where the
261
   * killer stands is what tells a gun kill from an ability kill — the position
262
   * is used here and goes no further.
263
   */
264
  function readFalls(state: MatchState): void {
265
    const after = marksOf(state.players);
266
    const shooters = new Set<string>();
267
    for (const shot of state.shots) {
268
      if (!shot.hit || shot.age > 0) continue;
269
      for (const player of state.players)
270
        if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
271
    }
272
    const falls = fallsBetween(marks, after, {
273
      at: state.now,
274
      events: state.events.slice(0, 6).map((e) => e.text),
275
      shooters,
276
    });
277
    marks = after;
278
    if (falls.length) feed = [...feed, ...falls];
279
    feed = trimFeed(feed, state.now);
280
  }
281
282
  /** One tick of this game: the bots, the rules, the feed, and the record. */
283
  function step(state: MatchState, dt: number): void {
284
    runBots(state, dt);
285
    tick(state, dt);
286
    readFalls(state);
287
    if (state.result && state.result !== lastResult) {
288
      lastResult = state.result;
289
      record(state.result, state.score.red, state.score.blue, state.round);
290
    }
291
    if (state.result === null) lastResult = null;
292
  }
293
294
  return {
295
    state,
296
    players: (s) => s.players,
297
    step,
298
    sit,
299
    stand,
300
    visible,
301
    snapshot: snapshotShape,
302
    sticky: "board",
303
    cadence,
304
    spectatorSnapshot: spectatorShape,
305
    command: runCommand,
306
  };
307
}
308
309
/** A served match of this game, on a fresh world unless one is handed over. */
310
export const makeValArena = (
311
  state: MatchState = newMatch(),
312
  record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult,
313
  cadence: (filled: number) => number = broadcastEvery,
314
) => makeArena<MatchState, Player>(valGame(state, record, cadence));
315
316
// The one live match the server serves.
317
const arena = makeValArena();
318
319
export const {
320
  match,
321
  findPlayer,
322
  addressCapped,
323
  takeSeat,
324
  attach,
325
  release,
326
  releaseAddress,
327
  seatCount,
328
  watch,
329
  unwatch,
330
  spectatorSnapshot,
331
  command,
332
  snapshot,
333
  snapshotForSeat,
334
  attendance,
335
  beat,
336
  advance,
337
} = arena;
338
export const startArena = arena.start;
339
export const stopArena = arena.stop;
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts