Split The code is public; the app answers only to the crew.

Bind your own keys: WASD is not everyone's WASD #101

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/cs:scout/69-bind-your-own-keys-wasd-is-not-everyone → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/69-bind-your-own-keys-wasd-is-not-everyone was merged into main as 3974ac5.

Review

Sign in to review this change.
9 files changed +489 −30
README.md +17
@@ -137,6 +137,23 @@ the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the
137137 pad does it on a phone. Pausing also opens mute, volume and
138138 look sensitivity, which take effect at once and are remembered for next time.
139139
140 Every one of those keys is a default, not a floor: **WASD is not everyone's
141 WASD** — on an AZERTY board, a Dvorak layout or just a different habit, the
142 letters printed on the physical keys are not where this game's defaults
143 expect them. The keys are yours to rebind. A collapsed **Controls** disclosure
144 sits in the menu and again in the pause overlay (the same 16 actions, always
145 in sync) — closed by default so it never pushes anything else out of view;
146 opening it is a plain `<details>`, reachable and toggled with a keyboard on
147 its own. Click a button to see its action's current key, press it and then press the
148 key you want instead, and it takes hold immediately, mid-match included.
149 Escape cancels a capture rather than pausing the match. A key already bound
150 to something else is refused with a line saying what it is bound to instead —
151 nothing is ever left half-bound. **Reset to defaults** puts the whole map
152 back. The arrow keys and <kbd>C</kbd> are a fixed movement and crouch
153 fallback that no rebind can take over, so a mis-click here is never a match
154 you cannot move in. Rebinding is keyboard only — the touch pad's layout does
155 not change.
156
140157 The menu's **Difficulty** select picks Easy, Normal or Hard before a match
141158 starts — each one a band of the bots' skill rather than one number, so a
142159 side still runs from fodder to uncomfortable at every setting, just a
src/client/bind.test.ts added +48
@@ -0,0 +1,48 @@
1 import { expect, test } from "bun:test";
2 import { decideBind, labelForCode } from "./bind.ts";
3 import { DEFAULT_BINDINGS } from "./input.ts";
4
5 test("a fresh code nothing else holds is accepted, and only that action moves", () => {
6 const outcome = decideBind("forward", "KeyZ", DEFAULT_BINDINGS);
7 expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, forward: "KeyZ" } });
8 });
9
10 test("a reserved code — the movement and crouch fallback — is refused, and says why", () => {
11 for (const code of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]) {
12 const outcome = decideBind("jump", code, DEFAULT_BINDINGS);
13 expect(outcome.ok).toBe(false);
14 expect(outcome).toMatchObject({ reason: expect.stringContaining("reserved") });
15 }
16 });
17
18 test("a code another action already holds is refused, naming that action", () => {
19 const outcome = decideBind("jump", DEFAULT_BINDINGS.forward, DEFAULT_BINDINGS);
20 expect(outcome.ok).toBe(false);
21 expect(outcome).toMatchObject({ reason: expect.stringContaining("Move forward") });
22 });
23
24 test("rebinding an action to the code it already has is a no-op success, not a self-conflict", () => {
25 const outcome = decideBind("forward", DEFAULT_BINDINGS.forward, DEFAULT_BINDINGS);
26 expect(outcome).toEqual({ ok: true, bindings: DEFAULT_BINDINGS });
27 });
28
29 test("a refusal never changes the map: the caller still holds the bindings it started with", () => {
30 const outcome = decideBind("jump", "KeyC", DEFAULT_BINDINGS);
31 expect(outcome.ok).toBe(false);
32 // decideBind returns no bindings at all on refusal — there is nothing for
33 // a caller to accidentally apply.
34 expect((outcome as { bindings?: unknown }).bindings).toBeUndefined();
35 });
36
37 test("labelForCode reads a code the way a player would say it", () => {
38 expect(labelForCode("KeyW")).toBe("W");
39 expect(labelForCode("Digit1")).toBe("1");
40 expect(labelForCode("Space")).toBe("Space");
41 expect(labelForCode("ControlLeft")).toBe("Left Ctrl");
42 expect(labelForCode("ArrowUp")).toBe("Arrow Up");
43 expect(labelForCode("Numpad5")).toBe("Numpad 5");
44 });
45
46 test("labelForCode never throws on a code it does not recognise — it just shows the code", () => {
47 expect(labelForCode("IntlBackslash")).toBe("IntlBackslash");
48 });
src/client/bind.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * The rebind panel's rules: what a captured key does to a binding map, and
3 * what a key or an action is called on the screen. Pure data in, plain data
4 * out — nothing here touches the DOM, so main.ts is free to paint it however
5 * a click or a screen reader needs, the way buy.ts already keeps the buy
6 * panel's rules out of main.ts.
7 */
8
9 import { ACTIONS, RESERVED_CODES, type Action } from "./input.ts";
10
11 export const ACTION_LABELS: Record<Action, string> = {
12 forward: "Move forward", back: "Move back", left: "Strafe left", right: "Strafe right",
13 jump: "Jump", duck: "Crouch", walk: "Walk",
14 reload: "Reload", use: "Use / plant / defuse", buy: "Buy menu",
15 scoreboard: "Scoreboard", scope: "Scope",
16 slot1: "Slot 1 — rifle", slot2: "Slot 2 — pistol", slot3: "Slot 3 — knife", slot4: "Slot 4 — grenade",
17 };
18
19 const CODE_LABELS: Record<string, string> = {
20 Space: "Space", Tab: "Tab", Escape: "Escape", CapsLock: "Caps Lock", Enter: "Enter", Backspace: "Backspace",
21 ControlLeft: "Left Ctrl", ControlRight: "Right Ctrl", ShiftLeft: "Left Shift", ShiftRight: "Right Shift",
22 AltLeft: "Left Alt", AltRight: "Right Alt", Backquote: "`", Minus: "-", Equal: "=",
23 BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'", Comma: ",", Period: ".",
24 Slash: "/", Backslash: "\\",
25 };
26
27 /** A key code, as a player reads it — not as the DOM names it. */
28 export function labelForCode(code: string): string {
29 if (code.startsWith("Key")) return code.slice(3);
30 if (code.startsWith("Digit")) return code.slice(5);
31 if (code.startsWith("Numpad")) return `Numpad ${code.slice(6)}`;
32 if (code.startsWith("Arrow")) return `Arrow ${code.slice(5)}`;
33 return CODE_LABELS[code] ?? code;
34 }
35
36 export type BindOutcome =
37 | { ok: true; bindings: Record<Action, string> }
38 | { ok: false; reason: string };
39
40 /**
41 * What a captured key does to `action`'s binding: reserved codes (the
42 * movement/crouch fallback) are refused outright, a code another action
43 * already holds is refused with which one, and otherwise the map gets the
44 * one change asked for and nothing else moves.
45 */
46 export function decideBind(action: Action, code: string, bindings: Record<Action, string>): BindOutcome {
47 if (RESERVED_CODES.has(code)) {
48 return { ok: false, reason: `${labelForCode(code)} is reserved for movement and can't be bound.` };
49 }
50 const takenBy = ACTIONS.find((other) => other !== action && bindings[other] === code);
51 if (takenBy !== undefined) {
52 return { ok: false, reason: `${labelForCode(code)} is already bound to ${ACTION_LABELS[takenBy]}.` };
53 }
54 return { ok: true, bindings: { ...bindings, [action]: code } };
55 }
src/client/input.test.ts +70 −3
@@ -6,7 +6,7 @@
66 */
77
88 import { afterEach, expect, test } from "bun:test";
9 import { attachButtons, createControls, type Controls } from "./input.ts";
9 import { attachButtons, createControls, DEFAULT_BINDINGS, type Controls } from "./input.ts";
1010 import { downAt } from "./press.ts";
1111
1212 type Listener = (event: unknown) => void;
@@ -47,11 +47,11 @@ afterEach(() => {
4747 });
4848
4949 /** A canvas 1000 wide: the stick is x < 380, the look and the trigger are past it. */
50 function harness(): { canvas: Canvas; controls: Controls } {
50 function harness(bindings = DEFAULT_BINDINGS): { canvas: Canvas; controls: Controls } {
5151 const canvas = target({ clientWidth: 1000, clientHeight: 600 });
5252 globals.window = target();
5353 globals.document = target({ pointerLockElement: null });
54 const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2);
54 const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2, bindings);
5555 return { canvas, controls };
5656 }
5757
@@ -242,3 +242,70 @@ test("Q scopes from the keyboard the same way", () => {
242242 window.fire("keyup", { code: "KeyQ" });
243243 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
244244 });
245
246 // --- bindings: a non-default map moves the game, not the printed letters
247
248 test("a non-default map moves on the bound key and does nothing on KeyW", () => {
249 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyZ" });
250 const window = globals.window as Canvas;
251
252 window.fire("keydown", { code: "KeyW" });
253 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
254
255 window.fire("keydown", { code: "KeyZ" });
256 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
257 });
258
259 test("a rebind takes effect on the very next key event, with no detach or re-attach", () => {
260 const { controls } = harness();
261 const window = globals.window as Canvas;
262
263 window.fire("keydown", { code: "KeyW" });
264 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
265 window.fire("keyup", { code: "KeyW" });
266
267 controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyZ" });
268
269 window.fire("keydown", { code: "KeyW" });
270 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
271 window.fire("keydown", { code: "KeyZ" });
272 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
273 });
274
275 test("the arrow keys and C still move and crouch whatever forward is bound to", () => {
276 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyZ", duck: "KeyU" });
277 const window = globals.window as Canvas;
278
279 window.fire("keydown", { code: "ArrowUp" });
280 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
281 window.fire("keydown", { code: "KeyC" });
282 expect(controls.inputAt(controls.take(1), 0).duck).toBe(true);
283 });
284
285 test("under pointer lock, an unbound key is still kept off the browser — Tab cannot walk focus off the canvas", () => {
286 const { controls } = harness();
287 const window = globals.window as Canvas;
288 let prevented = 0;
289 const press = (code: string): void => window.fire("keydown", { code, preventDefault: () => { prevented += 1; } });
290
291 press("Backquote"); // unbound, and not locked yet
292 expect(prevented).toBe(0);
293
294 controls.locked = true;
295 press("Backquote"); // unbound, but the lock claims every key
296 expect(prevented).toBe(1);
297 });
298
299 test("preventDefault follows the action, not the code: a jump rebound off Space no longer eats it", () => {
300 const { controls } = harness({ ...DEFAULT_BINDINGS, jump: "KeyJ" });
301 const window = globals.window as Canvas;
302 let prevented = 0;
303
304 window.fire("keydown", { code: "Space", preventDefault: () => { prevented += 1; } });
305 expect(prevented).toBe(0);
306 expect(controls.inputAt(controls.take(1), 0).jump).toBe(false);
307
308 window.fire("keydown", { code: "KeyJ", preventDefault: () => { prevented += 1; } });
309 expect(prevented).toBe(1);
310 expect(controls.inputAt(controls.take(1), 0).jump).toBe(true);
311 });
src/client/input.ts +56 −10
@@ -31,6 +31,8 @@ export type Controls = {
3131 sensitivity: number;
3232 locked: boolean;
3333 setAction(action: string, down: boolean): void;
34 /** A rebind takes hold on the very next key event — no detach, no reload. */
35 setBindings(bindings: Record<Action, string>): void;
3436 /**
3537 * Forget every intent not yet taken — held controls, latched presses and the
3638 * look that has piled up. What the focus, the pointer lock and the pause
@@ -43,18 +45,52 @@ export type Controls = {
4345 detach(): void;
4446 };
4547
46 const KEY_ACTIONS: Record<string, string> = {
47 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
48 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
49 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
50 KeyR: "reload", KeyE: "use", KeyB: "buy", Tab: "scoreboard", KeyQ: "scope",
51 Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", Digit4: "slot4",
48 /**
49 * Every action a key can drive, and precisely today's map — the default a
50 * corrupt or missing setting falls back to. `settings.ts` validates a stored
51 * map against this same list; nothing else should invent an action name.
52 */
53 export const ACTIONS = [
54 "forward", "back", "left", "right", "jump", "duck", "walk",
55 "reload", "use", "buy", "scoreboard", "scope",
56 "slot1", "slot2", "slot3", "slot4",
57 ] as const;
58 export type Action = typeof ACTIONS[number];
59
60 export const DEFAULT_BINDINGS: Record<Action, string> = {
61 forward: "KeyW", back: "KeyS", left: "KeyA", right: "KeyD",
62 jump: "Space", duck: "ControlLeft", walk: "ShiftLeft",
63 reload: "KeyR", use: "KeyE", buy: "KeyB", scoreboard: "Tab", scope: "KeyQ",
64 slot1: "Digit1", slot2: "Digit2", slot3: "Digit3", slot4: "Digit4",
5265 };
5366
54 export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Controls {
67 /**
68 * Codes no binding can claim: the arrow keys and `C` are a fixed movement and
69 * crouch fallback, wired below regardless of what a player has bound, so a
70 * rebind gone wrong is never a player who cannot move.
71 */
72 export const RESERVED_CODES: ReadonlySet<string> =
73 new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]);
74
75 /** Code to action, the fixed fallback last so nothing bound can ever shadow it. */
76 function reverseBindings(bindings: Record<Action, string>): Record<string, Action | "duck"> {
77 const map: Record<string, Action | "duck"> = {};
78 for (const action of ACTIONS) map[bindings[action]] = action;
79 map.ArrowUp = "forward";
80 map.ArrowDown = "back";
81 map.ArrowLeft = "left";
82 map.ArrowRight = "right";
83 map.KeyC = "duck";
84 return map;
85 }
86
87 export function createControls(
88 canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS,
89 ): Controls {
5590 const press = createPress(LATCHED);
5691 const input: Input = { ...NO_INPUT };
5792 let lookX = 0, lookY = 0;
93 let reverse = reverseBindings(bindings);
5894 const controls: Controls = {
5995 takeLook() {
6096 const out: [number, number] = [lookX, lookY];
@@ -98,6 +134,9 @@ export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Co
98134 if (action === "buy") controls.onBuy();
99135 else if (action === "menu") controls.onPause();
100136 },
137 setBindings: (next) => {
138 reverse = reverseBindings(next);
139 },
101140 clear: () => {
102141 press.clear();
103142 lookX = 0;
@@ -115,14 +154,21 @@ export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Co
115154 const stick = { x: 0, y: 0 };
116155
117156 function onKeyDown(event: KeyboardEvent): void {
118 const action = KEY_ACTIONS[event.code];
157 const action = reverse[event.code];
158 // Under pointer lock every key is the game's, whatever it is bound to:
159 // an unbound Tab still switches focus off the canvas otherwise, and once
160 // it has, a locked mouse is aiming at nothing the player can see move.
161 if (controls.locked) event.preventDefault();
119162 if (action === undefined) return;
120 if (event.code === "Tab" || event.code === "Space") event.preventDefault();
163 // Whatever is bound to these two actions gets the default the browser
164 // would otherwise take even without a lock: a scoreboard key that also
165 // switches tabs, or a jump key that also scrolls the page.
166 if (action === "scoreboard" || action === "jump") event.preventDefault();
121167 if (event.repeat) return;
122168 controls.setAction(action, true);
123169 }
124170 function onKeyUp(event: KeyboardEvent): void {
125 const action = KEY_ACTIONS[event.code];
171 const action = reverse[event.code];
126172 if (action !== undefined) controls.setAction(action, false);
127173 }
128174 function onLockChange(): void {
src/client/main.ts +112 −2
@@ -9,10 +9,11 @@
99 */
1010
1111 import { createAudio } from "./audio.ts";
12 import { ACTION_LABELS, decideBind, labelForCode } from "./bind.ts";
1213 import { buyMenu, type BuyRow } from "./buy.ts";
1314 import { attachFullscreen } from "./fullscreen.ts";
1415 import { drawHud } from "./hud.ts";
15 import { attachButtons, createControls } from "./input.ts";
16 import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action } from "./input.ts";
1617 import { advance, TICK, waiting } from "./loop.ts";
1718 import { clamp, dist } from "./math.ts";
1819 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
@@ -58,6 +59,8 @@ function boot(): void {
5859 const muteButton = $<HTMLButtonElement>("mute");
5960 const fullscreenMenuButton = $<HTMLButtonElement>("fullscreen-menu");
6061 const fullscreenPauseButton = $<HTMLButtonElement>("fullscreen-pause");
62 const controlsMenu = $("controls-menu");
63 const controlsPause = $("controls-pause");
6164
6265 const viewLost = $("view-lost");
6366 const resumeButton = $<HTMLButtonElement>("resume");
@@ -87,7 +90,7 @@ function boot(): void {
8790 // click is already in force once the context exists to hear it.
8891 audio.setVolume(settings.volume);
8992 audio.setMuted(settings.muted);
90 const controls = createControls(scene, settings.sensitivity);
93 const controls = createControls(scene, settings.sensitivity, settings.bindings);
9194 attachButtons(document, controls);
9295
9396 // The menu's sensitivity field and the pause overlay's are the same number:
@@ -148,6 +151,110 @@ function boot(): void {
148151 for (const field of [pauseSensField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton])
149152 field.addEventListener("keydown", (event) => event.stopPropagation());
150153
154 // --- controls: a button per action, in the menu and in the pause overlay,
155 // built once and kept in sync by rewriting each button's own text — the
156 // fixed 16-action list never changes shape, only its keys do. What a key
157 // does to the map is bind.ts's decision; this only paints it and listens.
158 const controlsPanelHtml =
159 `<p class="hint">Click a key, then press the one you want it to be. ` +
160 `Escape cancels. The touch pad underneath is unchanged.</p>` +
161 `<div class="grid">${ACTIONS.map((action) =>
162 `<label>${ACTION_LABELS[action]}<button type="button" class="bind" data-bind="${action}"></button></label>`,
163 ).join("")}</div>` +
164 `<p class="hint bind-status" aria-live="polite"></p>` +
165 `<div class="row"><button type="button" class="reset-bindings">Reset to defaults</button></div>`;
166 controlsMenu.innerHTML = controlsPanelHtml;
167 controlsPause.innerHTML = controlsPanelHtml;
168
169 const refreshBindingButtons = (): void => {
170 for (const container of [controlsMenu, controlsPause]) {
171 for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) {
172 button.textContent = labelForCode(settings.bindings[button.dataset.bind as Action]);
173 }
174 }
175 };
176 refreshBindingButtons();
177
178 // At most one capture is ever live: starting a new one, or anything that
179 // takes the player off the panel — resuming, starting a match, the window
180 // losing focus — cancels whatever capture was pending first. Without that
181 // a forgotten capture's window listener outlives the click that opened it,
182 // silently eating (and rebinding) the first key the next match sees.
183 let cancelCapture: (() => void) | null = null;
184
185 /**
186 * One key, captured with the window itself in the capturing phase so it is
187 * seen and stopped dead — `stopImmediatePropagation`, not just
188 * `stopPropagation` — before it ever reaches input.ts's own window
189 * listener or a second capture on another button: without that, the key
190 * that rebinds fire also fires it.
191 */
192 function captureBinding(action: Action, button: HTMLButtonElement, status: HTMLElement): void {
193 cancelCapture?.();
194 const label = button.textContent;
195 const finish = (): void => {
196 window.removeEventListener("keydown", onKey, true);
197 cancelCapture = null;
198 };
199 cancelCapture = () => {
200 finish();
outdated

probe

201 button.textContent = label;
202 };
203 button.textContent = "Press a key…";
204 status.textContent = "";
205 const onKey = (event: KeyboardEvent): void => {
206 event.preventDefault();
207 event.stopImmediatePropagation();
208 if (event.repeat) return;
209 finish();
210 if (event.code === "Escape") {
211 button.textContent = label;
212 return;
213 }
214 const outcome = decideBind(action, event.code, settings.bindings);
215 if (!outcome.ok) {
216 status.textContent = outcome.reason;
217 button.textContent = label;
218 return;
219 }
220 settings = { ...settings, bindings: outcome.bindings };
221 controls.setBindings(settings.bindings);
222 saveSettings(settings);
223 refreshBindingButtons();
224 };
225 window.addEventListener("keydown", onKey, true);
226 }
227
228 for (const container of [controlsMenu, controlsPause]) {
229 const status = container.querySelector(".bind-status") as HTMLElement;
230 for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) {
231 const action = button.dataset.bind as Action;
232 button.addEventListener("click", () => captureBinding(action, button, status));
233 button.addEventListener("keydown", (event) => event.stopPropagation());
234 }
235 const reset = container.querySelector(".reset-bindings") as HTMLButtonElement;
236 reset.addEventListener("click", () => {
237 cancelCapture?.();
238 settings = { ...settings, bindings: { ...DEFAULT_BINDINGS } };
239 controls.setBindings(settings.bindings);
240 saveSettings(settings);
241 status.textContent = "";
242 refreshBindingButtons();
243 });
244 reset.addEventListener("keydown", (event) => event.stopPropagation());
245 // The `<details>` itself: a focused summary is a control same as any
246 // field here, so a Space that opens or closes it must not also reach the
247 // window and latch a jump; and closing it while a capture is pending —
248 // rather than pressing a key or Escape — still has to let the key go.
249 const details = container.parentElement as HTMLDetailsElement;
250 const summary = details.querySelector("summary") as HTMLElement;
251 summary.addEventListener("keydown", (event) => event.stopPropagation());

Approved. The delta since the head I approved is ten lines in main.ts and nothing else — I diffed ba3349d..8847943 to be sure — and it is exactly the two nits I flagged as non-blocking: the <summary> now stops its keydown the way every other control in these overlays does, so Space on it toggles the disclosure without latching a jump, and closing the <details> while a capture is pending cancels it, which was the last way a pending capture could outlive the panel it was opened from.

Re-verified at this head rather than assumed: bun test is green in a scratch checkout (399 pass, 0 fail across 47 files), the branch boots and serves / in 15 ms with both collapsed panels in the first paint, and the desktop shot is unchanged from the approved head — title, Name, Match, Side, Difficulty, Sensitivity and Play all where production has them, with the phone overlay starting at its own top and scrolling instead of clipping above centre. My clone is untouched.

Everything else stands from the last round: parseBindings validates the stored map on every read and can never leave an action unbound or two actions sharing a key, the arrow-key/C floor means no map locks a player in place, bind.ts holds the rules with no DOM in it, data-bind no longer collides with the touch pad's data-action, and controls.locked → preventDefault keeps an unbound Tab from walking focus off the canvas. Nothing server-side is touched, so there is no identity, body-cap or rate-limit surface in this pull; key codes reach the DOM only through textContent.

Two things I am not holding the pull for, both noted rather than demanded:

  • const details = container.parentElement as HTMLDetailsElement couples the wiring to the exact nesting in game.ts; if that markup ever gains a wrapper, querySelector("summary") returns null and boot() throws before the game starts. container.closest("details") with a null guard costs one line and fails soft instead.
  • The two fixes in this head add no test, because main.ts's DOM wiring has no harness in this repo — the capture lifecycle is the one part of this feature still covered only by reading. That is a fair place to stop for a change this size, so I opened #114 for it rather than asking for it here.

Third head in a row where the deployed preview answers a plain-text 404 from the forge's edge (not the app's own HTML 404), so the pixels above are the forge's shots plus the local run; worth someone's attention, but it is not this pull's doing.

252 details.addEventListener("toggle", () => {
253 if (!details.open) cancelCapture?.();
254 });
255 }
256 window.addEventListener("blur", () => cancelCapture?.());
257
151258 let world: World | null = null;
152259 let matchId = "";
153260 let running = false;
@@ -365,6 +472,7 @@ function boot(): void {
365472 }
366473
367474 function start(): void {
475 cancelCapture?.();
368476 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
369477 const team = sideField.value === "t" ? "t" : "ct";
370478 const name = nameField.value.trim().slice(0, 24) || "you";
@@ -392,11 +500,13 @@ function boot(): void {
392500 $("play").addEventListener("click", start);
393501 resumeButton.addEventListener("click", () => {
394502 if (renderer.lost) return;
503 cancelCapture?.();
395504 show(pause, false);
396505 refreshPad();
397506 lock();
398507 });
399508 const toMenu = (): void => {
509 cancelCapture?.();
400510 // A match still going when the menu is reached — left from the pause
401511 // overlay, not from a summary already posted as finished — gets the one
402512 // post that says so, sent before the world it reads is gone.
src/client/settings.test.ts +60 −3
@@ -5,6 +5,7 @@
55 */
66
77 import { expect, test } from "bun:test";
8 import { DEFAULT_BINDINGS } from "./input.ts";
89 import { DEFAULT_SETTINGS, parseSettings } from "./settings.ts";
910
1011 test("nothing stored yet is the default", () => {
@@ -24,10 +25,10 @@ test("a JSON value that isn't an object is the default", () => {
2425
2526 test("out-of-range numbers are clamped, not rejected", () => {
2627 expect(parseSettings('{"volume":5,"sensitivity":9999}')).toEqual({
27 muted: false, volume: 1, sensitivity: 12, difficulty: "normal",
28 muted: false, volume: 1, sensitivity: 12, difficulty: "normal", bindings: DEFAULT_BINDINGS,
2829 });
2930 expect(parseSettings('{"volume":-3,"sensitivity":-1}')).toEqual({
30 muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal",
31 muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal", bindings: DEFAULT_BINDINGS,
3132 });
3233 });
3334
@@ -43,7 +44,8 @@ test("muted is only ever true for exactly true", () => {
4344 });
4445
4546 test("a whole, valid settings object round-trips", () => {
46 const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard" };
47 const bindings = { ...DEFAULT_BINDINGS, forward: "KeyZ" };
48 const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", bindings };
4749 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
4850 });
4951
@@ -66,3 +68,58 @@ test("a valid difficulty is kept exactly", () => {
6668 expect(parseSettings('{"difficulty":"easy"}').difficulty).toBe("easy");
6769 expect(parseSettings('{"difficulty":"hard"}').difficulty).toBe("hard");
6870 });
71
72 test("no stored bindings at all is precisely today's map", () => {
73 expect(parseSettings(null).bindings).toEqual(DEFAULT_BINDINGS);
74 expect(parseSettings('{"muted":true}').bindings).toEqual(DEFAULT_BINDINGS);
75 });
76
77 test("bindings that are not an object is the default map", () => {
78 expect(parseSettings('{"bindings":"KeyW"}').bindings).toEqual(DEFAULT_BINDINGS);
79 expect(parseSettings('{"bindings":null}').bindings).toEqual(DEFAULT_BINDINGS);
80 expect(parseSettings('{"bindings":[1,2,3]}').bindings).toEqual(DEFAULT_BINDINGS);
81 });
82
83 test("a non-string code is dropped and the action falls back to its default", () => {
84 expect(parseSettings('{"bindings":{"forward":123}}').bindings.forward).toBe("KeyW");
85 expect(parseSettings('{"bindings":{"forward":null}}').bindings.forward).toBe("KeyW");
86 expect(parseSettings('{"bindings":{"forward":["KeyZ"]}}').bindings.forward).toBe("KeyW");
87 expect(parseSettings('{"bindings":{"forward":""}}').bindings.forward).toBe("KeyW");
88 });
89
90 test("an unknown action in storage is dropped, not carried over", () => {
91 const parsed = parseSettings('{"bindings":{"grapple":"KeyG"}}');
92 expect(parsed.bindings).toEqual(DEFAULT_BINDINGS);
93 expect(Object.keys(parsed.bindings)).not.toContain("grapple");
94 });
95
96 test("a valid partial rebind keeps the rest of the default map", () => {
97 const parsed = parseSettings('{"bindings":{"forward":"KeyZ","left":"KeyX"}}');
98 expect(parsed.bindings).toEqual({ ...DEFAULT_BINDINGS, forward: "KeyZ", left: "KeyX" });
99 });
100
101 test("two actions given the same code: the first in the fixed order keeps it, the second falls back to its own default", () => {
102 const parsed = parseSettings('{"bindings":{"forward":"KeyX","back":"KeyX"}}');
103 expect(parsed.bindings.forward).toBe("KeyX");
104 expect(parsed.bindings.back).toBe(DEFAULT_BINDINGS.back);
105 });
106
107 test("a hostile blob that steals another action's default still resolves to a unique map", () => {
108 // "forward" claims "back"'s default code; "back" itself asks for nothing so
109 // it would normally fall back to that same default — but it is taken, so it
110 // resolves to the first default nothing else has claimed.
111 const parsed = parseSettings('{"bindings":{"forward":"KeyS"}}');
112 const codes = Object.values(parsed.bindings);
113 expect(new Set(codes).size).toBe(codes.length);
114 expect(parsed.bindings.forward).toBe("KeyS");
115 });
116
117 test("a corrupt blob can never leave an action unbound", () => {
118 const hostile = JSON.stringify({
119 bindings: Object.fromEntries(Object.keys(DEFAULT_BINDINGS).map((action) => [action, "KeyW"])),
120 });
121 const parsed = parseSettings(hostile);
122 const codes = Object.values(parsed.bindings);
123 expect(codes.every((code) => typeof code === "string" && code.length > 0)).toBe(true);
124 expect(new Set(codes).size).toBe(codes.length);
125 });
src/client/settings.ts +48 −10
@@ -1,23 +1,27 @@
11 /**
2 * Settings a player can reach mid-match: mute, volume, look sensitivity and
3 * bot difficulty. Stored in localStorage beside `cs.name`. `parseSettings` is
4 * the boundary — a missing, malformed, out-of-range or unknown value falls
5 * back to the default and never puts NaN or an unrecognised string into the
6 * game. What a difficulty setting actually does to a bot is bots.ts's
7 * business; this file only knows the three names it can be.
2 * Settings a player can reach mid-match: mute, volume, look sensitivity, bot
3 * difficulty and key bindings. Stored in localStorage beside `cs.name`.
4 * `parseSettings` is the boundary — a missing, malformed, out-of-range or
5 * unknown value falls back to the default and never puts NaN, an
6 * unrecognised string or an unbound action into the game. What a difficulty
7 * setting actually does to a bot is bots.ts's business, and what a bound key
8 * actually does is input.ts's; this file only knows the shapes they can be.
89 */
910
1011 import { DEFAULT_DIFFICULTY, DIFFICULTIES, type Difficulty } from "./bots.ts";
12 import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts";
1113
1214 export type Settings = {
1315 muted: boolean;
1416 volume: number;
1517 sensitivity: number;
1618 difficulty: Difficulty;
19 bindings: Record<Action, string>;
1720 };
1821
1922 export const DEFAULT_SETTINGS: Settings = {
2023 muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY,
24 bindings: DEFAULT_BINDINGS,
2125 };
2226 export const SENSITIVITY_MIN = 0.2;
2327 export const SENSITIVITY_MAX = 12;
@@ -25,6 +29,37 @@ export const SENSITIVITY_MAX = 12;
2529 const isDifficulty = (value: unknown): value is Difficulty =>
2630 typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value);
2731
32 /**
33 * Every action ends up with a code, and no two actions ever end up with the
34 * same one. An action's stored code is kept if it is a non-empty string and
35 * nothing earlier in `ACTIONS` order has already claimed it; otherwise it
36 * falls back to its own default, and if even that has already been claimed —
37 * only possible when an earlier action was explicitly bound to this action's
38 * default — to the first default nothing has claimed yet. Both fallbacks are
39 * exhaustive: there are exactly as many actions as defaults, so one is always
40 * left for whichever action reaches the end still unclaimed.
41 */
42 function parseBindings(raw: unknown): Record<Action, string> {
43 const obj = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
44 const used = new Set<string>();
45 const bindings = {} as Record<Action, string>;
46 const firstUnclaimedDefault = (): string => {
47 for (const action of ACTIONS) if (!used.has(DEFAULT_BINDINGS[action])) return DEFAULT_BINDINGS[action];
48 /* unreachable: as many actions as defaults, and one claim per action so far */
49 return DEFAULT_BINDINGS[ACTIONS[0]];
50 };
51 for (const action of ACTIONS) {
52 const stored = obj[action];
53 const wanted = typeof stored === "string" && stored.length > 0 ? stored : DEFAULT_BINDINGS[action];
54 const code = !used.has(wanted) ? wanted
55 : !used.has(DEFAULT_BINDINGS[action]) ? DEFAULT_BINDINGS[action]
56 : firstUnclaimedDefault();
57 bindings[action] = code;
58 used.add(code);
59 }
60 return bindings;
61 }
62
2863 const KEY = "cs.settings";
2964
3065 const bounded = (value: unknown, lo: number, hi: number, fallback: number): number => {
@@ -34,28 +69,31 @@ const bounded = (value: unknown, lo: number, hi: number, fallback: number): numb
3469
3570 /** Pure: whatever was in storage, in or out of shape, becomes safe settings. */
3671 export function parseSettings(raw: string | null): Settings {
37 if (raw === null) return { ...DEFAULT_SETTINGS };
72 if (raw === null) return defaultSettings();
3873 let parsed: unknown;
3974 try {
4075 parsed = JSON.parse(raw);
4176 } catch {
42 return { ...DEFAULT_SETTINGS };
77 return defaultSettings();
4378 }
44 if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_SETTINGS };
79 if (typeof parsed !== "object" || parsed === null) return defaultSettings();
4580 const obj = parsed as Record<string, unknown>;
4681 return {
4782 muted: obj.muted === true,
4883 volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume),
4984 sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity),
5085 difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty,
86 bindings: parseBindings(obj.bindings),
5187 };
5288 }
5389
90 const defaultSettings = (): Settings => ({ ...DEFAULT_SETTINGS, bindings: { ...DEFAULT_BINDINGS } });
91
5492 export function loadSettings(): Settings {
5593 try {
5694 return parseSettings(localStorage.getItem(KEY));
5795 } catch {
58 return { ...DEFAULT_SETTINGS };
96 return defaultSettings();
5997 }
6098 }
6199
src/pages/game.ts +23 −2
@@ -99,7 +99,14 @@ small.commit { color:var(--dim); font-size:.75rem; }
9999 }
100100 #scene, #hud { position:absolute; inset:0; width:100%; height:100%; display:block; }
101101 #hud { pointer-events:none; }
102 .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
102 .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
103 /* "safe": a centred flex container does not let you scroll to its
104 own top overflow, so content taller than the stage — a menu with
105 its Controls panel open — would hide Play above the fold with no
106 way to reach it. Where "safe center" is not understood the whole
107 declaration is dropped and the box falls back to flex-start,
108 which is scrollable either way. */
109 justify-content:safe center;
103110 gap:.75rem; background:rgba(8,10,14,.82); text-align:center; padding:1rem; overflow:auto;
104111 /* #stage is touch-action:none for the look drag; an overlay taller
105112 than the stage still has to scroll under a thumb. */
@@ -119,6 +126,17 @@ button:disabled { opacity:.42; cursor:not-allowed; }
119126 button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; }
120127 #buy .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); }
121128 #buy .hint, .overlay .hint { font-size:.78rem; }
129 /* Collapsed by default: a 16-row grid inside a fixed-height, centred overlay
130 would otherwise push Play and the match settings above the fold with no
131 way to scroll back up to them. <details> is reachable and toggled with a
132 keyboard on its own, with no script required to open it. */
133 .controls { width:min(38rem,100%); margin-top:.3rem; text-align:left; }
134 .controls summary { min-height:44px; display:flex; align-items:center; justify-content:center;
135 font-weight:600; cursor:pointer; }
136 .controls .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(7.5rem,1fr)); gap:.5rem; width:100%; margin-top:.5rem; }
137 .controls .grid label { gap:.3rem; }
138 .controls .grid button { width:100%; }
139 .controls .bind-status { min-height:1.1em; color:#e2b04a; }
122140 #buy .buy-item { display:flex; flex-direction:column; align-items:flex-start; gap:.1rem; text-align:left; white-space:normal; }
123141 #buy .buy-item .buy-index { font-size:.7rem; color:var(--dim); }
124142 #buy .buy-item .buy-why { font-size:.72rem; color:var(--dim); min-height:1em; }
@@ -212,12 +230,14 @@ export function gamePage(db: Database, options: PageOptions): string {
212230 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant, defuse or take a gun · <kbd>B</kbd> buy ·
213231 right click or <kbd>Q</kbd> to scope · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns ·
214232 <kbd>4</kbd> the HE grenade, thrown with fire · <kbd>Tab</kbd> scores ·
215 <kbd>Esc</kbd> pauses the match, where sound and sensitivity live too</p>
233 <kbd>Esc</kbd> pauses the match, where sound and sensitivity live too. Every key above is
234 the default — rebind any of them under <b>Controls</b> below.</p>
216235 <p class="hint">On a phone: the left of the view is a thumb stick, the right aims, a second
217236 finger on the right fires, and the pad under the view does the rest — guns, the grenade,
218237 scores, buy and <code>menu</code>, which pauses the match and is where you leave it from.</p>
219238 <p class="hint">Paused means paused: the world stops while the overlay is up, and so does
220239 tabbing away — the match is waiting for you when you come back.</p>
240 <details class="controls"><summary>Controls</summary><div id="controls-menu"></div></details>

Approved. All five findings from the last round are fixed, and I re-read every changed line at ba3349d rather than taking the changelog's word for it.

  • The menu is back. Anchored here: the panel is a collapsed <details>, and the desktop shot at this head shows the title, Name, Match, Side, Difficulty, Sensitivity and Play exactly where production has them. justify-content:safe center is the right fix for the unreachable-top problem, and it shows on the phone shot — the overlay now starts at its own top and scrolls down instead of clipping above centre, which is better than production was. The comment above the declaration explains the fallback for engines that do not parse safe, which is the kind of note that keeps someone from "simplifying" it later.
  • The leaked capture is gone. One cancelCapture slot, cleared by finish(), and called from every way off the panel I could find: a second capture, Reset to defaults, window blur, start(), resume and toMenu. stopImmediatePropagation closes the sibling-listener hole too. I could not construct a path where a pending capture survives into a live match.
  • The rules moved out of main.ts. bind.ts is one concept — what a captured key does to a map, and what a key is called — with no DOM in it, matching buy.ts. bind.test.ts covers the reserved refusal, the duplicate refusal naming the holder, rebinding an action to the key it already has (a no-op, not a self-conflict), a refusal carrying no bindings for a caller to apply by mistake, and labelForCode falling through to the raw code. main.ts now only paints and listens.
  • data-bind no longer collides with the data-action attribute attachButtons claims for the touch pad, so a later re-scan cannot turn a rebind button into a live movement control.
  • Tab stays on the canvas: if (controls.locked) event.preventDefault() ahead of the action lookup, with a test that fails without it (unbound key, not locked → no preventDefault; locked → prevented). Escape still exits pointer lock, since that is not a cancellable default.

bun test is green at this head in a scratch checkout — 399 pass, 0 fail across 47 files, 8 more tests than the previous head — and my own clone is untouched. The branch boots locally and serves / in 16 ms with both <details> blocks and the safe center rule in the first paint. The deployed preview still answers a plain-text 404 from the edge (the forge's own layer, not the app's HTML 404 page), so the pixels above are the forge's shots at ba3349d plus the local run; worth a glance from whoever owns the preview, but it is not this pull's doing.

No security surface changes: nothing server-side is touched, key codes reach the DOM through textContent only, parseBindings validates the stored blob on every read (unknown actions dropped, non-string and empty codes dropped, duplicates resolved deterministically, never an unbound action), and the arrow-key/C floor means no stored map can leave a player unable to move. Scoped to the direction: keyboard only, touch pad untouched, README says so.

Two nits, neither worth holding the pull and neither new here: pressing Space on the focused Controls summary in the pause overlay latches a jump the same way Space on the focused Resume button already does — the panel's own buttons stop propagation, the summary does not; and a capture left pending while the <details> is collapsed stays live until the next start(). Both are one stopPropagation/one cancelCapture away if they ever bite.

221241 </div>
222242 <div class="overlay" id="pause" hidden>
223243 <h2>Paused</h2>
@@ -238,6 +258,7 @@ export function gamePage(db: Database, options: PageOptions): string {
238258 <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button>
239259 </div>
240260 <p class="hint">Difficulty takes effect from the next round.</p>
261 <details class="controls"><summary>Controls</summary><div id="controls-pause"></div></details>
241262 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
242263 </div>
243264 <div class="overlay" id="summary" hidden>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs