quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 674c40ddee5c18295fdc6a04cd14d0d0c8bbfce9
| 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 you if you were looking at it — facing decides whether: anywhere |
| 25 | on your 75° screen is the full duration, a quick grace past its edge for |
| 26 | turning, nothing at all behind your back; distance decides how long, |
| 27 | inside a radius the size of a room (320–360 units on a 2400 × 1520 map), |
| 28 | full near the pop and a flicker at its edge — and a blinded player is sent |
| 29 | no enemies at all. A dead player watches through their living team mates' |
| 30 | eyes rather than through walls, so dying is not a wallhack to call out. |
| 31 | No spawn can see the other spawn: mid is staggered on purpose. A patched |
| 32 | client learns nothing a plain one does not. |
| 33 | - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a |
| 34 | checkpoint at 3.5s — a defender killed past half way leaves the defuse |
| 35 | half done for the next one. |
| 36 | - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a |
| 37 | plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is |
| 38 | refused server-side. |
| 39 | - **Who is alive.** The roster under the view lists your five with their |
| 40 | health from the snapshot's own team data, and the kill feed is derived in |
| 41 | `src/arena.ts` by comparing the players either side of a tick — it carries |
| 42 | names, the gun and an age, and never a position. |
| 43 | - **Bots** fill every empty seat and use their kit: smokes on the way in, |
| 44 | flashes before the entry, a recon dart, a molly on the planted spike. A |
| 45 | flash is thrown to take a site — on the way in, or back onto the planted |
| 46 | spike — only where the pop would land in front of the enemy's face, never |
| 47 | where the thrower or a team mate could be standing in it, and a team |
| 48 | spaces its flashes seconds apart (`src/bots.ts`); `src/bots.test.ts` |
| 49 | plays a match headless and keeps the share of time spent blind. They |
| 50 | shoot through the same spread everyone does, and they aim like something |
| 51 | with hands: 200–350ms to react to a target they have just seen, an aim |
| 52 | cone that opens several degrees on the swing and only settles while they |
| 53 | hold the same target, and more of both when you are crossing their view |
| 54 | (`src/engine/aim.ts`). Every roll of it comes from the match's seeded random, so |
| 55 | a seed replays the same round. |
| 56 | |
| 57 | Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits |
| 58 | (charges last the round), a free signature on a cooldown, and an ultimate |
| 59 | paid for with ult points from kills, plants and the two orbs on the map. |
| 60 | |
| 61 | Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. |
| 62 | |
| 63 | ## What is not |
| 64 | |
| 65 | - Your own body is *drawn* from a guess — the browser runs your own movement |
| 66 | forward from your own keys between snapshots, through the same |
| 67 | step-and-slide the server uses (`src/engine/glide.ts`), and carries the last |
| 68 | snapshot forward by up to a quarter second of held input. How fast it runs |
| 69 | is the server's answer and not one the browser worked out: the snapshot |
| 70 | carries `me.speed`, so a haste, an operator, a plant or a decided round |
| 71 | reaches the drawing as a number — a rule that stops you arrives as a zero. |
| 72 | It is a drawing |
| 73 | optimisation only: nothing is sent, every snapshot leans the drawn body |
| 74 | back onto the server's answer, and a real disagreement is taken outright. |
| 75 | The lag your own keys take to reach the server (a send every 50ms, a hop, |
| 76 | a tick — `LEAD_SLACK` in `static/engine/predict.js`) is not a disagreement: the |
| 77 | drawn body may lead the answer by that much along the way it is going and |
| 78 | is left alone there, so a start is not yanked back and a stop is not |
| 79 | dragged on. Anywhere the server would refuse — a wall, the spawn pen in |
| 80 | the buy phase, a barrier an ability put up — the drawing holds its last |
| 81 | good place rather than snapping to the server's. The gun kicks the moment |
| 82 | the trigger is pulled, and the server's word about the same shot is not a |
| 83 | second kick; whether it landed is still only the server's to say. |
| 84 | Where you are *looking* is the browser's too: the mouse moves it at frame |
| 85 | rate, and the server's angle is taken back only when it is telling you |
| 86 | something you did not say (`static/engine/yaw.js`). |
| 87 | - Everybody else is drawn by entity interpolation (`static/engine/interp.js`): each |
| 88 | body keeps the last few snapshots, and the frame is drawn about one |
| 89 | snapshot interval in the past, between the two that bracket that moment. |
| 90 | So they move at the speed the server moved them and arrive where it put |
| 91 | them, one interval late. A missed snapshot is carried one interval further |
| 92 | and then held. Snapshots are filed under an even clock — one interval |
| 93 | after the last, nudged a tenth of the way toward when each really landed — |
| 94 | rather than the moment they happened to arrive, because a rate is not a |
| 95 | rhythm: thirty a second delivered a few milliseconds early and late is a |
| 96 | body drawn slow, then fast. The moment being drawn advances at real time |
| 97 | and creeps toward its delay at no more than two percent of it, so the |
| 98 | delay adapting never shows as a short frame. Snapshots go out every tick |
| 99 | (30Hz, some 1.6 KiB a seat in a live round) to every seat the room can |
| 100 | hold; the cadence is a function of the room in `src/arena.ts`, with the |
| 101 | measured cost in its comment. The scoreboard — ten names, agents and tallies, |
| 102 | almost half of every snapshot — is sent only when it differs from what |
| 103 | that seat last received, and the page keeps the one it has; everything |
| 104 | else is in every snapshot. |
| 105 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 106 | no looking up or down, and everything that is not a wall — bodies, the |
| 107 | spike, ability zones — is a flat billboard scaled by distance and clipped |
| 108 | against the walls. It is drawn into an offscreen canvas 960 columns wide |
| 109 | (`static/engine/resolution.js`, never more than the canvas has pixels), one ray |
| 110 | every other column, and scaled onto the visible one, so the frame costs the |
| 111 | same on a retina screen as on any other; the crosshair, the HUD and the |
| 112 | minimap are drawn on top at full device resolution. Columns that would draw |
| 113 | the same rectangle are batched into one fill (`static/engine/slices.js`), the |
| 114 | map's tiles are drawn once a size and blitted after that, and if the frame |
| 115 | still runs over 20ms the render width drops a rung until it fits |
| 116 | (`static/engine/budget.js`). Fidelity is cheap here and frame rate is not. |
| 117 | All of it is presentation: the simulation and the snapshots are untouched. |
| 118 | - A body you have only just been shown is faded and grown into sight over a |
| 119 | tenth of a second, and slid in from the direction it came if it was seen |
| 120 | near there before; where somebody was last seen alive leaves an outline |
| 121 | that dims away over a second (`static/engine/presence.js`). The server still sends |
| 122 | only what you can see — that is the rule, and nothing here bends it. This |
| 123 | is a drawing about information you already had: an enemy who steps into a |
| 124 | sightline has no history, and without it they appear mid-stride rather than |
| 125 | walking into the room. |
| 126 | - One map, one match at a time, ten seats. There is no matchmaking, no |
| 127 | ranking and no account: your seat belongs to a session cookie, and a |
| 128 | playing seat is capped one per client address so a cookie — free to mint |
| 129 | again — cannot buy a second one on the other team. A second machine on a |
| 130 | second network is still a second person as far as the server can tell, |
| 131 | the same as any anonymous game without accounts; a socket that does not |
| 132 | get a seat is sent a spectator view with no position in it, never a side: |
| 133 | the page draws the score, the round, the clock and the kill feed, says |
| 134 | which of the two reasons it is watching for, and draws no map and no |
| 135 | bodies, because it is never told where anybody is. |
| 136 | - No overtime — first to 13 ends it at 12–12 too. A choice, not a gap: a |
| 137 | match is already some twenty-five rounds, the bots play both halves of it, |
| 138 | and a round that ends 13–12 reads as a result. Overtime would add rules a |
| 139 | visitor never sees and a match nobody sits through. |
| 140 | - The moment a round turns, the view says so: a banner with whether you won |
| 141 | the round and why (`static/banner.js`), the same words in the prompt line |
| 142 | and on the first paint, and a round card for the first three seconds of |
| 143 | the buy phase saying the round and your side. The server keeps the |
| 144 | outcome as data (`last` in the snapshot: who took it, why) — the HUD never |
| 145 | parses the log. |
| 146 | - Sight is a full circle rather than a view cone — you are drawn what is in |
| 147 | front of you, but the server reveals anything you have line of sight to, |
| 148 | so a team mate behind you still appears on the minimap. The world has no |
| 149 | vertical, so the hit location comes from how precisely the shot passes |
| 150 | the target. |
| 151 | - Assists are counted as a field but never awarded. |
| 152 | - The live match lives in memory and is gone if the process restarts; only |
| 153 | finished match results are written to SQLite under `FORGE_APP_DATA`. |
| 154 | |
| 155 | ## Playing |
| 156 | |
| 157 | Click the view to capture the mouse, then WASD move and the mouse looks; |
| 158 | **Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and |
| 159 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab** |
| 160 | holds the big map open, **V** swaps to the top-down view, **H** or **?** |
| 161 | brings back the how-to-play card, and **F3** or **`** prints the frame |
| 162 | readout: frame rate, mean frame, snapshots a second, the interpolation delay |
| 163 | in force, the width the world is being drawn at, and how many times the |
| 164 | drawing has taken the server's word outright (your body anchored, somebody |
| 165 | else started again, the view re-pointed) — every one of those is a jump, and |
| 166 | the number a minute is what "it stutters" should be argued with. On a phone |
| 167 | it stays top-down: the left thumb walks and the right thumb aims and fires. |
| 168 | |
| 169 | sh |
| 170 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 171 | bun test # the check every pull runs |
| 172 | VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts # the terminal interface |
| 173 | |
| 174 | |
| 175 | The terminal plays the same match over the same socket: it draws the grid, |
| 176 | the scoreboard and the round, and takes a seat. The exact command that |
| 177 | reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it. |
| 178 | |
| 179 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`, |
| 180 | `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json` |
| 181 | says how. |
| 182 | |
| 183 | ## Shape |
| 184 | |
| 185 | - `src/engine/` — the machinery any game on this server stands on: the grid and its collision (`grid.ts`, `glide.ts`), the bot aim model (`aim.ts`) and the served match with its idle gate, per-address seats, positionless spectator view and per-viewer vision filter (`arena.ts`). Nothing in it imports the game, and `src/engine/boundary.test.ts` keeps it that way. |
| 186 | - `static/engine/` — the client feel stack and renderer: interpolation, prediction, presence, yaw, steer, raycast (the ray maths), scene, slices, resolution, budget, topdown, gun. |
| 187 | - `src/map.ts` — this map's rows and what its letters mean. |
| 188 | - `src/weapons.ts` — the damage table. |
| 189 | - `src/economy.ts` — credits. |
| 190 | - `src/abilities.ts` — the agent kits. |
| 191 | - `src/state.ts` — the shape of a match, types only. |
| 192 | - `src/match.ts` — the round loop; `tick(state, dt)` is the whole game and takes no clock. |
| 193 | - `src/bots.ts` — the seats nobody is in. |
| 194 | - `src/arena.ts` — the one live match and its sockets, built on `src/engine/arena.ts`. |
| 195 | - `src/killfeed.ts` — the kill feed, read off the match a tick at a time. |
| 196 | - `src/db.ts` — finished match results, in SQLite under `FORGE_APP_DATA`. |
| 197 | - `src/server.ts` — the door. |
| 198 | - `src/pages/game.ts` — the server-rendered page. |
| 199 | - `src/tty.ts` — the terminal interface. |
| 200 | - `src/otel.ts` — tracing, in one file. |
| 201 | - `static/` — the browser client; the server bundles it into one content-named file at start-up: |
| 202 | - `val.js` wires the socket, the keys and the HUD. |
| 203 | - `scene.js` is the first-person view and `topdown.js` the minimap, each this game's layer over the engine's. |
| 204 | - `banner.js` is the words on the view when a round turns. |
| 205 | - `watch.js` is the spectator view. |
| 206 | - `beep.js`, `roster.js` and `prompt.js` are the rest. |
| 207 | - `static/engine/` holds the rest of the client: `gun.js`, `steer.js`, `yaw.js`, `interp.js` and `predict.js` for the feel, `slices.js` to batch wall columns, `resolution.js` and `budget.js` to decide how wide to draw, `presence.js` to fade bodies into sight and mark where they left it. |
| 208 | |
| 209 | The rules are pure functions and the tests play whole matches headlessly. |
| 210 | |
| 211 | ## It only runs while somebody is there |
| 212 | |
| 213 | The match is not a daemon playing to an empty room. One beat of the clock |
| 214 | happens per tick **while at least one connection is open** — a seat or a |
| 215 | watcher — and freezes otherwise, resuming exactly where it stopped. So the |
| 216 | score you find is the score you left, nothing burns CPU on the box overnight, |
| 217 | and no round is ever decided with nobody watching it. |