rook/val Public Public: anyone can read this; nobody can change it.

Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.

forked from benjivers/val

workbenchthis machine

This repository has no app yet.

following benjivers/val in step

Everything here is up to date with benjivers/val.

History

README.md

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 you if you were looking at it — facing decides whether: anywhere on your 75° screen is the full duration, a quick grace past its edge for turning, nothing at all behind your back; distance decides how long, inside a radius the size of a room (320–360 units on a 2400 × 1520 map), full near the pop and a flicker at its edge — 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. A flash is thrown to take a site — on the way in, or back onto the planted spike — only where the pop would land in front of the enemy's face, never where the thrower or a team mate could be standing in it, and a team spaces its flashes seconds apart (src/bots.ts); src/bots.test.ts plays a match headless and keeps the share of time spent blind. 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/engine/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/engine/glide.ts), and carries the last snapshot forward by up to a quarter second of held input. How fast it runs is the server's answer and not one the browser worked out: the snapshot carries me.speed, so a haste, an operator, a plant or a decided round reaches the drawing as a number — a rule that stops you arrives as a zero. 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. The lag your own keys take to reach the server (a send every 50ms, a hop, a tick — LEAD_SLACK in static/engine/predict.js) is not a disagreement: the drawn body may lead the answer by that much along the way it is going and is left alone there, so a start is not yanked back and a stop is not dragged on. Anywhere the server would refuse — a wall, the spawn pen in the buy phase, a barrier an ability put up — the drawing holds its last good place rather than snapping to the server's. The gun kicks the moment the trigger is pulled, and the server's word about the same shot is not a second kick; whether it landed is still only the server's to say. 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/engine/yaw.js).

  • Everybody else is drawn by entity interpolation (static/engine/interp.js): each

body keeps the last few snapshots, 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 are filed under an even clock — one interval after the last, nudged a tenth of the way toward when each really landed — rather than the moment they happened to arrive, because a rate is not a rhythm: thirty a second delivered a few milliseconds early and late is a body drawn slow, then fast. The moment being drawn advances at real time and creeps toward its delay at no more than two percent of it, so the delay adapting never shows as a short frame. Snapshots go out every tick (30Hz, some 1.6 KiB a seat in a live round) to every seat the room can hold; the cadence is a function of the room in src/arena.ts, with the measured cost in its comment. The scoreboard — ten names, agents and tallies, almost half of every snapshot — is sent only when it differs from what that seat last received, and the page keeps the one it has; everything else is in every snapshot.

  • 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. It is drawn into an offscreen canvas 960 columns wide (static/engine/resolution.js, never more than the canvas has pixels), one ray every other column, and scaled onto the visible one, so the frame costs the same on a retina screen as on any other; the crosshair, the HUD and the minimap are drawn on top at full device resolution. Columns that would draw the same rectangle are batched into one fill (static/engine/slices.js), the map's tiles are drawn once a size and blitted after that, and if the frame still runs over 20ms the render width drops a rung until it fits (static/engine/budget.js). Fidelity is cheap here and frame rate is not. All of it is presentation: the simulation and the snapshots are untouched.

  • A body you have only just been shown is faded and grown into sight over a

tenth of a second, and slid in from the direction it came if it was seen near there before; where somebody was last seen alive leaves an outline that dims away over a second (static/engine/presence.js). The server still sends only what you can see — that is the rule, and nothing here bends it. This is a drawing about information you already had: an enemy who steps into a sightline has no history, and without it they appear mid-stride rather than walking into the room.

  • 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: the page draws the score, the round, the clock and the kill feed, says which of the two reasons it is watching for, and draws no map and no bodies, because it is never told where anybody is.

  • No overtime — first to 13 ends it at 12–12 too. A choice, not a gap: a

match is already some twenty-five rounds, the bots play both halves of it, and a round that ends 13–12 reads as a result. Overtime would add rules a visitor never sees and a match nobody sits through.

  • The moment a round turns, the view says so: a banner with whether you won

the round and why (static/banner.js), the same words in the prompt line and on the first paint, and a round card for the first three seconds of the buy phase saying the round and your side. The server keeps the outcome as data (last in the snapshot: who took it, why) — the HUD never parses the log.

  • 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, H or ? brings back the how-to-play card, and F3 or ` prints the frame readout: frame rate, mean frame, snapshots a second, the interpolation delay in force, the width the world is being drawn at, and how many times the drawing has taken the server's word outright (your body anchored, somebody else started again, the view re-pointed) — every one of those is a jump, and the number a minute is what "it stutters" should be argued with. 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/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.
  • static/engine/ — the client feel stack and renderer: interpolation, prediction, presence, yaw, steer, raycast (the ray maths), scene, slices, resolution, budget, topdown, gun.
  • src/map.ts — this map's rows and what its letters mean.
  • src/weapons.ts — the damage table.
  • src/economy.ts — credits.
  • src/abilities.ts — the agent kits.
  • src/state.ts — the shape of a match, types only.
  • 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, built on src/engine/arena.ts.
  • src/killfeed.ts — the kill feed, read off the match a tick at a time.
  • src/db.ts — finished match results, in SQLite under FORGE_APP_DATA.
  • src/server.ts — the door.
  • src/pages/game.ts — the server-rendered page.
  • src/tty.ts — the terminal interface.
  • src/otel.ts — tracing, in one file.
  • static/ — the browser client; the server bundles it into one content-named file at start-up:

- val.js wires the socket, the keys and the HUD. - scene.js is the first-person view and topdown.js the minimap, each this game's layer over the engine's. - banner.js is the words on the view when a round turns. - watch.js is the spectator view. - beep.js, roster.js and prompt.js are the rest. - 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.

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.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open esc close

Keyboard shortcuts