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

Make the in-world cue a shape, and let the dead lie down

Colour alone fails at twenty pixels across a dim corridor, and fails
entirely for a colour-blind player. An enemy now wears a red chevron over
their head and a hard dark outline around body and head; a team mate wears
their name tag with a health pip in it. The silhouette is what carries.

The dead lie flat on the floor, desaturated, with no tag, no chevron and no
agent band — if something is upright with a marker over it, it is alive. On
the map your own team is always there, initial in the dot and crossed out
when down, and an enemy is drawn only while alive and in sight, so the map
stays honest about vision while your own five stay legible.

The how-to card and the README say all of this, since it is exactly what a
first-time player needs told.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 5e428dd Browse files
5 files changed +176 −31
README.md +5 −1
@@ -32,6 +32,10 @@ server:
3232 - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a
3333 plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is
3434 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.
3539 - **Bots** fill every empty seat and use their kit: smokes on the way in,
3640 flashes before the entry, a recon dart, a molly on the planted spike.
3741
@@ -91,6 +95,6 @@ no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
9195 live match and its sockets · `src/server.ts` the door · `static/` the browser client —
9296 `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray
9397 maths, `scene.js` the first-person view, `topdown.js` the minimap,
94 `gun.js`, `beep.js`, `steer.js` and `prompt.js` the rest; the server bundles
98 `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles
9599 them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
96100 headlessly.
src/pages/game.ts +7 −1
@@ -91,7 +91,8 @@ export function gamePage(opts: {
9191 .slot { background: #171a21; border: 1px solid #262b35; border-left: 3px solid #4a5162;
9292 border-radius: .35rem; padding: .2rem .35rem; font-size: .72rem; overflow: hidden; }
9393 .slot .who { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
94 .slot .agent { opacity: .6; font-size: .66rem; }
94 .slot .agent { display: block; opacity: .6; font-size: .66rem; white-space: nowrap;
95 overflow: hidden; text-overflow: ellipsis; }
9596 .slot .hp { display: block; height: 5px; margin-top: .2rem; border-radius: 3px; background: #2a2f3a; }
9697 .slot .hp i { display: block; height: 100%; border-radius: 3px; background: #6fcf6f; }
9798 .slot.mine { border-color: #6f8ecf; background: #1b2130; }
@@ -172,6 +173,11 @@ export function gamePage(opts: {
172173 <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and
173174 last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is
174175 the ultimate, paid for in ult points you earn by playing.</p>
176 <p><strong>Telling people apart.</strong> Your five are always listed under the view with their
177 health — struck through when they are down — and the last few kills scroll in the top right.
178 In the world an enemy wears a red chevron over their head and a hard dark outline, a team mate
179 wears their name and a health pip, and anybody lying flat on the floor is dead. On the map your
180 team is always shown (crossed out when down) and enemies appear only while you can see them.</p>
175181 <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right
176182 thumb aims and fires, and the buttons under the map buy and cast.</p>
177183 <ul id="helpagents"></ul>
src/scene.test.ts +57
@@ -117,6 +117,33 @@ test("a team mate gets a name tag and an enemy never does", () => {
117117 expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false);
118118 });
119119
120 test("the dead lie on the floor and wear no markers at all", () => {
121 const ctx = fakeContext();
122 drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view);
123 // Flat on the ground: an ellipse, not an upright body with a head.
124 expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true);
125 // No name tag, and no chevron — the chevron is the only closed path here.
126 expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
127 expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
128 });
129
130 test("an enemy wears a chevron and a team mate does not", () => {
131 const enemy = fakeContext();
132 drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
133 expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true);
134
135 const mate = fakeContext();
136 drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
137 expect(mate.ops.some((o) => o.op === "closePath")).toBe(false);
138 expect(mate.ops.some((o) => o.op === "fillText")).toBe(true);
139 });
140
141 test("a body has a hard dark edge, whichever side it is on", () => {
142 const ctx = fakeContext();
143 drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view);
144 expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true);
145 });
146
120147 test("a smoke between the eye and a body hides it", () => {
121148 const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
122149 const ctx = fakeContext();
@@ -161,6 +188,36 @@ test("the top-down view draws the real map, small or large", () => {
161188 expect(small.ops.some((o) => o.op === "translate")).toBe(true);
162189 });
163190
191 test("on the map a dead team mate is crossed out and a dead enemy is not there", () => {
192 const map = { rows: gridRows(), tile: TILE };
193 const box = { x: 0, y: 0, w: 900, h: 570 };
194 const look = (p: never) => p;
195
196 const mate = fakeContext();
197 drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box);
198 // Two strokes through the dot, and no dot: a cross is not a live player.
199 expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2);
200 expect(mate.ops.some((o) => o.op === "arc")).toBe(false);
201
202 const enemy = fakeContext();
203 drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box);
204 const empty = fakeContext();
205 drawTopdown(empty as never, map, snapshot([]), look, box);
206 expect(enemy.ops.length).toBe(empty.ops.length);
207 });
208
209 test("a living team mate carries their initial on the big map", () => {
210 const map = { rows: gridRows(), tile: TILE };
211 const big = fakeContext();
212 drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, {
213 x: 0,
214 y: 0,
215 w: 900,
216 h: 570,
217 });
218 expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true);
219 });
220
164221 test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
165222 const gun = makeGun();
166223 const ctx = fakeContext();
static/scene.js +64 −19
@@ -233,23 +233,44 @@ function drawBody(ctx, scene, thing, floorY, step, columns) {
233233 const cx = thing.column;
234234 const top = floorY - h;
235235 const mate = p.mate;
236 const skin = !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f";
237 const trim = AGENT_COLOUR[p.agent] || "#999";
238236
239 withRuns(ctx, scene, cx - w, cx + w * 1.4, thing.perp, step, columns, () => {
240 // Body, head, and a shoulder band in the agent's colour.
237 // The dead lie down. No tag, no chevron, no agent colour: if something is
238 // upright with a marker over it, it is alive and it can shoot you.
239 if (!p.alive) {
240 const lie = h * 0.16;
241 withRuns(ctx, scene, cx - w * 1.1, cx + w * 1.1, thing.perp, step, columns, () => {
242 ctx.fillStyle = "#4a4e57";
243 ctx.beginPath();
244 ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7);
245 ctx.fill();
246 ctx.strokeStyle = "#23262d";
247 ctx.lineWidth = Math.max(1, h * 0.02);
248 ctx.stroke();
249 });
250 return;
251 }
252
253 const skin = mate ? "#3f9fd6" : "#d8483f";
254 const trim = AGENT_COLOUR[p.agent] || "#999";
255 const edge = Math.max(1.2, h * 0.05);
256
257 withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.5, thing.perp, step, columns, () => {
258 // Body and head, each with a hard dark edge: the silhouette is what
259 // carries across a dim room, where the colour does not.
241260 ctx.fillStyle = skin;
242261 ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
262 ctx.strokeStyle = "#0b0d11";
263 ctx.lineWidth = edge;
264 ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
243265 ctx.fillStyle = trim;
244266 ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1);
245267 ctx.beginPath();
246268 ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7);
247269 ctx.fillStyle = skin;
248270 ctx.fill();
249 ctx.lineWidth = Math.max(1, h * 0.03);
250 ctx.strokeStyle = trim;
271 ctx.strokeStyle = "#0b0d11";
251272 ctx.stroke();
252 if (!p.alive) return;
273
253274 if (p.spike) {
254275 ctx.fillStyle = "#f0d060";
255276 ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3);
@@ -260,21 +281,45 @@ function drawBody(ctx, scene, thing, floorY, step, columns) {
260281 ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a";
261282 ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06);
262283 }
263 // A name tag over team mates only: an enemy is a shape, never a label.
264 if (mate) {
265 const size = Math.max(9, Math.min(18, h * 0.16));
266 ctx.font = `${size}px system-ui, sans-serif`;
267 ctx.textAlign = "center";
268 ctx.fillStyle = "rgba(10,12,16,0.65)";
269 const tw = ctx.measureText(p.name).width + 8;
270 ctx.fillRect(cx - tw / 2, top - h * 0.34, tw, size * 1.4);
271 ctx.fillStyle = "#cfe8ff";
272 ctx.fillText(p.name, cx, top - h * 0.34 + size * 1.05);
273 ctx.textAlign = "left";
274 }
284
285 if (mate) tag(ctx, p, cx, top, h);
286 else chevron(ctx, cx, top, h);
275287 });
276288 }
277289
290 /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read
291 * red and blue the same way. */
292 function chevron(ctx, cx, top, h) {
293 const size = Math.max(4, h * 0.16);
294 const y = top - h * 0.16;
295 ctx.beginPath();
296 ctx.moveTo(cx - size, y - size);
297 ctx.lineTo(cx + size, y - size);
298 ctx.lineTo(cx, y);
299 ctx.closePath();
300 ctx.fillStyle = "#ff6f5e";
301 ctx.fill();
302 ctx.lineWidth = Math.max(1, h * 0.02);
303 ctx.strokeStyle = "#140a08";
304 ctx.stroke();
305 }
306
307 /** A team mate wears their name and a pip of their health — never an enemy. */
308 function tag(ctx, p, cx, top, h) {
309 const size = Math.max(9, Math.min(18, h * 0.16));
310 const y = top - h * 0.34;
311 ctx.font = `${size}px system-ui, sans-serif`;
312 ctx.textAlign = "center";
313 const width = ctx.measureText(p.name).width + size * 1.4;
314 ctx.fillStyle = "rgba(10,12,16,0.65)";
315 ctx.fillRect(cx - width / 2, y, width, size * 1.4);
316 ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a";
317 ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7);
318 ctx.fillStyle = "#cfe8ff";
319 ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05);
320 ctx.textAlign = "left";
321 }
322
278323 function drawSpike(ctx, scene, thing, floorY, snap, step, columns) {
279324 const h = thing.size * 0.42;
280325 const cx = thing.column;
static/topdown.js +43 −10
@@ -122,15 +122,21 @@ function disc(ctx, x, y, r, fill, stroke) {
122122 if (stroke) ctx.stroke();
123123 }
124124
125 /** Everybody is a dot; you are an arrow, so you can tell at a glance where you face. */
125 /**
126 * Everybody is a dot and you are an arrow — but a team mate is on the map
127 * whether you can see them or not, with their initial in the dot and a cross
128 * when they are down, and an enemy is only ever drawn while they are alive
129 * and in sight. The map stays honest about vision and legible about your own.
130 */
126131 function drawPlayer(ctx, p, pos, s, me, labels) {
132 const mine = p.id === me.id;
133 if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out
127134 const x = pos.x * s;
128135 const y = pos.y * s;
129136 const r = Math.max(2.5, 16 * s);
130 const mine = p.id === me.id;
131137 const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b";
132138
133 if (mine) {
139 if (mine && p.alive) {
134140 ctx.save();
135141 ctx.translate(x, y);
136142 ctx.rotate(pos.angle);
@@ -146,6 +152,26 @@ function drawPlayer(ctx, p, pos, s, me, labels) {
146152 return;
147153 }
148154
155 // The dead of your own team keep their place on the map, crossed out.
156 if (!p.alive) {
157 ctx.strokeStyle = "#6a707c";
158 ctx.lineWidth = Math.max(1, 2 * s);
159 ctx.beginPath();
160 ctx.moveTo(x - r * 0.8, y - r * 0.8);
161 ctx.lineTo(x + r * 0.8, y + r * 0.8);
162 ctx.moveTo(x + r * 0.8, y - r * 0.8);
163 ctx.lineTo(x - r * 0.8, y + r * 0.8);
164 ctx.stroke();
165 if (labels) {
166 ctx.fillStyle = "rgba(180,185,195,0.6)";
167 ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`;
168 ctx.textAlign = "center";
169 ctx.fillText(p.name, x, y - r * 1.9);
170 ctx.textAlign = "left";
171 }
172 return;
173 }
174
149175 ctx.fillStyle = body;
150176 ctx.beginPath();
151177 ctx.arc(x, y, r, 0, 7);
@@ -153,12 +179,19 @@ function drawPlayer(ctx, p, pos, s, me, labels) {
153179 ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888";
154180 ctx.lineWidth = Math.max(1, 3 * s);
155181 ctx.stroke();
156 if (p.alive) {
157 ctx.strokeStyle = body;
158 ctx.beginPath();
159 ctx.moveTo(x, y);
160 ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2);
161 ctx.stroke();
182 ctx.strokeStyle = body;
183 ctx.beginPath();
184 ctx.moveTo(x, y);
185 ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2);
186 ctx.stroke();
187
188 // The initial inside the dot, when the dot is big enough to hold one.
189 if (p.mate && r >= 6) {
190 ctx.fillStyle = "#0c1016";
191 ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`;
192 ctx.textAlign = "center";
193 ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45);
194 ctx.textAlign = "left";
162195 }
163196 if (p.spike) {
164197 ctx.fillStyle = "#f0d060";
@@ -175,7 +208,7 @@ function drawPlayer(ctx, p, pos, s, me, labels) {
175208 ctx.fillText(p.name, x, y - r * 1.9);
176209 ctx.textAlign = "left";
177210 }
178 if (p.alive && p.hp < 100) {
211 if (p.hp < 100) {
179212 ctx.fillStyle = "#0008";
180213 ctx.fillRect(x - r, y + r * 1.2, r * 2, 3);
181214 ctx.fillStyle = "#6fcf6f";
↑↓ navigate ↵ open esc close

Keyboard shortcuts