benjivers/val Public Public: anyone can read this; the crew can change it.

Put the player inside the map: a first-person view, and instructions somebody can follow #4

Merged opened this in answer to

shipped production is serving it deploy b42aa0d

opened this

The match is now drawn from inside the player's head, with instructions a first-time player can follow.

First ten seconds of the preview: click the view — the pointer locks and you are standing in the map, looking down a corridor; move the mouse to look and WASD to walk (W goes the way you are facing). Press H for the how-to card, Tab to hold the big map open, V to drop back to the top-down view, Esc to release the mouse. On a phone it stays top-down, left thumb walks and right thumb aims.

The card (H or ?, dismissed per browser, never opened over a live round) says: the round — attackers carry the spike to site A or B and plant it, defenders stop them or defuse, 45s fuse, first to 13 with a swap at 12; the teams — red and blue, one attacking each round, team mates wear a name tag and enemies never do; the controls, key by key; one line on abilities (C and Q bought and lasting the round, E free on a cooldown, X the ultimate); and the five agents with their roles.

What landed. A raycaster over the tile grid map.ts already had: one ray a column, wall slices inversely tall, shaded by distance and darker on north–south faces, a ceiling/floor gradient with a horizon line. Bodies are billboards z-clipped against the wall depth buffer, coloured team versus enemy with the agent's colour as a shoulder band and a name tag on team mates only. A ray that crosses a smoke fogs from where it enters it, so a smoke is a volume rather than a disc. The spike is on the ground and beeps faster as the fuse burns (one oscillator, no files); there is a crosshair, a weapon that kicks, and a hit marker taken from the server's own shot list. The top-down drawing survives as the corner minimap (you are an arrow), the Tab map, the phone view and the V fallback. Aiming is pointer lock and the angle sent is the same angle as before — match.ts and arena.ts are untouched. Instructions: one contextual prompt at a time on the HUD, and a buy panel that prices every button, greys what you cannot afford and tooltips every ability off the agent's real kit. The terminal shows the same prompt from the same function and grew an h how-to.

Shape and cost. The client is now small modules under static/ — raycast.js (columns, slices, z-clip, smoke), scene.js, topdown.js, gun.js, beep.js, steer.js, prompt.js, with val.js as the wiring — bundled by Bun at start-up into the one content-named file the page has always loaded, so the CSP, the year-long cache and the zero dependencies are unchanged. /agents.json is new: the client cannot label a kit it does not know. 117 tests pass, 65 of them new: the column-to-ray maths, the wall-height and shading functions, the z-clip decision, smoke along a ray, the prompt for a given match state, the steering, the gun and the beep; the renderer run against a recording canvas (a body behind a pillar is not drawn, a smoke on the line hides it, an enemy gets no name tag); and a headless run of the bundled client against the live server that connects, paints the HUD, draws a first-person frame, swaps to the map with Tab and checks that holding W reaches the server as a direction matching the camera. The ray loop costs 0.17ms for 1800 rays and allocates nothing.

What I left out. No textures, no floor casting and no vertical look — walls are flat-shaded and the horizon is fixed, which is what keeps the frame cheap on a phone. Wall zones (Mender's Barrier, Rook's Stone Wall) fog rays and draw as a translucent slab rather than as real geometry, because the server models them as circular zones, not tiles. I did not touch the game rules, the bots or the snapshot contract. I have no browser in this sandbox, so the view was verified by rendering the depth and shading of real spawn positions to ASCII and by the recording-canvas tests rather than by eye; somebody should look at the actual colours and the weapon shape in the preview.

🤖 Generated with Claude Code

Closes #3

commented

Played it: the view itself works, but a person cannot tell who is on their team and who is dead. Right now the only answer to both questions is the colour of a body you can currently see — scene.js:236 is !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f" — and at twenty pixels across a shaded corridor, with an agent-coloured shoulder band on top, blue-versus-red is not a distinction anyone can make under fire. Worse, both questions are unanswerable when nobody is in sight, which is most of a round.

The fix is to stop making sight the only channel. Four things, in this order:

1. A team roster on the HUD, always visible. Five slots, one per team mate: agent name, a health bar, and unmistakably struck through and desaturated when dead. This needs no server change — every team mate is already in players on every snapshot with alive and hp, because visible() returns true for your own side. This single panel answers both of your questions at all times, and it is what every game of this shape has in the corner. Do this one first.

2. A kill feed. Last few seconds of kills, top-right, killer ▸ victim with the weapon. This does need a small server addition — arena.ts sends kills as a per-player count but no events — so add a short list of recent kills to the snapshot: killer name, victim name, weapon, timestamp, and no positions, so it cannot leak where anybody is. Rules stay untouched.

3. Make the in-world cue a shape, not a hue. Colour alone fails at distance and fails entirely for a colour-blind player. Give enemies a hard dark outline and a small chevron above the body; give team mates their name tag plus a health pip. Keep the palette, add the silhouette — the shape is what carries across a dim room.

4. The dead should never read as a threat. A corpse currently stands in place wearing a grey version of the same body. Draw dead players lying flat on the floor, desaturated, with no tag, no chevron and no shoulder band — and drop them from the minimap. If something is upright with a marker over it, it should always be alive.

Also: on the minimap, show all five team mates permanently (initial in the dot, ✕ when dead) and enemies only while they are actually visible — that keeps the map honest about vision while making your own team legible.

Everything above is a rendering and snapshot-shape change. Do not touch match.ts, and keep the kill feed free of coordinates.

commented

All four points are addressed, in the order you asked, plus the minimap rule.

1. Team roster, always visible. Five slots under the view: name, agent, health bar, the agent's colour down the left edge, your own slot outlined. Dead is struck through and desaturated and labelled "dead". No server change — static/roster.js is a pure read of the team mates already in every snapshot, with a stable order and health clamped, and src/roster.test.ts covers ordering, the dead, clamping and the diff key.

2. Kill feed, top-right, no coordinates. The rules keep no kill list and I did not add one to match.ts. Instead src/killfeed.ts compares the players either side of a tick: alive-then-dead is a victim, a kill count that went up is a killer, and the line the round loop already says settles which is which when two fall in the same tick. A shot that landed on that tick and started where the killer stands is what distinguishes a gun kill from an ability kill, so a molly is never reported as a Vandal — it reads "ability". The snapshot sends killer name, victim name, weapon name, head shot and an age in seconds; killfeed.test.ts asserts the entry has exactly those keys, and an integration test runs the real arena until bots trade and checks the names come from the real match.

3. Shape, not hue. Enemies get a hard dark outline around body and head plus a red chevron above them; team mates get their name tag with a health pip inside it. Both are tested by the recording canvas: the chevron is the only closed path in the scene, the tag the only text.

4. The dead lie down. A corpse is a flat desaturated ellipse on the floor — no tag, no chevron, no agent band. On the map your own five are always drawn (initial in the dot, crossed out when down) and enemies only while alive and in sight; a dead enemy draws nothing at all, asserted by comparing the op count against an empty player list.

One limit worth naming: the initial inside the dot only renders on the big map (Tab) and the phone's top-down view, where a dot is about 6px across. The corner minimap draws a dot roughly 2.5px wide and a letter there is mush, so mates are the blue dot and the ✕ stays for the dead. If you want initials in the corner too, the minimap needs to be about twice its current size.

143 tests pass (bun test). In the first ten seconds: look under the view for your five, watch the top right as bots trade, then click the view and walk into a team mate — name tag with a health pip — and an enemy, which carries a chevron and a dark silhouette; anything lying flat is already dead.

🤖 Generated with Claude Code

Changes requested — before the latest push

test

Changes requested — before the latest push

Request changes. The view itself is solid — vision, the wire, and server authority all hold — but the new kill feed ships with the exact symptom reported, and the root cause is diagnosable and fixable in one file.

Verified, holds:

  1. Vision is still honest. src/arena.ts is untouched except for the killfeed hook (git diff main wren/3-put-the-player-inside-the-map-a-first-pe -- src/arena.ts is 40 lines, all additive); snapshot() still filters state.players through visible() before sending, and static/scene.js:167 / static/topdown.js:82 draw only from snap.players — nothing else. Connected to the live preview over /ws, sat in a live round: me.x/y/hp never moved from the server's own values no matter what I sent (see #3). No wallhack.
  2. The feed leaks nothing. src/killfeed.ts's Fall type carries killer, victim, weapon, headshot, at — no coordinates — and arena.ts:222-229 maps that straight to the wire (k, v, w, h, t). readFalls() uses positions only to decide whether a kill was a gun kill (shooters), and that stays server-side.
  3. The server owns everything. Live socket test against wss://val--4--forge-apps.benjivers.com/ws: bought operator at 50 credits during buy phase — refused (credits and weapon unchanged); sent {t:"buy",what:"classic"} mid-round — refused; sent {t:"i",x:99999,y:99999} and {t:"i",hp:9999} — both ignored, command() in arena.ts only ever reads mx/my/a/f/u/r/c off an "i" message, there is no code path that reads a position or hp field from the client at all.
  4. A person arriving mid-round or mid-buy gets a live seat immediately — confirmed live, joined during buy phase (clock 7.6) and during a live round, takeSeat() swaps a bot in place with its credits and position intact both times.
  5. The 143 tests are real. Mutated three load-bearing lines and confirmed each breaks a test, then restored:

- visible() short-circuited to return true → 4 tests fail in match.test.ts (blind/wallblock/dead-spectator cases). - spike.checkpoint = DEFUSE_CHECKPOINT (match.ts:498) changed to = 0 → "a defuse killed at the half way mark leaves the checkpoint for the next defender" fails. - clampCredits (economy.ts:15) replaced with an identity → "nothing goes past 9000" fails. Full suite is green again after restoring (143 pass, 0 fail).

Blocking:

  1. The kill feed can genuinely print "X killed X" — not a team kill, not a self-kill, but two different players sharing a name. src/match.ts:74: BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]. The Math.floor(random(state) * 20) term ranges over all 20 names, so it fully overrides the TEAMS.indexOf(team) * 7 + index * 3 spacing that was clearly meant to keep the 10 bots apart — (base + uniform(0..19)) mod 20 is uniform regardless of base. I simulated makePlayer over 2000 matches (5v5, newMatch() + makePlayer for each seat): 1776/2000 (88.8%) had at least one duplicate bot name. This is the reported "Kit" twice, and it is also exactly how "Sage killed Sage" happens: hurt() in match.ts:456 writes ` ${from.name} killed ${target.name} by name, not id, and killfeed.ts's named() matches killer↔victim the same way, so two different players who happen to share a name produce a feed line that reads as a self-kill. I ruled out the other two explanations: friendly fire is impossible in this codebase (bullets skip other.side === player.side, molly skips player.side !== zone.side, both in match.ts), and a real self-kill is impossible the same way (other.id === player.id` is also skipped) — so it has to be the name collision.

This bug is pre-existing in match.ts (untouched by this diff), so it isn't a regression this pull introduced — but this pull is the one that puts a killfeed.ts in front of it and text-matches on .name, and 88.8% of matches will show it. Fix belongs in src/match.ts's makePlayer/BOT_NAMES draw (draw without replacement, e.g. shuffle the 20 names once per match and assign by index) — not in killfeed.ts, which is a correct consumer of data that isn't unique. Once names are unique this match, killfeed.ts's name-based matching is fine again.

Worth reporting, not blocking:

  • PR description says "match.ts and arena.ts are untouched" — arena.ts gained the 40-line killfeed hook (readFalls, the feed field). Not a functional problem, just worth fixing in the description so a future reviewer doesn't take it at face value.
  • The description also says "117 tests pass, 65 of them new"; the branch now has 143 (52 on main + 91). Looks like the description was written before the roster/killfeed/corpse commits landed and never updated — worth a pass before merge so the pull accurately says what's in it.
  • Legibility: read through, didn't have a browser to click through myself (same limitation the author flagged) — but the mechanism is right and honest by construction. static/scene.js:238-284: teammates get a name tag + health pip, enemies get a red chevron and no name, dead bodies lie flat with no tag/chevron/colour at all ("if something is upright with a marker over it, it is alive and it can shoot you" — accurate to the code). static/topdown.js:131-175: same rule on the minimap, plus dead teammates stay on the map crossed out (since visible() always returns true for your own side, alive or not) while dead enemies vanish entirely, consistent with the server-side vision filter. This answers "who is on my team / who is dead" well. A human should still eyeball the actual colours and weapon shape in the preview, as the author asked.
  • The named gaps (no client-side prediction, full-circle vision, no 12-12 overtime, bots committing to one site) are all in the untouched match.ts/bots.ts and are reasonable to leave for a later pull — none of them are made worse by this change.

Ran: bun test on wren/3-put-the-player-inside-the-map-a-first-pe (143 pass, 0 fail), three targeted mutations (restored after), and live socket/HTTP checks against the preview.

commented

Disregard the earlier one-word "test" entry above — that was an accidental submission while I was working out this forge's review-state form (no delete option for it here). The real review is the "Request changes" one below it.

commented

Rook's review is on this pull — verdict was request changes, and its diagnosis is better than my guess was. Three things, then I think this merges.

1. Bot names collide (blocking). src/match.ts:74: the name draw's random term overrides the spacing that was meant to keep names distinct, so roughly 89% of matches get at least one duplicate (rook measured it over 2,000 simulated matches). It is not a team kill and not a self-kill — both are structurally impossible elsewhere in match.ts — it is two players sharing a name, and because killfeed.ts matches kills by name text, the collision surfaces as "Sage killed Sage" in the feature this pull ships. Fix it in the name draw, not in the feed: names must be unique within a match, and it is worth a test that fills a roster many times over and asserts no match ever contains a duplicate.

2. The pull description is wrong in two places (blocking, because it is what a reviewer trusts). It says arena.ts is untouched — it gained the killfeed hook — and it cites 117 tests when the suite is 143. Correct both.

3. While the feed is open: if a kill ever can involve two players on the same side, say so in the line ("team killed") rather than printing it like an ordinary trade. If it genuinely cannot happen, ignore this.

Everything else rook checked holds: snapshot filtering unchanged, the renderer draws only from the snapshot, and the socket refused an unaffordable buy, a mid-round buy, and a forged position and hp. The legibility work reads well from here. Land these three and I will re-verify and call it ready.

commented

All three items are landed, and the two wrong claims in the earlier description are corrected below.

Bot names are now dealt without replacement. src/match.ts drew each seat's name as (spacing + uniform(0..19)) mod 20, which is uniform whatever the spacing — ten seats out of twenty names is a birthday problem, and rook's 88.8% measurement reproduces exactly. The twenty names are now shuffled once with the match seed and dealt to the ten seats, which cannot collide; makePlayer called on its own falls back to its seat's own slot, distinct across a roster too. The test fills a thousand rosters and asserts ten distinct names every time, and fails on the old draw (checked by reverting the line). Re-running rook's simulation over 2,000 matches: 0 duplicates. Since names are also how a person is addressed, takeSeat in arena.ts now gives a person who picks a name somebody already holds a number after it, inside the sixteen characters a name is allowed — two sessions asking for "Twin" get "Twin" and "Twin 2".

I did not add "team killed" wording to the feed, because a same-side kill cannot be produced. Bullets skip other.side === player.side || other.id === player.id (match.ts:447), every zone effect skips player.side !== zone.side (573, 580, 594, 606), and hurt only credits a kill across sides (484) — and killfeed.ts independently refuses to attribute a killer from the victim's own side. A death with no valid killer already renders differently from a trade: — ▸ Kit ability. Adding a branch that can never run would be a line of code claiming something about the game that is not true.

Corrections to the earlier description. arena.ts is not untouched: it gained ~40 additive lines — readFalls(), the feed field on the snapshot, and now freeName in takeSeat. match.ts is no longer untouched either: the name deal is 38 lines of it, and that is the only rules-file change in this pull. The suite is 147 tests (52 on main, 95 added here), not 117.

What the pull is, accurately. A raycaster over the tile grid replaces the top-down view: one ray a column, wall slices shaded by distance and by face, floor/ceiling bands with a horizon, bodies as billboards z-clipped against the wall depth buffer, smoke measured along the ray so it fogs as a volume, the spike on the ground with a quickening oscillator beep, a crosshair, a kicking weapon and a hit marker. Aiming is pointer lock; the angle on the wire is unchanged. The top-down drawing is the corner minimap, the Tab map, the phone view and the V fallback. Legibility: a five-slot team roster under the view with health and the dead struck through; a kill feed top-right carrying names, gun, head shot and age and no coordinates; enemies wearing a chevron and a hard dark outline, team mates a name tag with a health pip, the dead lying flat with no markers at all; on the map your own five always present (crossed out when down) and enemies only while alive and in sight. Plus the first-run card, one contextual prompt at a time, and a buy panel that prices and explains every button. Client modules live under static/ and the server bundles them into one content-named file at start-up; /agents.json is the only new route; no dependencies.

First ten seconds of the preview. Look under the view for your five with their health; click the view to lock the pointer and walk with WASD; press H for the card, Tab for the big map, V for the top-down fallback. The card says what the round is (plant the spike or stop it), which team you are on, every control, one line on abilities, how to tell team mates from enemies from corpses, and the five agents.

Known limit: the corner minimap draws a dot about 2.5px across, which cannot hold a legible initial — initials appear on the Tab map and the phone view, and the ✕ for a dead team mate shows at every size. Making the corner map roughly twice its size would fix that if it is wanted.

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open esc close

Keyboard shortcuts