benjivers/val Public Public: anyone can read this; the crew can change it.
Files 3c63a82833bc2b178340f47c85d7acc2a4952706
val
Five attackers with the spike, five defenders, abilities on cooldown. First to 13.
A 5v5 played in a browser, from inside the player's head — the map is a tile grid and the view is a raycaster over it, so there is no 3D library and no dependency at all. One match is always running on the server: open the page and you are given a seat in the round that is happening, taken from a bot where it stands. When a match ends it shows the result for a few seconds and starts a new one, so nobody ever arrives at a frozen board.
What is real
Everything that decides a round is computed on the server and only the server:
- Movement, shooting and hit detection. The client sends intent — a
direction, an angle, a held key — never a position. Damage is by hit location (how close the ray passes the target) with falloff and armour: a Vandal head shot kills through full armour at any range, a Phantom's does not past fifteen metres.
- Line of sight. A snapshot only carries the enemies that viewer can
see. A wall of the map blocks the sight line, a smoke blocks it, a flash blinds by the angle you were facing, and a blinded player is sent no enemies at all. A dead player watches through their living team mates' eyes rather than through walls, so dying is not a wallhack to call out. No spawn can see the other spawn: mid is staggered on purpose. A patched client learns nothing a plain one does not.
- The spike. 100s round, 4s plant, 45s fuse, 7s defuse with a
checkpoint at 3.5s — a defender killed past half way leaves the defuse half done for the next one.
- Credits. 800 to start, 9,000 cap, +200 a kill, +300 to the team on a
plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is refused server-side.
- Who is alive. The roster under the view lists your five with their
health from the snapshot's own team data, and the kill feed is derived in
src/arena.ts by comparing the players either side of a tick — it carries
names, the gun and an age, and never a position.
- Bots fill every empty seat and use their kit: smokes on the way in,
flashes before the entry, a recon dart, a molly on the planted spike. They
shoot through the same spread everyone does, and they aim like something
with hands: 200–350ms to react to a target they have just seen, an aim
cone that opens several degrees on the swing and only settles while they
hold the same target, and more of both when you are crossing their view
(src/aim.ts). Every roll of it comes from the match's seeded random, so
a seed replays the same round.
Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits (charges last the round), a free signature on a cooldown, and an ultimate paid for with ult points from kills, plants and the two orbs on the map.
Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
What is not
- Your own body is drawn from a guess — the browser runs your own movement
forward from your own keys between snapshots, through the same
step-and-slide the server uses (src/glide.ts), and carries the last
snapshot forward by up to a quarter second of held input. It is a drawing
optimisation only: nothing is sent, every snapshot leans the drawn body
back onto the server's answer, and a real disagreement is taken outright.
Where you are looking is the browser's too: the mouse moves it at frame
rate, and the server's angle is taken back only when it is telling you
something you did not say (static/yaw.js).
- Everybody else is drawn by entity interpolation (
static/interp.js): each
body keeps the last few snapshots with the times they landed, and the frame is drawn about one snapshot interval in the past, between the two that bracket that moment. So they move at the speed the server moved them and arrive where it put them, one interval late. A missed snapshot is carried one interval further and then held. Snapshots go out every tick (30Hz, some 3.4 KiB a seat) while two or fewer seats are filled, and every other tick above that.
- The view is a raycaster: walls are one tile tall and flat-shaded, there is
no looking up or down, and everything that is not a wall — bodies, the spike, ability zones — is a flat billboard scaled by distance and clipped against the walls.
- One map, one match at a time, ten seats. There is no matchmaking, no
ranking and no account: your seat belongs to a session cookie, and a playing seat is capped one per client address so a cookie — free to mint again — cannot buy a second one on the other team. A second machine on a second network is still a second person as far as the server can tell, the same as any anonymous game without accounts; a socket that does not get a seat is sent a spectator view with no position in it, never a side.
- No overtime — first to 13 ends it at 12–12 too.
- Sight is a full circle rather than a view cone — you are drawn what is in
front of you, but the server reveals anything you have line of sight to, so a team mate behind you still appears on the minimap. The world has no vertical, so the hit location comes from how precisely the shot passes the target.
- Assists are counted as a field but never awarded.
- The live match lives in memory and is gone if the process restarts; only
finished match results are written to SQLite under FORGE_APP_DATA.
Playing
Click the view to capture the mouse, then WASD move and the mouse looks; Esc gives the pointer back. Click fires, R reloads, F plants and defuses (hold it), C / Q / E / X cast, B opens the buy, Tab holds the big map open, V swaps to the top-down view and H or ? brings back the how-to-play card. On a phone it stays top-down: the left thumb walks and the right thumb aims and fires.
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
bun test # the check every pull runs
VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts # the terminal interface
The terminal plays the same match over the same socket: it draws the grid,
the scoreboard and the round, and takes a seat. The exact command that
reaches it is in FORGE_APP_SSH when the app runs, and the page prints it.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV, FORGE_APP_URL,
FORGE_APP_SSH and FORGE_APP_COMMIT when it starts the app; forge.json
says how.
Shape
src/map.ts the grid and its geometry · src/weapons.ts the damage table ·
src/economy.ts credits · src/abilities.ts the agent kits ·
src/match.ts the round loop (tick(state, dt) is the whole game and takes
no clock) · src/bots.ts the seats nobody is in · src/arena.ts the one
live match and its sockets · src/server.ts the door · static/ the browser client —
val.js wires the socket, the keys and the HUD, raycast.js is the ray
maths, scene.js the first-person view, topdown.js the minimap,
gun.js, beep.js, steer.js, roster.js, yaw.js, interp.js and prompt.js the rest; the server bundles
them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
headlessly.
It only runs while somebody is there
The match is not a daemon playing to an empty room. One beat of the clock happens per tick while at least one connection is open — a seat or a watcher — and freezes otherwise, resuming exactly where it stopped. So the score you find is the score you left, nothing burns CPU on the box overnight, and no round is ever decided with nobody watching it.