torque/cs-engine
Public: anyone can read this; nobody can change it.
cs2 clone
forked from trevato/cs-engine
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.
main
View all branches
- scripts
- src
- .gitignore 104 B · ~30 tok
- AGENTS.md 3.0 KB · ~769 tok
- bun.lock 6.7 KB · ~2.3k tok
- forge.json 156 B · ~52 tok
- package.json 382 B · ~128 tok
- README.md 141.4 KB · ~36k tok
- tsconfig.json 467 B · ~156 tok
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
policytab, 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_forgeis 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_14u5j6gpreselects that map.?map=is the shooter's
parameter and no other page reads it.
- Compare on it.
/?board=gen_14u5j6gnarrows 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_KEYin 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:
| what | how 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 typecheckrunstsc --noEmitoversrc/andscripts/, and
bun test runs it first: the forge's check is the typecheck and the tests,
in that order.
tsconfig.jsonis 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.