Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

spectating is super buggy. i want it buttery smooth #146

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/133-spectating-is-super-buggy-i-want-it-butt → main 2 commits fork

  • Merged. vesper/133-spectating-is-super-buggy-i-want-it-butt was merged into main as e4d284a.

Review

Sign in to review this change.
13 files changed +749 −121
README.md +10 −1
@@ -112,6 +112,14 @@ phone, where there is no keyboard and no pointer to lock. A browser that will no
112112 put an element full screen — an iPhone will not — never shows that button rather
113113 than showing one that does nothing.
114114
115 Killed, you watch the rest of the round out of a teammate's eyes: fire moves
116 to the next player on your side and aim moves back one, the camera eases onto
117 whoever you picked instead of snapping around with their aim, and it stays on
118 them until they die or you ask for someone else. With your whole side down and
119 the bomb still ticking it watches whoever is left alive rather than your own
120 body. `src/client/spectate.ts` is that camera, and nothing else in the game
121 reads it.
122
115123 At `/range` the same controls drive the second game, on its own bindings:
116124 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
117125 <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload,
@@ -292,7 +300,8 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
292300 (the rules),
293301 `world` (the simulation),
294302 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
295 `binding`, `scene`, `hud`, `audio`, `main`. Everything in here is headless and
303 `binding`, `scene`, `hud`, `spectate` (whose eyes a frame is drawn from once
304 you are dead, and the camera that eases onto them), `audio`, `main`. Everything in here is headless and
296305 tested: the WebGL2 is the engine's now, and this game has no renderer of its
297306 own. The level is handed in rather than imported: `createWorld(level, …)`
298307 keeps it on the world and the bots, the bullets, the bomb and the meshes read
src/client/hud.test.ts +65 −8
@@ -97,13 +97,20 @@ function scene(change: (world: World) => void = () => {}): World {
9797 const WIDTH = 1280;
9898 const HEIGHT = 720;
9999
100 /** Draw one frame of the HUD onto a fresh recorder. */
100 /**
101 * Draw one frame of the HUD onto a fresh recorder. `view` is whose eyes it is
102 * drawn from — left out, yours, which is every frame of a round you are alive
103 * in; a player once you are dead and watching them.
104 */
101105 function draw(
102106 world: World,
103 options: { width?: number; height?: number; scoreboard?: boolean } = {},
107 options: { width?: number; height?: number; scoreboard?: boolean; view?: Player } = {},
104108 ): Recorder {
105109 const paint = recorder();
106 drawHud(paint, world, options.width ?? WIDTH, options.height ?? HEIGHT, options.scoreboard ?? false);
110 drawHud(
111 paint, world, options.width ?? WIDTH, options.height ?? HEIGHT,
112 options.scoreboard ?? false, options.view ?? world.me,
113 );
107114 return paint;
108115 }
109116
@@ -381,15 +388,65 @@ test("the use bar fills as far as the plant or the defuse has got", () => {
381388
382389 // --- dead, and the scoreboard ----------------------------------------------
383390
384 const WATCHING = "You are dead — watching the round out";
391 const ALONE = "You are dead — watching the round out";
392 const SWITCH = "fire — next player · aim — the one before";
385393
386 test("a dead player is told they are watching, until the match itself is over", () => {
387 expect(says(draw(scene()), WATCHING)).toBe(false);
388 expect(says(draw(scene((w) => { w.me.alive = false; })), WATCHING)).toBe(true);
394 /** A teammate to watch, and the world with you dead in it. */
395 function spectating(change: (world: World) => void = () => {}): { world: World; mate: Player } {
396 const world = scene((w) => {
397 w.me.alive = false;
398 change(w);
399 });
400 const mate = world.players.find((p) => p !== world.me && p.team === world.me.team) as Player;
401 return { world, mate };
402 }
403
404 test("a dead player watching nobody is told so, until the match itself is over", () => {
405 // The whole side down and the round still running — a planted bomb — is the
406 // only frame left where the camera is your own body and there is no name.
407 expect(says(draw(scene()), ALONE)).toBe(false);
408 expect(says(draw(scene((w) => { w.me.alive = false; })), ALONE)).toBe(true);
389409 expect(says(draw(scene((w) => {
390410 w.me.alive = false;
391411 w.match.phase = "match_over";
392 })), WATCHING)).toBe(false);
412 })), ALONE)).toBe(false);
413 });
414
415 test("watching someone says whose eyes these are, in their team's colour, and how to change them", () => {
416 const { world, mate } = spectating();
417 const paint = draw(world, { view: mate });
418 expect(says(paint, `Watching ${mate.name}`)).toBe(true);
419 expect(says(paint, SWITCH)).toBe(true);
420 expect(says(paint, ALONE)).toBe(false);
421 expect(line(paint, `Watching ${mate.name}`).fill).toBe("#6fa8e8"); // this side is CT
422
423 // Alive, there is nothing to say: no banner, no keys.
424 const playing = draw(scene());
425 expect(words(playing).some((w) => w.startsWith("Watching"))).toBe(false);
426 expect(says(playing, SWITCH)).toBe(false);
427 });
428
429 test("the numbers under a spectated round are the watched player's, not your corpse's", () => {
430 const { world, mate } = spectating((w) => {
431 w.me.health = 89; // a corpse's last number, which is nobody's business now
432 w.me.money = 1234;
433 });
434 mate.health = 57;
435 mate.money = 4300;
436 mate.weapon = "ak47";
437 mate.ammo["ak47"] = { mag: 21, reserve: 60 };
438 const paint = draw(world, { view: mate });
439 expect(says(paint, "57")).toBe(true);
440 expect(says(paint, "$4300")).toBe(true);
441 expect(says(paint, "21 / 60")).toBe(true);
442 expect(says(paint, WEAPONS.ak47.name)).toBe(true);
443 // Nothing of yours is on the screen: not your health, not your wallet.
444 expect(says(paint, "89")).toBe(false);
445 expect(says(paint, "$1234")).toBe(false);
446
447 // And their crosshair is drawn, because *they* are aiming: a dead player's
448 // own view had none, which is what made the old spectate look broken.
449 expect(paint.calls.some((c) => c.op === "moveTo")).toBe(true);
393450 });
394451
395452 test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => {
src/client/hud.ts +52 −22
@@ -13,7 +13,7 @@
1313 import { clamp } from "../engine/index.ts";
1414 import { CS_MOVE } from "./movement.ts";
1515 import { inaccuracy, WEAPONS } from "./weapons.ts";
16 import { currentWeapon, useProgress, type World } from "./world.ts";
16 import { currentWeapon, useProgress, type Player, type World } from "./world.ts";
1717
1818 /** Everything this module asks of the thing it draws on. Nothing else. */
1919 export type Paint = {
@@ -39,32 +39,46 @@ export type Paint = {
3939 const T_COLOR = "#e2b04a";
4040 const CT_COLOR = "#6fa8e8";
4141
42 /** What a dead player is told, in the two cases there are. */
43 const DEAD = "You are dead — watching the round out";
44 const WATCHING = "Watching";
45 const SWITCH = "fire — next player · aim — the one before";
46
4247 const clock = (seconds: number): string => {
4348 const s = Math.max(0, Math.ceil(seconds));
4449 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
4550 };
4651
52 /**
53 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
54 * the player you are watching once you are dead — and it is what every number
55 * about a body is read off: the crosshair that opens as far as *their* gun is
56 * inaccurate, their health, their armour, their magazine, their defuse bar. The
57 * round, the feed and the scoreboard belong to the match and are the same from
58 * anybody's eyes. Left out, the view is you, which is every frame of a round
59 * you are alive in.
60 */
4761 export function drawHud(
4862 ctx: Paint,
4963 world: World,
5064 width: number,
5165 height: number,
5266 showScoreboard: boolean,
67 view: Player = world.me,
5368 ): void {
5469 ctx.clearRect(0, 0, width, height);
5570 ctx.textBaseline = "alphabetic";
56 const me = world.me;
57 const weapon = currentWeapon(me);
71 const weapon = currentWeapon(view);
5872 const pad = Math.max(12, Math.round(width * 0.02));
5973
6074 // --- crosshair, opening up exactly as much as the gun is inaccurate
61 if (me.alive) {
62 const speed = Math.hypot(me.vel[0], me.vel[1]);
75 if (view.alive) {
76 const speed = Math.hypot(view.vel[0], view.vel[1]);
6377 const spread = inaccuracy(weapon, {
6478 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
65 inAir: !me.onGround,
66 ducking: me.duckAmount > 0.5,
67 scoped: me.scoped,
79 inAir: !view.onGround,
80 ducking: view.duckAmount > 0.5,
81 scoped: view.scoped,
6882 });
6983 const gap = 4 + spread * 14;
7084 const arm = 7;
@@ -76,7 +90,7 @@ export function drawHud(
7690 ctx.lineTo(width / 2 + dx * (gap + arm), height / 2 + dy * (gap + arm));
7791 }
7892 ctx.stroke();
79 if (me.scoped) {
93 if (view.scoped) {
8094 ctx.fillStyle = "rgba(0,0,0,0.55)";
8195 ctx.beginPath();
8296 ctx.rect(0, 0, width, height);
@@ -131,18 +145,18 @@ export function drawHud(
131145 // --- health, armour, money
132146 ctx.textAlign = "left";
133147 ctx.font = big;
134 ctx.fillStyle = me.health > 30 ? "#f3f5f8" : "#ff6b5b";
135 ctx.fillText(String(Math.max(0, Math.round(me.health))), pad, height - pad);
148 ctx.fillStyle = view.health > 30 ? "#f3f5f8" : "#ff6b5b";
149 ctx.fillText(String(Math.max(0, Math.round(view.health))), pad, height - pad);
136150 ctx.font = small;
137151 ctx.fillStyle = "#9fd0ff";
138152 ctx.fillText(
139 `${me.armor > 0 ? `armour ${me.armor}${me.helmet ? " + helmet" : ""}` : "no armour"}`,
153 `${view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour"}`,
140154 pad, height - pad - Math.round(30 * scale),
141155 );
142156 ctx.fillStyle = "#7ddc8a";
143157 ctx.font = mid;
144 ctx.fillText(`$${me.money}`, pad, pad + Math.round(18 * scale));
145 if (me.team === "ct" && me.defuseKit) {
158 ctx.fillText(`$${view.money}`, pad, pad + Math.round(18 * scale));
159 if (view.team === "ct" && view.defuseKit) {
146160 ctx.fillStyle = "#9fd0ff";
147161 ctx.font = small;
148162 ctx.fillText("defuse kit", pad, pad + Math.round(38 * scale));
@@ -152,13 +166,13 @@ export function drawHud(
152166 ctx.textAlign = "right";
153167 ctx.font = big;
154168 ctx.fillStyle = "#f3f5f8";
155 const ammo = me.ammo[weapon.id];
169 const ammo = view.ammo[weapon.id];
156170 const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
157171 ctx.fillText(ammoText, width - pad, height - pad);
158172 ctx.font = small;
159173 ctx.fillStyle = "#c8ccd4";
160 ctx.fillText(weapon.name + (me.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale));
161 if (world.time < me.reloadUntil) {
174 ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale));
175 if (world.time < view.reloadUntil) {
162176 ctx.fillStyle = "#f0c060";
163177 ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale));
164178 }
@@ -197,7 +211,7 @@ export function drawHud(
197211 }
198212
199213 // --- planting or defusing
200 const progress = useProgress(world, me);
214 const progress = useProgress(world, view);
201215 if (progress !== null) {
202216 const w = Math.round(260 * scale), h = Math.round(14 * scale);
203217 const x = width / 2 - w / 2, y = height * 0.62;
@@ -210,11 +224,27 @@ export function drawHud(
210224 ctx.fillText(progress.label, width / 2, y - 8 * scale);
211225 }
212226
213 if (!me.alive && world.match.phase !== "match_over") {
227 // --- dead: whose eyes this is, and how to move to the next pair
228 if (!world.me.alive && world.match.phase !== "match_over") {
214229 ctx.textAlign = "center";
215 ctx.font = big;
216 ctx.fillStyle = "rgba(255,255,255,0.85)";
217 ctx.fillText("You are dead — watching the round out", width / 2, height * 0.4);
230 if (view === world.me) {
231 // Nobody left to watch: your own body is the only camera in the round.
232 ctx.font = big;
233 ctx.fillStyle = "rgba(255,255,255,0.85)";
234 ctx.fillText(DEAD, width / 2, height * 0.4);
235 } else {
236 // Above the bottom row, so it never lands on the health or the ammo —
237 // at 360 pixels wide those are a thumb's width from the middle.
238 const w = Math.round(420 * scale), h = Math.round(34 * scale);
239 const y = height - pad - Math.round(64 * scale);
240 panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
241 ctx.font = mid;
242 ctx.fillStyle = view.team === "t" ? T_COLOR : CT_COLOR;
243 ctx.fillText(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale));
244 ctx.font = small;
245 ctx.fillStyle = "#c8ccd4";
246 ctx.fillText(SWITCH, width / 2, y);
247 }
218248 }
219249
220250 if (showScoreboard) drawScoreboard(ctx, world, width, height, scale);
src/client/main.test.ts +31
@@ -288,6 +288,37 @@ test("match_over stops the run, shows the menu and focuses play", () => {
288288 expect(stage.draws.length).toBe(drawn);
289289 });
290290
291 test("dying moves the camera behind a teammate, and fire moves it behind the next", () => {
292 const stage = playing();
293 stage.lock(true);
294 stage.step(FRAME);
295 const world = worldOf(stage);
296 if (world === null) throw new Error("the first frame drew nothing");
297
298 // Alive, the camera is your own eye and every other living body is drawn.
299 const mine = [...(stage.draws[0]?.scene.camera.pos ?? [])];
300 expect(stage.draws[0]?.scene.instanceCount).toBe(10);
301
302 world.me.alive = false;
303 stage.step(FRAME);
304 const watched = [...(stage.draws[1]?.scene.camera.pos ?? [])];
305 // Somewhere else entirely, and one body fewer: the one the camera is inside.
306 expect(watched).not.toEqual(mine);
307 expect(stage.draws[1]?.scene.instanceCount).toBe(9);
308 // And it is a teammate's eye, not a bot from the other side of the map.
309 const mates = world.players.filter((p) => p.alive && p.team === world.me.team);
310 expect(mates.some((p) => p.pos[0] === watched[0] && p.pos[1] === watched[1])).toBe(true);
311
312 // The trigger is the only control a dead player has left: it walks along.
313 button(stage, "fire").fire("pointerdown");
314 stage.step(FRAME);
315 const next = [...(stage.draws[2]?.scene.camera.pos ?? [])];
316 expect(next).not.toEqual(watched);
317 expect(mates.some((p) => p.pos[0] === next[0] && p.pos[1] === next[1])).toBe(true);
318 // The HUD says whose eyes they are.
319 expect(stage.hud.paint.texts.some((t) => t.startsWith("Watching "))).toBe(true);
320 });
321
291322 test("a phone gets the touch pads, a mouse does not", () => {
292323 expect(playing({ coarse: true }).by("touch").hidden).toBe(false);
293324 expect(playing().by("touch").hidden).toBe(true);
src/client/main.ts +27 −21
@@ -35,6 +35,7 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
3535 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
3636 import { meshFor } from "./models.ts";
3737 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
38 import { createSpectator, cycle, follow } from "./spectate.ts";
3839 import { buyable, WEAPONS } from "./weapons.ts";
3940 import {
4041 buy, canBuy, createWorld, eyePos, priceOf, update, type BuyItem, type Player, type World,
@@ -227,6 +228,9 @@ export function boot(host: Host): Frame {
227228 // them. `scene.ts` keeps the line buffer inside the scene for the same reason.
228229 const frameScene = createScene();
229230 const camera: Camera = { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 };
231 // Who the camera is behind once you are dead, kept across the whole run:
232 // `spectate.ts` moves it, and a fresh match cuts it back to you.
233 const spectator = createSpectator();
230234 let matchId = "";
231235 // The map the renderer is holding the geometry of. It starts as the one the
232236 // meshes were built from, and a match on another map swaps that one mesh.
@@ -290,8 +294,14 @@ export function boot(host: Host): Frame {
290294
291295 controls.onAction = (action, down) => {
292296 if (!down) return;
293 if (action === "buy") openBuy(buyPanel.hidden);
294 else if (action === "fullscreen") full.toggle();
297 if (action === "buy") return openBuy(buyPanel.hidden);
298 if (action === "fullscreen") return full.toggle();
299 // Dead, the trigger is the only thing left to press: it walks the camera
300 // along your side, and the aim button walks it back. The touch pad's fire
301 // is the same action, so a phone gets it without a button of its own.
302 if (world === null || !running || world.me.alive || !buyPanel.hidden) return;
303 if (action === "fire") cycle(world, spectator, 1);
304 else if (action === "scope") cycle(world, spectator, -1);
295305 };
296306 controls.onUnlock = () => {
297307 if (running && buyPanel.hidden) show(pause, true);
@@ -377,21 +387,20 @@ export function boot(host: Host): Frame {
377387 const saved = host.storage.getItem("cs.name");
378388 if (saved !== null && nameField.value === "") nameField.value = saved;
379389
380 // --- sound for whatever happened in the ticks we just ran
381 function playSounds(current: World): void {
390 /**
391 * Sound for whatever happened in the ticks we just ran, heard from `ears` —
392 * the player the camera is behind. Dead, that is whoever you are watching:
393 * a round heard from your own corpse is the other half of a broken spectate.
394 */
395 function playSounds(current: World, ears: Player): void {
382396 for (const effect of current.effects) {
383397 if (effect.at <= lastSound) continue;
384 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(current.me));
398 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears));
385399 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
386400 }
387401 lastSound = current.time;
388402 }
389403
390 const spectated = (current: World): Player => {
391 if (current.me.alive) return current.me;
392 return current.players.find((p) => p.alive && p.team === current.me.team) ?? current.me;
393 };
394
395404 function frame(elapsed: number): void {
396405 frameAverage += (elapsed * 1000 - frameAverage) * 0.05;
397406 // One layer of the atlas per frame, and the browser has the rest of it: the
@@ -421,7 +430,12 @@ export function boot(host: Host): Frame {
421430 const steps = loop.advance(elapsed);
422431 for (let step = 0; step < steps; step++)
423432 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false });
424 playSounds(world);
433
434 // The camera, and with it whose eyes this frame is drawn and heard from.
435 // It is moved after the steps and before anything reads it, and it is the
436 // one thing here that moves at the frame rate rather than at 64 Hz.
437 const view = follow(world, spectator, camera, elapsed);
438 playSounds(world, view);
425439
426440 // The buy window closes itself when the round goes live.
427441 if (!buyPanel.hidden && !canBuy(world, me)) openBuy(false);
@@ -437,15 +451,7 @@ export function boot(host: Host): Frame {
437451 play.focus();
438452 }
439453
440 const view = spectated(world);
441 const eye = eyePos(view);
442 camera.pos[0] = eye[0];
443 camera.pos[1] = eye[1];
444 camera.pos[2] = eye[2];
445 camera.yaw = view.yaw - view.punch[0] * 0.4;
446 camera.pitch = view.pitch - view.punch[1] * 0.4;
447 camera.fov = view.scoped ? 40 : 90;
448 renderer.draw(buildScene(world, camera, frameScene), elapsed);
454 renderer.draw(buildScene(world, camera, frameScene, view), elapsed);
449455
450456 const dpr = Math.min(win.devicePixelRatio || 1, 2);
451457 const w = Math.round(hudCanvas.clientWidth * dpr);
@@ -454,7 +460,7 @@ export function boot(host: Host): Frame {
454460 hudCanvas.width = w;
455461 hudCanvas.height = h;
456462 }
457 drawHud(hudCtx, world, w, h, scoreboard);
463 drawHud(hudCtx, world, w, h, scoreboard, view);
458464 by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
459465 }
460466 loop.start(frame);
src/client/scene.test.ts +28 −1
@@ -18,7 +18,7 @@ import { CS_HULL } from "./movement.ts";
1818 import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts";
1919 import { DE_FORGE } from "./map.ts";
2020 import { SHORT } from "./match.ts";
21 import { createWorld, type Effect, type World } from "./world.ts";
21 import { createWorld, type Effect, type Player, type World } from "./world.ts";
2222
2323 const camera = (): Camera => ({ pos: [10, 20, 30], yaw: 45, pitch: -10, fov: 90 });
2424
@@ -57,6 +57,33 @@ test("the level is drawn first, every living other player after it, and never me
5757 expect(meshes(scene)).toEqual(["level", ...Array<string>(8).fill("player")]);
5858 });
5959
60 test("the body left out is the one the camera is inside, which is not always me", () => {
61 // Dead, the camera sits behind a teammate — and that teammate's model used to
62 // be drawn anyway, one hull thick, right in front of the lens. What is left
63 // out is whoever the eye belongs to, and my own corpse is drawn like anyone's.
64 const w = world();
65 const watched = w.players.find((p) => p !== w.me && p.team === w.me.team) as Player;
66 const scene = buildScene(w, camera(), createScene(), watched);
67 const drawn = scene.instances.slice(1, scene.instanceCount);
68 expect(drawn.length).toBe(9);
69 expect(drawn.some((i) => i.pos[0] === watched.pos[0] && i.pos[1] === watched.pos[1])).toBe(false);
70 expect(drawn.some((i) => i.pos[0] === w.me.pos[0] && i.pos[1] === w.me.pos[1])).toBe(true);
71 });
72
73 test("the gun in view is the watched player's, and goes with them when they die", () => {
74 const w = world();
75 const watched = w.players.find((p) => p !== w.me && p.team === w.me.team) as Player;
76 watched.primary = "awp";
77 watched.weapon = "awp";
78 w.me.alive = false;
79 const scene = buildScene(w, camera(), createScene(), watched);
80 expect(scene.viewModel?.mesh).toBe("sniper");
81
82 watched.alive = false;
83 buildScene(w, camera(), scene, watched);
84 expect(scene.viewModel).toBe(null);
85 });
86
6087 test("a duck squashes a player by the hull it has, a standing one by nothing", () => {
6188 const w = world();
6289 const other = w.players.find((p) => p !== w.me) as (typeof w.players)[number];
src/client/scene.ts +16 −10
@@ -23,7 +23,7 @@ import {
2323 } from "../engine/index.ts";
2424 import { CS_HULL } from "./movement.ts";
2525 import { WEAPONS } from "./weapons.ts";
26 import type { World } from "./world.ts";
26 import type { Player, World } from "./world.ts";
2727
2828 export type Tint = [number, number, number];
2929
@@ -116,8 +116,14 @@ function place(scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number
116116 * Fill `into` with what this world looks like from this camera, and hand the
117117 * same object back. The order the instances go in is the order they are drawn:
118118 * the level, then every other living player, then the bomb.
119 *
120 * `view` is whose eyes the camera is in — you, or the teammate you are watching
121 * once you are dead (`src/client/spectate.ts` decides which). Everything that
122 * is a fact about the eye rather than about the world is read off it: the body
123 * that is left out because the camera is inside its head, and the gun that is
124 * drawn in view space because it is the one those hands are holding.
119125 */
120 export function buildScene(world: World, camera: Camera, into: Scene): Scene {
126 export function buildScene(world: World, camera: Camera, into: Scene, view: Player = world.me): Scene {
121127 into.camera.pos[0] = camera.pos[0];
122128 into.camera.pos[1] = camera.pos[1];
123129 into.camera.pos[2] = camera.pos[2];
@@ -129,8 +135,9 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene {
129135 resetScene(into);
130136 place(into, "level", ORIGIN, 0, 1, WHITE);
131137 for (const p of world.players) {
132 // The eye is inside your own head: drawing it would fill the screen.
133 if (!p.alive || p === world.me) continue;
138 // The eye is inside this head: drawing it would fill the screen. Whose head
139 // that is moves when you die, which is why it is `view` and not `world.me`.
140 if (!p.alive || p === view) continue;
134141 place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE);
135142 }
136143 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
@@ -178,19 +185,18 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene {
178185 );
179186 }
180187
181 // --- the gun in your hands: a walk cycle it sways on, a kick that decays
182 const me = world.me;
183 if (me.alive && !me.scoped) {
188 // --- the gun in these hands: a walk cycle it sways on, a kick that decays
189 if (view.alive && !view.scoped) {
184190 // Simulated seconds, not frame seconds: a match that restarts goes back to
185191 // zero and a paused frame moves nothing, so the clamp holds both ends.
186192 const dt = clamp(world.time - into.keep.time, 0, 0.1);
187 const speed = Math.hypot(me.vel[0], me.vel[1]);
193 const speed = Math.hypot(view.vel[0], view.vel[1]);
188194 into.keep.bobPhase += dt * (4 + speed * 0.02);
189195 const bobX = Math.sin(into.keep.bobPhase) * Math.min(speed / 250, 1) * 0.05;
190196 const bobY = Math.abs(Math.cos(into.keep.bobPhase)) * Math.min(speed / 250, 1) * 0.04;
191 const kick = Math.max(0, 0.12 - (world.time - me.lastShotAt)) * 2.2;
197 const kick = Math.max(0, 0.12 - (world.time - view.lastShotAt)) * 2.2;
192198 const held = into.keep.viewModel;
193 held.mesh = WEAPONS[me.weapon]?.kind ?? "rifle";
199 held.mesh = WEAPONS[view.weapon]?.kind ?? "rifle";
194200 held.offset[0] = bobX;
195201 held.offset[1] = bobY - kick * 0.12;
196202 held.offset[2] = kick * 0.35;
src/client/spectate.test.ts added +272
@@ -0,0 +1,272 @@
1 /**
2 * Spectating, with no browser in the room: a world, a camera object and a `dt`
3 * the test chooses. Every claim here is one of the four things that were wrong
4 * with watching a round out — who you watch, that it stays who you chose, that
5 * you can change it, and that the camera moves like a camera rather than like a
6 * simulation that ticks 64 times a second.
7 *
8 * The roster `createWorld` lays out is you first, then the five terrorists,
9 * then the four counter-terrorists left: seats 0 and 6..9 are your side when
10 * you play CT, which is what the seat numbers below mean.
11 */
12
13 import { expect, test } from "bun:test";
14 import { eyeHeight, type Camera } from "../engine/index.ts";
15 import { DE_FORGE } from "./map.ts";
16 import { SHORT } from "./match.ts";
17 import { CS_HULL } from "./movement.ts";
18 import {
19 ANGLE_TAU, CUT_DISTANCE, POS_TAU, createSpectator, cycle, follow, watched, type Spectator,
20 } from "./spectate.ts";
21 import { createWorld, type Player, type World } from "./world.ts";
22
23 const FRAME = 1 / 60;
24
25 const camera = (): Camera => ({ pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 });
26
27 /** A match nobody is playing, with everyone standing where they spawned. */
28 function world(): World {
29 const w = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 11);
30 w.time = 100;
31 return w;
32 }
33
34 const seat = (w: World, index: number): Player => w.players[index] as Player;
35
36 /** Where the camera should end up if it were placed on `p` this instant. */
37 const eyeOf = (p: Player): [number, number, number] =>
38 [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)];
39
40 /** Kill everyone but the seats named, so what is watchable is exactly them. */
41 function onlyAlive(w: World, ...seats: number[]): void {
42 for (const [index, p] of w.players.entries()) p.alive = seats.includes(index);
43 }
44
45 /** A dead player, a spectator on seat `at`, and the camera already cut to them. */
46 function watching(w: World, at: number): { spectator: Spectator; cam: Camera } {
47 const spectator = createSpectator();
48 spectator.target = seat(w, at).id;
49 const cam = camera();
50 follow(w, spectator, cam, FRAME);
51 return { spectator, cam };
52 }
53
54 // --- who ------------------------------------------------------------------
55
56 test("while you are alive there is nobody to watch: the camera is your own head", () => {
57 const w = world();
58 const spectator = createSpectator();
59 const cam = camera();
60 expect(watched(w, spectator)).toBe(w.me);
61 expect(follow(w, spectator, cam, FRAME)).toBe(w.me);
62 expect(cam.pos).toEqual(eyeOf(w.me));
63
64 // And it is *placed*, never eased: a smoothed camera on your own mouse is
65 // input lag. A step of movement is on screen whole on the next frame.
66 w.me.pos[0] += 40;
67 w.me.yaw = 33;
68 follow(w, spectator, cam, FRAME);
69 expect(cam.pos).toEqual(eyeOf(w.me));
70 expect(cam.yaw).toBe(33);
71
72 // Cycling a round you are playing is not a thing: it stays you.
73 expect(cycle(w, spectator, 1)).toBe(w.me);
74 });
75
76 test("dying watches a living teammate, and never the enemy while one of yours stands", () => {
77 const w = world();
78 w.me.alive = false;
79 const view = watched(w, createSpectator());
80 expect(view).not.toBe(w.me);
81 expect(view.team).toBe("ct");
82 expect(view.alive).toBe(true);
83 // The seat after yours, which is the first of your side's bots.
84 expect(view).toBe(seat(w, 6));
85 });
86
87 test("the camera stays on the player you chose, even when an earlier seat lives", () => {
88 const w = world();
89 w.me.alive = false;
90 const { spectator, cam } = watching(w, 7);
91 expect(watched(w, spectator)).toBe(seat(w, 7));
92
93 // Seat 6 is alive and comes first in the roster: the old camera took whoever
94 // `find` reached first and this is the line that says it does not any more.
95 expect(seat(w, 6).alive).toBe(true);
96 follow(w, spectator, cam, FRAME);
97 expect(watched(w, spectator)).toBe(seat(w, 7));
98
99 // Seat 6 dying is not your camera's business either.
100 seat(w, 6).alive = false;
101 expect(follow(w, spectator, cam, FRAME)).toBe(seat(w, 7));
102 });
103
104 test("when the one you are watching dies the camera walks on, and does not jump back", () => {
105 const w = world();
106 w.me.alive = false;
107 const { spectator, cam } = watching(w, 7);
108 seat(w, 7).alive = false;
109 // The next seat along, not the top of the roster.
110 expect(follow(w, spectator, cam, FRAME)).toBe(seat(w, 8));
111 expect(spectator.target).toBe(seat(w, 8).id);
112 });
113
114 test("fire walks your side, wraps around it, and skips the dead", () => {
115 const w = world();
116 onlyAlive(w, 6, 8, 9);
117 const spectator = createSpectator();
118 expect(watched(w, spectator)).toBe(seat(w, 6));
119 expect(cycle(w, spectator, 1)).toBe(seat(w, 8)); // seat 7 is dead: skipped
120 expect(cycle(w, spectator, 1)).toBe(seat(w, 9));
121 expect(cycle(w, spectator, 1)).toBe(seat(w, 6)); // and round it goes
122 // Aim walks the other way, through the same three.
123 expect(cycle(w, spectator, -1)).toBe(seat(w, 9));
124 expect(cycle(w, spectator, -1)).toBe(seat(w, 8));
125 });
126
127 test("with your whole side gone the camera watches whoever is left, not your body", () => {
128 // A T team wiped with the bomb down is a round that keeps running, and it
129 // used to be spent looking out of your own corpse.
130 const w = world();
131 onlyAlive(w, 2, 3);
132 const spectator = createSpectator();
133 expect(watched(w, spectator)).toBe(seat(w, 2));
134 expect(cycle(w, spectator, 1)).toBe(seat(w, 3));
135
136 // Nobody at all alive — the tick a round ends on — is your own body, and no
137 // exception.
138 onlyAlive(w);
139 expect(watched(w, spectator)).toBe(w.me);
140 expect(follow(w, spectator, camera(), FRAME)).toBe(w.me);
141 });
142
143 // --- how it moves ---------------------------------------------------------
144
145 test("a change of target is a cut: the camera is placed, not flown", () => {
146 const w = world();
147 onlyAlive(w, 6, 7);
148 const spectator = createSpectator();
149 const cam = camera();
150 follow(w, spectator, cam, FRAME);
151 expect(cam.pos).toEqual(eyeOf(seat(w, 6)));
152
153 cycle(w, spectator, 1);
154 follow(w, spectator, cam, FRAME);
155 expect(cam.pos).toEqual(eyeOf(seat(w, 7)));
156 expect(cam.yaw).toBe(seat(w, 7).yaw);
157 });
158
159 test("between cuts the camera eases towards the body, and gets there", () => {
160 const w = world();
161 onlyAlive(w, 6);
162 const { spectator, cam } = watching(w, 6);
163 const target = seat(w, 6);
164 const from = target.pos[0];
165
166 // Forty units in one frame is a run, not a teleport: the camera follows it
167 // part of the way, in the direction of travel.
168 target.pos[0] = from + 40;
169 follow(w, spectator, cam, FRAME);
170 expect(cam.pos[0]).toBeGreaterThan(from);
171 expect(cam.pos[0]).toBeLessThan(from + 40);
172 expect(cam.pos[0]).toBeCloseTo(from + 40 * (1 - Math.exp(-FRAME / POS_TAU)), 8);
173
174 // And it closes the gap rather than trailing forever: half a second of
175 // frames and the camera is on the body to within a hundredth of a unit.
176 for (let frame = 0; frame < 30; frame++) follow(w, spectator, cam, FRAME);
177 expect(Math.abs(cam.pos[0] - (from + 40))).toBeLessThan(0.01);
178 });
179
180 test("the ease is the same motion at 30 frames a second and at 60", () => {
181 const w = world();
182 onlyAlive(w, 6);
183 const target = seat(w, 6);
184
185 const slow = watching(w, 6);
186 const fast = watching(w, 6);
187 target.pos[0] += 50;
188 target.yaw += 30;
189 follow(w, slow.spectator, slow.cam, 1 / 30);
190 follow(w, fast.spectator, fast.cam, 1 / 60);
191 follow(w, fast.spectator, fast.cam, 1 / 60);
192
193 // Two half-frames leave the camera exactly where one whole one does: the ease
194 // is framed in seconds, so a fast machine is smoother and not further along.
195 expect(fast.cam.pos[0]).toBeCloseTo(slow.cam.pos[0], 8);
196 expect(fast.cam.yaw).toBeCloseTo(slow.cam.yaw, 8);
197 });
198
199 test("a body that teleports is a cut, and so is a round that restarts under it", () => {
200 const w = world();
201 onlyAlive(w, 6);
202 const { spectator, cam } = watching(w, 6);
203 const target = seat(w, 6);
204 target.pos[0] += CUT_DISTANCE + 1;
205 follow(w, spectator, cam, FRAME);
206 expect(cam.pos).toEqual(eyeOf(target));
207 });
208
209 test("the camera turns the short way round the wrap, never the long way", () => {
210 const w = world();
211 onlyAlive(w, 6);
212 const { spectator, cam } = watching(w, 6);
213 const target = seat(w, 6);
214 target.yaw = 170;
215 spectator.cut = true; // place it on 170 exactly, so the turn is the whole sum
216 follow(w, spectator, cam, FRAME);
217 expect(cam.yaw).toBe(170);
218 target.yaw = -170;
219 follow(w, spectator, cam, FRAME);
220 // Twenty degrees up through 180, not three hundred and forty back through 0.
221 expect(cam.yaw).toBeGreaterThan(170);
222 expect(cam.yaw).toBeCloseTo(170 + 20 * (1 - Math.exp(-FRAME / ANGLE_TAU)), 8);
223 });
224
225 test("a watched scope zooms the view in, and your own snaps", () => {
226 const w = world();
227 onlyAlive(w, 6);
228 const { spectator, cam } = watching(w, 6);
229 seat(w, 6).scoped = true;
230 follow(w, spectator, cam, FRAME);
231 expect(cam.fov).toBeLessThan(90);
232 expect(cam.fov).toBeGreaterThan(40);
233 for (let frame = 0; frame < 30; frame++) follow(w, spectator, cam, FRAME);
234 expect(cam.fov).toBeCloseTo(40, 2);
235
236 // Yours is not eased at all: a scope you pressed is on screen on the frame
237 // you pressed it.
238 const mine = world();
239 const own = createSpectator();
240 const cam2 = camera();
241 mine.me.scoped = true;
242 follow(mine, own, cam2, FRAME);
243 expect(cam2.fov).toBe(40);
244 });
245
246 test("following allocates nothing: the camera's position is the array it was handed", () => {
247 const w = world();
248 onlyAlive(w, 6, 7);
249 const { spectator, cam } = watching(w, 6);
250 const pos = cam.pos;
251 seat(w, 6).pos[1] += 10;
252 follow(w, spectator, cam, FRAME);
253 cycle(w, spectator, 1);
254 follow(w, spectator, cam, FRAME);
255 expect(cam.pos).toBe(pos);
256 });
257
258 test("a dead frame with no time in it moves nothing and breaks nothing", () => {
259 // A tab that comes back, a frame with dt 0, a frame the clock slept through:
260 // none of them are a reason for the camera to be somewhere impossible.
261 const w = world();
262 onlyAlive(w, 6);
263 const { spectator, cam } = watching(w, 6);
264 const target = seat(w, 6);
265 target.pos[1] += 20;
266 follow(w, spectator, cam, 0);
267 expect(cam.pos[1]).toBe(eyeOf(target)[1] - 20);
268 // A frame that claims to have taken three hours is one long frame, clamped:
269 // the camera catches up in it rather than overshooting past the body.
270 follow(w, spectator, cam, 9999);
271 expect(Math.abs(cam.pos[1] - eyeOf(target)[1])).toBeLessThan(0.1);
272 });
src/client/spectate.ts added +180
@@ -0,0 +1,180 @@
1 /**
2 * Who you are watching once you are dead, and where the camera sits while you
3 * watch them.
4 *
5 * Death used to hand the camera to `players.find(alive && my team)` on every
6 * frame, which is four bugs in one line. The camera was placed *inside* the
7 * watched player, whose model was still drawn, so a teammate's chest filled the
8 * screen. The choice was whatever the array order said, so it could not be
9 * changed and it jumped to a stranger the instant the one you were watching
10 * died. It was written straight from the simulation, so it moved in 64 Hz
11 * staircases under a 144 Hz frame and snapped wherever a bot's aim snapped.
12 * And when the last of your team fell with the bomb still ticking, the fallback
13 * was `me` — you spent the rest of the round staring out of your own corpse.
14 *
15 * So this module holds three things and nothing else. **The choice**: one
16 * player, kept until they die or you ask for another. **The order it moves
17 * in**: the world's own player order, your side while any of it stands and
18 * everyone alive once none of it does. **The camera that follows them**: an
19 * exponential ease towards their eye, framed in seconds so it is the same
20 * motion at 30 frames a second and at 144, with a hard cut whenever the choice
21 * changes or the body teleports — a respawn is not a fly-through.
22 *
23 * Your own view is never eased. While you are alive the camera is placed
24 * exactly where your head is, because a smoothed camera is input lag and the
25 * mouse is your hand. The easing is only ever applied to somebody else's.
26 *
27 * Nothing in here allocates: the camera is written in place, and the order is
28 * walked by index rather than filtered into a list.
29 */
30
31 import { angleDelta, clamp, eyeHeight, type Camera } from "../engine/index.ts";
32 import { CS_HULL } from "./movement.ts";
33 import type { Player, World } from "./world.ts";
34
35 /**
36 * How long the camera takes to close 1 - 1/e of the gap to the body it is
37 * following, in seconds. Position is the tighter of the two because a camera
38 * that trails a running player reads as drift; the angles are looser because
39 * what they are smoothing is a policy that re-aims every tick.
40 */
41 export const POS_TAU = 0.045;
42 export const ANGLE_TAU = 0.07;
43
44 /** The same, for the zoom a watched sniper's scope brings on. */
45 export const FOV_TAU = 0.05;
46
47 /**
48 * A body that moved further than this between two frames did not run there: it
49 * respawned, or the round restarted under it. Easing across that is a second of
50 * flying through walls, so it is a cut. A player at the 250-unit run cap covers
51 * eight units in a 30 Hz frame, so there is a factor of sixteen of headroom.
52 */
53 export const CUT_DISTANCE = 128;
54
55 /** The field of view a scope brings on, and the one the game is played at. */
56 const SCOPED_FOV = 40;
57 const FOV = 90;
58
59 /** The frame the camera is willing to catch up over. A slept tab is a cut anyway. */
60 const MAX_DT = 0.25;
61
62 export type Spectator = {
63 /** The id of the player being watched, or -1 before the first frame. */
64 target: number;
65 /** True when the next `follow` places the camera instead of easing it. */
66 cut: boolean;
67 };
68
69 export const createSpectator = (): Spectator => ({ target: -1, cut: true });
70
71 /** Does anybody on your side still stand? While one does, you watch your own. */
72 function sideStands(world: World): boolean {
73 return world.players.some((p) => p.alive && p.team === world.me.team);
74 }
75
76 /** May this camera sit behind `p`'s eyes right now? */
77 const canWatch = (world: World, p: Player, anyone: boolean): boolean =>
78 p.alive && (anyone || p.team === world.me.team);
79
80 /**
81 * The first player this camera may watch, starting at `from` and walking
82 * `step` at a time around the roster. Null when there is nobody at all.
83 */
84 function around(world: World, from: number, step: number, anyone: boolean): Player | null {
85 const n = world.players.length;
86 for (let k = 0; k < n; k++) {
87 const p = world.players[(((from + step * k) % n) + n) % n] as Player;
88 if (canWatch(world, p, anyone)) return p;
89 }
90 return null;
91 }
92
93 /** An angle folded back into [-180, 180), which is the range a heading lives in. */
94 const wrap = (a: number): number => a - Math.floor((a + 180) / 360) * 360;
95
96 /** Where `p` stands in the roster, or -1. */
97 const seatOf = (world: World, id: number): number => world.players.findIndex((p) => p.id === id);
98
99 /**
100 * Whose eyes the camera is in. You, while you are alive — there is no
101 * spectating a round you are playing. Otherwise the player you chose while they
102 * live, then the next one along from where they stood, and your own body only
103 * when there is nothing else left to watch. It decides and changes nothing;
104 * `follow` and `cycle` are what commit a choice.
105 */
106 export function watched(world: World, spectator: Spectator): Player {
107 if (world.me.alive) return world.me;
108 const anyone = !sideStands(world);
109 const seat = seatOf(world, spectator.target);
110 const chosen = seat < 0 ? null : (world.players[seat] as Player);
111 if (chosen !== null && canWatch(world, chosen, anyone)) return chosen;
112 // The one you were watching is gone: take the next along from where they
113 // stood, so the camera walks the roster rather than snapping back to its top.
114 return around(world, seat < 0 ? 0 : seat + 1, 1, anyone) ?? world.me;
115 }
116
117 /**
118 * Watch the next player along, or the one before at `step` of -1, and hand them
119 * back. A round you are playing has nobody to cycle to, so it does nothing.
120 */
121 export function cycle(world: World, spectator: Spectator, step: number): Player {
122 if (world.me.alive) return world.me;
123 const anyone = !sideStands(world);
124 const now = watched(world, spectator);
125 const next = around(world, seatOf(world, now.id) + step, step, anyone) ?? now;
126 spectator.target = next.id;
127 return next;
128 }
129
130 /**
131 * Move `camera` one frame towards whoever is being watched, and hand that
132 * player back so the frame can draw the world from them: the scene leaves their
133 * body out and puts their gun in your hands, and the HUD reads their health.
134 *
135 * `dt` is the frame's own seconds, not the simulation's — this is the one place
136 * in the game that is allowed to move at the frame rate, because it is where
137 * the 64 Hz staircase is smoothed into something a screen can show.
138 */
139 export function follow(world: World, spectator: Spectator, camera: Camera, dt: number): Player {
140 const view = watched(world, spectator);
141 const cut = spectator.cut || view.id !== spectator.target;
142 spectator.target = view.id;
143 spectator.cut = false;
144
145 const x = view.pos[0];
146 const y = view.pos[1];
147 const z = view.pos[2] + eyeHeight(view, CS_HULL);
148 // The punch is the recoil the body is wearing, which is part of what you are
149 // watching; it is eased with everything else rather than shaken in raw.
150 const yaw = view.yaw - view.punch[0] * 0.4;
151 const pitch = view.pitch - view.punch[1] * 0.4;
152 const fov = view.scoped ? SCOPED_FOV : FOV;
153
154 const jumped = Math.hypot(x - camera.pos[0], y - camera.pos[1], z - camera.pos[2]) > CUT_DISTANCE;
155 // Your own head is never eased: that would be input lag on your own mouse.
156 if (cut || jumped || view === world.me) {
157 camera.pos[0] = x;
158 camera.pos[1] = y;
159 camera.pos[2] = z;
160 camera.yaw = yaw;
161 camera.pitch = pitch;
162 camera.fov = fov;
163 return view;
164 }
165
166 const step = clamp(dt, 0, MAX_DT);
167 const near = 1 - Math.exp(-step / POS_TAU);
168 const turn = 1 - Math.exp(-step / ANGLE_TAU);
169 const zoom = 1 - Math.exp(-step / FOV_TAU);
170 camera.pos[0] += (x - camera.pos[0]) * near;
171 camera.pos[1] += (y - camera.pos[1]) * near;
172 camera.pos[2] += (z - camera.pos[2]) * near;
173 // The turn is the short way round — `angleDelta` is signed and wrapped — and
174 // the result is wrapped back, so a camera that spun with a bot all round is
175 // still a heading and not a running total of every degree it ever turned.
176 camera.yaw = wrap(camera.yaw + angleDelta(camera.yaw, yaw) * turn);
177 camera.pitch += (pitch - camera.pitch) * turn;
178 camera.fov += (fov - camera.fov) * zoom;
179 return view;
180 }
src/games.ts +8
@@ -175,6 +175,14 @@ export type Game = {
175175 * for the engine's side of that swap (`Renderer.replace`, which it never calls)
176176 * and the example is unchanged to the byte: it ships no renderer at all.
177177 * Measured 2026-09-20 after #138, on the bundles this branch commits.
178 * #133 gave the shooter a spectator — `src/client/spectate.ts`, the choice of
179 * who you watch once you are dead and the eased camera that follows them — and
180 * the app grew 1,488 raw / 579 gzipped bytes for it: the module itself, the
181 * `view` the scene and the HUD are now drawn from, and the banner that names
182 * whose eyes they are. Measured 2026-09-20 after #133 on top of #132 and #138:
183 * app 79,454 raw / 36,638 gzipped, which fits the 82,000 / 38,000 #138 raised
184 * with 2,546 raw and 1,362 gzipped to spare, so the ceiling did not move.
185 * Neither other bundle changed a byte: nothing outside `src/client/` did.
178186 * The range's numbers here are #121's moving plates and #125's painter at once:
179187 * both landed a range bundle and neither swept the other's, so static/ held two
180188 * and every reader of it failed until a plain `bun run build` resolved them.
src/pages/game.ts +2
@@ -93,6 +93,8 @@ const menu = (visitor: Who): string => `
9393 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
9494 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
9595 <kbd>Esc</kbd> pause</p>
96 <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player,
97 aim for the one before.</p>
9698 `;
9799
98100 const PAUSE = `
static/app.c59820edf9.js added +58

Diff not shown — a line 63,335 characters wide (limit 2,000). +58

View file
static/app.ec73e969a1.js deleted −58

Diff not shown — a line 61,852 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs