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

forked from benjivers/val

workbenchthis machine
Files 54c3aae2ea16956d3123256dfdd05930d31be4c5
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 960 columns wide
77
  (`static/resolution.js`, never more than the canvas has pixels), one ray
78
  every other column, and scaled onto the visible one, so the frame costs the
79
  same on a retina screen as on any other; the crosshair, the HUD and the
80
  minimap are drawn on top at full device resolution. Columns that would draw
81
  the same rectangle are batched into one fill (`static/slices.js`), the
82
  map's tiles are drawn once a size and blitted after that, and if the frame
83
  still runs over 20ms the render width drops a rung until it fits
84
  (`static/budget.js`). Fidelity is cheap here and frame rate is not.
85
  All of it is presentation: the simulation and the snapshots are untouched.
86
- A body you have only just been shown is faded and grown into sight over a
87
  tenth of a second, and slid in from the direction it came if it was seen
88
  near there before; where somebody was last seen alive leaves an outline
89
  that dims away over a second (`static/presence.js`). The server still sends
90
  only what you can see — that is the rule, and nothing here bends it. This
91
  is a drawing about information you already had: an enemy who steps into a
92
  sightline has no history, and without it they appear mid-stride rather than
93
  walking into the room.
94
- One map, one match at a time, ten seats. There is no matchmaking, no
95
  ranking and no account: your seat belongs to a session cookie, and a
96
  playing seat is capped one per client address so a cookie — free to mint
97
  again — cannot buy a second one on the other team. A second machine on a
98
  second network is still a second person as far as the server can tell,
99
  the same as any anonymous game without accounts; a socket that does not
100
  get a seat is sent a spectator view with no position in it, never a side:
101
  the page draws the score, the round, the clock and the kill feed, says
102
  which of the two reasons it is watching for, and draws no map and no
103
  bodies, because it is never told where anybody is.
104
- No overtime — first to 13 ends it at 12–12 too.
105
- Sight is a full circle rather than a view cone — you are drawn what is in
106
  front of you, but the server reveals anything you have line of sight to,
107
  so a team mate behind you still appears on the minimap. The world has no
108
  vertical, so the hit location comes from how precisely the shot passes
109
  the target.
110
- Assists are counted as a field but never awarded.
111
- The live match lives in memory and is gone if the process restarts; only
112
  finished match results are written to SQLite under `FORGE_APP_DATA`.
113
114
## Playing
115
116
Click the view to capture the mouse, then WASD move and the mouse looks;
117
**Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and
118
defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab**
119
holds the big map open, **V** swaps to the top-down view, **H** or **?**
120
brings back the how-to-play card, and **F3** or **`** prints the frame
121
readout: frame rate, mean frame, snapshots a second, the interpolation delay
122
in force and the width the world is being drawn at. On a phone it stays top-down: the left
123
thumb walks and the right thumb aims and fires.
124
125
```sh
126
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
127
bun test                                                # the check every pull runs
128
VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts        # the terminal interface
129
```
130
131
The terminal plays the same match over the same socket: it draws the grid,
132
the scoreboard and the round, and takes a seat. The exact command that
133
reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it.
134
135
The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`,
136
`FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json`
137
says how.
138
139
## Shape
140
141
`src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table ·
142
`src/economy.ts` credits · `src/abilities.ts` the agent kits ·
143
`src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes
144
no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
145
live match and its sockets · `src/server.ts` the door · `static/` the browser client —
146
`val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray
147
maths, `scene.js` the first-person view, `slices.js` batches its wall
148
columns, `resolution.js` and `budget.js` decide how wide to draw it,
149
`presence.js` fades bodies into sight and marks where they left it,
150
`topdown.js` the minimap,
151
`gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles
152
them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
153
headlessly.
154
155
## It only runs while somebody is there
156
157
The match is not a daemon playing to an empty room. One beat of the clock
158
happens per tick **while at least one connection is open** — a seat or a
159
watcher — and freezes otherwise, resuming exactly where it stopped. So the
160
score you find is the score you left, nothing burns CPU on the box overnight,
161
and no round is ever decided with nobody watching it.
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts