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

forked from trevato/cs

Smoke that actually blocks sight: one cloud, in the simulation as well as on the screen #254

Merged opened this

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

Merged

vesper/cs-engine:vesper/165-smoke-that-actually-blocks-sight-one-clo → main 1 commit fork

  • Merged. vesper/165-smoke-that-actually-blocks-sight-one-clo was merged into main as 5dbe90e.

Review

Sign in to review this change.
29 files changed +957 −128
scripts/build.test.ts +1 −1
@@ -41,7 +41,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
4141
4242 test("the entry table is what the app ships today, each with the size it may not pass", () => {
4343 expect(ENTRIES).toEqual([
44 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 98_000, gzip: 39_000 } },
44 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 103_000, gzip: 41_000 } },
4545 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
4646 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } },
4747 ]);
src/budget.test.ts +37
@@ -188,6 +188,43 @@ test("a 64 Hz tick with ten bodies stays well inside the frame budget", () => {
188188 expect(alive).toBe(10);
189189 }, PATIENCE);
190190
191 /**
192 * The same tick with smoke standing in the round: ten clouds, one per body,
193 * which is the most this game can have at once and far more than a round ever
194 * does. It is measured against the *same* ceiling as the plain tick, because
195 * what #165 promised is that a cloud does not buy a sight test a second price:
196 * `canSee` traces the level's fifty-odd boxes first, as it always has, and then
197 * runs `segmentSphere` over the clouds — a dot product and at most one square
198 * root each, with no allocation — and in a round nobody smoked the loop is a
199 * length check on an empty array and costs nothing measurable at all.
200 *
201 * Measured on the machine that wrote this, idle: a plain tick 0.102 ms, this
202 * one 0.107 ms. Ten clouds cost about **0.005 ms a tick** — three hundredths of
203 * one per cent of the 15.625 ms a frame has, and a twentieth of what a tick
204 * already cost — and both are nearly ten times under the 1.0 ms ceiling, which
205 * did not move for any of this.
206 */
207 test("a tick with ten clouds of smoke standing is still well inside the budget", () => {
208 useShipped();
209 let clouds = 0;
210 const each = measure("a 64 Hz tick with ten clouds standing", TICK_CEILING, () => {
211 const world = liveRound(7);
212 // One cloud on each body, opened a moment ago so every one of them is at
213 // its full radius for the whole batch rather than growing out of nothing.
214 for (const p of world.players)
215 world.clouds.push({ pos: [p.pos[0], p.pos[1], p.pos[2] + 58], born: world.time - 2, owner: p.id });
216 const batch = timed(TICKS, () => {
217 for (let i = 0; i < TICKS; i++) update(world, TICK, NO_INPUT);
218 });
219 clouds = world.clouds.length;
220 return batch;
221 });
222
223 expect(each).toBeLessThan(TICK_CEILING);
224 // The ticks that were measured really did have ten clouds standing in them.
225 expect(clouds).toBe(10);
226 }, PATIENCE);
227
191228 /**
192229 * The guard's own guard. A ceiling nothing can reach is a green light wired to
193230 * nothing: a loop the JIT lifted out, a clock read twice in a row, a batch that
src/client/audio.ts +12
@@ -198,6 +198,18 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
198198 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08, pan });
199199 return;
200200 }
201 if (sound === "smoke") {
202 // The canister opening: no bang at all. A long, soft, wide hiss that
203 // fades away rather than cracks — it is the one grenade noise in this
204 // game that is a *process* and not an event, and it is quiet, because
205 // what a smoke costs you is what you can see and never what you hear.
206 // It carries like a footstep does and not like a blast: a cloud across
207 // the map is not information anybody should get for free.
208 const soft = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
209 device.noiseBurst({ frequency: 3200, gain: 0.2 * soft, length: 1.4, q: 0.5, rate: 0.9, pan });
210 device.tone({ frequency: 420, slideTo: 210, gain: 0.1 * soft, length: 0.9, pan });
211 return;
212 }
201213 if (sound === "flash") {
202214 // The flash: the opposite noise to the blast in every way. A bright,
203215 // tight crack with no body under it and a ring above it that climbs
src/client/binding.test.ts +2 −1
@@ -19,12 +19,13 @@ test("the bindings are the ones the keyboard has always had", () => {
1919 expect(CS_KEYS.KeyB).toBe("buy");
2020 expect(CS_KEYS.KeyG).toBe("grenade");
2121 expect(CS_KEYS.KeyT).toBe("flash");
22 expect(CS_KEYS.KeyV).toBe("smoke");
2223 expect(CS_KEYS.Tab).toBe("scoreboard");
2324 expect(CS_KEYS.Digit1).toBe("slot1");
2425 expect(CS_KEYS.Digit3).toBe("slot3");
2526 expect(CS_MOUSE[0]).toBe("fire");
2627 expect(CS_MOUSE[2]).toBe("scope");
27 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash"]);
28 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke"]);
2829 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
2930 // the browser for the screen, and nothing about an `Input` changes for it.
3031 expect(CS_KEYS.KeyF).toBe("fullscreen");
src/client/binding.ts +8 −5
@@ -13,7 +13,8 @@ export const CS_KEYS: Record<string, string> = {
1313 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
1414 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
1515 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
16 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyB: "buy", Tab: "scoreboard",
16 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyV: "smoke",
17 KeyB: "buy", Tab: "scoreboard",
1718 Digit1: "slot1", Digit2: "slot2", Digit3: "slot3",
1819 // Neither of these is a thing the world knows about: `main.ts` hears them on
1920 // `onAction`, and `readIntent` below never sees either. F asks the browser for
@@ -28,7 +29,7 @@ export const CS_KEYS: Record<string, string> = {
2829 export const CS_MOUSE: Record<number, string> = { 0: "fire", 2: "scope" };
2930
3031 /** On-screen buttons that are a tap, not a hold. */
31 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash"] as const;
32 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke"] as const;
3233
3334 export type Intent = {
3435 input: Input;
@@ -55,9 +56,11 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num
5556 use: down("use"),
5657 fire: down("fire"),
5758 scope: down("scope"),
58 // Two pockets, two keys. Both held is the flash, because the one you just
59 // reached for is the one you meant; releasing it throws the HE still held.
60 throwing: down("flash") ? "flash" : down("grenade") ? "he" : null,
59 // Three pockets, three keys, read newest-reach-first: the one nearest the
60 // front of this list wins while more than one is held, because the one you
61 // just reached for is the one you meant, and releasing it throws whichever
62 // of the others is still down.
63 throwing: down("smoke") ? "smoke" : down("flash") ? "flash" : down("grenade") ? "he" : null,
6164 switchTo: null,
6265 };
6366 return {
src/client/bots.ts +81 −1
@@ -19,6 +19,13 @@
1919 * — the arena's, the trainer's — behave exactly as they did before, down to the
2020 * coin flip, because `usePlan` was never called on them.
2121 *
22 * Smoke is handled in the same layer, and deliberately not in the policy. A bot
23 * cannot see through a cloud because `canSee` says so, which is one change in
24 * one function and covers the shooting; what it cannot answer is the walking,
25 * so `skirt` below bends the next step of a path around a cloud rather than
26 * into it. Both are scripted, both are outside the network, and the shipped
27 * weights are the same eight kilobytes they were.
28 *
2229 * The weights are not in here and are not imported: they arrive as data. In a
2330 * browser `src/client/main.ts` downloads `weights.json` as this game's own
2431 * asset and calls `usePolicy` before Play is pressable; in Bun
@@ -38,6 +45,7 @@ import { CS_HULL } from "./movement.ts";
3845 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
3946 import { SHAPE } from "./packed.ts";
4047 import { ease, grip, gripRate, sprayPull } from "./spray.ts";
48 import { cloudRadius } from "./grenade.ts";
4149 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
4250 import { buy } from "./economy.ts";
4351 import type { Input, Player, World } from "./world.ts";
@@ -430,6 +438,74 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
430438 return best;
431439 }
432440
441 // --- smoke ----------------------------------------------------------------
442
443 /**
444 * How far outside a cloud a bot aims to walk, in units. A hull's width, so it
445 * skirts the fringe of one rather than clipping through it — and so that a
446 * diversion is a visible step around something and not a shuffle along its
447 * edge.
448 */
449 const SMOKE_CLEARANCE = CS_HULL.radius * 2;
450
451 /**
452 * Where this bot should walk instead of straight at `step`, given the clouds
453 * standing in the round.
454 *
455 * A cloud is not a wall — a body walks through one exactly as it walks through
456 * air, and it should, because walking through smoke is half of what smoke is
457 * for — but it *is* a wall to this bot's eyes: `canSee` refuses a line through
458 * one, so a bot that strolls into a cloud arrives blind at whatever is waiting
459 * on the other side and has given away the fight it was walking to. So the
460 * scripted layer treats it as something to go around, and the policy is told
461 * nothing: the shipped weights, `OBS_SIZE` and every head are untouched by all
462 * of this.
463 *
464 * Two cases and no third. Standing in one, the shortest way out is straight
465 * away from the middle, and that is where the bot walks. Heading into one, the
466 * step is pushed sideways to whichever edge is nearer — the tangent, plus the
467 * clearance — so the path bends around the cloud and rejoins itself. A cloud
468 * behind the bot, beside the walk, or past the end of this step is left alone,
469 * which is every cloud on most ticks; a round nobody smoked never reaches here
470 * at all, because the caller checks the list is empty first.
471 *
472 * The step comes back unchanged when there is nothing to do, so the common case
473 * allocates nothing.
474 */
475 export function skirt(world: World, bot: Player, step: Vec3): Vec3 {
476 for (const cloud of world.clouds) {
477 const radius = cloudRadius(cloud, world.time);
478 if (radius <= 0) continue;
479 const keep = radius + SMOKE_CLEARANCE;
480 const cx = cloud.pos[0] - bot.pos[0];
481 const cy = cloud.pos[1] - bot.pos[1];
482 const here = Math.hypot(cx, cy);
483 if (here < keep) {
484 // Inside it, or in its fringe: out, by the shortest line there is. Dead
485 // in the middle there is no such line, so the bot keeps walking rather
486 // than picking a direction out of nothing.
487 if (here < 1e-6) continue;
488 return [bot.pos[0] - (cx / here) * keep, bot.pos[1] - (cy / here) * keep, step[2]];
489 }
490 const dx = step[0] - bot.pos[0];
491 const dy = step[1] - bot.pos[1];
492 const len = Math.hypot(dx, dy);
493 if (len < 1e-6) continue;
494 const ux = dx / len, uy = dy / len;
495 // How far along the walk the cloud sits, and how far off to one side.
496 const along = cx * ux + cy * uy;
497 if (along <= 0 || along > len + keep) continue;
498 const off = cx * -uy + cy * ux;
499 if (Math.abs(off) >= keep) continue;
500 // Around the near edge: the perpendicular, away from the side the cloud is
501 // on, by just enough to put the walk on the tangent.
502 const side = off >= 0 ? -1 : 1;
503 const push = (keep - Math.abs(off)) * side;
504 return [step[0] + -uy * push, step[1] + ux * push, step[2]];
505 }
506 return step;
507 }
508
433509 /** An enemy shooting nearby is a direction to look, even through a wall. */
434510 function listen(world: World, bot: Player, brain: BotBrain): void {
435511 for (const other of world.players) {
@@ -659,7 +735,11 @@ export function botThink(world: World, bot: Player, dt: number): Input {
659735 const next = brain.path[brain.step];
660736 if (next !== undefined) {
661737 if (dist([bot.pos[0], bot.pos[1], 0], [next[0], next[1], 0]) < 48) brain.step += 1;
662 const aim = aimAt(bot, [next[0], next[1], bot.pos[2] + 48]);
738 // The path is A*'s and it knows nothing about smoke, which is right: the
739 // cells are still walkable and a cloud is gone in fifteen seconds. What
740 // bends is the *step*, here and only while there is a cloud to bend it.
741 const walk = world.clouds.length === 0 ? next : skirt(world, bot, next);
742 const aim = aimAt(bot, [walk[0], walk[1], bot.pos[2] + 48]);
663743 const look = brain.suspect !== null ? aimAt(bot, brain.suspect) : { yaw: aim.yaw, pitch: 0 };
664744 turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt);
665745 const off = angleDelta(bot.yaw, aim.yaw);
src/client/economy.test.ts +1 −1
@@ -21,7 +21,7 @@ const buyer = (over: Partial<Buyer> = {}): Buyer => ({
2121 armor: 0, helmet: false, defuseKit: false,
2222 weapon: "usp", primary: null, secondary: "usp",
2323 ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") },
24 pocket: { he: 0, flash: 0 },
24 pocket: { he: 0, flash: 0, smoke: 0 },
2525 ...over,
2626 });
2727
src/client/economy.ts +5 −4
@@ -25,11 +25,12 @@ export const emptyAmmo = (id: WeaponId): Ammo =>
2525 ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve });
2626
2727 /**
28 * How many of each thrown weapon a player may carry into a round. One HE and
29 * one flashbang — two grenades, bought separately in the freeze, each refused
30 * once the pocket holds its limit, and both gone with the body that held them.
28 * How many of each thrown weapon a player may carry into a round. One HE, one
29 * flashbang and one smoke — three grenades, bought separately in the freeze,
30 * each refused once the pocket holds its limit, and all gone with the body that
31 * held them.
3132 */
32 export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1 };
33 export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1, smoke: 1 };
3334
3435 export type BuyItem = WeaponId | "armor" | "helmet" | "kit";
3536
src/client/generate.test.ts +6 −1
@@ -370,7 +370,12 @@ test("a bot walks a map nobody wrote, on that map's own cells", () => {
370370 * field list still comes to the digest this line held before,
371371 * 48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae.
372372 */
373 const GENERATED_ROUND = "d7bd4969d00345feff718f37a6ed13ff6967f8884ca716bc639bff63f085d82e";
373 // Re-pinned for #165, which put a smoke grenade in the game: the hash moved
374 // and the play did not. A pocket is three counts now rather than two, a
375 // grenade's kind is an index into three rather than a flag, and every tick
376 // carries however many clouds are standing — and nothing in this run throws
377 // anything, so `clouds` is empty on all 640 ticks of it.
378 const GENERATED_ROUND = "619ce3102805d11dc40e8d545267e99034bfb3bdab2961202703423a87f6e500";
374379
375380 test("a seeded round on a generated map plays exactly as it played", () => {
376381 const level = generateLevel(5);
src/client/grenade.test.ts +2 −2
@@ -458,7 +458,7 @@ test("a flash is bought alongside the HE, one of each, and thrown on its own key
458458 expect(buy(world, world.me, "he")).toBe(true);
459459 expect(buy(world, world.me, "flash")).toBe(true);
460460 // Carried alongside, not instead of: both are in the pocket at once.
461 expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash });
461 expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash, smoke: 0 });
462462 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
463463 // One of each a round: the second of either is refused and costs nothing.
464464 expect(buy(world, world.me, "flash")).toBe(false);
@@ -472,7 +472,7 @@ test("a flash is bought alongside the HE, one of each, and thrown on its own key
472472 for (let t = 0; t < 32; t++) update(world, TICK, { ...NO_INPUT, throwing: "flash" });
473473 expect(world.grenades.length).toBe(1);
474474 expect(world.grenades[0]?.weapon).toBe("flash");
475 expect(world.me.pocket).toEqual({ he: 1, flash: 0 });
475 expect(world.me.pocket).toEqual({ he: 1, flash: 0, smoke: 0 });
476476 });
477477
478478 /** Everyone but `p` put out of the round, so only the flash can change anything. */
src/client/grenade.ts +131 −15
@@ -1,10 +1,11 @@
11 /**
22 * The thrown grenade: a body, a fuse, and whatever it does when the fuse runs
3 * out. Two kinds are thrown today — the HE and the flashbang — and the only
4 * thing in this file that knows which is `Grenade.weapon`: the flight, the
5 * bounce, the fuse and the sounds are one piece of machinery both use, and what
6 * arrives at the far end is `blastHits` for one and `flashHits` for the other.
7 * A third kind is a row in `weapons.ts` and a function here.
3 * out. Three kinds are thrown today — the HE, the flashbang and the smoke — and
4 * the only thing in this file that knows which is `Grenade.weapon`: the flight,
5 * the bounce, the fuse and the sounds are one piece of machinery all three use,
6 * and what arrives at the far end is `blastHits` for one, `flashHits` for
7 * another and `cloudFrom` for the third. A fourth kind is a row in `weapons.ts`
8 * and a function here.
89 *
910 * There is no solver in this file. A grenade in flight is a `Body` like a
1011 * player is, and it is moved by the same `move` in `src/engine/physics.ts` —
@@ -17,11 +18,17 @@
1718 * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as
1819 * on a desktop, and the same number in a replay as in a match.
1920 *
20 * Neither kind reaches anything it cannot see. `blastHits` and `flashHits` both
21 * trace from the middle of the detonation through the engine's own `visible`,
22 * so a wall, a crate or a corner takes all of it: a grenade cannot kill through
23 * a wall here and a flash cannot blind through one, which are the properties
24 * the two tests of that name hold.
21 * Neither of the first two reaches anything it cannot see. `blastHits` and
22 * `flashHits` both trace from the middle of the detonation through the engine's
23 * own `visible`, so a wall, a crate or a corner takes all of it: a grenade
24 * cannot kill through a wall here and a flash cannot blind through one, which
25 * are the properties the two tests of that name hold.
26 *
27 * The smoke is the odd one and it is odd in the other direction: it reaches
28 * nobody at all, and what it leaves behind outlives it. `cloudFrom` turns a
29 * spent canister into a `Cloud` — a ball with a clock — which the world keeps
30 * and `canSee` in `src/client/world.ts` traces against. Nothing about a cloud
31 * is drawing: it is the same ball on the screen and in the simulation.
2532 */
2633
2734 import {
@@ -30,7 +37,10 @@ import {
3037 } from "../engine/index.ts";
3138 import { hitboxes, type Actor } from "./combat.ts";
3239 import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts";
33 import { blastDamage, blindSeconds, fuseOf, WEAPONS, throughArmor, type ThrownId } from "./weapons.ts";
40 import {
41 blastDamage, blindSeconds, fuseOf, SMOKE, WEAPONS, throughArmor,
42 type Smoke, type ThrownId,
43 } from "./weapons.ts";
3444
3545 /** A grenade in the world: a body, which kind it is, whose it is, and how long it has left. */
3646 export type Grenade = Body & {
@@ -66,11 +76,11 @@ const HAND = 18;
6676
6777 /**
6878 * The sounds a grenade makes, in the order one round of it happens. The first
69 * two belong to every thrown thing; the last of them is the kind's own — the
70 * HE's `blast`, the flashbang's `flash` — which is why this is one widening
71 * type and not a second one per grenade.
79 * two belong to every thrown thing; the rest are each a kind's own — the HE's
80 * `blast`, the flashbang's `flash`, the canister's `smoke` — which is why this
81 * is one widening type and not a second one per grenade.
7282 */
73 export type GrenadeSound = "throw" | "bounce" | "blast" | "flash";
83 export type GrenadeSound = "throw" | "bounce" | "blast" | "flash" | "smoke";
7484
7585 /**
7686 * Below this the knock of a bounce is not worth a sound: a grenade rolling to
@@ -132,6 +142,44 @@ export function stepGrenade(g: Grenade, dt: number, boxes: Box[]): boolean {
132142 );
133143 }
134144
145 /**
146 * How slowly a grenade has to be moving, in u/s, to count as having stopped.
147 * A shell settling on concrete never quite reaches zero — the solver leaves it
148 * a fraction of a unit a second — so "at rest" is a small number and not one.
149 */
150 const REST_SPEED = 20;
151
152 /**
153 * How many seconds past its fuse a smoke will wait for the floor before it
154 * opens anyway. A canister thrown off a ledge, wedged in a corner or bouncing
155 * down a long stair is still a grenade somebody threw, and one that never opens
156 * is worse than one that opens in the air. Two seconds is longer than any throw
157 * on any map this game generates takes to settle.
158 */
159 const REST_PATIENCE = 2;
160
161 /** Is this grenade lying still enough for a cloud to grow where it stopped? */
162 export const atRest = (g: Grenade): boolean =>
163 g.onGround && Math.hypot(g.vel[0], g.vel[1], g.vel[2]) < REST_SPEED;
164
165 /**
166 * Has this one's fuse run out — has it done whatever it does yet?
167 *
168 * For the HE and the flashbang this is the comparison it has always been: zero
169 * on the fuse is the tick it goes off, in the air or on the floor, because a
170 * blast and a flash happen wherever the thing happens to be.
171 *
172 * A smoke is different in the one way that matters to a round: a cloud grows
173 * where the canister *stopped*, so a fuse that runs out in mid-flight is held
174 * — the fuse keeps burning negative, which is what makes the wait a number the
175 * digest can see — until the thing is on the floor and still, or until
176 * `REST_PATIENCE` seconds have gone by and it opens where it is.
177 */
178 export function spent(g: Grenade): boolean {
179 if (g.weapon !== "smoke") return g.fuse <= 0;
180 return g.fuse <= -REST_PATIENCE || (g.fuse <= 0 && atRest(g));
181 }
182
135183 /** The middle of the explosion: the middle of the grenade, not its feet. */
136184 export const blastCentre = (g: Grenade): Vec3 => [g.pos[0], g.pos[1], g.pos[2] + NADE_HULL.stand / 2];
137185
@@ -238,3 +286,71 @@ export function flashHits(g: Grenade, actors: Looker[], boxes: Box[]): Blinded[]
238286 }
239287 return blinded;
240288 }
289
290 // --- the cloud ------------------------------------------------------------
291
292 /**
293 * A cloud of smoke standing in the world: where its middle is, when it opened,
294 * and whose canister it was. Everything else about it — how wide it is right
295 * now, whether it is still there at all — is arithmetic on `born` and the
296 * world's clock, which is why there is no radius on this record and no tick of
297 * a cloud anywhere in the game. A cloud is not stepped; it is *read*.
298 */
299 export type Cloud = {
300 pos: Vec3;
301 /** The simulated second the canister opened. */
302 born: number;
303 /** The player id that threw it. Nothing reads it yet; the feed will. */
304 owner: number;
305 };
306
307 /**
308 * How high above the canister the middle of the ball sits, in units. A smoke
309 * lying on the floor with its middle at its own height would be a ball half
310 * buried in concrete and a hole a crouching body could see under; lifting it
311 * most of a standing hull puts the bottom of the ball on the floor and the top
312 * of it over a head, which is the wall a smoke is supposed to be.
313 */
314 const CLOUD_LIFT = 58;
315
316 /** The cloud a spent canister leaves behind, opening at `time`. */
317 export const cloudFrom = (g: Grenade, time: number): Cloud => ({
318 pos: [g.pos[0], g.pos[1], g.pos[2] + CLOUD_LIFT],
319 born: time,
320 owner: g.owner,
321 });
322
323 /**
324 * How wide this cloud is at `time`, in units, and zero once it is gone —
325 *
326 * r(age) = radius · age / grow age < grow
327 * = radius grow ≤ age < life − fade
328 * = radius · (life − age) / fade life − fade ≤ age < life
329 * = 0 age < 0 or age ≥ life
330 *
331 * One number, read by three callers that must never disagree: the sight test in
332 * `world.ts`, the ball `scene.ts` places, and the wash `hud.ts` puts over the
333 * screen when your eye is inside it. A cloud that blocked a bot's line for a
334 * second longer than it was drawn would be exactly the bug this shape rules
335 * out.
336 *
337 * The default is the one smoke grenade in the table. It is an argument because
338 * a test wants to say "a cloud this wide" without reaching for the weapon.
339 */
340 /**
341 * Is this cloud over? Asked by the world's housekeeping rather than by the
342 * radius above, because the two differ at exactly one instant and it is the
343 * instant that matters: a cloud is *born* at radius zero and is not gone, and a
344 * prune written against the radius would drop every cloud on the tick it opened.
345 */
346 export const cloudGone = (cloud: Cloud, time: number, smoke: Smoke = SMOKE.smoke): boolean =>
347 time - cloud.born >= smoke.life;
348
349 export function cloudRadius(cloud: Cloud, time: number, smoke: Smoke = SMOKE.smoke): number {
350 const age = time - cloud.born;
351 if (age < 0 || age >= smoke.life) return 0;
352 if (age < smoke.grow) return smoke.radius * (age / smoke.grow);
353 const left = smoke.life - age;
354 if (left < smoke.fade) return smoke.radius * (left / smoke.fade);
355 return smoke.radius;
356 }
src/client/hud.test.ts +2 −2
@@ -616,12 +616,12 @@ test("the pocket line names each grenade you are carrying, and both at once", ()
616616 const none = draw(scene());
617617 expect(words(none).some((t) => t.includes("HE"))).toBe(false);
618618 const both = draw(scene((w) => {
619 (w.me as Player).pocket = { he: 1, flash: 1 };
619 (w.me as Player).pocket = { he: 1, flash: 1, smoke: 0 };
620620 }));
621621 const named = words(both).find((t) => t.includes("HE")) ?? "";
622622 expect(named).toContain("HE");
623623 expect(named).toContain("flash");
624 const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1 }; }));
624 const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1, smoke: 0 }; }));
625625 const line = words(only).find((t) => t.includes("flash")) ?? "";
626626 expect(line).not.toContain("HE");
627627 });
src/client/hud.ts +28 −1
@@ -30,7 +30,7 @@ import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from
3030 import { inaccuracy, WEAPONS } from "./weapons.ts";
3131 import { useProgress } from "./bomb.ts";
3232 import { scoreLines } from "./summary.ts";
33 import { currentWeapon, type Player, type World } from "./world.ts";
33 import { currentWeapon, eyePos, insideSmoke, type Player, type World } from "./world.ts";
3434
3535 /**
3636 * Everything this module asks of the thing it draws on. Nothing else: it is the
@@ -100,6 +100,22 @@ export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WH
100100 const PEAK = 0.92;
101101 const WHITEOUT_FADE = 0.9;
102102
103 /**
104 * How much grey is over the world when the eye is inside a cloud of smoke.
105 *
106 * It is a constant and not a ramp, on purpose: the simulation's answer inside a
107 * cloud is a flat *no* — `canSee` refuses a line that meets the ball at all, so
108 * a bot one unit inside one is as blind as a bot in the middle — and a wash
109 * that faded with depth would be the screen disagreeing with the round. Like
110 * the white-out above it stops short of 1, so the crosshair and the world are a
111 * ghost behind it rather than gone; unlike the white-out it does not fade,
112 * because a cloud does not fade while you are standing in it.
113 *
114 * From outside, a cloud is a ball `scene.ts` places and this draws nothing at
115 * all. The two are the same fact from the two sides of one surface.
116 */
117 export const FOG = 0.9;
118
103119 /**
104120 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
105121 * the player you are watching once you are dead — and it is what every number
@@ -171,6 +187,16 @@ export function drawHud(
171187 ctx.fillRect(0, 0, width, height);
172188 }
173189
190 // --- and in the smoke: the same idea from inside a cloud instead of outside
191 // one. The ball `scene.ts` draws is opaque and its far faces are culled, so
192 // an eye within it would otherwise see straight through the thing that is
193 // blocking every sightline in the simulation. Which eye is the view's, so a
194 // spectator sees the fog the body they are watching is standing in.
195 if (view.alive && insideSmoke(world, eyePos(view))) {
196 ctx.fillStyle = `rgba(196,200,208,${FOG})`;
197 ctx.fillRect(0, 0, width, height);
198 }
199
174200 const scale = ui.scale;
175201 const big = ui.font(30);
176202 const mid = ui.font(19);
@@ -216,6 +242,7 @@ export function drawHud(
216242 weapon.name +
217243 (view.pocket.he > 0 ? " · HE" : "") +
218244 (view.pocket.flash > 0 ? " · flash" : "") +
245 (view.pocket.smoke > 0 ? " · smoke" : "") +
219246 (view.hasBomb ? " · bomb" : ""),
220247 width - pad, height - pad - Math.round(30 * scale),
221248 { align: "right", font: small, fill: GREY },
src/client/main.test.ts +2 −1
@@ -958,7 +958,8 @@ test("every mesh name a scene of this game can place is handed in at constructio
958958 // table misses is an exception on the frame a player first holds that gun.
959959 const meshes = gameMeshes();
960960 expect(Object.keys(meshes).sort()).toEqual([
961 "bomb", "flash", "grenade", "knife", "level", "pistol", "player", "rifle", "smg", "sniper",
961 "bomb", "cloud", "flash", "grenade", "knife", "level", "pistol", "player", "rifle", "smg",
962 "smoke", "sniper",
962963 ]);
963964 // Every kind a weapon can be, and the kind `scene.ts` falls back to.
964965 for (const weapon of Object.values(WEAPONS)) expect(meshes[weapon.kind]).toBeDefined();
src/client/main.ts +4 −2
@@ -1171,8 +1171,10 @@ export function boot(host: Host): Frame {
11711171 export function gameMeshes(level: Level = DE_FORGE): Record<string, Mesh> {
11721172 const kinds = new Set(Object.values(WEAPONS).map((weapon) => weapon.kind));
11731173 // `grenade` arrives as a weapon kind and is the HE's body in the world; the
1174 // flashbang shares that kind and needs its own shell, so it is named here.
1175 const names = ["level", "player", "bomb", "flash", ...kinds];
1174 // flashbang and the smoke share that kind and need their own shells, so they
1175 // are named here — and so is `cloud`, which is no weapon at all but the ball
1176 // a spent smoke leaves standing.
1177 const names = ["level", "player", "bomb", "flash", "smoke", "cloud", ...kinds];
11761178 return Object.fromEntries(names.map((name) => [name, meshFor(name, level)]));
11771179 }
11781180
src/client/models.ts +68 −3
@@ -58,11 +58,19 @@ export function bombMesh(): Mesh {
5858 *
5959 * The flashbang is the same shell in a different shape: taller, narrower and
6060 * banded top and bottom, the way CS's is, so the two are told apart in the air
61 * at a glance. Both are the one recipe because both are one thrown thing.
61 * at a glance. The smoke is the squat one — a fat canister with a vent collar
62 * — which is CS's shape too. All three are the one recipe because all three are
63 * one thrown thing.
6264 */
63 export function grenadeMesh(kind: "he" | "flash" = "he"): Mesh {
65 export function grenadeMesh(kind: "he" | "flash" | "smoke" = "he"): Mesh {
6466 const b = builder();
6567 const SHELL = 7, BAND = 8;
68 if (kind === "smoke") {
69 cube(b, 0, 0, 3.5, 7, 7, 8, SHELL); // the fat canister
70 cube(b, 0, 0, 7, 8, 8, 2, BAND); // the vent collar at its top
71 cube(b, 0, 0, 8.5, 2.5, 2.5, 2, BAND); // the spoon on top
72 return finish(b);
73 }
6674 if (kind === "flash") {
6775 cube(b, 0, 0, 3, 5, 5, 10, SHELL); // the canister
6876 cube(b, 0, 0, 1, 6, 6, 2, BAND); // the band around its foot
@@ -76,6 +84,60 @@ export function grenadeMesh(kind: "he" | "flash" = "he"): Mesh {
7684 return finish(b);
7785 }
7886
87 /**
88 * The radius the cloud mesh below is built at, in units. A mesh is uploaded
89 * once and a cloud grows, so the ball is built at one size and the scene sets
90 * `size` to the radius it wants over this one — which also stretches the
91 * texture as it grows, which is what billowing looks like from outside.
92 */
93 export const CLOUD_MESH_RADIUS = 64;
94
95 /**
96 * The lumps a cloud is built of, as [x, y, z, radius] in units of the ball's
97 * own radius. Nine of them: one filling the middle and eight around it at a
98 * little under the full radius, in two staggered rings so the silhouette is
99 * bumpy from every side and from above. Written down rather than generated
100 * because it is nine rows and a reader can see the shape.
101 */
102 const PUFFS: [number, number, number, number][] = [
103 [0, 0, 0, 0.78],
104 [0.52, 0.18, 0.1, 0.52],
105 [-0.46, 0.34, -0.06, 0.55],
106 [0.14, -0.55, 0.12, 0.5],
107 [-0.2, -0.44, -0.2, 0.47],
108 [0.3, 0.42, -0.34, 0.45],
109 [-0.38, -0.12, 0.42, 0.46],
110 [0.08, 0.2, 0.56, 0.44],
111 [-0.1, 0.5, 0.3, 0.42],
112 ];
113
114 /**
115 * A cloud of smoke: nine overlapping cubes about the origin, at
116 * `CLOUD_MESH_RADIUS`, on the sand layer. It is the *same ball* the sight test
117 * in `src/client/world.ts` traces against — one mesh, opaque, drawn in the pass
118 * that was already there, with no blending and no sorting — which is the whole
119 * argument for drawing it this way: what you cannot see past on the screen and
120 * what a bot cannot see past in the simulation are the same sphere, to the unit.
121 *
122 * Opaque means the inside of one is not drawn at all — the far faces are culled
123 * — so being inside a cloud is drawn by `src/client/hud.ts` instead, as a wash
124 * over the world. Outside it is a solid mass; inside it is fog. Two drawings of
125 * one fact, and neither of them a new pass.
126 *
127 * There is no tenth material for it: the sand layer is smooth fbm noise with no
128 * seam and no straight line in it, and the tint `scene.ts` places it with turns
129 * that sand into a cool pale grey. The lighting the renderer already does — a
130 * hemisphere ambient plus one sun — is what makes the lumps read as lumps.
131 */
132 export function cloudMesh(): Mesh {
133 const b = builder();
134 const SAND = 0;
135 const r = CLOUD_MESH_RADIUS;
136 for (const [x, y, z, size] of PUFFS)
137 cube(b, x * r, y * r, z * r, size * 2 * r, size * 2 * r, size * 2 * r, SAND);
138 return finish(b);
139 }
140
79141 /**
80142 * A first-person weapon, in view space: X right, Y up, Z back, so the barrel
81143 * runs down -Z and the model needs no rotation at all.
@@ -100,7 +162,8 @@ export function viewModelMesh(kind: string): Mesh {
100162
101163 /**
102164 * A mesh by the name a scene calls it: `level`, `player`, `bomb`, `grenade`,
103 * `flash`, or a weapon kind for the gun in your hands. `main.ts` builds one of each at start-up and
165 * `flash`, `smoke`, `cloud`, or a weapon kind for the gun in your hands.
166 * `main.ts` builds one of each at start-up and
104167 * hands them to the engine's renderer, which keeps them by name and never
105168 * learns what a name is made of — that is this file, and this is the one line a
106169 * second game's model table would replace.
@@ -114,5 +177,7 @@ export function meshFor(name: string, level: Level): Mesh {
114177 if (name === "bomb") return bombMesh();
115178 if (name === "grenade") return grenadeMesh();
116179 if (name === "flash") return grenadeMesh("flash");
180 if (name === "smoke") return grenadeMesh("smoke");
181 if (name === "cloud") return cloudMesh();
117182 return viewModelMesh(name);
118183 }
src/client/replay.test.ts +126 −37
@@ -29,11 +29,13 @@ import { DE_FORGE } from "./map.ts";
2929 import { SHORT } from "./match.ts";
3030 import { CHECKPOINT_CHARS } from "../engine/index.ts";
3131 import {
32 compare, digest, fold, reader, snapshot, TICK, TICKS_PER_SECOND,
32 compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, TICK, TICKS_PER_SECOND,
3333 withoutEntropy, type Pin, type Run,
3434 } from "./replay.ts";
3535 import { colOf, rowOf, walkable, type Level } from "./level.ts";
36 import { createWorld, type Player } from "./world.ts";
36 import { createWorld, update, type Player } from "./world.ts";
37 import { cloudRadius } from "./grenade.ts";
38 import { SMOKE } from "./weapons.ts";
3739 import { createArena, reset, stepInput } from "../rl/arena.ts";
3840 import { useShipped } from "./shipped.ts";
3941
@@ -49,7 +51,18 @@ const SECONDS = 40;
4951 const TICKS = SECONDS * TICKS_PER_SECOND;
5052
5153 /**
52 * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #185,
54 * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #165,
55 * which put a smoke grenade in the game, and for the reason #164 and #142 were
56 * re-pinned before it: what moved is the *hash*, not the play. A pocket is
57 * three counts now rather than two, a live grenade's kind is an index into
58 * three rather than a flag, and a tick carries however many clouds are standing
59 * with the radius of each — so every second of every run differs from the
60 * numbers before it. Nothing in this script throws anything and the bots buy no
61 * grenades, so `clouds` is empty on all 2,560 ticks of this run and `smoke` is
62 * zero in all ten pockets. The run *with* a smoke in it is SMOKE_ROUND below,
63 * and that one is new rather than moved.
64 *
65 * Before that it was re-pinned for #185,
5366 * and this time the *play* moved, on purpose: a bot whose gun has nothing left
5467 * in it now puts it away and takes out the next thing that can fire. The first
5568 * twenty-three seconds are the same to the bit — the pistol round is fought
@@ -90,18 +103,18 @@ const TICKS = SECONDS * TICKS_PER_SECOND;
90103 * anything: the bots buy no grenades and the scripted person never presses
91104 * either key, so no flash goes off and no `blind` is ever non-zero here.
92105 */
93 const ROUND = "7a7f4535278c1daa96c1adf3fe72bffeb957a332b08e469c160304b3e936b359";
106 const ROUND = "2baf12fbafbff25007a9147511d1307ee9f4d5cf7d0b2473d7dae4fea876490e";
94107 const ROUND_SECONDS = [
95 "5978706f2efd", "aa6aa9a5a3c9", "f822e864db93", "6f1cf4de1926",
96 "25923b9cb132", "83b06bc2430e", "b97f8d621af5", "c1b94afedda0",
97 "700c15a0f6c1", "32921cefb2ff", "8241df979380", "c815eb110a37",
98 "95d7115059f4", "e131dfcfdb54", "a56027278fc7", "3e66ed95e89c",
99 "df1d01273592", "392385270f63", "c41cf61d1f0f", "e5a9d02a9e68",
100 "88d708808f5f", "b956000db9df", "c04cbb5e0444", "32c110c9cfad",
101 "5aa46f32cacc", "7b21f3168603", "ec5690d1c569", "33df275c9fe8",
102 "6552dd9625ab", "93098f77ddc6", "bcaa4f830efc", "8ddf93ee8995",
103 "71fb5532c520", "a0e5a420db35", "778f27867fca", "3c9a93e518d4",
104 "0ef3e79c0406", "0dc24683c2de", "e7f34118de6e", "d66ce8d2348e",
108 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
109 "0fab6fec7329", "46cbc192435f", "db863794da65", "0e5d2d2d2685",
110 "c693a6f1b5b1", "dd5ba820b82f", "6a9d536dc726", "3e3b6bf68300",
111 "49e7ea12852b", "088f53f05a7b", "ff5cfd8a23ee", "79b70ab03724",
112 "458b377ac8b0", "81c86e729615", "7db95c82ca95", "9ce89515f860",
113 "74b44ef7c080", "1c8476381a49", "e4716444e2e0", "4b6a1819fbae",
114 "13a62553b5fe", "ca237b750358", "d2bb12aba939", "86eb5bef3ba7",
115 "ad7857be9d5b", "db763c785460", "8f761bbb7fd6", "7f701a5bf90f",
116 "30e1a75cffa6", "2ed2a01fb154", "aebb3d0af576", "297ffec85d70",
117 "357635b90451", "168bd830c746", "c8eaf7365264", "5b20351850e8",
105118 ];
106119
107120 /**
@@ -114,20 +127,65 @@ const ROUND_SECONDS = [
114127 * this code still folds to
115128 * 37ea4cc90ecb55e2489ee1f219654b563a16e93893a6222c6e17f23f56b3c5d3.
116129 */
117 const DUEL = "bffdda30cf85fd8b2ea4840ad4b53e8041054411fcb2d6adcd7537be8f1ebbef";
130 const DUEL = "0a9868af8d274a54cb508e272550a536daa70da43320e410d71e0955603aabc1";
118131 const DUEL_SECONDS = [
119 "f3d6534f1590", "3f1e2479a841", "43b52fff16ef", "bd2c6c29eaf3",
120 "0c965cb1929c", "7572a5087ec4", "c4b9c83c357b", "b352f5a86d06",
121 "1e533cbaf327", "090b9d34147d", "a2f17103d7cf", "6831ee8d38c1",
122 "258eb2af7c8b", "9bb124c5f5fd", "fdac71af56c4", "1ae133a994ad",
123 "1d96558e6129", "989f1591cc59", "03836444fe3d", "2442e7241118",
124 "4795750cc613", "8c40d6315858", "6da5618d9b4d", "217b93298e47",
125 "49c8cc22abaa", "1f1706709ae2", "faa170572b54", "bd67caf0320a",
126 "a89d510d047b", "645baf0eb839", "36eaedb9bb79", "ddc41eb233e9",
127 "45377fd1cdea", "b0796f4fa162", "02afbd2180fd", "02bd15880c74",
128 "4649a72e207a", "78940559b08f", "2144ea37bcab", "ebd6cf00bfa7",
132 "5801e5552f24", "8c954aa18e59", "e0d4012395e3", "d728c9517bef",
133 "90f577fd6976", "0581ff4913f0", "859b91e38680", "95ebb3020bff",
134 "188efe0ff806", "40545e6cbbaa", "6a79b15e9ef7", "3d26abcb945d",
135 "b476b2f78f25", "f239dbac03b0", "4b43967f5fbd", "8ea790547f96",
136 "dbfe8a84389c", "bfaf7863d538", "5b4b77f9e81c", "fcb9d1c161df",
137 "65e9a1323500", "526ef5ca58a8", "b31de7c52e5c", "1b6f694361a1",
138 "943f03fe6d87", "f50bd6603e42", "2dd50a989a7c", "dd2d7e769b47",
139 "2b347f3641c6", "ce95dda16049", "9ebf2b4b63ff", "ee506214e211",
140 "4226cac848e4", "a846c73a59ad", "20351bc13ff8", "6ececa0d6d2d",
141 "b2e941ea7c88", "41fe8a636cee", "b4b9ff99eecf", "89e11246d8a2",
129142 ];
130143
144 /**
145 * Thirty seconds of the same round with a smoke grenade thrown into it: seed 7
146 * on de_forge again, so the only difference between this and the pin above is
147 * the canister. `SMOKE_SCRIPT` in `replay.ts` says what the person does; what
148 * matters is the shape of what follows, and it is in the checkpoints.
149 *
150 * The first second is the freeze before either script has bought anything and
151 * hashes identically to ROUND's; second 2 is where the two part, because the
152 * two scripts buy different things. The canister is thrown at tick 640, its
153 * fuse runs out a second and a half later and it opens once it has come to
154 * rest; from there the ball grows over a second and a half, stands for eleven,
155 * and thins away over two and a half, and the round plays on for six more
156 * seconds with the sightline back — because a bot that cannot see through a
157 * cloud picks another fight and walks another way, and one that can goes back
158 * to picking the first.
159 *
160 * This is the pin the whole issue is about. The cloud's own arithmetic is
161 * tested in `grenade.test.ts` and the sight test in `world.test.ts`; what only a
162 * digest can hold is that a cloud standing in a round *changes the round*, tick
163 * for tick, and goes on changing it the same way tomorrow.
164 */
165 const SMOKE_SECONDS_LONG = 30;
166 const SMOKE_TICKS = SMOKE_SECONDS_LONG * TICKS_PER_SECOND;
167 const SMOKE_ROUND = "6484de2cd2ba8f6b9beba2620c1432f9701b64eea95b0710674cc9e2cf59b841";
168 const SMOKE_ROUND_SECONDS: readonly string[] = [
169 "c858433691b7", "ae8816aaf901", "28196227d006", "5dd7f1376bf8",
170 "764ca02fa9f0", "5ffb3df0e281", "1bd6a3521cb1", "59fde5add12d",
171 "6c43f1cba43b", "78791606a640", "37beb5da7147", "18f2f45184e2",
172 "c83eb155baee", "c3d81a2d00f8", "961ca06c12b0", "3e98ea17a149",
173 "de868179bf35", "6134bc3a91eb", "66705f6c4e85", "41500f852715",
174 "82b63aa21f80", "7221bbca2d61", "4bf7ac3920dc", "d9f786de91cf",
175 "a6197ae41785", "a6a828c675b7", "bf6377ccc22d", "d8a7a7d93b84",
176 "64b65f8f4879", "cb3d1576cec1",
177 ];
178
179 const SMOKE_PIN: Pin = {
180 label: "round with a smoke in it", constant: "SMOKE_ROUND",
181 digest: SMOKE_ROUND, seconds: SMOKE_ROUND_SECONDS,
182 };
183
184 /** The smoked round, exactly as its pin was taken. */
185 const smokeRun = (): Run =>
186 withoutEntropy(() =>
187 digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), SMOKE_TICKS, SMOKE_SCRIPT));
188
131189 const ROUND_PIN: Pin = { label: "ten-bot round", constant: "ROUND", digest: ROUND, seconds: ROUND_SECONDS };
132190 const DUEL_PIN: Pin = { label: "arena duel", constant: "DUEL", digest: DUEL, seconds: DUEL_SECONDS };
133191
@@ -161,6 +219,37 @@ test("a seeded ten-bot round plays exactly as it played", () => {
161219 expect(run.seconds).toEqual(ROUND_SECONDS);
162220 });
163221
222 test("a seeded round with a smoke grenade thrown into it plays exactly as it played", () => {
223 const run = smokeRun();
224 must(run, SMOKE_PIN);
225 expect(run.digest).toBe(SMOKE_ROUND);
226 expect(run.ticks).toBe(SMOKE_TICKS);
227 expect(run.seconds).toEqual([...SMOKE_ROUND_SECONDS]);
228 // The same seed on the same map: the first second, before either script has
229 // bought anything, is the same second to the character. Everything after the
230 // cloud opens is not, which is the whole claim — a cloud changes what a round
231 // does, and goes on changing it the same way.
232 expect(run.seconds[0]).toBe(ROUND_SECONDS[0]);
233 expect(run.seconds[12]).not.toBe(ROUND_SECONDS[12]);
234 });
235
236 /** A cloud really did stand in the pinned run, rather than the script missing. */
237 test("the smoked round has a cloud standing in it, and it is gone by the end", () => {
238 const stood = withoutEntropy(() => {
239 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
240 const next = reader(SMOKE_SCRIPT);
241 let widest = 0;
242 for (let tick = 0; tick < SMOKE_TICKS; tick++) {
243 update(world, TICK, next(tick, world, world.me));
244 for (const cloud of world.clouds) widest = Math.max(widest, cloudRadius(cloud, world.time));
245 }
246 return { widest, left: world.clouds.length, pocket: world.me.pocket.smoke };
247 });
248 expect(stood.widest).toBeCloseTo(SMOKE.smoke.radius, 3);
249 expect(stood.left).toBe(0);
250 expect(stood.pocket).toBe(0);
251 });
252
164253 test("a seeded duel in the training arena plays exactly as it played", () => {
165254 const run = duelRun();
166255 must(run, DUEL_PIN);
@@ -228,26 +317,26 @@ const OPEN_MAP = "f5ec0f03c150cdbc92359df63271653c6a2be8c0f485bdf86d028f636eacd9
228317 const OPEN_MAP_BANDS: readonly string[] = [
229318 "f6fa28441414", "aab631e1a5fa", "ebc69b760b9d", "db19d9a43600",
230319 ];
231 const OPEN_ROUND = "b5d418adfbadc051285a06177f7e97ad15ce5ffde347cf8e51f47da76ea9e496";
320 const OPEN_ROUND = "0e66a5e70cb3a08e2223ae783e71eadcd2921ab72c24ade58ad273f46949c792";
232321 const OPEN_ROUND_SECONDS: readonly string[] = [
233 "1ad83b1cc8ed", "230f205bffb1", "9a22187ee262", "b1a1b9f147cb",
234 "52bd60b8b4c1", "219b9ec017a2", "e143e86a3980", "3f54189e301c",
235 "6499a8a1375d", "a87763d45101", "a012a6608bfe", "81b538e9769f",
236 "bcd612a208ca", "a45c2ba991f2", "cc2cf4a19c6a", "0b095af1719b",
237 "5fda5a176d18", "366a4f144a4e", "09fa64b30ca6", "066be5b8acf8",
322 "6d0fbacbd279", "1f2bb687d697", "1e9a0750ec3a", "31e71073342a",
323 "eb30f1715453", "95ae3acb2964", "818975909e82", "f1ee5d766f18",
324 "bb0c65852bf7", "7b9793596110", "29feef4fb8f3", "137e09c5c485",
325 "e82aa824dc98", "495ca8c2ef53", "c2b9abc2c229", "ef673bdf0f71",
326 "271dff4a23ae", "a7b9a6f6d084", "0437a724cd8e", "ab60865f4146",
238327 ];
239328
240329 const TIGHT_MAP = "4bd818fd2e654f16fb41b6bd718951acbb22ac24572162a2be348e7edfcccc15";
241330 const TIGHT_MAP_BANDS: readonly string[] = [
242331 "1ca81af8ea02", "c4888f792a55", "d08dbb1ee415", "1efd9cc4a8e9",
243332 ];
244 const TIGHT_ROUND = "b283d2d6cf8b2c82d80e3d2f8e1b9bfb8f731dd70009310b12a930a056876223";
333 const TIGHT_ROUND = "794c2c6cbb53f2fb6290641e3b7d584c4ef77967c26bab3253550086e6be105d";
245334 const TIGHT_ROUND_SECONDS: readonly string[] = [
246 "1ad83b1cc8ed", "230f205bffb1", "9a22187ee262", "b1a1b9f147cb",
247 "52bd60b8b4c1", "219b9ec017a2", "e143e86a3980", "8c22014cab10",
248 "6707adad67e0", "79b78073cafe", "b8a200b895d9", "40375defc572",
249 "6e4039ce2f67", "e7a50e9bb180", "0e56112c62be", "e2d9f7b0b01b",
250 "5e358b1dce56", "04a9df6200ea", "a575c4789200", "a32602bcecdf",
335 "6d0fbacbd279", "1f2bb687d697", "1e9a0750ec3a", "31e71073342a",
336 "eb30f1715453", "95ae3acb2964", "818975909e82", "185485d36211",
337 "24dcd109d121", "005682b4d399", "1d865b0a75a8", "2bf98b93a745",
338 "e0785e7358eb", "29d2cda90977", "3a93a3ee310e", "ddf65d1e134e",
339 "c914eacf7106", "88d6ad2428e3", "16d50610dbdb", "7e2e5a7f5f7d",
251340 ];
252341
253342 const OPEN: Generated = {
src/client/replay.ts +72 −5
@@ -10,9 +10,9 @@
1010 * and updates the constant in the same pull.
1111 *
1212 * What is hashed is the simulation and nothing else — PLAYER_FIELDS,
13 * GRENADE_FIELDS and MATCH_FIELDS below are the whole list. Effects, the kill feed and the
14 * messages are presentation: they would make the digest fragile and would
15 * catch nothing the state does not already catch.
13 * GRENADE_FIELDS, CLOUD_FIELDS and MATCH_FIELDS below are the whole list.
14 * Effects, the kill feed and the messages are presentation: they would make the
15 * digest fragile and would catch nothing the state does not already catch.
1616 *
1717 * The folding itself is `src/engine/digest.ts`, because the range needed the
1818 * same instrument and may not import this file. What stays here is what is the
@@ -23,7 +23,8 @@
2323 import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts";
2424 import { buy, type BuyItem } from "./economy.ts";
2525 import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts";
26 import type { Grenade } from "./grenade.ts";
26 import { cloudRadius, type Cloud, type Grenade } from "./grenade.ts";
27 import type { ThrownId } from "./weapons.ts";
2728 import type { MatchState, Phase } from "./match.ts";
2829
2930 export const TICK = 1 / 64;
@@ -57,6 +58,7 @@ export const PLAYER_FIELDS: { name: string; of: (p: Player) => number }[] = [
5758 // What is in each pocket: bought, thrown, and lost with the body that held it.
5859 { name: "he", of: (p) => p.pocket.he },
5960 { name: "flash", of: (p) => p.pocket.flash },
61 { name: "smoke", of: (p) => p.pocket.smoke },
6062 // How long this body cannot see for. It is simulation, not presentation: the
6163 // white-out a person gets and the shaky hands a bot gets are both read off it.
6264 { name: "blind", of: (p) => p.blind },
@@ -77,7 +79,28 @@ export const GRENADE_FIELDS: { name: string; of: (g: Grenade) => number }[] = [
7779 { name: "fuse", of: (g) => g.fuse },
7880 // Which kind it is — an HE and a flashbang fly identically and end very
7981 // differently, so two otherwise equal ticks are not the same state.
80 { name: "kind", of: (g) => (g.weapon === "flash" ? 1 : 0) },
82 { name: "kind", of: (g) => KINDS.indexOf(g.weapon) },
83 ];
84
85 /** The order a grenade's kind is hashed in. Widened by a new thrown weapon. */
86 const KINDS: ThrownId[] = ["he", "flash", "smoke"];
87
88 /**
89 * Everything hashed about a cloud of smoke standing in the round: where it is,
90 * and how wide it is on this tick. The radius rather than the second it opened
91 * because the radius is what the simulation *acts* on — `canSee` traces against
92 * exactly this number — and because it moves every tick while the cloud grows
93 * and again while it thins, so a cloud that opened a tick early or late is a
94 * different run from the second it opened.
95 *
96 * Like the grenades, the count goes in first: a tick with one cloud standing
97 * and a tick with none are different states and hash differently.
98 */
99 export const CLOUD_FIELDS: { name: string; of: (c: Cloud, world: World) => number }[] = [
100 { name: "pos.x", of: (c) => c.pos[0] },
101 { name: "pos.y", of: (c) => c.pos[1] },
102 { name: "pos.z", of: (c) => c.pos[2] },
103 { name: "radius", of: (c, world) => cloudRadius(c, world.time) },
81104 ];
82105
83106 /** Everything hashed about the match. */
@@ -94,6 +117,8 @@ export function snapshot(world: World): string {
94117 for (const p of world.players) for (const field of PLAYER_FIELDS) parts.push(quantise(field.of(p)));
95118 parts.push(quantise(world.grenades.length));
96119 for (const g of world.grenades) for (const field of GRENADE_FIELDS) parts.push(quantise(field.of(g)));
120 parts.push(quantise(world.clouds.length));
121 for (const c of world.clouds) for (const field of CLOUD_FIELDS) parts.push(quantise(field.of(c, world)));
97122 for (const field of MATCH_FIELDS) parts.push(quantise(field.of(world.match)));
98123 return `${parts.join(",")}\n`;
99124 }
@@ -150,6 +175,47 @@ export const SCRIPT: Beat[] = [
150175 { atTick: 2496, input: {} },
151176 ];
152177
178 /**
179 * Thirty seconds with a smoke grenade in them. The script above throws nothing
180 * — the bots buy no grenades and the scripted person never presses a throw key
181 * — so before this every pinned run in this repository had an empty `clouds`
182 * list on every tick, and the one thing in the simulation that can take a
183 * sightline away was pinned by nothing at all.
184 *
185 * This one buys a smoke in the freeze, walks out of spawn and throws it at
186 * tick 640 — four seconds into the live round, which is early enough that the
187 * cloud is standing while the two sides are still walking towards each other.
188 * From there the fuse, the canister settling, the ball growing over a second
189 * and a half, fifteen seconds of it standing between whoever is on either side
190 * of it, and the two and a half seconds of it thinning away are all inside the
191 * run: the last second of the cloud is second 24, and the six after it are the
192 * round playing on with the sightline back.
193 *
194 * What it pins is not the cloud's arithmetic, which has its own tests. It is
195 * that a cloud *changes the round* — a bot that cannot see through one picks
196 * another fight, walks another way, and everything downstream of that moves
197 * with it.
198 */
199 export const SMOKE_SCRIPT: Beat[] = [
200 { atTick: 0, input: {} },
201 { atTick: 64, input: {}, buy: "smoke" }, // 300 of the 800 start money
202 { atTick: 512, input: { forward: 1 } }, // the round goes live
203 { atTick: 608, input: { forward: 1, side: 1 } },
204 // One press of the throw key, one canister out of the pocket.
205 { atTick: 640, input: { forward: 1, throwing: "smoke" } },
206 { atTick: 648, input: { forward: 1 } },
207 { atTick: 832, input: { forward: 1, side: -1 } },
208 { atTick: 960, input: { fire: true } },
209 { atTick: 976, input: {} },
210 { atTick: 1088, input: { reload: true } },
211 { atTick: 1096, input: { forward: 1 } },
212 { atTick: 1280, input: { use: true } },
213 { atTick: 1408, input: { forward: 1, duck: true } },
214 { atTick: 1536, input: { forward: -1 } },
215 { atTick: 1792, input: { side: 1, walk: true } },
216 { atTick: 1920, input: {} },
217 ];
218
153219 /**
154220 * A cursor over the script: hand it a tick and it returns the input held at
155221 * that tick, taking any buy on the way past. The input is a fresh object each
@@ -222,6 +288,7 @@ export function compare(run: Run, pin: Pin): string | null {
222288 hashes:
223289 `${PLAYER_FIELDS.map((f) => f.name).join(", ")} per player, ` +
224290 `${GRENADE_FIELDS.map((f) => f.name).join(", ")} per live grenade, ` +
291 `${CLOUD_FIELDS.map((f) => f.name).join(", ")} per standing cloud, ` +
225292 `and ${MATCH_FIELDS.map((f) => f.name).join(", ")}`,
226293 every: TICKS_PER_SECOND,
227294 unit: "second",
src/client/scene.ts +51 −7
@@ -21,6 +21,8 @@ import {
2121 clamp, createScene as createPooledScene, effectAt, hullHeight, nextInstance, pushLine, resetScene,
2222 type Camera, type Scene as Frame, type Vec3,
2323 } from "../engine/index.ts";
24 import { cloudRadius } from "./grenade.ts";
25 import { CLOUD_MESH_RADIUS } from "./models.ts";
2426 import { CS_HULL } from "./movement.ts";
2527 import { FLASH, HE, WEAPONS } from "./weapons.ts";
2628 import type { Player, World } from "./world.ts";
@@ -51,12 +53,16 @@ export const LINE_CAPACITY = 4096;
5153
5254 /**
5355 * Instances one frame may place: the level, every other living player and the
54 * bomb, which is eleven in the 5v5 `main.ts` starts. The pool is built at this
55 * size once and never grows, so the headroom is for a bigger match rather than
56 * for a busy frame; past it a player is simply not drawn, which is the engine's
56 * bomb, which is eleven in the 5v5 `main.ts` starts, plus whatever is thrown —
57 * up to three grenades in the air per body, and up to one cloud of smoke per
58 * body standing for fifteen seconds. Ten clouds and ten bodies is thirty-one
59 * before a single grenade is in the air, which is what moved this number from
60 * 32 to 48: a frame in which a whole side smoked a site at once was already
61 * dropping instances at the old cap. The pool is built at this size once and
62 * never grows; past it a thing is simply not drawn, which is the engine's
5763 * policy at a cap and the same one `LINE_CAPACITY` has always had.
5864 */
59 export const INSTANCE_CAPACITY = 32;
65 export const INSTANCE_CAPACITY = 48;
6066
6167 /** How long a blast is drawn for, in seconds, and in how many spokes. */
6268 const BLAST_SECONDS = 0.45;
@@ -74,6 +80,18 @@ const TEAM_TINT: Record<string, Tint> = {
7480 };
7581
7682 const WHITE: Tint = [1, 1, 1];
83
84 /**
85 * What a cloud is tinted. The mesh is painted with the sand layer — smooth fbm
86 * noise, no seam and no straight line — and sand is (196, 174, 132), so these
87 * three numbers are what turn it into a cool pale grey of about (210, 214, 224)
88 * before the renderer's light touches it. Brighter than white on blue on
89 * purpose: smoke lit from a sky is cooler than the ground it is standing on.
90 */
91 const SMOKE_TINT: Tint = [1.07, 1.23, 1.7];
92
93 /** Which body a thrown thing is drawn as, by kind. */
94 const GRENADE_MESH: Record<string, string> = { he: "grenade", flash: "flash", smoke: "smoke" };
7795 const ORIGIN: Vec3 = [0, 0, 0];
7896
7997 /**
@@ -108,14 +126,22 @@ export function createScene(): Scene {
108126 };
109127 }
110128
111 /** One mesh placed, or nothing at all once the pool is spent. */
112 function place(scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint): void {
129 /**
130 * One mesh placed, or nothing at all once the pool is spent. `size` is how big
131 * the mesh stands beside the size it was built at, and is 1 for every model in
132 * this game but the cloud, which is one ball uploaded once and drawn at
133 * whatever radius it has grown to.
134 */
135 function place(
136 scene: Scene, mesh: string, pos: Vec3, yaw: number, scale: number, tint: Tint, size = 1,
137 ): void {
113138 const into = nextInstance(scene);
114139 if (into === null) return;
115140 into.mesh = mesh;
116141 into.pos[0] = pos[0]; into.pos[1] = pos[1]; into.pos[2] = pos[2];
117142 into.yaw = yaw;
118143 into.scale = scale;
144 into.size = size;
119145 into.tint[0] = tint[0]; into.tint[1] = tint[1]; into.tint[2] = tint[2];
120146 }
121147
@@ -153,7 +179,22 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
153179 // the yaw is the simulated time, so a frame decides nothing and a paused
154180 // one turns it no further.
155181 for (const g of world.grenades)
156 place(into, g.weapon === "flash" ? "flash" : "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
182 place(into, GRENADE_MESH[g.weapon] ?? "grenade", g.pos, g.onGround ? 0 : world.time * 540, 1, WHITE);
183 // Every cloud standing, as the ball the simulation traces against: the same
184 // centre and the same radius `canSee` reads, so what fills the screen and
185 // what takes a sightline away are one object. It is opaque and it is drawn in
186 // the pass that was already here — no blend, no sort, no second texture — and
187 // being *inside* one is drawn by `hud.ts` instead, because the far faces of a
188 // ball you are standing in are culled and there is nothing left to see.
189 for (const cloud of world.clouds) {
190 const radius = cloudRadius(cloud, world.time);
191 if (radius <= 0) continue;
192 // A yaw off its own position, so two clouds on one site are not one mesh
193 // stamped twice. It is a fact about where it opened and never about the
194 // frame, so a paused game turns it no further.
195 place(into, "cloud", cloud.pos, (cloud.pos[0] + cloud.pos[1] * 3) % 360, 1, SMOKE_TINT,
196 radius / CLOUD_MESH_RADIUS);
197 }
157198
158199 // --- tracers, sparks and the planted bomb's beam, all as lines
159200 // Read by index, oldest first: the effects are the engine's ring and a
@@ -191,6 +232,9 @@ export function buildScene(world: World, camera: Camera, into: Scene, view: Play
191232 // radius — which is why the reach is read off the weapon and not typed
192233 // twice — but it never brightens the screen from out here: what it costs
193234 // the person looking at it is `blind` in the simulation, and the HUD's.
235 // A smoke has no star: the cloud it leaves is the drawing, and a flare
236 // of light where a canister opened would say the wrong thing entirely.
237 if (effect.weapon === "smoke") continue;
194238 const flash = effect.weapon === "flash";
195239 const seconds = flash ? FLASH_SECONDS : BLAST_SECONDS;
196240 if (age >= seconds) continue;
src/client/weapons.test.ts +2 −2
@@ -105,9 +105,9 @@ test("the flashbang is in the table with its own numbers, and no bullet ones", (
105105 expect(FLASH.magazine).toBe(0);
106106 expect(FLASH.blast).toBeUndefined();
107107 expect(HE.flash).toBeUndefined();
108 // Both are thrown, and nothing else in the table is.
108 // All three are thrown, and nothing else in the table is.
109109 expect(Object.values(WEAPONS).filter((w) => isThrown(w.id)).map((w) => w.id).sort())
110 .toEqual(["flash", "he"]);
110 .toEqual(["flash", "he", "smoke"]);
111111 // Adding it did not move a gun's spray: the patterns are seeded by index and
112112 // the thrown weapons are last, so the AK's tenth bullet is the AK's tenth.
113113 expect(recoilOffset("ak47", 9)).toEqual(recoilOffset("ak47", 9));
src/client/weapons.ts +58 −11
@@ -19,13 +19,15 @@
1919 import { clamp, rng } from "../engine/index.ts";
2020
2121 export type WeaponId =
22 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp" | "he" | "flash";
22 | "knife" | "glock" | "usp" | "deagle" | "mp9" | "ak47" | "m4a4" | "awp"
23 | "he" | "flash" | "smoke";
2324
24 /** The weapons that are thrown rather than fired: the two kinds in a pocket. */
25 export type ThrownId = "he" | "flash";
25 /** The weapons that are thrown rather than fired: the three kinds in a pocket. */
26 export type ThrownId = "he" | "flash" | "smoke";
2627
2728 /** Is this a weapon somebody throws? Narrows the id as well as answering. */
28 export const isThrown = (id: WeaponId): id is ThrownId => id === "he" || id === "flash";
29 export const isThrown = (id: WeaponId): id is ThrownId =>
30 id === "he" || id === "flash" || id === "smoke";
2931
3032 /**
3133 * What a thrown weapon does when its fuse runs out. Only the HE has one,
@@ -51,6 +53,27 @@ export type Flash = {
5153 awayAngle: number; // degrees; at or past this it went off behind you and is nothing
5254 };
5355
56 /**
57 * What a smoke grenade does when its fuse runs out: it takes *sight*, and not
58 * from one body at one instant like a flash does but from everybody, for as
59 * long as the cloud stands. These five numbers are the whole of it — no hit
60 * point, no armour and nobody to credit.
61 *
62 * The cloud is a ball, and that is a decision rather than a shortcut: a ball is
63 * the one shape a sight test can be answered against for a dot product and a
64 * square root (`segmentSphere` in `src/engine/trace.ts`), and a sight test is
65 * already on the hot path ten times a tick. What it costs is measured in
66 * `src/budget.test.ts`.
67 */
68 export type Smoke = {
69 fuse: number; // seconds from leaving the hand to the canister opening
70 radius: number; // units; the ball, once it has grown
71 grow: number; // seconds from the pop to the full radius
72 /** Seconds from the pop to nothing at all, the thinning below included. */
73 life: number;
74 fade: number; // seconds of that life it spends shrinking back to nothing
75 };
76
5477 export type Weapon = {
5578 id: WeaponId;
5679 name: string;
@@ -75,6 +98,8 @@ export type Weapon = {
7598 blast?: Blast;
7699 /** Set on the flashbang, and on nothing else. */
77100 flash?: Flash;
101 /** Set on the smoke grenade, and on nothing else. */
102 smoke?: Smoke;
78103 };
79104
80105 /** The HE grenade, by the name everything that throws one calls it. */
@@ -89,8 +114,28 @@ export const HE: Weapon & { blast: Blast } = { id: "he", name: "HE Grenade", kin
89114 */
90115 export const FLASH: Weapon & { flash: Flash } = { id: "flash", name: "Flashbang", kind: "grenade", team: "both", price: 200, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, flash: { fuse: 1.6, radius: 1200, maxBlind: 1.6, fullAngle: 30, awayAngle: 135 } };
91116
92 /** The fuse on a thrown weapon, in seconds: the one number both kinds have. */
93 export const fuseOf = (weapon: Weapon): number => weapon.blast?.fuse ?? weapon.flash?.fuse ?? 0;
117 /**
118 * The smoke grenade. CS's $300, and the only thing in this table that changes
119 * how a round is *played* rather than how a fight ends: a ball 180 units across
120 * the radius — about three and a half metres, wide enough to cover a doorway or
121 * the mouth of a site and not a whole room — standing for fifteen seconds,
122 * which is a quarter of a round and long enough to take a sightline out of the
123 * fight it was going to decide.
124 *
125 * It grows over a second and a half rather than appearing, and spends its last
126 * two and a half shrinking back to nothing, so there is no tick at which a
127 * sightline blinks shut or blinks open: what the screen shows and what
128 * `canSee` answers are the same radius, read off the same clock.
129 *
130 * The fuse is the HE's 1.5 seconds, but it is not the whole story — a smoke
131 * opens where the canister *stopped*, so `spent` in `src/client/grenade.ts`
132 * holds it until the thing has come to rest.
133 */
134 export const SMOKE: Weapon & { smoke: Smoke } = { id: "smoke", name: "Smoke Grenade", kind: "grenade", team: "both", price: 300, damage: 0, armorPenetration: 0, rangeModifier: 1, rpm: 0, magazine: 0, reserve: 0, reloadTime: 0, killReward: 300, carrySpeed: 245, spread: 0, moveSpread: 0, recoil: 0, automatic: false, smoke: { fuse: 1.5, radius: 180, grow: 1.5, life: 15, fade: 2.5 } };
135
136 /** The fuse on a thrown weapon, in seconds: the one number all three kinds have. */
137 export const fuseOf = (weapon: Weapon): number =>
138 weapon.blast?.fuse ?? weapon.flash?.fuse ?? weapon.smoke?.fuse ?? 0;
94139
95140 const WEAPON_LIST: Weapon[] = [
96141 { id: "knife", name: "Knife", kind: "knife", team: "both", price: 0, damage: 55, armorPenetration: 0.85, rangeModifier: 1, rpm: 240, magazine: 0, reserve: 0, reloadTime: 0, killReward: 1500, carrySpeed: 250, spread: 0, moveSpread: 0, recoil: 0, automatic: true },
@@ -104,15 +149,17 @@ const WEAPON_LIST: Weapon[] = [
104149 { id: "ak47", name: "AK-47", kind: "rifle", team: "t", price: 2700, damage: 36, armorPenetration: 0.775, rangeModifier: 0.98, rpm: 600, magazine: 30, reserve: 90, reloadTime: 2.5, killReward: 300, carrySpeed: 215, spread: 0.16, moveSpread: 3.4, recoil: 1.5, automatic: true },
105150 { id: "m4a4", name: "M4A4", kind: "rifle", team: "ct", price: 3100, damage: 33, armorPenetration: 0.7, rangeModifier: 0.97, rpm: 666, magazine: 30, reserve: 90, reloadTime: 3.1, killReward: 300, carrySpeed: 225, spread: 0.15, moveSpread: 3.2, recoil: 1.25, automatic: true },
106151 { id: "awp", name: "AWP", kind: "sniper", team: "both", price: 4750, damage: 115, armorPenetration: 0.975, rangeModifier: 0.99, rpm: 41, magazine: 10, reserve: 30, reloadTime: 3.7, killReward: 100, carrySpeed: 200, spread: 0.05, moveSpread: 12, recoil: 3.5, zoom: 40, automatic: false },
107 // The thrown weapons. Their bullet numbers are the empty ones — neither is
108 // ever held, fired or reloaded — and everything they do is in `blast` and
109 // `flash`: CS's $300 HE, 98 hit points at the centre falling off over 350
110 // units on a 1.5-second fuse with armour stopping about half of it, and CS's
111 // $200 flashbang, which takes sight instead. They are last in the list on
152 // The thrown weapons. Their bullet numbers are the empty ones — none of them
153 // is ever held, fired or reloaded — and everything they do is in `blast`,
154 // `flash` and `smoke`: CS's $300 HE, 98 hit points at the centre falling off
155 // over 350 units on a 1.5-second fuse with armour stopping about half of it;
156 // CS's $200 flashbang, which takes sight for a moment; and CS's $300 smoke,
157 // which takes a sightline for fifteen seconds. They are last in the list on
112158 // purpose: the spray patterns below are seeded by index, so a thrown weapon
113159 // added here never moves a gun's pattern.
114160 HE,
115161 FLASH,
162 SMOKE,
116163 ];
117164
118165 export const WEAPONS: Record<WeaponId, Weapon> = Object.fromEntries(
src/client/world.test.ts +75 −2
@@ -7,10 +7,16 @@ import { SHORT } from "./match.ts";
77 import { WEAPONS } from "./weapons.ts";
88 import { buy } from "./economy.ts";
99 import {
10 applyInput, createWorld, currentWeapon, EFFECT_CAPACITY, NO_INPUT, shoot, startRound, update,
10 applyInput, canSee, createWorld, currentWeapon, EFFECT_CAPACITY, eyePos, insideSmoke, NO_INPUT,
11 shoot, startRound, throwOne, update,
1112 type Input, type Player, type Sound, type World,
1213 } from "./world.ts";
13 import { effectAt, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3 } from "../engine/index.ts";
14 import { cloudRadius, type Cloud } from "./grenade.ts";
15 import { SMOKE } from "./weapons.ts";
16 import { CS_HULL } from "./movement.ts";
17 import {
18 effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3,
19 } from "../engine/index.ts";
1420
1521 const TICK = 1 / 64;
1622 const run = (world: World, seconds: number, input: Input = NO_INPUT): void => {
@@ -324,3 +330,70 @@ test("a match plays itself to the end inside its own time", () => {
324330 expect(world.match.matchWinner).not.toBe(null);
325331 expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2);
326332 });
333
334 // --- smoke ----------------------------------------------------------------
335
336 /**
337 * A cloud standing between two bodies, placed by hand rather than thrown: what
338 * is under test here is the sight rule, and a throw is `grenade.test.ts`'s.
339 */
340 const BOX = DE_FORGE;
341
342 const smoked = (world: World, at: Vec3, agoSeconds = SMOKE.smoke.grow): void => {
343 world.clouds.push({ pos: at, born: world.time - agoSeconds, owner: 0 });
344 };
345
346 test("a cloud takes a sightline away, and gives it back when it has thinned out", () => {
347 const world = createWorld(BOX, SHORT, "me", "ct", 2, 5);
348 const [a, b] = world.players as [Player, Player];
349 a.pos = [0, 0, 0];
350 b.pos = [400, 0, 0];
351 a.alive = b.alive = true;
352 expect(canSee(world, a, b)).toBe(true);
353 // Halfway between them, grown: neither can see the other, either way round.
354 smoked(world, [200, 0, eyeHeight(a, CS_HULL)]);
355 expect(canSee(world, a, b)).toBe(false);
356 expect(canSee(world, b, a)).toBe(false);
357 // And a body standing in one is blind in every direction, which is the whole
358 // of why `segmentSphere` answers a start inside the ball with "yes".
359 a.pos = [200, 0, 0];
360 expect(canSee(world, a, b)).toBe(false);
361 expect(insideSmoke(world, eyePos(a))).toBe(true);
362 // Past the end of its life it is nothing at all: the line is back.
363 (world.clouds[0] as { born: number }).born = world.time - SMOKE.smoke.life;
364 a.pos = [0, 0, 0];
365 expect(canSee(world, a, b)).toBe(true);
366 });
367
368 test("a cloud beside the line, behind the eye or past the target is not in the way", () => {
369 const world = createWorld(BOX, SHORT, "me", "ct", 2, 5);
370 const [a, b] = world.players as [Player, Player];
371 a.pos = [0, 0, 0];
372 b.pos = [400, 0, 0];
373 const eye = eyeHeight(a, CS_HULL);
374 for (const at of [[200, 400, eye], [-400, 0, eye], [900, 0, eye]] as Vec3[]) {
375 world.clouds = [];
376 smoked(world, at);
377 expect(canSee(world, a, b)).toBe(true);
378 }
379 });
380
381 test("a smoke grenade waits to come to rest, then stands its stated life", () => {
382 const world = createWorld(BOX, SHORT, "me", "ct", 2, 9);
383 world.match.phase = "live";
384 const me = world.me;
385 me.pocket.smoke = 1;
386 expect(throwOne(world, me, "smoke")).toBe(true);
387 // It is still a grenade while its fuse burns, and no cloud exists yet.
388 for (let i = 0; i < Math.round(SMOKE.smoke.fuse * 64); i++) update(world, 1 / 64, NO_INPUT);
389 expect(world.clouds.length).toBe(0);
390 // It opens once it has settled, and the ball grows from nothing.
391 for (let i = 0; i < 64 * 2; i++) update(world, 1 / 64, NO_INPUT);
392 expect(world.grenades.length).toBe(0);
393 expect(world.clouds.length).toBe(1);
394 const cloud = world.clouds[0] as Cloud;
395 expect(cloudRadius(cloud, cloud.born + SMOKE.smoke.grow)).toBeCloseTo(SMOKE.smoke.radius, 6);
396 // And it is gone by the end of its life, list and all.
397 for (let i = 0; i < 64 * (SMOKE.smoke.life + 1); i++) update(world, 1 / 64, NO_INPUT);
398 expect(world.clouds.length).toBe(0);
399 });
src/client/world.ts +99 −17
@@ -8,7 +8,9 @@
88 * the one thing in a round that is not a person: `src/client/grenade.ts` says
99 * how one flies and what its blast reaches, and what a blast *means* — the
1010 * damage, the hit marker, the line in the feed — is here with every other kind
11 * of harm.
11 * of harm. One of the three leaves something behind: a smoke grenade opens into
12 * a `Cloud`, the world keeps the list of them, and `canSee` at the foot of this
13 * file is where a cloud stops being a picture and becomes a wall.
1214 *
1315 * The simulation runs at a fixed 64 ticks a second and knows nothing about
1416 * WebGL, so it can be stepped in a test without a browser.
@@ -18,14 +20,15 @@ import { beepDue, dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./b
1820 import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts";
1921 import { emptyAmmo, type Ammo } from "./economy.ts";
2022 import {
21 blastCentre, blastHits, flashHits, stepGrenade, throwGrenade, THROW_LIFT,
22 type Grenade, type GrenadeSound,
23 blastCentre, blastHits, cloudFrom, cloudGone, cloudRadius, flashHits, spent, stepGrenade,
24 throwGrenade, THROW_LIFT,
25 type Cloud, type Grenade, type GrenadeSound,
2326 } from "./grenade.ts";
2427 import type { Level } from "./level.ts";
2528 import type { RoundPlan } from "../plan/plan.ts";
2629 import {
2730 angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move,
28 normalize, pushEffect, rng, sub, visible,
31 normalize, pushEffect, rng, segmentSphere, sub, visible,
2932 type Body, type Effects, type Vec3, type Wish,
3033 } from "../engine/index.ts";
3134 import * as rules from "./match.ts";
@@ -53,7 +56,7 @@ export type Player = Actor & Body & {
5356 /**
5457 * What is in the pocket, by kind: at most `CARRY[kind]` of each, thrown one
5558 * at a time. A pocket rather than a count because a flashbang is carried
56 * alongside the HE and not instead of it.
59 * alongside the HE and the smoke, and not instead of either.
5760 */
5861 pocket: Record<ThrownId, number>;
5962 /**
@@ -172,6 +175,15 @@ export type World = {
172175 effects: Effects<Effect>;
173176 /** Every grenade in the air or on the floor right now, oldest first. */
174177 grenades: Grenade[];
178 /**
179 * Every cloud of smoke standing right now, oldest first. It is simulation and
180 * not decoration: `canSee` below traces against these, so a cloud in this
181 * list is a wall to everybody's eyes and a cloud out of it is not. A round
182 * nobody threw a smoke in keeps an empty array, and every loop over it is a
183 * length check — which is what makes the sight test cost the same as it
184 * always did in the rounds that are most of them.
185 */
186 clouds: Cloud[];
175187 feed: FeedEntry[];
176188 messages: { at: number; text: string }[];
177189 /**
@@ -213,7 +225,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
213225 money: 800,
214226 weapon: secondary, primary: null, secondary,
215227 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
216 defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0 }, blind: 0,
228 defuseKit: false, hasBomb: false, pocket: { he: 0, flash: 0, smoke: 0 }, blind: 0,
217229 nextShotAt: 0, reloadUntil: 0, shotIndex: 0, lastShotAt: -99, scoped: false,
218230 firedThisPress: false, threwThisPress: null,
219231 punch: [0, 0], stride: 0,
@@ -261,7 +273,8 @@ export function createWorld(
261273 const world: World = {
262274 match, players, me, time: 0,
263275 bomb: newBomb(),
264 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], feed: [], messages: [], plan: null,
276 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], clouds: [],
277 feed: [], messages: [], plan: null,
265278 random, level, bombPlanted: false, pendingBuy: false,
266279 };
267280 startRound(world);
@@ -354,7 +367,7 @@ export function startRound(world: World): void {
354367 p.health = 100;
355368 // A grenade survives the round its owner did, like their gun does; one they
356369 // died holding is left on the floor with them.
357 if (!p.alive) p.pocket = { he: 0, flash: 0 };
370 if (!p.alive) p.pocket = { he: 0, flash: 0, smoke: 0 };
358371 p.alive = true;
359372 // A flash does not carry into the next round, however late it went off.
360373 p.blind = 0;
@@ -379,6 +392,10 @@ export function startRound(world: World): void {
379392 if (carrier !== undefined) world.bomb = giveBomb(carrier);
380393 world.bombPlanted = false;
381394 world.grenades = [];
395 // A cloud does not outlive the round it was thrown in, however late it
396 // opened: fifteen seconds of smoke standing through the next freeze would be
397 // one side starting a round blind for something nobody did.
398 world.clouds = [];
382399 world.feed = [];
383400 for (const p of world.players) if (p.bot) botBuy(world, p);
384401 }
@@ -535,8 +552,11 @@ export function throwOne(world: World, p: Player, kind: ThrownId = "he"): boolea
535552 function detonate(world: World, g: Grenade): void {
536553 const centre = blastCentre(g);
537554 record(world, { kind: "blast", at: world.time, pos: centre, weapon: g.weapon });
538 heard(world, centre, g.weapon === "flash" ? "flash" : "blast", g.owner);
555 heard(world, centre, g.weapon === "flash" ? "flash" : g.weapon === "smoke" ? "smoke" : "blast", g.owner);
539556 if (g.weapon === "flash") return blind(world, g);
557 // A smoke hurts nobody and reaches nobody. What it does is *stay*: the
558 // canister is spent, and the cloud it left is a thing the round now has.
559 if (g.weapon === "smoke") return void world.clouds.push(cloudFrom(g, world.time));
540560 explode(world, g, centre);
541561 }
542562
@@ -574,18 +594,38 @@ function blind(world: World, g: Grenade): void {
574594 }
575595 }
576596
577 /** Every live grenade, one tick older: moved, heard, and detonated on time. */
597 /**
598 * Every live grenade, one tick older: moved, heard, and detonated on time.
599 *
600 * "On time" is `spent` in `grenade.ts` rather than a zero on the fuse, because
601 * a smoke waits for the floor. The list is filtered by the same question, so a
602 * canister still rolling with its fuse past zero stays in the round instead of
603 * disappearing on the tick it was supposed to open.
604 */
578605 function updateGrenades(world: World, dt: number): void {
579606 if (world.grenades.length === 0) return;
580 let spent = false;
607 let any = false;
581608 for (const g of world.grenades) {
582609 if (stepGrenade(g, dt, world.level.boxes)) heard(world, g.pos, "bounce", g.owner);
583 if (g.fuse <= 0) {
610 if (spent(g)) {
584611 detonate(world, g);
585 spent = true;
612 any = true;
586613 }
587614 }
588 if (spent) world.grenades = world.grenades.filter((g) => g.fuse > 0);
615 if (any) world.grenades = world.grenades.filter((g) => !spent(g));
616 }
617
618 /**
619 * Clouds that have thinned away to nothing, dropped. A cloud at zero radius
620 * blocks nothing and is drawn as nothing — `cloudRadius` answers the same
621 * question every reader asks — so this is housekeeping and not a rule: what it
622 * buys is that the loop in `canSee` stays short in a round where ten smokes
623 * have been thrown over two minutes.
624 */
625 function expireClouds(world: World): void {
626 if (world.clouds.length === 0) return;
627 if (world.clouds.some((cloud) => cloudGone(cloud, world.time)))
628 world.clouds = world.clouds.filter((cloud) => !cloudGone(cloud, world.time));
589629 }
590630
591631 // --- the bomb -------------------------------------------------------------
@@ -693,7 +733,7 @@ export function update(world: World, dt: number, input: Input): void {
693733 p.armor = 0;
694734 p.helmet = false;
695735 p.defuseKit = false;
696 p.pocket = { he: 0, flash: 0 };
736 p.pocket = { he: 0, flash: 0, smoke: 0 };
697737 p.secondary = p.team === "t" ? "glock" : "usp";
698738 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
699739 p.weapon = p.secondary;
@@ -719,6 +759,10 @@ export function update(world: World, dt: number, input: Input): void {
719759 // After everyone has moved, so a grenade is solved against where people
720760 // ended the tick rather than where they started it.
721761 updateGrenades(world, dt);
762 // And the clouds the spent ones left, once they have thinned to nothing. A
763 // cloud born on this very tick survives it: it is at zero radius and not at
764 // the end of its life, which is the one instant the two differ at.
765 expireClouds(world);
722766 // Once a tick, and not once per player: `bombTick` runs ten times and the
723767 // fuse is one clock.
724768 bombBeep(world, dt);
@@ -728,11 +772,49 @@ export function update(world: World, dt: number, input: Input): void {
728772 expireEffects(world.effects, world.time, EFFECT_LIFE);
729773 }
730774
731 /** Is `target` visible to `from`, eye to eye? Used by bots and by the HUD. */
775 /**
776 * Is `target` visible to `from`, eye to eye? The one question the whole game
777 * asks about sight: the bots pick their fights with it, a bot deciding whether
778 * it is safe to cut a wire asks it, and the observation the policy is handed
779 * carries its answer. So this is the single place a cloud has to be accounted
780 * for, and it is.
781 *
782 * Two walls, in the order that costs least. The level first — fifty-odd boxes
783 * through the engine's `visible`, which is the test this has always been — and
784 * then the clouds, which in most rounds is an empty array and a length check.
785 * Neither is cheaper to skip: a wall is still a wall inside a smoke.
786 *
787 * A cloud is solid to sight and not translucent: a line that clips the edge of
788 * the ball is blocked exactly as one through the middle is. That is the
789 * approximation this issue was allowed to make and it is the one that keeps the
790 * test cheap — `segmentSphere` is a dot product and at most one square root —
791 * and it is also what makes a body *standing in* a cloud blind in every
792 * direction, which is what a smoke is for.
793 */
732794 export function canSee(world: World, from: Player, target: Player): boolean {
733 return visible(eyePos(from), eyePos(target), world.level.boxes);
795 const eye = eyePos(from);
796 const at = eyePos(target);
797 if (!visible(eye, at, world.level.boxes)) return false;
798 return !throughSmoke(world, eye, at);
734799 }
735800
801 /**
802 * Does the line from `eye` to `at` pass through any cloud standing right now?
803 * Separate from `canSee` because a cloud is a fact about a *line* and not about
804 * two players: the HUD asks it about the camera, and a test asks it about two
805 * points nobody is standing on.
806 */
807 export function throughSmoke(world: World, eye: Vec3, at: Vec3): boolean {
808 for (const cloud of world.clouds) {
809 if (segmentSphere(eye, at, cloud.pos, cloudRadius(cloud, world.time))) return true;
810 }
811 return false;
812 }
813
814 /** Is this point inside a cloud — is an eye here in the fog? */
815 export const insideSmoke = (world: World, at: Vec3): boolean =>
816 world.clouds.some((cloud) => dist(at, cloud.pos) < cloudRadius(cloud, world.time));
817
736818 export const facing = (p: Player, target: Vec3): number => {
737819 const to = sub(target, eyePos(p));
738820 const yaw = (Math.atan2(to[1], to[0]) * 180) / Math.PI;
src/engine/GUIDE.md +6
@@ -176,6 +176,12 @@ index is carried so the thing you hit knows what it is made of — and a `Hit`
176176 carries the fraction along the segment, the point, the normal and the thing that
177177 was hit, which is whatever you put in the list.
178178
179 And one shape that is not a box: `segmentSphere` answers whether a segment meets
180 a ball, for a dot product and at most one square root, with no allocation and no
181 hit record — it is in the way or it is not. It is what a cloud of smoke is made
182 of in the shooter, where it sits on the hot path of "can A see B" ten times a
183 tick, and it is the reason that question is still cheap with smoke in the round.
184
179185 A shot in the example is one ray and one box, and it is the whole of hitscan:
180186 `src/range/targets.ts` does the same against a list with `traceWorld`. A miss is
181187 still a shot the player saw leave the barrel, so the tracer is written before the
src/engine/index.ts +1 −1
@@ -19,7 +19,7 @@ export {
1919 export type { Mat4, Vec3 } from "./math.ts";
2020
2121 /** Rays against axis-aligned boxes, and the `Box` they are traced against. */
22 export { rayBox, segmentLength, traceWorld, visible } from "./trace.ts";
22 export { rayBox, segmentLength, segmentSphere, traceWorld, visible } from "./trace.ts";
2323 export type { Box, Hit } from "./trace.ts";
2424
2525 /**
src/engine/surface.json +2 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.1.0",
2 "version": "0.2.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -161,6 +161,7 @@
161161 "sample",
162162 "scale",
163163 "segmentLength",
164 "segmentSphere",
164165 "softmax",
165166 "stuck",
166167 "sub",
src/engine/trace.ts +50 −2
@@ -1,6 +1,13 @@
11 /**
2 * Rays against boxes. Bullets are hitscan, sight is a ray, and both ask the
3 * same question: what is the first thing between here and there.
2 * Rays against boxes, and a segment against a sphere. Bullets are hitscan,
3 * sight is a ray, and both ask the same question: what is the first thing
4 * between here and there.
5 *
6 * A box is what a level is built of and a sphere is what a cloud is: the
7 * shooter's smoke grenade is a ball nothing can see through, and a ball is the
8 * one shape a segment can be tested against for the price of a dot product and
9 * at most one square root — which is why it is here rather than approximated
10 * out of boxes.
411 */
512
613 import { sub, length, type Vec3 } from "./math.ts";
@@ -62,4 +69,45 @@ export function traceWorld(from: Vec3, to: Vec3, boxes: Box[]): Hit | null {
6269 export const visible = (from: Vec3, to: Vec3, boxes: Box[]): boolean =>
6370 traceWorld(from, to, boxes) === null;
6471
72 /**
73 * Does the segment `from → to` meet the ball of radius `radius` about `centre`?
74 * A segment and not a ray: a sphere behind you or past the far end is not in
75 * the way, and both of those are cases a plain quadratic answers wrongly.
76 *
77 * The arithmetic, with `d = to − from` and `f = from − centre`:
78 *
79 * |f + t·d|² = r² → (d·d)t² + 2(f·d)t + (f·f − r²) = 0
80 *
81 * `c = f·f − r²` at or below zero is a start already inside the ball, which is
82 * a meeting whatever the roots say — and it is the case that is asked most, so
83 * it is answered first and without the square root. Otherwise the ball is
84 * entered at the smaller root, and the segment meets it exactly when that root
85 * lies in [0, 1]: `t < 0` is a ball behind the eye and `t > 1` one past the
86 * target. A degenerate segment — both ends the same point — has `a = 0`, and
87 * the `c ≤ 0` answer above has already covered the only way it can meet.
88 *
89 * Nothing is allocated and nothing is returned but a boolean: what is wanted of
90 * a cloud is whether it is in the way, never where it was entered.
91 */
92 export function segmentSphere(from: Vec3, to: Vec3, centre: Vec3, radius: number): boolean {
93 if (radius <= 0) return false;
94 const fx = (from[0] as number) - (centre[0] as number);
95 const fy = (from[1] as number) - (centre[1] as number);
96 const fz = (from[2] as number) - (centre[2] as number);
97 const c = fx * fx + fy * fy + fz * fz - radius * radius;
98 if (c <= 0) return true;
99 const dx = (to[0] as number) - (from[0] as number);
100 const dy = (to[1] as number) - (from[1] as number);
101 const dz = (to[2] as number) - (from[2] as number);
102 const a = dx * dx + dy * dy + dz * dz;
103 if (a <= 0) return false;
104 const half = fx * dx + fy * dy + fz * dz;
105 // The ball is wholly behind the eye: the near root cannot be positive.
106 if (half >= 0) return false;
107 const disc = half * half - a * c;
108 if (disc < 0) return false;
109 const t = (-half - Math.sqrt(disc)) / a;
110 return t <= 1;
111 }
112
65113 export const segmentLength = (from: Vec3, to: Vec3): number => length(sub(to, from));
src/games.ts +17 −1
@@ -449,6 +449,22 @@ export type Game = {
449449 * raw and 2,873 gzipped to spare, so no ceiling moved. Neither other bundle
450450 * changed a byte: nothing outside `src/client/` and `src/pages/game.ts` did.
451451 * Measured 2026-09-21.
452 * #165 put the third grenade in the game — a smoke, which is the first thing
453 * here that changes what a bot can *see*: a canister that waits until it has
454 * come to rest, a `Cloud` the world keeps, `segmentSphere` behind the engine's
455 * door, the ball `canSee` traces against, the lumpy mesh it is drawn as, the
456 * wash the HUD puts over an eye inside one, the step `bots.ts` bends around
457 * one, a sound, a key and a row in the weapon table. The app grew 3,067 raw /
458 * 1,107 gzipped bytes for all of it: 95,598 → 98,665 raw and 37,788 → 38,895
459 * gzipped. That is 665 bytes past the raw half of the 98,000 / 39,000 #214
460 * brought *down*, and the gzipped half had 105 left, which is not headroom
461 * anybody can land a feature in — so the ceiling is 103,000 / 41,000 here,
462 * today's size plus about four per cent of raw and five of gzip, on the same
463 * reasoning #164, #185 and #148 raised the last three. Neither other bundle
464 * moved a byte: the range is 34,810 raw / 14,568 gzipped, the same numbers
465 * #216 measured, and nothing outside `src/client/`, `src/engine/trace.ts` and
466 * `src/pages/game.ts` changed — the range and the example ship no world, no
467 * weapons and no cloud. Measured 2026-09-21.
452468 */
453469 export const GAMES: readonly Game[] = [
454470 {
@@ -456,7 +472,7 @@ export const GAMES: readonly Game[] = [
456472 href: "/",
457473 label: "cs",
458474 entry: "src/client/main.ts",
459 ceiling: { raw: 98_000, gzip: 39_000 },
475 ceiling: { raw: 103_000, gzip: 41_000 },
460476 assets: [
461477 {
462478 name: "weights",
src/pages/game.ts +8 −2
@@ -274,7 +274,7 @@ const menu = (visitor: Who, asked: Asked): string => `
274274 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
275275 <p class="hint" id="prep">Preparing textures…</p>
276276 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
277 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang ·
277 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke ·
278278 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
279279 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
280280 <kbd>Esc</kbd> or <kbd>P</kbd> pause</p>
@@ -420,6 +420,7 @@ export function gamePage(
420420 { action: "reload", label: "reload" },
421421 { action: "grenade", label: "nade" },
422422 { action: "flash", label: "flash" },
423 { action: "smoke", label: "smoke" },
423424 { action: "buy", label: "buy" },
424425 { action: "fire", label: "fire" },
425426 ],
@@ -447,7 +448,12 @@ export function gamePage(
447448 the centre falling off over 350 units, on a 1.5-second fuse, and only to what it can see —
448449 and a flashbang thrown the same way, which takes sight rather than health: up to 1.6 seconds
449450 of it, by the angle you were looking at it from and how far away it went off, and none at all
450 through a wall or over your shoulder.
451 through a wall or over your shoulder, and a smoke grenade, which is the one that changes how
452 a round is played: it opens where the canister came to rest and stands a ball 180 units
453 across for fifteen seconds, growing and thinning on the round's own clock. It blocks sight in
454 the simulation and not only on the screen — the one function that answers "can this body see
455 that one" traces the ball, so a bot behind smoke is as blind as you are and will not shoot
456 what it cannot see, and it walks around a cloud rather than into one.
451457 Everything you see is generated — the map, the textures, the models and the sounds — so the
452458 whole game is one small download and no asset files.</p>
453459 ${policyHtml(card)}

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs