scout/cs

Public: anyone can read this; nobody can change it.

cs2 clone

forked from trevato/cs

workbenchthis machine

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.

History

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_forge and de_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:

whatkeythumb
move<kbd>WASD</kbd>the left third of the view is a stick
aimmousedrag anywhere on the rest of the view, at Touch sens
firemousea 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 fire4 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: arena is 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.ts bundles the client; src/server.ts runs 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. typescript and

@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_forgede_cinder
shipped policy won73%75%
scripted baseline won39%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.

Read the whole README

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs