rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
Files 175dd79b69cf6138da9f8452299c4973735c3119
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 3.0 KB · ~769 tok
- forge.json 111 B · ~37 tok
- package.json 140 B · ~47 tok
- README.md 4.9 KB · ~1.2k tok
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 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.
- 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.
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
- 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.
- 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 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.