Two things, and the first is the reason for the second: nobody can tell what they are looking at.
1. First person, not dots
Draw the match from inside the player's head instead of above it. No 3D library — the content security policy only admits scripts from this origin, and a vendored engine would break the zero-dependency rule for a page that has to load on a phone. Do it the way the map is already built for: a raycaster.
You already have everything it needs. map.ts is a tile grid with solidTile/solidAt, and you raycast it for movement and now for sight. Cast one ray per screen column across the field of view, take the distance to the first solid tile, and draw a wall slice whose height is inversely proportional to that distance. That is the whole renderer.
What it needs to be legible rather than a smear of grey:
Shade walls by distance and by face. North–south faces darker than east–west, and everything dimmer further away. Without those two cues a corridor reads as a flat wall and people walk into corners.
Floor and ceiling as two bands, different enough to tell which way is up at a glance. A horizon line is what stops it feeling like a screensaver.
Players as billboarded sprites — a body drawn as a simple shape, scaled by distance, z-clipped against the wall slices so somebody behind a corner is actually hidden. Teammates and enemies must be unmistakable at a glance: different colour and a name tag over teammates only. Draw the agent's colour so a Sable reads as a Sable.
Smokes are volume, not a circle. A ray that crosses a smoke zone fogs from there on — that is what makes the ability feel like the real thing, and the server already knows which zones block sight.
The spike visible on the ground when dropped and planted, with its own colour and a beep that gets faster as the fuse burns (an oscillator, no audio files).
A crosshair, a weapon shape at the bottom of the screen that kicks when you fire, and a hit marker.
Keep the top-down as a minimap in a corner — same drawing you have now, smaller, with the player as an arrow. Tab expands it to full screen. On a coarse pointer (a phone), stay in the top-down view: a raycaster with touch look is miserable, and the game should still be playable there.
Aiming is pointer lock: click the canvas to capture the mouse, move to look, Escape releases. Send the same angle you send today — the server contract does not change, and neither does anything in match.ts or arena.ts. This is a rendering and input change only.
2. Instructions somebody can actually follow
Right now a first-time player gets a board with no explanation. Fix that in three places:
A first-run card over the canvas: what the round is (plant the spike or stop it), what the two teams are, the controls, and one line on abilities. Dismiss with a button; reopen any time with H or ?. Remember the dismissal per browser, and never show it during a live round.
Contextual prompts on the HUD, one at a time, in the player's own words: "Buy phase — 15s left. Click a gun you can afford.", "You have the spike — get to site A or B", "Hold F to plant", "Spike down, 32s — hold F to defuse", "You are blind", "Waiting for the next round — you're in it". These are the difference between a demo and a game.
Label the buy panel: price on every button, greyed when unaffordable, a tooltip line on every ability saying what it does and its charges. The agent's kit should be readable without leaving the page.
The bar
Keep 60fps on a laptop: one canvas, one draw per frame, no per-frame allocation in the ray loop, and the existing 15Hz snapshots with the smoothing you already have. Keep the client a file under static/ named by content, keep the CSP untouched, and keep the server authoritative — nothing you draw may depend on state the server did not send.
The renderer is the risky part: land it first and keep the top-down as a fallback you can switch to with a key, so a broken frame never makes the game unplayable. Test what can be tested without a browser — the column-to-ray maths, the wall-height function, the sprite z-clip decision and the prompt chosen for a given match state are all pure functions.
In your pull description, say what to press in the first ten seconds and what the first-run card says.
Two things, and the first is the reason for the second: nobody can tell what they are looking at.
1. First person, not dots
Draw the match from inside the player's head instead of above it. No 3D library — the content security policy only admits scripts from this origin, and a vendored engine would break the zero-dependency rule for a page that has to load on a phone. Do it the way the map is already built for: a raycaster.
You already have everything it needs.
map.tsis a tile grid withsolidTile/solidAt, and you raycast it for movement and now for sight. Cast one ray per screen column across the field of view, take the distance to the first solid tile, and draw a wall slice whose height is inversely proportional to that distance. That is the whole renderer.What it needs to be legible rather than a smear of grey:
Aiming is pointer lock: click the canvas to capture the mouse, move to look, Escape releases. Send the same angle you send today — the server contract does not change, and neither does anything in
match.tsorarena.ts. This is a rendering and input change only.2. Instructions somebody can actually follow
Right now a first-time player gets a board with no explanation. Fix that in three places:
The bar
Keep 60fps on a laptop: one canvas, one draw per frame, no per-frame allocation in the ray loop, and the existing 15Hz snapshots with the smoothing you already have. Keep the client a file under
static/named by content, keep the CSP untouched, and keep the server authoritative — nothing you draw may depend on state the server did not send.The renderer is the risky part: land it first and keep the top-down as a fallback you can switch to with a key, so a broken frame never makes the game unplayable. Test what can be tested without a browser — the column-to-ray maths, the wall-height function, the sprite z-clip decision and the prompt chosen for a given match state are all pure functions.
In your pull description, say what to press in the first ten seconds and what the first-run card says.