| 1 |
# val |
| 2 |
|
| 3 |
Five attackers with the spike, five defenders, abilities on cooldown. First to 13. |
| 4 |
|
| 5 |
A 5v5 played in a browser, from inside the player's head — the map is a tile |
| 6 |
grid and the view is a raycaster over it, so there is no 3D library and no |
| 7 |
dependency at all. One match is always running on the server: open the page |
| 8 |
and you are given a seat in the round that is happening, taken from a bot |
| 9 |
where it stands. When a match ends it shows the result for a few seconds and |
| 10 |
starts a new one, so nobody ever arrives at a frozen board. |
| 11 |
|
| 12 |
## What is real |
| 13 |
|
| 14 |
Everything that decides a round is computed on the server and only the |
| 15 |
server: |
| 16 |
|
| 17 |
- **Movement, shooting and hit detection.** The client sends intent — a |
| 18 |
direction, an angle, a held key — never a position. Damage is by hit |
| 19 |
location (how close the ray passes the target) with falloff and armour: |
| 20 |
a Vandal head shot kills through full armour at any range, a Phantom's |
| 21 |
does not past fifteen metres. |
| 22 |
- **Line of sight.** A snapshot only carries the enemies that viewer can |
| 23 |
see. A wall of the map blocks the sight line, a smoke blocks it, a flash |
| 24 |
blinds by the angle you were facing, and a blinded player is sent no |
| 25 |
enemies at all. A dead player watches through their living team mates' |
| 26 |
eyes rather than through walls, so dying is not a wallhack to call out. |
| 27 |
No spawn can see the other spawn: mid is staggered on purpose. A patched |
| 28 |
client learns nothing a plain one does not. |
| 29 |
- **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a |
| 30 |
checkpoint at 3.5s — a defender killed past half way leaves the defuse |
| 31 |
half done for the next one. |
| 32 |
- **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a |
| 33 |
plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is |
| 34 |
refused server-side. |
| 35 |
- **Who is alive.** The roster under the view lists your five with their |
| 36 |
health from the snapshot's own team data, and the kill feed is derived in |
| 37 |
`src/arena.ts` by comparing the players either side of a tick — it carries |
| 38 |
names, the gun and an age, and never a position. |
| 39 |
- **Bots** fill every empty seat and use their kit: smokes on the way in, |
| 40 |
flashes before the entry, a recon dart, a molly on the planted spike. They |
| 41 |
shoot through the same spread everyone does, and they aim like something |
| 42 |
with hands: 200–350ms to react to a target they have just seen, an aim |
| 43 |
cone that opens several degrees on the swing and only settles while they |
| 44 |
hold the same target, and more of both when you are crossing their view |
| 45 |
(`src/aim.ts`). Every roll of it comes from the match's seeded random, so |
| 46 |
a seed replays the same round. |
| 47 |
|
| 48 |
Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits |
| 49 |
(charges last the round), a free signature on a cooldown, and an ultimate |
| 50 |
paid for with ult points from kills, plants and the two orbs on the map. |
| 51 |
|
| 52 |
Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. |
| 53 |
|
| 54 |
## What is not |
| 55 |
|
| 56 |
- Your own body is *drawn* from a guess — the browser runs your own movement |
| 57 |
forward from your own keys between snapshots, through the same |
| 58 |
step-and-slide the server uses (`src/glide.ts`), and carries the last |
| 59 |
snapshot forward by up to a quarter second of held input. It is a drawing |
| 60 |
optimisation only: nothing is sent, every snapshot leans the drawn body |
| 61 |
back onto the server's answer, and a real disagreement is taken outright. |
| 62 |
Where you are *looking* is the browser's too: the mouse moves it at frame |
| 63 |
rate, and the server's angle is taken back only when it is telling you |
| 64 |
something you did not say (`static/yaw.js`). |
| 65 |
- Everybody else is drawn by entity interpolation (`static/interp.js`): each |
| 66 |
body keeps the last few snapshots with the times they landed, and the frame |
| 67 |
is drawn about one snapshot interval in the past, between the two that |
| 68 |
bracket that moment. So they move at the speed the server moved them and |
| 69 |
arrive where it put them, one interval late. A missed snapshot is carried |
| 70 |
one interval further and then held. Snapshots go out every tick (30Hz, some |
| 71 |
3.4 KiB a seat) while two or fewer seats are filled, and every other tick |
| 72 |
above that. |
| 73 |
- The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 74 |
no looking up or down, and everything that is not a wall — bodies, the |
| 75 |
spike, ability zones — is a flat billboard scaled by distance and clipped |
| 76 |
against the walls. It is drawn into an offscreen canvas a fixed number of |
| 77 |
columns wide (`static/resolution.js`: 1280 on a wide window, 960 otherwise, |
| 78 |
never more than the canvas has pixels) and scaled onto the visible one, so |
| 79 |
the frame costs the same on a retina screen as on any other; the crosshair, |
| 80 |
the HUD and the minimap are drawn on top at full device resolution. Columns |
| 81 |
that would draw the same rectangle are batched into one fill |
| 82 |
(`static/slices.js`), and if the frame still runs over 20ms the render |
| 83 |
width drops a rung until it fits (`static/budget.js`). All of that is |
| 84 |
presentation: the simulation and the snapshots are untouched by it. |
| 85 |
- One map, one match at a time, ten seats. There is no matchmaking, no |
| 86 |
ranking and no account: your seat belongs to a session cookie, and a |
| 87 |
playing seat is capped one per client address so a cookie — free to mint |
| 88 |
again — cannot buy a second one on the other team. A second machine on a |
| 89 |
second network is still a second person as far as the server can tell, |
| 90 |
the same as any anonymous game without accounts; a socket that does not |
| 91 |
get a seat is sent a spectator view with no position in it, never a side. |
| 92 |
- No overtime — first to 13 ends it at 12–12 too. |
| 93 |
- Sight is a full circle rather than a view cone — you are drawn what is in |
| 94 |
front of you, but the server reveals anything you have line of sight to, |
| 95 |
so a team mate behind you still appears on the minimap. The world has no |
| 96 |
vertical, so the hit location comes from how precisely the shot passes |
| 97 |
the target. |
| 98 |
- Assists are counted as a field but never awarded. |
| 99 |
- The live match lives in memory and is gone if the process restarts; only |
| 100 |
finished match results are written to SQLite under `FORGE_APP_DATA`. |
| 101 |
|
| 102 |
## Playing |
| 103 |
|
| 104 |
Click the view to capture the mouse, then WASD move and the mouse looks; |
| 105 |
**Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and |
| 106 |
defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab** |
| 107 |
holds the big map open, **V** swaps to the top-down view, **H** or **?** |
| 108 |
brings back the how-to-play card, and **F3** or **`** prints the frame |
| 109 |
readout: frame rate, mean frame, snapshots a second, the interpolation delay |
| 110 |
in force and the width the world is being drawn at. On a phone it stays top-down: the left |
| 111 |
thumb walks and the right thumb aims and fires. |
| 112 |
|
| 113 |
```sh |
| 114 |
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts |
| 115 |
bun test |
| 116 |
VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts |
| 117 |
``` |
| 118 |
|
| 119 |
The terminal plays the same match over the same socket: it draws the grid, |
| 120 |
the scoreboard and the round, and takes a seat. The exact command that |
| 121 |
reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it. |
| 122 |
|
| 123 |
The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`, |
| 124 |
`FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json` |
| 125 |
says how. |
| 126 |
|
| 127 |
## Shape |
| 128 |
|
| 129 |
`src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table · |
| 130 |
`src/economy.ts` credits · `src/abilities.ts` the agent kits · |
| 131 |
`src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes |
| 132 |
no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one |
| 133 |
live match and its sockets · `src/server.ts` the door · `static/` the browser client — |
| 134 |
`val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray |
| 135 |
maths, `scene.js` the first-person view, `slices.js` batches its wall |
| 136 |
columns, `resolution.js` and `budget.js` decide how wide to draw it, |
| 137 |
`topdown.js` the minimap, |
| 138 |
`gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles |
| 139 |
them into one content-named file at start-up. The rules are pure functions and the tests play whole matches |
| 140 |
headlessly. |
| 141 |
|
| 142 |
## It only runs while somebody is there |
| 143 |
|
| 144 |
The match is not a daemon playing to an empty room. One beat of the clock |
| 145 |
happens per tick **while at least one connection is open** — a seat or a |
| 146 |
watcher — and freezes otherwise, resuming exactly where it stopped. So the |
| 147 |
score you find is the score you left, nothing burns CPU on the box overnight, |
| 148 |
and no round is ever decided with nobody watching it. |