sentinel/cs
Public: anyone can read this; nobody can change it.
cs2 clone
forked from trevato/cs
This repository has no app yet.
following trevato/cs in step
Everything here is up to date with trevato/cs.
Proposes updates from trevato/cs.
main
- scripts
- src
- .gitignore 90 B · ~26 tok
- AGENTS.md 3.1 KB · ~789 tok
- bun.lock 6.5 KB · ~2.2k tok
- forge.json 116 B · ~39 tok
- package.json 369 B · ~123 tok
- README.md 70.2 KB · ~18k tok
- tsconfig.json 411 B · ~137 tok
README.md
cs
A Counter-Strike-shaped defusal shooter that runs in the browser, and the leaderboard of the matches people played. Five against five on either of two maps: bomb rounds, a buy menu and an economy, bots that pick a site as a side, get the bomb down and shoot back.
Run it locally
One command, no install step — Bun (1.3 is what this is tested on) and nothing else, because the app has no dependencies:
bun run src/server.ts
Then open <http://127.0.0.1:8080>. The command prints the address it bound.
The defaults are the local ones: PORT=8080, HOST=127.0.0.1, and
FORGE_APP_DATA=./data, so the leaderboard lands in data/app.db — a
git-ignored SQLite file. Delete the directory to start from an empty
leaderboard, or point FORGE_APP_DATA somewhere else to keep two.
The rest of the commands:
bun run check # types, then tests — what every pull runs
bun run typecheck # just the types: tsc --noEmit, strict
bun test # just the tests, for a faster loop
bun run scripts/build.ts # write static/app.<hash>.js, for inspection
bun run src/tty.ts # the terminal interface, same database
bun run scripts/train.ts --iters 1000 # retrain the bots' policy
bun run scripts/train.ts --iters 1000 --workers 4 # or on four cores
bun run scripts/train.ts --bench # how fast the arena steps
bun run scripts/measure.ts # what a hundred rounds say — see below
bun run scripts/cost.ts # what a tick of the simulation costs — see below
The client is bundled at start-up, in memory, from the sources on disk — the
server logs how long it took — and is not committed: static/app.*.js is
git-ignored, so a merge can never serve a bundle that predates it.
bun run scripts/build.ts writes the same bundle to a file for anyone who
wants one, but the running server never reads it.
What is real and what is not
- Real.
The guns and penetration. The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style movement (ground friction, air acceleration, an 18-unit step, strafe jumping, and the crouch-jump: a jump peaks at 57 units, 7 short of the 64-unit crates both maps are full of, but duck in the air and the legs tuck up under a head that stays put, so the feet clear 75 and you land on top — jump without ducking and the crate is still in your way. The jump itself is no higher; standing back up in the air is refused while the legs have nowhere to go, the same way it is under a low ceiling. The bots do not jump, so a crate top is a place a bot will not follow you to: one sent after something up there, the bomb included, walks to the foot of the crate and waits), CS damage with range falloff, armour and hitgroups, a knife that pays for a flank — 55 in the chest, 180 in the back, where "the back" is CS's own test of standing within 61.65° of straight behind someone and armour stops the same share either way, so one stab from behind ends a full-health player with a vest on — deterministic spray patterns, a draw on every weapon — 0.4 s on the knife up to 1.25 s on the AWP, before which the trigger and the scope do nothing, which is what makes the three carry speeds a choice — a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and loss bonuses. Cover is not all equal: a bullet goes through a crate if the round is strong enough for the thickness it meets. Crates are the only penetrable material — walls, floors, the site plates and metal stop everything — and every crate on either map is 128 units across, so the AWP (230 units), AK-47 (175), M4A4 (165) and Desert Eagle (150) punch one face-on while the Glock (48), USP (55) and MP9 (60) manage nothing but a clipped corner, and a knife nothing at all. The thickness is measured along the bullet's own line, so a crate crossed at an angle is thicker than one crossed square. What comes out the far side hits for a share of its damage — 85% for the AWP, 70% for the AK, less for the rest — taken off before armour sees it. One surface a shot: two crates in a line stop the round as surely as a wall, and the far side of a punched crate is marked where the bullet went in as well as where it stopped. The bots do not know any of this: their sight is still blocked by a crate, so shooting one off a box they think is cover is yours to use.
The drop economy. The economy has CS's loop: weapons drop. A player killed leaves the gun they were holding on the floor with whatever was left in the magazine — a knife never drops, and someone killed holding one leaves their primary — and it can be picked up. With no primary you take it by walking over it; with one already, <kbd>E</kbd> swaps, and the gun you were holding lands at your feet. There is no team rule: a CT who finds an AK-47 keeps it. The floor is swept at the start of the next round, and whatever you are carrying then comes back with a full magazine.
Utility. There is one piece of utility: an HE grenade, $300 in the buy menu, one in a pocket at a time. <kbd>4</kbd> puts it in your hand and fire throws it — out of your eye, a little above where you are looking, carrying whatever speed you were already running at. It falls under the same 800 u/s² the rest of the game does, bounces off walls and crates keeping 45% of the speed it hit them with, and goes off 1.6 seconds after it left your hand wherever it is by then. The blast is 98 damage at its centre, falling linearly to nothing at 350 units, halved by armour (which takes half of what it stops), and it does nothing at all through a wall — the same sight line a bot sees you with, its centre to your eye. It cannot hurt your own side, and it can very much kill you. A grenade thrown is gone; one kept is still yours next round, the same rule the guns follow. The bots do not buy or throw one: their fight was learned in an arena that has no grenades in it, and they keep buying guns.
Feedback. Hit feedback runs both ways: a hit marker and a beep when you
hit someone — four ticks around the crosshair, red for a headshot, and for
the hit that finished them a heavier mark, held twice as long, closed
into an X through the middle, so the fifth bullet does not look like the
four before it; your own grenade confirms the same way, once for each enemy
the blast caught; a round a teammate stops sparks on their body, marks a
plus in your side's colour with no beep, and says "Blocked by <name>" once a
burst — still no friendly fire — and when you are hit a view punch away from
the shot, a wedge around the crosshair pointing at whoever fired, a vignette
that reddens as your health falls and a pain sound. A hit also tags, for
everyone the same way: each point of damage takes 1% of the victim's ground
speed, never more than half of it however many land, and it runs back at
the full 100% a second — 30 in the chest holds a runner to 175 u/s and is
gone in 0.3 s — while a player in the air is not tagged at all, and a new
round starts everyone untagged. A fall costs health the way CS's does:
nothing landing under 580 u/s, then 0.0888 a point for every u/s past it,
rounded to whole points, on the one tick the fall ends — armour is no help,
the ledger and the round report put it down to a fall, a death to one
reads as a fall in the kill feed and pays nobody, and you feel it as the
same flinch, vignette and pain sound a hit gives, with no wedge because it
came from straight below. The honest part: nothing on de_forge or
de_cinder is tall enough to reach it. The highest floor anyone can stand
on is a crate top at 128 units, which lands at about 452 u/s; a drop needs
roughly 210 units before it costs anything (320 costs 12). The rule is here
so the simulation is whole, and so taller geometry gets it for free.
Sound and bodies. The gun says what it is doing, too. Every shot lights
a muzzle flash — a star of additive light at the end of the barrel, sized
by the weapon and gone in three frames — on the gun in your hands and on
every other gun on the map, so a firefight across the site reads as people
firing rather than as lines appearing in the air; a knife and a grenade have
no muzzle and flash for nobody. A reload is something the hands do: the
gun drops out of the way and comes back, driven by how far through the
weapon's own reload time you are, and it is level again exactly when the
magazine is full. A weapon you switch to is raised over its own draw —
the knife's 0.4 s, the AWP's 1.25 s — so it is level on the frame the trigger
starts answering, and the gun is up and the gun can shoot are one moment
rather than two; while it is on its way the ammo counter and the weapon's
name dim, so a click that does nothing says why. A grenade, whose draw is
nothing at all, still gets a tenth of a second of raise rather than popping
into your hands, and a round starts with the gun already up and so raises
nothing. Both of those are animation and nothing else — the switch itself is
still instantaneous, as it always was, so a gun still on its way up fires the
moment the simulation says it may, and no bot, no observe.ts input and no
rule of the round can tell the difference. Every world sound is panned
around the listener's view and fades with distance — a shot or a footstep to
your left plays from the left channel, to your right the right — and a
running player who is not walking, ducking or airborne leaves footsteps this
way too, so someone closing in from behind is something you hear before you
see. Legs swing on that same stride: the ground a player has covered is
the phase the other nine bodies are posed from, so an enemy crossing a
doorway reads as someone running and a player at a dead stop is still. One
stride of ground is one step — a cycle of legs is two of them, right foot
then left — so the legs alternate at exactly the cadence you hear and the
swing is widest at the footfall. It is one draw each, hinged in the vertex
shader, and the simulation neither sets it nor reads it — a walker's legs
move on a stride nobody hears. The gun rides the upper body, so a running
player's muzzle flash comes off the end of the barrel their lean has moved
rather than where the gun was modelled. The pose is a picture and nothing
else: hitboxes are axis-aligned on a player's position and know nothing
about it, so a sprinting enemy's swung foot is drawn further out than the
box a bullet finds it in. The head is the exception, because the head is the
shot worth taking — it is left out of the lean, so what you aim at and what
you hit are the same box.
The bomb. A planted bomb beeps: about once a second right after the plant, quickening to a few a second — and rising in pitch — as the fuse runs out, so a CT can walk to the site by ear with no map open. The plant, the defuse, the detonation and a trigger pulled on an empty magazine each have their own sound too. Going off is the same blast rule the HE uses — falling linearly from full at the centre to nothing at 900 units, halved by armour — with its own, much larger, centre damage: close enough in it kills through full kevlar, far enough out you end the round alive and hurt. A wall does nothing to stop it — the bomb ignores cover in CS, and this does too — and nobody is credited for the kill, the way blowing yourself up with your own grenade never is. A footstep only carries so far, but the bomb's beep and the blast that ends it carry across the whole map.
The leaderboard. The leaderboard is a real SQLite table under
FORGE_APP_DATA, also served as plain JSON from GET /api/leaderboard for
the terminal or any other client — with a guest flag per row instead of the
identity it comes from, because a guest's identity is the session cookie that
owns their matches and that does not travel. The board itself is the top ten,
but a visitor sitting eleventh or below still gets their own row: highlighted
in place when they are inside it, pinned below the table (and in the you
field of the JSON, null for someone who has not played) with their true
rank when they are not — a rank shared with anyone else tied on the same
kills and damage, so a pinned row can read the same number as the table
row above it. The terminal has no visitor to highlight — an
operator's pty, not a browser's session — so it shows the same top ten and
nothing pinned beneath it. A row is live, finished or
abandoned — one word, computed once in src/db.ts and read by the page,
GET /api/leaderboard and the terminal alike, never worked out twice. Live is
a match still posting; finished is one that played to a result; abandoned is
everything else — the pause overlay's Leave match button says so on the way
out, and a row nothing has updated in ten minutes reads the same way on its
own, so a crashed browser or a closed tab cannot sit on the board forever. An
abandoned match still counts as a match, with its kills and its damage, the
same as any other — it just never counts as a win, which already required
finished.
- Generated, not licensed. The maps (
de_forgeandde_cinder), the textures, the player and
weapon models and every sound are produced in code at start-up. There are no asset files and nothing from the real game is used — the numbers follow CS's published shape so the habits transfer, and the rest is original.
- Learned, and measured. The bots do not aim by rule. A 32-input, two-layer
policy decides where to point, when to shoot and which way to move, trained with
PPO in src/rl against millions of ticks of this same simulation. The numbers on
the page and in the terminal's policy tab are what the run that produced the
shipped weights measured, on the same fixed duels for the policy and for the
scripted bots it replaced.
- Simulated. The opponents are bots, and the match runs entirely in your
browser: there is no multiplayer and no server-side authority. Scores are client-reported, so the server checks them against what a match can produce (kills fit in the rounds, headshots fit in the kills, the clock fits the rounds) and rejects the rest. Treat the leaderboard as a scoreboard among friends, not as a ranking.
- Signed, not claimed. The numbers on a result are the browser's to report;
the name on them is not. A match is credited to the forge identity that posted
it — the x-forge-identity and x-forge-signature headers the forge puts on
a proxied request, verified with FORGE_APP_IDENTITY_KEY in src/who.ts —
and a browser nobody signed for is recorded as a guest (guest 4f2a, one per
session cookie), never under a name it asked for. The name in the menu is
what the match calls you and goes nowhere near the board. Standings group by
identity, so a forge rename follows the player instead of splitting the row.
Playing
<kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
<kbd>Shift</kbd> walk, mouse aims and fires, the right mouse button or
<kbd>Q</kbd> scopes on the AWP, <kbd>R</kbd> reload,
<kbd>E</kbd> plants, defuses or takes the gun you are standing on (the HUD says
which gun when it is one you have to ask for). Planting puts a line on the
message log naming the site it went down on — Bomb planted — B site — in
the same words placeAt gives a callout, so a plant you did not see is still
a place to run to rather than a beep to go hunting for. <kbd>B</kbd> opens the buy menu in the freeze
time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns and <kbd>4</kbd> takes
out the HE grenade if you bought one (fire throws it, and the hand goes back to
the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows
the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and menu on the
pad does it on a phone. The menu's Name, Match length and Side
are remembered for next time too, the same as Map and Difficulty below — a
returning player presses Play instead of filling the form in again. Pausing
also opens mute, volume, look sensitivity and the scope's own share of it —
and on a phone, the thumb's own sensitivity too — which take effect at once
and are remembered for next time.
A gun has to come up before it fires. Every weapon has a draw — the knife
0.4 s, a pistol 0.5–0.6 s, a rifle 1 s, the AWP 1.25 s — and for that long
after you press <kbd>1</kbd>, <kbd>2</kbd> or <kbd>3</kbd> the trigger does
nothing and the scope will not come up. You can see all of it: the gun spends
exactly those seconds coming up in your hands and the ammo counter is dim until
it is level, so the wait is something you watch rather than something you
discover by clicking. Walking is untouched, and that is the
whole point of it: the knife is the fastest thing to run on (250 u/s against
the AK's 215 and the AWP's 200), so running on it is a real choice and not a
free one — you pay the rifle's second at the other end, which is where you
wanted to be shooting. A gun taken off the floor is the same: walk over a
dropped AWP and it is 1.25 s before it will fire, so the swap is a commitment
too. Asking for the weapon already in your hands costs nothing at all, a round
starts with everyone's gun already up, and the numbers are one per weapon in
src/client/weapons.ts (deployTime) beside the carry speed they exist to
make matter.
The scope has its own sensitivity. The AWP draws 40° where the hip draws
90°, so one number for both means the same flick sweeps the same degrees across
less than half as much screen — the scoped turn that overshoots. Scope sens
is what the look is multiplied by while the scope is up, mouse and thumb alike,
and it sits beside Sensitivity in the menu and again in the pause overlay,
the two in step and live mid-match. Its default 0.36 is not a taste: it is
tan 20° / tan 45°, the correction that makes a flick cross the same share of
the screen scoped and unscoped. The whole relationship lives in
src/client/zoom.ts — the hip's HIP_FOV there and the AWP's zoom in
src/client/weapons.ts are what the default is derived from, and the camera
takes its field of view from the same function, so there is one place to change
and no second place to forget. Raise it for a quicker scope, down to 0.1 for a
slower one, or set it to 1 for the old behaviour where the scope turns exactly
as fast as the hip. It is the person's view only: no bot's turn rate moves with
it, and nothing the policy sees changes.
A thumb has its own sensitivity, apart from the mouse's. Sensitivity above
used to be read for a touch drag too, so tuning the phone's aim for a 360px
screen detuned the mouse and back again — the two devices never wanted the same
number. Touch sens is stored and clamped exactly like Sensitivity (in
src/client/settings.ts) and reaches only a touch whose role is look — the
Controls.touchSensitivity in src/client/input.ts, run through the same
lookDelta the mouse and the scope go through, so the stick's 60px throw and
the look pipeline's units never move. It lives in the pause overlay only, next
to Sensitivity, and only where the browser reports a coarse pointer: a
mouse-and-keyboard page never grows a field for a thumb it does not have. Its
default is 2.2, the mouse's own default, so nothing changes underfoot until
you touch the new number yourself.
Every one of those keys is a default, not a floor: WASD is not everyone's
WASD — on an AZERTY board, a Dvorak layout or just a different habit, the
letters printed on the physical keys are not where this game's defaults
expect them. The keys are yours to rebind. A collapsed Controls disclosure
sits in the menu and again in the pause overlay (the same 17 actions, always
in sync) — closed by default so it never pushes anything else out of view;
opening it is a plain <details>, reachable and toggled with a keyboard on
its own. Click a button to see its action's current key, press it and then press the
key you want instead, and it takes hold immediately, mid-match included.
Escape cancels a capture rather than pausing the match. A key already bound
to something else is refused with a line saying what it is bound to instead —
nothing is ever left half-bound. Reset to defaults puts the whole map
back. The arrow keys and <kbd>C</kbd> are a fixed movement and crouch
fallback that no rebind can take over, so a mis-click here is never a match
you cannot move in. Rebinding is keyboard only — the touch pad's layout does
not change.
The radio is <kbd>Z</kbd> — a binding like any other, so it is under
Controls with the rest — and it has three calls and no more: go A,
go B and hold. The panel is the same kind of overlay as the buy menu —
three big buttons, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> call the one they
number, <kbd>Z</kbd>, <kbd>Esc</kbd> or Close closes it — and the call goes
on the message line as You: go A so you can see the team heard it. Go A and
go B send your four teammates to that site, whatever the round rolled: a
terrorist carrying the bomb takes it there exactly as it would have taken it to
the plan's site, and anyone already walking turns round on the next tick. It
outranks the side's own reasons for leaving the plan too — a counter-terrorist
that would have rotated to wherever its team was dying goes where you said
instead, for as long as the call stands.
Hold leaves the sites alone and stops the push — the side defends where it
stands rather than pushing on, repositioning to cover instead of freezing in
the open. Only your own side hears it, only one call is live at a time (a
second replaces the first), a call stands for twenty seconds and then the side
goes back to the site the round rolled, and every new round starts with the
radio clear. Being dead does not take the radio away: you are already watching
the round through a teammate's eyes, and the four still alive are the ones who
can do something about what you can see.
And the team talks back. A teammate that can see an enemy says where it
is on the same message line — Krait: enemy — long A — and it says it in the
map's own words: the places are map.ts's named areas, the rooms the layout
is actually carved out of, so a callout can only ever name somewhere that
exists. One thing gets said and no more, a sighting, and it is said on
contact — the tick a bot first sees somebody it was not already seeing —
so a ninety-second standoff down one corridor is one line and not ninety
seconds of them. A footstep through a wall says nothing at all: a bot that
hears something goes and looks, quietly. Only your own side ever says
anything, and only about an enemy it can actually see: a callout is what the
teammate standing there has in its sights, never what the other side's bots
know, so it can tell you nothing the radar would have been a wallhack for
showing you. It is deliberately quiet — no bot says two things within six
seconds, the side says at most one thing every four (the line is readable for
four), and nobody calls the same place again for twelve. A round where nothing
happens is silent, and a round restart clears the chatter and leaves the
result of the last one on screen.
There are two maps, and the menu's Map select beside Difficulty picks
which one a match is played on. de_forge is the default and the dust-shaped
one: both sites along the top of the grid, a mid between them, a long corridor
down the right and a tunnel down the left. de_cinder puts the sites on a
diagonal instead — A in the north-west, B halfway down the east wall — around a
courtyard the rotation between them has to cross: swapping sites on de_forge
is one corridor behind the defenders' own spawn, and on de_cinder it is a
walk through the middle where the other side can be. Each site on either map has
the long way in and another way in; on de_cinder those are the west long and
the vents for A, the ramp and the kiln door for B. The choice is remembered for
next time like the other settings, and it applies at the start of a match, not
to one already running. What the bots make of the second map is measured in
Measuring the round, below — they were trained on the first.
The menu's Difficulty select picks Easy, Normal or Hard before a match starts — each one a band of the bots' skill rather than one number, so a side still runs from fodder to uncomfortable at every setting, just a different stretch of it. It is remembered for next time, same as the other settings, and the scoreboard (<kbd>Tab</kbd>) says which one a match is being played at. The same select is in the pause overlay for a match already running — a difficulty picked there takes effect from the next round, never the one in progress, so changing it mid-fight cannot hand a bot new hands between one shot and the next.
Motion you did not ask for, turned down — and nothing else. A Motion
select sits beside Scope sens in the menu and again in the pause overlay, the
two in step and live on the next frame. It starts at whatever the machine
already asks for: on a first run the game reads
(prefers-reduced-motion: reduce) through matchMedia and starts at
Reduced if the system says so; Full is the other setting and is the
game exactly as it has always drawn. That is a default and never a decision —
the query is only ever the fallback parseSettings uses when storage names no
motion at all, so a choice you have made is read first and wins outright, on
every load, in both directions.
Reduced turns down exactly two things, both of them decoration: the
walk's sway on the gun in your hands (bobScale goes to zero, which is the
same gun a player standing still is already holding), and the fade on a damage
wedge, which holds its strength for its second and then goes instead of
sliding away. The red vignette is the same picture either way by construction:
there is no clock anywhere in it, only your health, so it is already the
steady tint reduced motion would ask for and it still deepens as you are shot,
which is information.
Everything that is the game is untouched, and that is the point: the recoil
punch on the camera, the flinch when you are hit, the crosshair opening with
your inaccuracy, the muzzle flash that says somebody fired, the tracers, the
blood, the bullet holes and the kill feed are identical on both settings —
so is the gun's kick when you fire, its raise on a switch and its dip on a
reload, which are things that happened rather than idle motion. A view that
does not kick is an aim advantage, not an accessibility feature, and a
setting that quietly made the game easier to play would be a worse thing to
offer than none at all. The spectate camera is already a cut and not a swing:
when you die it is the watched teammate's eye position from the first frame,
with nothing to interpolate, so there was nothing there to turn down.
hud.ts takes the one motion value the way it takes the view and decides
once a frame, and the renderer takes it as a number when it changes, so
neither of them grew a second path through the hot loop.
The crosshair is yours. A collapsed Crosshair disclosure sits under Controls in the menu and again in the pause overlay, with the same six settings in both: colour (one of six names — green, white, cyan, yellow, magenta or red — and never a colour you type, since a string that reaches the canvas is an input like any other), size, gap, thickness, a centre dot and dynamic. Every change takes hold on the next frame, mid-match included, and is remembered for next time alongside sound, sensitivity and your keys. Dynamic is the default, and it is the one worth keeping: the arms open exactly as far as the gun is currently inaccurate, so a crosshair that has settled is a shot that will land and a crosshair still wide is the game telling you to stop moving before you fire. Turn it off and the crosshair is the same size whatever you are doing. The defaults are the crosshair this game has always drawn — green, four pixels of gap, seven-pixel arms, two wide, no dot — so nothing moves until you ask it to, and a setting that was never saved, or one saved before these existed, loads as exactly that.
Paused means paused. While the pause overlay or the match-over card is up the world does not run: no ticks, no round clock, no bots, no bomb timer, and the canvas keeps the last frame it drew. A hidden tab pauses the same way, so tabbing away mid-round is not a death — the pause overlay is what you come back to, and the round resumes exactly where it stopped, even a minute later. The buy menu is the exception: it opens in the freeze time, and a frozen freeze clock is a round that never starts, so the world goes on while you shop (your shots do not — a click on a gun is a purchase and never a trigger). The panel counts the freeze time down in its header and closes itself the moment it runs out. Every row says why it is dead when it is: too little money, the other side's gear, one you are already carrying, or the buy window itself being shut — the reason is on the button, not just its colour, so a screen reader hears it too. The digits <kbd>1</kbd>–<kbd>9</kbd> buy the row they number, the same way clicking it would.
A refresh, a crash or a closed tab is not the end of a match. At the start
of every freeze time — the first round or the twentieth, a fresh match or a
resumed one — the browser writes what it would take to pick the match back up
to localStorage: the map, the round, the score, the loss streak and each
player's money and gear. Nothing mid-round is in it — no position, no health,
no bomb — because there is no tick to resume into, only a round to start over
from its freeze time, the same as any other. Coming back to the page while
that is still there offers Resume match — de_cinder, round 9, 5–3 next to
Play, naming the map, the round and the score; starting a new match instead
discards it, which the menu says before you do. A resumed match keeps the same
id the leaderboard already has for it, so the row it made continues rather
than forking into a second one — including a row the ten-minute rule had
already marked abandoned, which simply reads live again the moment a post
lands. It is one browser on one device: the snapshot is localStorage, which
does not follow you to another tab's private window, another browser or
another machine, and two tabs open on the same one both resuming it will both
post under that id — safe, because every number the server keeps only ever
moves up to the largest a post has claimed, never back down, so the worst two
tabs racing does is over-count progress neither alone made, not corrupt the
row or hand it to somebody else. The snapshot is cleared the moment a match
is decided or Leave match is pressed, so there is nothing left to offer once
either happens.
If the browser takes the 3D view away — a phone that backgrounded the tab,
an operating system reclaiming graphics memory, a driver that reset — the match
pauses instead of playing on behind a frozen canvas. The renderer catches
webglcontextlost, asks the browser for the context back (without that the
loss is permanent), and stops making GL calls entirely; the pause overlay comes
up with a line saying the view is coming back, and Resume is disabled while
there is nothing to resume to. When the context returns the renderer builds
everything it owns a second time — both programs, the texture array, the
meshes, the level and the line buffer — at the same render scale and the same
viewport, and the next frame is the scene you left. No reload, no lost round,
and the level is uploaded once for the new context rather than once a frame. If
the browser never gives it back, the overlay is where the match stays: Leave
match goes to the menu.
A Fullscreen button sits in the menu and in the pause overlay — wherever the browser can do it at all; iOS Safari on an iPhone cannot, so there the button is not shown and the game plays exactly as it does without one. It fullscreens the view and the pad together, not the view alone, so a phone in fullscreen keeps every button it had before. Leaving fullscreen with the system gesture or <kbd>Esc</kbd> pauses a running match, the same as losing pointer lock does, and puts the page back exactly as it was.
On a touch screen the same actions are a pad, and the view is the aim:
| what | key | thumb |
|---|---|---|
| move | <kbd>WASD</kbd> | the left third of the view is a stick |
| aim | mouse | drag anywhere on the rest of the view, at Touch sens |
| fire | mouse | a second finger on the right of the view, or the fire bar |
| scope (AWP only) | right click or <kbd>Q</kbd> | scope, held like the mouse |
| crouch, jump, use, reload | <kbd>Ctrl</kbd> <kbd>Space</kbd> <kbd>E</kbd> <kbd>R</kbd> | the pad under the view |
| guns | <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> | 1 rifle, 2 pistol, 3 knife |
| grenade | <kbd>4</kbd>, then fire | 4 nade, then the fire bar |
| scoreboard | <kbd>Tab</kbd> | hold scores |
| buy | <kbd>B</kbd> | buy, and Close when you are done |
| radio (go A, go B, hold) | <kbd>Z</kbd> | radio, then one of the three buttons |
pause the match, leave it, or change mute/volume/sensitivity/Touch sens | <kbd>Esc</kbd> | menu |
Firing is a second finger rather than a button under your aiming thumb because a touch keeps the target it started on for its whole life: the finger already dragging the view goes on steering while another one anywhere on the right holds the trigger, and the trigger finger's own movement is thrown away. The pad lives under the view rather than on it — on a phone the view is 4:3 and the HUD already owns all four of its corners, so a button on the glass would be sitting on the radar, the health or the ammo.
When you die the camera moves to a teammate who is still up and the HUD becomes theirs — their health, armour, ammo and gun, no crosshair — over a line naming who killed you, with what, and how much health they had left, so a lost duel says whether you were outplayed or one bullet away. That last number is a fact about the moment you died, not a live reading of the killer, and it is silent when nobody else's death caused yours (a fall, the bomb, your own grenade). Clicking, or the fire key, moves on to the next teammate. Whoever dies stays on the floor where they fell, lying the way they were facing and tinted their side, until the next round starts — so the rifle dropped in a doorway has a body under it and a fight leaves a record of itself. A body is decoration and nothing more: bullets go through it, it blocks nobody, and no bot has ever seen one. The round's result stays on the screen through the freeze time. For the few seconds right after the round ends, before the buy menu's freeze time takes the middle of the screen, a damage report sits under the result: everyone you hurt and everyone who hurt you this round, with the hits and the kills, and one line for the totals either way. When the match ends a card says who won, the final score from your side and what you did, with a button to play again and one back to the menu.
The world steps at a fixed 64 Hz and your screen draws at 60, 120 or whatever it feels like, so most frames land partway between two ticks. What is drawn is put there: every other player, every grenade, the bomb and the dead toppling are drawn between where they were when the last tick ended and where they are now, at the sub-tick remainder the loop already had in hand. At 60 Hz against 64 that is the difference between a bot strafing across a doorway smoothly and one that jumps two ticks every fifteenth frame. Your own camera is not interpolated: your aim is this frame's mouse and your eye is the newest tick, because a smoother world is not worth 15 ms of lag on your own view. A round start, a respawn or anything else that moves a body further in one tick than it could walk snaps instead of sliding. The simulation is untouched by any of it — the step is still fixed, the bots still see what they saw, and the training arena, which draws nothing, does not pay for it at all.
The counter in the corner of the view is the measured frame time. When a machine cannot hold 60 fps the scene is drawn into a smaller buffer — half the device pixel ratio at the worst, stepped at most twice a second, the HUD text still at full resolution — and the counter says what fraction of the pixels you are getting. Nothing on the page moves when it changes. Only frames that were actually drawn in an unclamped time count towards it, so a pause or a minute in another tab cannot talk the scale down.
A wall you have shot stays shot. Every round that hits the level leaves a mark where it landed, lying in that surface and turned by the normal the trace already reported, so a corner somebody has been spraying reads as sprayed and a missed shot leaves evidence that it missed there. They are a fixed ring of 64 — two full magazines of evidence, allocated once at start-up, the oldest replaced when the ring is full — they fade out over 25 seconds of the clock and not a number of frames, and a new round starts on clean walls. Nothing is marked for a hit on a player, or for the sparks a grenade throws into open air. All 64 of them are one draw: each is a small fan of triangles multiplied into the wall behind it, in the same two attributes the tracers use, so a sprayed corner costs one draw call and no second shader.
What a frame costs is counted, not hoped for: one draw for the level, one per
player drawn, one per body, one per gun on the floor, one per grenade, one for
a bomb that is down, one for every bullet hole in the level at once, one
batched draw for every line and one more for the muzzle flashes over them, plus
the view model and its own flash — two programs, switched at most four times.
src/client/renderer.test.ts draws a frame through the recording context and
checks the count against that rule, so a change that costs a second draw per
player fails the check rather than a phone; the worst round the game can build
(ten players, ten bodies, a floor of twenty-four dropped guns, ten grenades in
the air, a planted bomb, a sprayed wall and a screen full of tracers) is 60
draws against a budget of 80.
The radar in the bottom-left corner is your view, not the room: rotated so the way you are facing is up, it shows your living teammates, the bomb once it is planted, and both sites marked A and B — never the other team, because a radar that shows the enemy is a wallhack. Its map is baked once and turned with the canvas as you turn; nobody re-walks the grid to draw it.
Over the head of every player you can actually see is their name — your teammates in your side's colour with their health under it, an enemy's name and nothing more. "Can actually see" is the same geometry the bots shoot by: in front of the eye, inside the field of view, traced clear of the level eye to eye, and alive. A name through a wall would be a wallhack, so there is one trace per player per frame and no plate without it. The plates fade with distance and are clamped in size, so the far side of the map is a hint rather than a billboard; while you are dead they are the watched teammate's view, and a held <kbd>Tab</kbd> takes them off the screen. The nine traces and the projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
Shape
src/client/— the game, in modules named for one thing each:math,map
(a MapDef is a value — grid, boxes, spawns, sites, places — that buildMap
makes from a layout's areas and crates; a place is one of those areas
under the name a person would say it by, labelled once when the map is built
out of the area's own name — a site is A site, ct mid is CT mid —
and placeAt(map, pos) says which one a world point stands in, resolved
last-painted-first so it agrees with the cells buildGrid actually painted
and can never name a room you could not be standing in. That is the whole
vocabulary anything in the game has for saying where, there is no second
table of coordinates beside it, and map.test.ts holds every map to it:
a walkable cell nothing named is a red test. DE_FORGE and DE_CINDER are two tables
of numbers through one builder, and everything that reads a grid takes the
map rather than reaching for one; the catalogue — MAPS, MAP_NAMES,
DEFAULT_MAP, mapFor, built once each — is the seam a player chooses
through, offered as a Map select beside Difficulty and validated the same way
settings.ts validates difficulty. A third layout is a third table, a third
key and nothing else: the structural tests in map.test.ts run over every
map in the catalogue, so a site inside a crate, a crate stood in rock or a
room nothing can walk to fails the moment it is added — and world.test.ts
plays a seeded headless match out on each map in turn, so a layout the bots
cannot path is a red test rather than a match that never ends),
physics, weapons, combat, grenades (a thrown thing's flight: gravity,
bounce, fuse and how far a blast reaches), match (the rules), world (the simulation),
buy (the buy panel's rows — what a side can afford, what it already owns
and why the rest are dead — read off world and handed to main as plain data),
bots, plan (which site a side commits to, what the radio can say about
it, and when the carrier has run out of clock),
callout (what a teammate says out loud when it sees somebody, in the map's
own words, and the three gaps that keep four of them from filling the screen),
observe, action, policy, weights, models, textures,
press (the input latch), loop (how much simulation one frame is worth:
the fixed step, the caps, and a pause that banks nothing),
interpolate (what is drawn between two ticks: a position lerped, a yaw
turned the short way round the circle, and a teleport that snaps),
quality (the render scale the frame time buys),
decals (the round's bullet holes: a fixed ring of marks, as the vertices
one draw needs and the colours a clock fades them through),
spectate (whose eyes you borrow when you are dead), bodies (the dead of
this round, where they fell), radar (the blips the
HUD's minimap draws, and nothing it draws them with),
project (a world point as a screen point, from the same camera the
renderer draws with, or null when there is no pixel for it),
stride (what a body's limbs are doing, as three angles: the leg swing, the
free arm against it and the lean over the hips, off the ground a player has
covered — one footfall per step of it, the same step the ear is counting —
a picture, with no reading of it in the simulation),
summary (the end of
the match as numbers, and the final scoreboard both sides read from — the
same kills-then-damage ranking the in-match one uses, marked with which row
is yours, and the markup the #summary overlay drops in once, escaped the
same way the server-rendered page is), ears (a sound's pan and volume, as plain data),
hearing (what a sound is worth to someone standing somewhere else: how loud
it arrives through however many walls, and where they think it came from),
earshot (which of a tick's effects the person hears: the fixed ceilings a
footstep, a dry trigger, a plant and a defuse carry, the bomb's beep and its
blast carrying everywhere, and the cue it hands main — position, name,
weapon, pitch, amount — for audio.ts to play and flinch to feel),
motion (whether the decoration moves: two values, the media query that
supplies the first-run default and nothing else, and the line between what
is decoration and what is the game),
settings (mute, volume, sensitivity, difficulty and motion: read,
validated, and never NaN or an unknown name),
renderer, hud, input, audio, main.
Everything except the last four and weights is headless and tested;
renderer and hud are checked against recording contexts, which is how a
frame's draws are counted without a GPU — and how a lost context is tested
too, since the recorder's canvas takes listeners and a test can fire
webglcontextlost at it. Every GL resource the renderer owns comes from one
build(gl), so the restore is that same function a second time. audio
gets the same treatment against a recording AudioContext: createAudio
takes its context constructor as an argument, so audio.test.ts supplies a
fake that logs every node it creates, every connect, every .value set
and every scheduled ramp, in place of the browser default
(window.AudioContext ?? webkitAudioContext) main.ts still gets.
weights is scripts/train.ts's output, not code anyone writes by hand, so
there is nothing in it to assert beyond the numbers it happened to train to.
src/rl/— training, which the game never imports:arenais the environment
(a real duel on de_forge with the round and the economy taken out), ppo is
the algorithm, with its backward pass checked against finite differences, and
shard, worker, pool and cores are the same update spread over the
cores this process is allowed — shard has no test file of its own, but
collect and gradients run for real, through worker.ts, wherever
src/rl/pool.test.ts runs a pool. scripts/train.ts
runs them and writes src/client/weights.ts.
src/server.ts— the HTTP server and the security floor:content-security-policy
(scripts and styles from this origin only, frame-ancestors 'none',
base-uri 'none'), x-content-type-options: nosniff,
referrer-policy: strict-origin-when-cross-origin, a permissions-policy
that asks for nothing, cross-origin-opener-policy: same-origin and
cross-origin-resource-policy: same-origin (so no window we open or that
opens us keeps a handle on us, and no other origin can embed the page or the
bundle), and strict-transport-security once served over https. Every path
carries it — the page, the bundle, the JSON endpoints, and every error.
src/db.ts is the schema and its queries, src/result.ts validates what a
browser may claim, src/who.ts verifies the forge's signed identity
headers, src/player.ts turns "who is asking" into who a match is credited
to, src/limit.ts is the rate limit POST /api/match stands behind, and
src/pages/game.ts renders the page.
src/bundle.tsbundles the client;src/server.tsruns it at start-up and
serves the result from memory as /static/app.<hash>.js, cached for a year.
scripts/build.ts is a thin CLI over the same function, for a file on disk.
Neither the file nor the served bytes are ever stale, because there is
nothing committed to go stale. The bundle's gzip is made once at start-up,
beside the build; / is gzipped per request, since it differs by session.
src/compress.ts is the one question that decides either: whether a
request's Accept-Encoding actually offered gzip.
- No runtime dependencies: nothing is installed to run this.
typescriptand
@types/bun are dev-only, for bun run typecheck — the compiler and the
platform's own types, shipping no code into the bundle.
It also has a terminal: the exact command is in FORGE_APP_SSH when the app
runs (the app is the login name, the forge is the host). It opens src/tty.ts
on a real pty over the same database: the same standings, the same matches, a
who column that says signed or guest (a terminal has no markup, so the
tag a name cannot fake is a column of its own), and x removes one.
<kbd>Tab</kbd> walks the three tabs — the third is the policy card the page
shows, which the tab key could not reach until now.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or
preview), FORGE_APP_URL, FORGE_APP_SSH, FORGE_APP_COMMIT and
FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.
Without that key nothing can be verified, so everybody is a guest and the
board is a board of guests — there is no password and no way around it. The
server says so in one line at start-up and the terminal says so at the bottom
of the screen, because a leaderboard of nothing but guests is a missing secret
and not a bug in the app.
POST /api/match is the one endpoint that writes. It is same-origin only,
capped at the bytes actually read, and rate limited: 30 results a minute for a
signed player, and for a guest 30 a minute for their session inside 240 a
minute for their socket address — the one thing a guest cannot mint, and behind
the forge's proxy a ceiling all guests share. A match id belongs to the
identity that first sent it: anyone else posting it gets a 409. A database
written before identity existed is migrated on open — those rows were never
signed for, so they become the guests they always were.
A post the server refuses does not vanish quietly: src/client/posting.ts
turns the status into a short, honest line — offline, rate-limited, or
someone else's match — shown beside the leaderboard until a later post
succeeds. Only the post that ends or leaves a match gets a second try, once,
after the retry-after the server sent (clamped to 1–10s) or about a second
otherwise; a per-round post just waits for the next round to try again.
The bots
They are split in two. Getting to the site is a plan — src/client/plan.ts
decides it, A* over the map grid walks it, and the rest of it is ordinary code:
buying, planting, defusing, picking a dropped bomb back up, and listening to
the room. The fight is a reflex, and that is a policy learned with PPO.
Both halves are map-agnostic, and only one of them has seen both maps. The
plan takes a MapDef and asks it for the sites, the spawns and a path; there
is no per-map branch anywhere in plan.ts, bots.ts or findPath, so
de_cinder is played by exactly the code that plays de_forge. The policy is
the half that was trained on de_forge — scripts/train.ts still duels
there and nowhere else, and that is what CARD on the page and in the
terminal's policy tab measures. What it learned is not a map, though: its 32
inputs are distances, angles and clocks relative to the bot itself (see What
it sees), so nothing it holds is in map coordinates — and rather than take
that on faith, src/rl/arena.ts takes any map from the catalogue (defaulting
to de_forge, so CARD's number did not move) and scripts/duel.ts plays
the shipped weights, and the scripted baseline they are measured against, over
the same 240 fixed duels on whichever one you name:
bun run scripts/duel.ts # de_forge, what CARD was measured on
bun run scripts/duel.ts --map de_cinder # the other map, same seeds, same duels
Measured at 013620e:
de_forge | de_cinder | |
|---|---|---|
| shipped policy won | 73% | 75% |
| scripted baseline won | 39% | 43% |
The policy is not worse on de_cinder — if anything a couple of points
better, and the gap over its own baseline (34 points on de_forge, 32 on
de_cinder) holds either way, so the egocentric inputs are transferring the
way the design intended. (de_forge's numbers here are close to but not
exactly CARD's 72%/42% — the weights have not changed since they were
written, but the arena they are dropped into has, in the commits since; see
scripts/duel.ts for a rerun against today's code.) What does change with
the map is the shape of the fights those inputs describe — a courtyard with
four mouths hands the policy more duels at once than a corridor does, and a
site the terrorists reach first is a site they hold rather than take — and
that is a fact about the level, not the bot, which is what the two tables in
Measuring the round are about.
The plan is one site a side a round, rolled from the match's seed and the round
number rather than by each bot for itself, so the five terrorists arrive
together and the carrier is not walking in alone. The counter-terrorists stack
that roll three and two, so neither site is ever empty, and they turn towards
the site the last of them died at — the kill feed and the radar, nothing they
could not see. Rolling one site a side rather than one a bot took the plant
rate from 38% to 65% over twenty seeded SHORT matches when it landed.
A bot with an empty primary slot now detours to a dropped rifle a few steps
off its path (worthTheDetour in plan.ts is the whole arithmetic) but never
crosses the map, the site, or the bomb carrier's own plan for one.
A bot with the AWP scopes it. Past SCOPE_RANGE (500 units) it zooms in,
and it stays zoomed until the target is inside UNSCOPE_RANGE (350). The gap
between the two is there so that an enemy stepping back and forth across one
line can't flip the scope every tick. Once zoomed it barely moves (at most
SCOPED_MOVE, 15% of its stick, on either axis), and it doesn't fire until
its feet are under STEADY_SPEED (60 u/s). That makes the shot 0.11°, where
the same gun fired from the hip while strafing is 7.7°. Inside 500 units, or
while the terrorists are out of clock and running for the site, it keeps its
speed and fires from the hip. Below SCOPE_SKILL (0.3), which covers most of
Easy, it never scopes at all. All of this sits in snipe in bots.ts and runs
after whichever fight made the decision, learned or scripted. The policy has no
scope action and its inputs have not changed, so the shipped weights mean what
they were trained on. In throwaway duels down one long line (a skill-0.8 AWP
bot against a skill-0.8 AK bot, 80–250 duels a range), scoping from 500 never
lost. The scripted fight gained 14 points at 700 units and 40 at 1000. The
learned fight was level from 450 to 1100 units, because at that range the
bot's own aim error decides the duel more than the spread does, and it gained
14 points at 1400. Over 40 seeded SHORT matches on seed 7 the side split held
at T 63% before and after.
A bot tops up its own magazine, too — but only the moment pickTarget finds
nobody to see, which is the same trace the fight already runs, so this can
never be the thing that starts a reload in front of someone. TOP_UP_SHARE
in bots.ts is the threshold: half a magazine, because a duel is decided in a
handful of shots and waiting for less would mean topping up mid-fight almost
as often as waiting for empty does. A bot that does run dry mid-fight draws
its sidearm instead of standing through a rifle's reload, if the sidearm
still has bullets in it — outOfAmmo in bots.ts — and only reloads once
that, too, is what is left in its hands. Neither of these reads the
difficulty: a full magazine going into a fight is worth the same whatever
skill is holding it. Measured headless over 30 seeded SHORT matches on
seed 41, before and after: the plant rate rises from 67% to 81%, and rounds
ending in an elimination fall from 26% to 12% — the fight a bot used to lose
standing still through its own reload.
The clock is part of it, and both sides read it. plan.ts holds the two
numbers that say how late is late, and nothing else in the code decides it.
PUSH_WINDOW (12 seconds) is the fighting the terrorists can still afford on
the way in: once what is left of the round is the walk, the plant and that
window, the whole side — not just the carrier — stops trading ground for duels
and walks onto the site, with the policy still aiming and still firing on the
way. HUNT_AT (42 seconds) is the mirror of it: with the clock that low and no
bomb on the floor, the terrorists are not coming to the corner a defender is
sitting in, so the defence leaves it and sweeps the half of the map they came
from (muster in map.ts, the middle of a side's spawn). A call outranks
both: hold stops the push where it stands, and go A names the room to hunt
in. Measured headless over 30 seeded SHORT matches on seed 41, before and
after: rounds that end on the clock fall from 25% to 9%, the plant rate
holds at 61–62%, and detonations hold at 103–105 — what those rounds turn into
is a fight somebody wins rather than a timer nobody does.
A person gets a say over their own side's half of that through the radio
(see Playing). A call is one line of data on the world — the side, the word,
the time — and plan.ts reads it in front of the roll: while it stands, that
side's bots are handed the called site instead of the rolled one — and told
that a person said so, which is what puts it in front of the contact rotation
the defence does for itself — and when it expires or the round starts they are
handed the roll again. bots.ts only
asks for its orders and never learns where they came from, and a world nobody
has called anything in does not pay for the feature: with the radio silent the
answer is the same siteFor it always was, which is why the arena and
scripts/measure.ts print the same numbers they printed before.
The other half of that radio is the team talking back (see Playing).
callout.ts is the whole rule: the one thing a bot may say, the words it is
said in, and three gaps — four seconds for the side, six for one bot, twelve
before the same place is called again. world.ts's callout is the gate in
front of it and it is ordered by price: the person's own side, a round
actually being played, then two numbers off the rate limit. Only once all
three have said yes does anything name a place or build a string, and the
whole gate sits behind bots.ts's contact edge — a bot that can already see
the enemy it is shooting at does not reach it at all. So the arena pays
nothing: scripts/cost.ts reports the same 0.12 ms mean and 0.23 ms p95 a
tick it did before, and scripts/measure.ts plays the same 87 rounds with the
same plant rate and the same side split, in the same 78 seconds.
- What it hears — the sounds the world already makes, read off the effect
list once a tick (src/client/hearing.ts, and listen in bots.ts). A
gunshot is the loudest thing on the map and an AWP the loudest gun; a plant
and a defuse carry past the site; a running footstep carries about a
corridor and a half; a dry trigger barely leaves the hands it clicked in. A
wall between costs a few hundred units of apparent distance rather than
silence, and the point a bot turns towards is off by more the further away
the noise was — a footstep across the map is a direction, not a pin. A bot
never hears itself or its own side.
What stays silent: walking (<kbd>Shift</kbd>) and crouching make no
footstep at all, so a quiet flank is genuinely quiet — that is the whole
point of the key. The bomb's own beep is heard by the person and ignored by
the bots, because it repeats twice a second from somewhere both sides
already know and would drown out everything else near a planted site. And
none of this reaches the policy: what a bot hears moves its feet and its
view, never the 32 numbers below. The training arena empties the effect list
every step, so the opponent the weights were trained against is deaf, and
src/rl/arena.test.ts pins that.
- What it sees — 32 numbers, all egocentric (
src/client/observe.ts): the
sine and cosine of its aim error, the range, the enemy's health and whether it is looking back, its own health, armour, magazine, recoil, current inaccuracy and how long since it last saw anything, plus how much open floor is ahead and to each side. Nothing in map coordinates, so what it learns in one corridor works in the next one.
- What it does — six small categorical heads (
src/client/action.ts): a
turn rate, a pitch rate, forward, sideways, fire, and stance. Flattening a structured action into independent heads instead of one 3402-way choice is the trick PufferLib calls emulation, and it is why one plain PPO loop drives all of it.
- Where it learned —
src/rl/arena.ts, a duel on de_forge with the real
movement, the real bullets and the real damage, and no round, economy or bomb. It steps about 13,000 ticks a second on one core, allocation-free; the policy decides every fourth tick and the decision is held in between.
- How it learned —
src/rl/ppo.ts, about four hundred lines: GAE, a
clipped surrogate, an entropy bonus, Adam, and a backward pass written out by hand and checked against finite differences in the tests. There is a curriculum on how far apart the duel starts and on how hard the opponent is, because handed a good opponent from the first step the policy learns to hide and never comes back from it.
- Who it learned against — a mix that moves with the run: targets that do
not shoot back at the start, then the scripted bots, then a league of four frozen copies of itself, drawn at random. Practising only against your newest self is how a policy learns to beat one opponent and forgets the rest.
- How fast — the run is cut into shards, one worker each, over the cores the
process is actually allowed (src/rl/cores.ts reads the cgroup quota, because
this container reports 80 cores and is allowed two). Each shard steps its own
environments into its own rows of one shared rollout and differentiates its
own slice of each minibatch; the plan, the normalisation and the Adam step
stay on one thread. Nothing is copied — every buffer that crosses a thread is
a view onto a SharedArrayBuffer — so the experience is identical bit for bit
however many shards there are, which src/rl/pool.test.ts checks against both
one shard and real workers. Measured on two allowed cores, whole updates and
not just stepping: 9k ticks a second on one thread, 17k across two, and 5k
across eight — oversubscribing a quota does not fail to help, it hurts.
- What it costs you — about eight kilobytes of int8 in the bundle, one
dequantisation when the page loads, and roughly 7,700 multiply-adds per bot per decision: nine bots deciding at 16 Hz is well under a millisecond a second.
Skill is still a handicap laid on top, so the ladder the game already had
survives: a low-skill bot gets the same intent from the policy but cannot turn
as fast, has a wobble on its aim it cannot see, and waits longer before it
shoots. The Difficulty setting picks which band of that 0..1 skill scale a
match's bots are drawn from — Easy, Normal or Hard — so the ladder from fodder
to uncomfortable still runs inside whichever one is chosen; see Playing for
where to set it. src/client/bots.ts keeps the pre-policy fight as scriptedFight,
unused by the game, because it is the opponent the policy trains against and the
baseline it is measured against — a baseline that moves with the policy measures
nothing.
The site at <https://puffer.ai/> could not be reached from this sandbox (the
network here is the forge and the package registry), so the influence is from
what PufferLib is known for rather than from its documentation: flat observation
and action spaces, environment throughput treated as the thing that decides
whether a run is possible, and plain PPO rather than a clever algorithm. Hence
the shape of src/rl: the same one-process PPO loop, an arena that steps
without allocating, and the parallelism in how the work is cut up rather than in
a framework.
Measuring the round
scripts/measure.ts plays a handful of seeded matches headless — the person
idle, the bots playing both sides — through the same world.ts a browser
steps, and prints one table: how often the bomb goes down, how rounds end, and
how long they take. src/client/rounds.ts does the arithmetic on the rounds
it is handed and is what the tests check; the script only steps worlds and
prints. Same flags, same seed, same table, so a change to the bots can be
argued about with a diff:
bun run scripts/measure.ts # 12 seeded SHORT matches
bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed
bun run scripts/measure.ts --rules long # COMPETITIVE rules instead
bun run scripts/measure.ts --difficulty hard # harder bots instead of normal
bun run scripts/measure.ts --map de_cinder # the other map, same seed
--map takes a name from the catalogue and nothing else; a name MAP_NAMES
does not hold falls back to the default rather than measuring silently.