torque/cs-engine

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

cs2 clone

forked from trevato/cs-engine

workbenchthis machine

This repository has no app yet.

following trevato/cs-engine in step

Everything here is up to date with trevato/cs-engine.

Proposes updates from trevato/cs-engine.

History

README.md

cs-engine

A browser game engine with three games on it. The first, at /, is cs: a Counter-Strike-shaped defusal shooter, five against five on one map — bomb rounds, a buy menu and an economy, bots that walk to a site and shoot back — and the leaderboard of the matches people played. The second, at /range, is built only out of the engine the shooter was taken apart into: a shooting range — two drills of seeded targets, plates that pop up and plates that slide across the lane, a shot traced through the engine's boxes, a thirty second clock and a score, with the fixed step and the controls under it — and it is drawn by the engine's renderer: its own room, its own plates and its own tracer, out of an atlas and two meshes it painted and built itself. The third, at /example, is the example: the guide's own game — one room seen from above, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else — served to say what building on this engine actually costs. The engine in src/engine/ is the point of the repository; the shooter is what proves it, and the example is the price tag. /engine is where a stranger takes it: the package as one download, the modules behind its door, and its whole guide.

PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
bun run scripts/build.ts [dir]                          # build every <name>.<hash>.js on disk, with its size (default static/)
bun run scripts/train.ts --iters 1200                   # retrain the bots' policy, on 96 maps
bun run scripts/train.ts --iters 1200 --maps 1          # the way it used to train: de_forge only
bun run scripts/train.ts --bench                        # how fast the arena steps
bun run scripts/pack.ts [dir]                           # write the engine out as a package (default dist/engine)
bun run scripts/surface.ts                              # is the door still the surface the version was recorded for?
bun run scripts/surface.ts --write 0.2.0                # record today's door as that version
bun run scripts/cost.ts                                 # what each group behind the door adds to a bundle
bun run scripts/cost.ts --guide                         # the same, as the lines GUIDE.md carries
bun run scripts/room.ts                                 # what one room costs: CPU a world-second, snapshot bytes, rooms a core
bun run scripts/room.ts --seconds 30 --margin 0.25      # a longer window, and a quarter of a core for rooms
bun test                                                # the check every pull runs
bun run scripts/mutate-result.ts                        # break each rule in src/result.ts and src/gate.ts, see a test go red
bun run scripts/alone.ts                                # every test file in a process of its own: does any pass only in company?
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal interface

The bots' round: planning with TypeSafe AI

The bots have had two layers for a while — A* solves the objective, a trained policy runs the fight — and nothing in between: every bot flipped its own coin for a site, so five attackers arrived at two sites in ones and twos. There is now a third layer, the round itself.

A round plan is one site, one shape (stack, split or fake), a tempo and a save, per side. src/plan/plan.ts cuts it into one job per bot — the site it walks to, whether it goes in first or gives the entry its beat, how far off the site it is willing to stand, and whether it buys at all — and src/client/bots.ts plays it. The fight is untouched: the policy and its weights are exactly what they were.

Where the plan comes from depends on one environment variable:

  • No TYPESAFE_API_KEY (every preview, and production until somebody sets

one): the page plans every round itself with localPlan, which is arithmetic over the situation. Nothing is sent anywhere, and the bots are still a team.

  • With a key: the page posts the round's situation — the map, the round

number, the score, what each side can afford, and nothing about you — to POST /api/plan on this server, which puts it to TypeSafe AI as typed questions: a choice between the two sites, a rubric for the tempo, a yes/no for the save. The answer is validated back into a plan or dropped for the local one. The key is read from this process's environment at start-up and never reaches a page.

What is real and what is not: the plan is real — a model really answers it when a key is set, and you can watch the call in the round's line on the HUD, which says which side is doing what. Everything else about the game is still local: the match, the fight, the physics and the weights all run in your browser.

A planned match is still the match its id names (#238). A model asked the same question twice may answer it two ways, so with a key the answer is made part of the match: the page posts the match id beside the situation, and src/plan/served.ts answers each round of a match once — the model's plan, or the local one it fell back to — records it in the plan table under FORGE_APP_DATA keyed by the match id and the situation, and hands every later playing of that id in that situation the recorded plan without asking again. The record is SQLite, not a process's memory, so a restart keeps it. The page never waits for it: the local plan is in force from the first tick, an answer is held and played from the tick the freeze ends (offerPlan in src/client/bots.ts), so when it arrived is never part of the round, and one that arrives after the freeze is dropped. The server gives the model five seconds, retries included, of an eight-second freeze. What is not guaranteed: if the first playing's answer crosses the network after that freeze ended, that playing ran the local plan while the record holds the model's, and later playings get the record. The chosen alternative — "a pasted id plans locally" — was cheaper and airtight, but it would have spent the planner on nobody's shared match and made a fresh match's link replay as a different one. The pin is PLANNED_ROUND in src/client/replay.test.ts. Without a key, nothing is recorded and nothing changed.

TYPESAFE_MODEL overrides the model (the SDK's default is jev-latest). Planning is rate-limited per caller and under a spend ceiling for the whole process; over either, or on any failure, the round gets the local plan, which is a plan and not an error.

What the planner cost

The planner counts what it does, where it decides it: rounds put to it, answers from its memory, requests joined in flight, rounds a model answered, and each failure by kind — timeout, connection, HTTP status, an answer that was not a plan. The HTTP requests it sends upstream are counted too, the SDK's one retry included, because that is what is billed. The per-caller limit and the spend ceiling each count their own refusals (src/limit.ts), and a round asked of a server with no key is counted as that. TypeSafe reports tokens, not a price, so the readout counts input and output tokens and never shows money.

Where to read it:

  • The terminal — the policy tab, under the card: planner on or off, the

model, the ceilings (per caller, whole process, timeout, retries, how many answers are remembered), then every count. The key is never shown, not even in part.

  • / — one sentence under the paragraph about planning, the same sentence

the terminal prints; with no key it says no round has been asked of a model.

These are integers in the server process, reset when it restarts and labelled with when it started: a readout, not a metrics system. Nothing is logged per round. The terminal is a separate process, so the server leaves a copy in FORGE_APP_DATA/planner.json (zeros at start-up, then at most once a second after a round is planned), and the terminal reads and validates that file. src/plan/readout.ts is the shape and the sentence.

The radio: three calls, and your side goes

<kbd>Z</kbd> says go A, <kbd>Q</kbd> go B and <kbd>H</kbd> hold (on a phone, the three buttons on the left pad). A call stands in front of the round's plan for your side for 20 seconds and then falls back to it: a site call is every bot on your side on that site at once (a stack, at a rush, the lurker too); a hold keeps them where they stand until the call runs out, except the bomb, which never waits. The HUD's message line says what was called and by whom, and your side hears the radio's blip — the other side does not, and its bots never see the call. src/plan/call.ts is the override (steer and heed, plain data beside plan.ts), src/client/radio.ts makes a call in a world, and src/client/bots.ts cuts its jobs from whatever those two say, so a call that expires is the rolled plan again on the next tick. A seeded round with a call in it is pinned in src/client/replay.test.ts (CALL_ROUND). In a room the radio keys say the radio is for matches against bots: the room's server takes no calls yet.

What is real and what is not

  • Real. 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 the 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, and standing back up in the air is refused while the legs have nowhere to go, the same way it is under a low ceiling. It lives in the engine's move, so the range's body tucks its legs too. 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 nearest floor beside it and waits), CS damage with range falloff, armour and hitgroups, deterministic spray patterns, two thrown grenades — an HE, and a flashbang that blinds by how much of it you could see (the line, the angle and the distance, for at most 1.6 seconds) — a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and loss bonuses. The round is audible: a footstep as a foot lands — not while you walk with <kbd>Shift</kbd> and not while you duck, which is what walking is for — the bomb beeping faster as its fuse runs down, the plant, the cut wire and a body going down, each placed where it happened and heard from the eye the camera is behind. A footstep is at full volume inside 256 Source units and a quarter of that at 1,024; your own boots are a third of somebody else's. Bodies read as people when they move: a player running bobs once a footfall, phased by the floor they have crossed rather than the clock, twists at the hips and leans into the run; one who lands after a jump sinks into it; one who dies goes over and stays down. All of it is drawing, worked out from the round's own numbers, and none of it is in the simulation. The leaderboard is a real SQLite table under FORGE_APP_DATA.

  • Two kinds of map. de_forge is drawn by hand, cell by cell; the other

kind is carved from a number by src/client/generate.ts — pick generated on the menu and the match id is the seed, so every match is a layout nobody has played. A generated map is stamped twice, once at each cell and once half a circle away with T for CT and A for B, so it is the same map for both sides and the walk to your site is the walk to theirs, measured to the unit. It is not shipped until it passes its own gate: spawns to stand on, two sites to plant on, a path from each spawn to each site, and no floor that cannot be walked to. A map costs 2.1 ms to make, which is the press of the Play button.

  • A match is its id. The uuid a match is started under is the seed of the

whole of it, not just of the map: which spawn each body takes, which terrorist carries the bomb, what the bots buy, and where every shot lands inside its cone. Two matches begun on one id are the same match tick for tick — that is a test in src/client/main.test.ts — and the id is the one the leaderboard already stores, so a round on the board is a round that can be played again. It used to be the wall clock, and nothing about a match could be reproduced.

  • Generated, not licensed. The map (de_forge), the textures, the player and

weapon models and every sound are produced in code at start-up, by four generic tools in the engine — a grid of cells that meshes and paths, a box mesh builder, an atlas painter over hashed value noise, and a two-voice sound device — driven by this game's recipes in src/client/{map,models,materials,audio}.ts. The range does the same for itself out of the same two tools and on its own numbers, in src/range/{level,paint}.ts: a room and a three-layer atlas that owe de_forge nothing. 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 the engine's PPO against millions of ticks of this same simulation, in the arena under src/rl. 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. Those numbers are a snapshot of one afternoon, and src/rl/guard.ts is what keeps them honest between afternoons: every push replays the shipped weights on named seeds and holds them to a floor.

  • 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 in the rounds) and rejects the rest. Treat the leaderboard as a scoreboard among friends, not as a ranking.

  • A result that did not land says so. The page posts the result of every

round as it is scored, and the line under the leaderboard says which of three things happened to the last one: it is on the board, it is not on the board yet and is being tried again, or it is not on the board and nothing more will be tried. A post that threw or came back 429 or 5xx is kept as the one pending round — never a queue, and always the newest numbers — and retried from the frame loop with a doubling wait, four attempts at most, one in flight. A 422, 409, 400, 403 or 413 is the server refusing the round itself, so it is not retried and the line says the round is not on the board. The line is a live region, so it is announced and not only drawn. Nothing is kept across a visit: a round that never reaches the server before you close the tab is gone, which is what the page says rather than promising otherwise.

  • A match ends on a scoreboard. The last round scored does not hand you the

menu back: the run stops and the page shows what you just played — who won, the final score, your own line (kills, deaths, damage, headshot share and the rounds you won) and both teams' rows in the order the <kbd>Tab</kbd> scoreboard draws them. On a phone held sideways the ten rows scroll inside a fixed height, so the buttons under them stay on the screen. The numbers on that screen are the same object that was posted to the leaderboard — src/client/summary.ts builds it once — so the screen and the row cannot disagree, and the line under it says whether the result reached the board, is still on its way, or is not going to. Play again starts another match on the settings still in the menu; Back to the page puts the menu back and hands the screen over if the stage had it.

  • The menu remembers you. Your name, your side and the match length are

kept in this browser's localStorage (cs.name, cs.side, cs.length) when a match starts, and put back in the menu on your next visit. A stored value that is not one of the menu's own answers is ignored, and the page's default stands.

  • Play is the first thing. The menu opens on your name, one sentence that

says what Play gives you — the length, the side and the map, and whether it is the round of a match id — the plan of that map, and Play. Every other control (length, side, map, map name, sensitivity, motion, match id, today's match and rooms) is in one fold under Play, a <details> that opens in place with no script and from the keyboard. The sentence is written by one function (src/client/offer.ts) from the values the controls hold, on the server and again whenever a control changes, so your stored side and length are what it says. A link that asks for anything — ?match=, ?map=, ?room= — opens the fold, so what it filled in is on the screen. A new setting for the menu goes inside that fold, not beside Play.

A match you can play again

A match is one short string. The id picks the spawns, the bots, the bomb carrier and the seed every roll in the round comes out of — and the map too, but only when the Map control asks for one carved from the match. The map is a choice made beside the id, so the thing that carries a whole match is the link: handing that over is a rematch, a challenge to a friend and the first line of a bug report.

  • Paste it. The menu has a Match id field, and it takes both of the

things people actually copy. A bare id plays that match again — the same spawns, the same bots, the same seed — on whatever map the Map control is on, which it leaves alone. A whole link from this page fills in the id and the map, so you get the match you were given rather than its round on somebody else's layout. Leave the field empty and you get a new match, exactly as before. On a deployment where a model plans the bots, the plans come with the id too: each round's plan is kept with the match the first time it is asked (see A planned match is still the match its id names above).

  • Link to it. /?match=<id>&map=<de_forge|generated|gen_…> preselects both.

The page's own Link to this match, under the stage, is that URL for whatever you are playing: a plain <a href> a keyboard reaches and a thumb can hit, not a clipboard trick. The map is in the link because the id carves a map only when the match asked for a generated one; on de_forge the id is still the whole of everything else.

  • Read it. The id of the match you are playing is in the This match

section under the stage with the map's name beside it (one click selects the whole id), and in the pause panel, which is all there is to read when the stage has the screen. In the terminal, the matches tab prints the id of the match under the cursor, ready to paste into the same field.

  • A rematch is a new row. The id is the match; the key a result is stored

under is one playing of it — <match>--<nonce>, where the nonce is twelve hex from a fresh uuid. So two people playing one id, or one person playing it twice, are separate rows on the leaderboard rather than one overwriting the other, and matchOf reads the match back out of a key, which is why pasting a row's id works as well as pasting the id you were given.

A pasted id is an input, and it is checked twice against one definition: src/matchid.ts says what an id is (hex and dashes, eight to sixty-four characters — the shape src/result.ts has always accepted for a result's matchId), src/server.ts runs the ?match= query through it before the page is rendered, the page escapes what it renders anyway, and src/client/chooser.ts runs the field through the same door again before anything reaches generateLevel. Anything else is a fresh match and a sentence saying so, never an error page and never a refusal to play.

A pasted link is an input twice over, and src/matchlink.ts is the one place that reads one. It reads and never follows: the URL is parsed, the origin has to be this app's and the scheme http: or https: (so javascript:…, //somewhere.else/?match=… and a different port are all refused), only match and map are taken out of it, and those two go through src/matchid.ts and src/mapname.ts exactly as the server's own query string does — the path, the fragment and every other parameter are dropped. A URL that is not ours, or ours with neither parameter on it, gets what a bad id gets: the sentence under the field and a fresh match.

A map you can keep

A generated map is carved from a number, and that number has a name: gen_14u5j6g, base36 of the seed. The name is the map — the same name is the same forty by forty cells on every machine, for ever — so a layout you liked is a thing you can write down and hand to somebody who has never played the round it happened in.

  • Choose it. The menu's Map control is three things in two fields: the select

(de_forge, or generated — a new one every match) and a Map name field. A name in the field is that exact map and wins over the select; empty, the select decides.

  • Read it before you play. The line above Play names the map Play is about to

give you — de_forge, or the name of the map that is about to be carved. On generated the page draws the match id the moment it needs to name that map, so the name on the screen is the map you get and not a guess.

  • Keep it. Beside that name is Copy this map's link, which puts the whole

URL on the clipboard. It is an <a href="/?map=gen_…"> first: a keyboard reaches it, it is 44px to press, and a plain click — or a browser with no clipboard, or one that refuses the write — simply opens the page on that map. The name of the map you are playing, under the stage, is a link to it too.

  • Open it. /?map=gen_14u5j6g preselects that map. ?map= is the shooter's

parameter and no other page reads it.

  • Compare on it. /?board=gen_14u5j6g narrows the all-time leaderboard to

results played on that map, and /?board=de_forge to the fixed one; no ?board= is every map added together, and the page says in a sentence which one it is showing. The links above the table are every map, de_forge, and the generated maps with at least three finished results (LISTED_RESULTS in src/db.ts), most played first, at most eight maps in all (LISTED_MAPS); a map a link named that is not listed takes the last place, and the line under the links says the rule. The list is kept one row a map in board_map, moved by each write, and read as the first few entries of board_map_by_results (SEARCH board_map USING COVERING INDEX board_map_by_results (results>?), no sort step), however many maps have ever been generated. ?board= goes through the same src/mapname.ts grammar as a stored result; anything else is every map and a sentence saying so. A round played from a page on one map's board keeps it: the page posts to /api/match?board=<map> and the leaderboard it gets back is that map's, so the URL stays the state after the table refreshes. The board on one map is one range of the standing_by_rank index (SEARCH standing USING INDEX standing_by_rank (map=?), no sort step), about 0.04 ms at 20,000 matches on 200 maps.

A name is a look as well as a layout. The atlas holds nine materials, painted once at boot; src/client/looks.ts writes down eight named sets of them — forge, foundry, quarry, depot, bunker, kiln, plaza, hangar — and a generated map picks one from the same seed that carved it, so gen_9ix is a metal-floored hangar with red-painted sites on every machine and gen_5 is a quarry. Nothing is repainted and nothing more is downloaded: the layers were in the texture already, and what changed is which one a box's vertices name. The sets are named rather than rolled per surface because nine materials do not combine freely — looks.test.ts measures each set against the actual shaders and holds it to the distances a room needs to read: cover off its floor and off its wall, a bombsite off the floor it sits on, and a player legible against all three. de_forge is the forge set, which is the numbers this game shipped with, unchanged. The line beside the menu's map preview ends with the look's name, so a player reading rather than looking gets it too.

A name is an input and it is checked twice against one definition: src/mapname.ts says what a name is — gen_ and base36, and the test is the round trip, so gen_0ya and gen_zzzzzzzz are typos rather than maps — src/server.ts runs the ?map= query through it before the page is rendered, the page escapes what it renders anyway, and src/client/chooser.ts runs the field through the same door again before anything reaches generateLevel. Anything else is de_forge and a sentence saying so, never an error page. The seed itself is pinned: src/client/generate.test.ts folds gen_14u5j6g's cells into a digest through the engine's digest door and holds it against a committed constant, so a change to the generator that would move somebody's map fails the test rather than surprising them.

The match of the day

Everyone who plays today plays the same match. The rule is the whole of it: take the calendar day in UTC and write it without its dashes — the match of 2026-09-21 is the match 20260921. That is a match id like any other, so it carves the map and seeds the bots, the spawns, the bomb carrier and the spray, and every visitor derives it from their own clock; the server never tells anyone what it is. UTC, said on the page, so the day turns at the same moment for everybody.

Pick today's match in the menu fills the controls with it — the id, the Map control on generated, and first to 5, because the board ranks by rounds won and rounds won compares nothing across two lengths. The map is named under the controls before you press Play, like any other match.

Today's board stands beside the all-time table rather than replacing it: one row a player, their best run at today's match, ranked by rounds won, then kills, then damage — the order printed beside it. Play it as often as you like; only the better run counts, and the row says how many there were. Which day a result counts for is read out of its id by the server and never taken from the browser: a result is filed under the day its id names and never under a day that has not begun here, so deriving next week's id early buys nothing. It is a score against bots on a fixed seed, reported by the browser that played it — not a competitive ladder.

src/daily.ts is the rule, dayBoard in src/db.ts is the one extra query, and the terminal's fourth tab is the same board.

Who a score belongs to

The numbers in a result are the browser's word; the name on it is not. Whoever is signed in to the forge arrives with two headers on every proxied request — x-forge-identity (<userId>|<username>|<role>|<expiresMs>) and x-forge-signature, an HMAC of it under FORGE_APP_IDENTITY_KEY — and src/who.ts recomputes that signature and compares it in constant time. A result posted with a verified identity is recorded under that name, whatever the name field said, and a result posted without one is a guest.

  • A guest picks their own name and nobody checked it, so it is shown as

name (guest) everywhere it appears — the leaderboard, the match feed and the terminal — and the standings group by identity and name together. A guest typing someone else's name gets their own row, never theirs. Those rows are kept in their own standing table, recomputed for the one player a save or a delete touched, so drawing the leaderboard on / reads ten rows rather than grouping every match ever played (13.9 ms at 20,000 matches before, 0.2 ms after); an older database builds the table once when it opens.

  • A match belongs to whoever started it: a signed-in player's from any browser

they are on, a guest's from the browser session that began it. Anyone else posting that match id is refused with a 409.

  • Writes are rate-limited: forty results back to back and one more every two

seconds, keyed by identity for a signed-in player and by socket address and session cookie for a guest. A guest is limited twice, because neither key alone is enough: behind the forge's proxy the address alone would be one bucket for every guest on the internet, and the cookie alone is something the caller writes and can rotate, so the address carries a second, wider bucket underneath (two hundred back to back, four a second sustained). Signed-in players are not behind that one, so a flood of guests cannot lock them out. Over either is a 429 with a Retry-After. A real match posts once a round scored, so a player never sees any of it.

  • With no FORGE_APP_IDENTITY_KEY in the environment there is nothing to

verify against, so everyone is a guest and the start-up log says so. That is the only sign-in this app has: no password, no cookie of its own that names a person, nothing a page can set.

A match nobody finished

A result is posted as every round is scored, so the newest rows under Recent matches are often matches somebody is still playing — and one of them is sometimes a tab that was closed hours ago. A row is read against the clock rather than believed: liveness(row, now) in src/db.ts calls a row finished, live or abandoned, and a match that has not been written to for IDLE_SECONDS — ten minutes — is abandoned wherever it is rendered: · abandoned in the feed on /, (abandoned) in the terminal's matches tab. One function, two renderers, so the page and the terminal cannot disagree.

Ten minutes is derived, not picked. The longest round the rules allow is 172 seconds (freeze 12 + round 115 + bomb 40 + end delay 5), and a post that has to be retried adds at most 14 more (src/client/pending.ts), so the gap between two posts of a live match has a ceiling of 186 seconds; ten minutes is over three times it.

Nothing is written, deleted or rewritten to say any of it — this is how a row is read. A tab that was asleep posts again, updated_at moves, and the same row reads as live with no flag to clear and no special case anywhere. The standings are untouched either way: an abandoned match counts the rounds, kills and damage it scored, exactly as it did before.

The numbers, on a page of their own

/weapons is every weapon in the registry with what it costs, what it fires, how fast it lets you walk and how much of a vest it goes through — and beside it the thing a player actually argues about: what one shot does to a person. Head, chest and leg, against a bare body and against kevlar and a helmet, with the shots it takes to put that body down, and a last column saying how far one shot to the head still kills from. The Deagle's row says all 8,192 units; the M4A4 through a helmet says never; the AK says 2,840 units.

Under the tables is the one weapon with two damage numbers. The knife does 180 in someone's back and 55 anywhere else — 153 through kevlar and a helmet, which ends a whole body in one stab where the same body takes three from the front — and a back is anything more than 118° off where they are looking, measured flat on the ground, so it is a flank and not a duel won by strafing. That sentence is computed too: it is computeDamage's back arm and shotCost's front, printed by backSaid in src/sheet.ts, and a weapon without a backstabDamage prints no line at all. Nothing else in the table has a back — a rifle fired at someone's back does exactly what a rifle has always done.

It has an address because it is the one part of this app you send to somebody: paste /weapons and they get the numbers and nothing else — no leaderboard, no menu, no essays. It prints, too: the nav, the pill, the footer and the terminal line are dropped on paper and the tables stop being scroll boxes, so what comes out of a printer is the figures. The shooter's page keeps three sentences of the same sheet and a link to it, rather than the whole table behind a <details>: a closed <details> still ships every byte of it to every visitor, and moving it took 1,586 gzipped bytes (9,231 raw) off /.

Not one of those figures is typed. src/sheet.ts reads the table in src/client/weapons.ts for the stats and fires the shots through src/client/combat.ts for the damage — the same applyHit a bullet in a round goes through, into a body with the health src/client/world.ts spawns and the armour src/client/economy.ts sells — so change a number in weapons.ts and the page moves with it, and a test fails if it does not. That holds for the summary on / as much as for the tables: how many guns there are, what the dearest costs and which of them kill with one shot to a helmeted head are all read off the same sheet. src/pages/weapons.ts renders it into the first paint (no request, no script, the whole document built and gzipped once at start-up), and the terminal's weapons tab renders the same sheet as columns. The assumptions are printed under the tables, because a figure without the body it was measured against is a rumour.

That rule is no longer the table's alone. Every number in the prose of /, /range and /example is rendered from the module that owns it — the HE's damage, falloff and fuse, the flashbang's blind time, the step height and the smoke's ball from src/client/, the range's speed, gravity, friction, jump apex, crouch drop, drills and atlas from src/range/, the two games' fixed steps from each game's own pinned script, and the bundle sizes from the bundles themselves. A jump apex is not stored anywhere, so src/pages/tuning.ts derives it once (v₀²/2g) for the page and the test both, and a test jumps a body through the engine's solver to check the shortcut. What holds it together is one test helper, src/pages/digits.test-helper.ts: it reads a page, throws away the shell, the styles and the rows a database filled, and reports every group of digits left that no module accounts for — with the sentence it sits in, so the fix is obvious. Words are still words: "about eight kilobytes", "thirty seconds on the clock" and "a quarter of the air acceleration" are prose, and the rule is about digits, not about claims.

The engine, to take away

/engine is the engine as something you can leave with. It says what the package is — TypeScript sources, one door (index.ts), no dependencies, the guide and the sample game — and offers it as one file, cs-engine-<version>.tar.gz, with its size, its sha256 and the two lines that start a game on it:

tar -xzf cs-engine-<version>.tar.gz
bun build cs-engine-<version>/example.ts --outdir out

Under that are the sizes the guide's cost story is made of (the same Cost /example prints, plus the package's own bytes), every module the door re-exports with the comment the door writes above it, and GUIDE.md itself. The guide is shown as its own text, escaped and preformatted, with an anchor on every heading (/engine#guide-renderer) and a contents list — not rendered as markdown, because that is a dependency or a second parser, and the page says so. A prettier reading is a later change.

What is real: the archive is built by this server when it starts, from the tree it runs — scripts/pack.ts into FORGE_APP_DATA/engine/cs-engine-<version>/, tarred by src/tar.ts and gzipped at level 9 into FORGE_APP_DATA/engine/cs-engine-<version>.tar.gz, then read back once. That costs about 30 ms of boot (printed in the boot log) and never runs again; a request is a map lookup and a write of bytes that already exist. The version is the one in src/engine/surface.json that the door is held to, and it names the surface, not the behaviour — so the download's URL also carries ten hex digits of the archive's sha256, which is what makes a year of immutable caching honest. Every file in the archive has mtime 0 and owner 0, so the same tree always packs to the same bytes and the same hash.

Playing

<kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, <kbd>Shift</kbd> walk, mouse aims and fires, <kbd>R</kbd> reload, <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the flashbang, if you bought them, <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze time — and buying never takes the mouse off the game: the panel opens with the pointer still locked, every tile is printed with the key that buys it (the number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>, left to right), <kbd>X</kbd> buys last round's loadout again as far as the money goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The mouse buys too: with the lock kept the browser draws no cursor, so the panel draws its own — moving the mouse moves it instead of your view, and the left button presses the tile under it. The tiles are still buttons, so a thumb taps them and a keyboard tabs to them, and with the panel open the trigger and the weapon keys are off the player while your feet still work — <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and a switch costs CS's deploy time: the carry speed changes on the tick you press the key, so tapping <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come back to cannot fire, scope or reload until it is up (a pistol 0.7 s, a rifle or the knife 1.0 s, the AWP 1.25 s, counted in whole ticks). You can watch it: the gun comes up from below over exactly that draw and is level on the tick the trigger answers, and the ammo counter and the weapon's name stay dim until then (raiseLeft in src/client/scene.ts, read off the same deployUntil the simulation keeps). A bullet that lands also tags, the same for everyone: 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 (tag in src/client/world.ts). 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, a death to one reads as Fall in the kill feed and pays nobody, and you feel it as the pain flash with no arc, because it came from straight below (land in src/client/world.ts, fallDamage in src/client/movement.ts, and the landing speed is the engine's move return value). The honest part: nothing on de_forge or a generated map is tall enough to reach it — the highest floor anyone stands on is a 128-unit crate, which lands at about 452 u/s, and 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. Knife-running a sightline is a decision, and the sums are in src/client/weapons.ts — <kbd>Tab</kbd> shows the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and <kbd>Esc</kbd> or <kbd>P</kbd> pauses. Right mouse pulls up the scope on the one gun that has one, and the mouse slows down with the zoom: the view narrows from 90° to 40° across, and the look is scaled by tan(40°/2) / tan(90°/2) = 0.364, so the same hand movement crosses the same fraction of the screen scoped as unscoped — CS's zoom_sensitivity_ratio at 1, and src/client/look.ts is the whole of it. Unscoped, the Sensitivity box on the menu means exactly what it says: at the default view that scale is 1 and the number is untouched. On a touch screen the left of the view is a thumb stick, the right looks around — at Touch sens, the thumb's own number beside Sensitivity, which the mouse never reads and which the menu shows only where the browser reports a coarse pointer (Controls.touchSensitivity in src/engine/controls.ts; it starts at 2.2, the mouse's own default) — and the buttons along the bottom do the rest — including the one in the top corner, which is full screen and is how you get one on a phone, where there is no keyboard and no pointer to lock. A browser that will not put an element full screen — an iPhone will not — never shows that button rather than showing one that does nothing.

The whole mouse plays, not just its two big buttons. The wheel jumps either way it is turned — counted in notches, so one click of a wheel is one press whether the browser sends a hundred pixels, three lines or a page, and a trackpad's stream of small deltas has to travel a click's distance to be one — the wheel pressed pulls the knife, and the two buttons under your thumb plant or defuse and throw an HE. On Windows those two are the browser's Back and Forward: while the game has the mouse their mousedown and their auxclick are prevented, so a thumb mid-round cannot leave the page with the match on it. At /range the thumb pair reloads and switches drill and the middle button is left to the browser. A button no game binds keeps whatever meaning the browser gave it.

Motion you did not ask for, turned down — and nothing else. A Motion select sits beside Sensitivity in the menu. It starts wherever the machine already asks: the page reads (prefers-reduced-motion: reduce) through matchMedia once at boot and starts at Reduced if the system says so; Full is the game exactly as it has always drawn. The player can move it either way, from the menu or mid-match from the pause panel. Reduced turns down exactly two things, both decoration: the walk's sway on the gun in your hands (it goes to zero, the gun a player standing still already holds), and the fade on the red arc that says where a hit came from, which holds its strength for its life and then goes. Everything that is the game is untouched — the gun's kick when you fire, its raise on a switch, the crosshair opening with your spread, the pain flash, the tracers, the blood and the kill feed are identical on both settings. A view that does not kick is an aim advantage, not an accessibility feature. src/client/motion.ts holds the two values and the line between them; scene and hud take the one value with the frame.

Settings change mid-match, from the pause panel. Sensitivity, Touch sens, Motion and Sound (on or off — the mix has one level, so there is no volume slider to pretend otherwise) are in the menu and again in the pause panel. A change in either place applies on the spot — the next mouse movement turns at the new number, without leaving the round — is copied into the other place, and is remembered in the browser's localStorage (cs.sens, cs.touchsens, cs.motion, cs.sound), so the sensitivity you settled on is there on your next visit. What storage holds is parsed like any other input: a number outside 0.2–12 is the nearest end, and junk is the default. Side, length and map stay in the menu, because changing them is starting a different match. src/client/settings.ts is the whole of it.

Windows is asked for the mouse the way Windows needs. Controls.lock() asks for unadjustedMovement, because without it Chromium on Windows hands the page a movementX with the OS's pointer speed and "enhance pointer precision" already applied — the same flick is worth a different angle than it is on a Mac, and non-linearly so — and falls back to a plain ask on a browser that will not take the dictionary. The answer is read rather than dropped: a refusal leaves the pause panel up with a sentence, instead of a match running with a dead mouse. The Keyboard Lock API is asked for every key each game binds, which on Windows matters twice over: crouch is <kbd>Ctrl</kbd>, so crouch-and-forward is Ctrl+W and crouch-and-reload is Ctrl+R, and out of full screen no browser hands those over — so a match in progress puts a beforeunload confirmation in the way.

And it is sharp at 125%. Windows ships display scaling at 125% and 150% far more often than anything else does, and a crosshair stroked through the centre of a 1601-pixel-wide backing store covers three columns of pixels at two thirds of its colour each: a dim, smeared cross that a 1.0 laptop never shows you. src/engine/pixels.ts is the one place that says how large a canvas's backing store is and where a bar of pixels has to start for both its edges to land on one; the overlay draws in device pixels with no context transform, and a crosshair arm is a rectangle filled on whole pixels whose thickness bends by a pixel rather than letting the centre move by half of one. Sizes are written in CSS pixels and turned into whole device pixels there, so the HUD is the same size on the glass at 1, 1.25, 1.5 and 2 instead of shrinking to 70% on a dense screen. All three games go through it, including the range's no-WebGL2 horizon, which is the picture a machine whose driver refused the shaders actually gets.

If it still misbehaves, there is a line to paste. The menu prints one: the browser and the platform, what the WebGL2 driver calls itself — on Windows the ANGLE string naming the Direct3D device — the stage's size, the pixel ratio, whether the pointer lock is raw and whether the keyboard lock was granted. src/engine/support.ts is a pure function of those facts; every one of them comes from outside the page, so every one is flattened of control characters, stripped of the separator and cut to 120 characters, and it reaches the DOM through textContent. When the picture cannot be built at all — ANGLE on D3D11 refuses shaders a desktop driver waves through — the boot catches the throw, the menu says so in words with the driver's own log on the end of that line, and the loop does not start. A black canvas with no sentence is the one bug report nobody can answer.

Escape is the one key the browser reads before the page does, and on a Mac what it means is give the window back: full screen drops with the whole animation, mid-round. So the game asks for the key rather than obeying it. Where a browser has the Keyboard Lock API — Chrome and Edge — src/engine/escape.ts holds Escape while the stage has the screen, so a pause is a pause and the screen stays; that browser leaves full screen when Escape is held down, which is the platform's own way out. Safari and Firefox have no such API and take the screen whatever the page asks, so the pause panel says so and Resume puts it back — a click is a gesture, which is the only thing a browser will hand a screen to. <kbd>P</kbd> pauses the same way and is a key no browser has a meaning of its own for, so it never costs the screen on any of them. The same thing happens at /range, where Escape gives the mouse back: it keeps the screen where it can, and Capture the mouse takes back what a browser took.

The radar sits in the top corner: the level from above, north up and never turning, with the rooms in grey, the two bombsites lettered A and B, your side in your side's colour and a white wedge for where you are and which way you are looking. An enemy appears on it while somebody alive on your side can see them and goes the moment nobody can — there is no memory of where one was a second ago. The bomb is on it too: yellow in the hands of whoever is carrying it, on the floor for the terrorists to come back for, and blinking red where it was planted, for both sides. It is the only thing on the screen that says where you are on a map generated for that one match, which is why #176 built it.

The same drawing is on the menu, before you press Play: a plan of the map the button would start — the rooms, the two bombsites lettered, and the two spawns in the sides' own colours, which is the one thing a round never shows you. Picking de_forge shows de_forge, picking generated shows the map this match's id carves, and typing a name shows that exact map. It is the radar's drawPlan given a whole level rather than a second drawing of the same data, and it is drawn once per choice rather than once a frame: a choice has to stand still for 200 ms before the level behind it is carved, so typing gen_14u5j6g a character at a time costs one map and not ten, and nothing is drawn at all while a match is running. Beside it is the same map in words — the size of the grid, and where the sites and the spawns are by the compass — so the canvas is hidden from the accessibility tree instead of being announced as nothing.

You can tell where the shot came from. Take a hit and two things happen at once. A red arc is stroked on a ring around the middle of the view, in the direction of whoever hit you relative to where you are facing now — turn ninety degrees and it walks ninety degrees round the ring, so it is a bearing you can turn by rather than a picture of where you were standing. It fades over 1.25 seconds and every live hit draws its own, so two people shooting you from two sides is two arcs. And the screen flashes red as hard as the hit was: the damage over fifty, capped, which is 0.16 of red for an AK round through armour and the full 0.3 for an AWP body shot, gone a quarter of a second later. It is drawn under the crosshair on purpose — an M4 fires every 0.09 s, and a wash that tinted your sights would be the game charging you for being shot at twice. A flashbang is the deliberate other way round: whiteout goes over everything, because taking the round off you is what a flash is for. The world records where the damage came from — the shooter's feet, or the middle of the blast — and the HUD reads it back; neither is a new number the simulation has to keep, so no pinned replay moved.

Killed, you watch the rest of the round out of a teammate's eyes: fire moves to the next player on your side and aim moves back one, the camera is placed on whoever you picked — asking for someone else is a cut, never a flight across the map — and from there it eases along with them instead of snapping around with their aim, and it stays on them until they die or you ask for someone else. With your whole side down and the bomb still ticking it watches whoever is left alive rather than your own body. src/client/spectate.ts is that camera, and nothing else in the game reads it. When nobody at all is left standing, the camera rises out of your body — up and back along the way you faced, stopped short of any wall — and looks down at where you fell.

Over whoever you are watching sits one line about your own death: who killed you, with what, whether it was a headshot, and how much health they had left — so a lost duel says whether you were outplayed or one bullet away. That number is the killer's health at the moment of the kill, kept in the feed line (killerHealth in src/client/world.ts) and sent with it over a room's wire, not a live reading. A death nobody else caused names no one's health: a fall reads "You fell too far", your own grenade "You killed yourself with HE Grenade".

Whoever dies stays on the floor where they fell until the next round stands them up: the player model tipped onto its face along the way they were facing, dropped onto the floor if they died in the air, and tinted their side, dimmed. It topples over 0.4 s from exactly where the living model stood, so a kill reads as a fall and not a disappearance. A body is drawing and nothing else — one more instance of the player mesh in src/client/scene.ts, turned over by the engine's fallenMatrix — so bullets go through it, it blocks nobody, no bot has ever seen one, and no pinned replay moved.

The guns are in src/client/weapons.ts and follow CS's shape: damage falls off with range, armour eats a share of it, a helmet is the only thing that helps a head, and a rifle held down climbs a pattern you can learn. One number is deliberately not CS's. The Desert Eagle one-taps. A headshot through a helmet kills at every distance a bullet in this game travels — 110 health at 8,192 units, which is longer than the diagonal of any map it generates — because the Deagle carries at 0.955 per 500 units rather than CS's 0.81, which ran out of kill about 2,000 units from the barrel and left the $700 pistol doing 66 to a head across a map that is 5,120 units a side. It is still two in the chest at any range, and no other gun reaches like that: an AK headshot stops killing well short of the far wall, and the AWP is what the far wall is for.

At /range the same controls drive the second game, on its own bindings: <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload, <kbd>Enter</kbd> starts a thirty second run, <kbd>T</kbd> chooses the drill between runs and <kbd>G</kbd> is full screen — G rather than F here, because F is this game's trigger, and at /example it is <kbd>F</kbd> again. There are two drills. Pop-up is the one the range shipped with: three plates at a time, standing still down the lane, each worth 100 to 200 points by how far away it stood. Tracking is the second: two at a time, nearer and longer-lived, each sliding across the lane at 90 to 260 units a second until its lifetime or its lane runs out, worth 100 to 160 for the distance plus a third of a point for every unit a second it is crossing at — so you lead it, and the fast one pays most. Switching mid-run does nothing rather than corrupting the run, and the panel keeps the score, the clock, the accuracy and a best per drill, because a tracking score means nothing beside a pop-up one. The room, the plates and the tracer are drawn by the engine's renderer and the range makes its own noise through the engine's sound device — a dry crack, a tone for a hit, a knock as a plate stands up, a tick for each of the last three seconds and one falling tone when the clock runs out, none of them the shooter's. You walk, and you walk on the range's own numbers. Both games step the same solver — move in src/engine/physics.ts, which holds not one tuned value of its own — and hand it a different tuning and a different hull on every call: the shooter's are CS_MOVE and CS_HULL in src/client/movement.ts, the range's are RANGE_MOVE and RANGE_HULL in src/range/movement.ts, and they agree about nothing. The range is slower (180 u/s against 250), heavier (900 u/s² of gravity against 800, so its jump clears 40.5 units against 57), stickier (7 of friction against 5.2), a worse strafe-jumper on purpose (a quarter of the air acceleration and half the air cap), wider in the hull and deeper and faster in the crouch — which drops the eye 22 units and changes what you can hit down the lane, because the shot is traced from wherever the eye actually is. (The shooter's grenade is a third set of numbers through the same move, NADE_MOVE — the solver is used by three bodies now and holds the values of none of them.) That two games run one solver on two sets of numbers is the proof the physics extraction was real, and src/range/movement.test.ts checks the range's second of running, its jump's apex, its crouch and its walls against its own tuning rather than against a number typed twice. A browser with no WebGL2 keeps the game on the 2D horizon it shipped with — a plate as a square drawn from the angle it subtends — and the panel says which of the two you are looking at.

At /example the smallest game on the engine runs on the game's own table: <kbd>←</kbd> and <kbd>→</kbd> turn, <kbd>WASD</kbd> walks, <kbd>Space</kbd> fires one shot a press, <kbd>Enter</kbd> — or the Start button — begins thirty seconds. You are looking at the room from above: the blue dots are where the shot goes, the yellow bar on the far wall is the plate, and a hit puts up the next one. On a touch screen the four buttons do the same.

What a third game cost

The example is the answer to "what does building on this engine take?", so it is written down as a number. The game itself was already there — src/engine/example.ts, the 79 lines the engine's guide is written out of, which guide.test.ts executes block by block — and /example did not copy a line of it. What it added was:

whathow much
src/example/main.ts (the wiring)112 lines
src/example/plan.ts (the drawing)86 lines
src/pages/example.ts (its page)76 lines
src/games.ts (its registry entry)10 lines
src/server.ts (one line in RENDER)1 line
example.<hash>.js (its bundle)8,754 raw / 4,229 gzipped bytes

That is 198 lines of game, an eighth of the shooter's bundle, and no new branch in the server. What it did not have to write: the loop and its fixed step, the bindings-table reader, the touch buttons, the pointer handling, the ray against a box, the seeded random, the vector maths, the panel, the bar, the crosshair, the m:ss clock, the text scaling, the headless page its tests boot on, the document it is served in, the header's nav, the cross-links on the other two pages and its line in the 404 — the last four because they are projections of src/games.ts and nobody edited them. The 2D is the point: no renderer, no atlas, no mesh and no sound ship in that bundle, and src/example/bundle.test.ts reads the built file to prove it.

The one dependency, and why

Nothing in src/ imports a package: the app boots, plays and serves with Bun and nothing else, and independence.test.ts says so by reading every import in the tree. The repository does take two build-time devDependencies — typescript and @types/bun — because types that nothing checks are comments. Every module here is carefully typed and, until this landed, not one of those types was verified: a wrong type, a dropped argument or an undefined from an index on a path no test walks compiled and shipped.

It matters more since the engine started leaving the repository. scripts/pack.ts writes a package of TypeScript sources, and the game that imports it typechecks them in its own build — so a type error in here is not a private matter, it is an error in somebody else's project, arriving from a package that never checked itself.

  • bun run typecheck runs tsc --noEmit over src/ and scripts/, and

bun test runs it first: the forge's check is the typecheck and the tests, in that order.

  • tsconfig.json is strict and then some: strict,

noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch and verbatimModuleSyntax. Nothing is any, nothing is @ts-ignore.

  • It costs about 0.6 s to typecheck the whole tree and under a second to

install, once, next to a suite that takes thirteen.

  • The suite's result does not depend on which file loads first.

scripts/alone.test.ts imports every module under src/ and scripts/ as the first thing a fresh process loads, so a cycle that reads another module's value while it initialises fails there whichever end it is entered from (#248 was packed.ts reading OBS_SIZE out of observe.ts inside the observe → world → bots → packed ring; OBS_SIZE now lives in src/client/obs-size.ts, which imports nothing). bun run scripts/alone.ts goes further and runs every test file in a process of its own, which is what catches a test that leans on state another file set up. It is the whole suite again, so it is a command rather than part of bun test.

Shape

Read the whole README

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs