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

Choose how hard the bots are, before the match and during it #64

Merged opened this

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

Merged

scout/cs:scout/38-choose-how-hard-the-bots-are-before-the → main 2 commits fork

  • Merged. scout/38-choose-how-hard-the-bots-are-before-the was merged into main as f9dc23a.

Review

Sign in to review this change.
9 files changed +233 −24
README.md +16 −2
@@ -127,6 +127,16 @@ the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the
127127 pad does it on a phone. Pausing also opens mute, volume and
128128 look sensitivity, which take effect at once and are remembered for next time.
129129
130 The menu's **Difficulty** select picks Easy, Normal or Hard before a match
131 starts — each one a band of the bots' skill rather than one number, so a
132 side still runs from fodder to uncomfortable at every setting, just a
133 different stretch of it. It is remembered for next time, same as the other
134 settings, and the scoreboard (<kbd>Tab</kbd>) says which one a match is being
135 played at. The same select is in the pause overlay for a match already
136 running — a difficulty picked there takes effect from the next round, never
137 the one in progress, so changing it mid-fight cannot hand a bot new hands
138 between one shot and the next.
139
130140 Paused means paused. While the pause overlay or the match-over card is up the
131141 world does not run: no ticks, no round clock, no bots, no bomb timer, and the
132142 canvas keeps the last frame it drew. A hidden tab pauses the same way, so
@@ -206,7 +216,8 @@ with the canvas as you turn; nobody re-walks the grid to draw it.
206216 this round, where they fell), `radar` (the blips the
207217 HUD's minimap draws, and nothing it draws them with), `summary` (the end of
208218 the match as numbers), `ears` (a sound's pan and volume, as plain data),
209 `settings` (mute, volume and sensitivity: read, validated, and never NaN),
219 `settings` (mute, volume, sensitivity and difficulty: read, validated, and
220 never NaN or an unknown name),
210221 `renderer`, `hud`, `input`, `audio`, `main`.
211222 Everything except the last five is headless and tested; `renderer` and `hud`
212223 are checked against recording contexts, which is how a frame's draws are
@@ -318,7 +329,10 @@ rather than 37%.
318329 Skill is still a handicap laid on top, so the ladder the game already had
319330 survives: a low-skill bot gets the same intent from the policy but cannot turn
320331 as fast, has a wobble on its aim it cannot see, and waits longer before it
321 shoots. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`,
332 shoots. The **Difficulty** setting picks which band of that 0..1 skill scale a
333 match's bots are drawn from — Easy, Normal or Hard — so the ladder from fodder
334 to uncomfortable still runs inside whichever one is chosen; see *Playing* for
335 where to set it. `src/client/bots.ts` keeps the pre-policy fight as `scriptedFight`,
322336 unused by the game, because it is the opponent the policy trains against and the
323337 baseline it is measured against — a baseline that moves with the policy measures
324338 nothing.
src/client/bots.test.ts +67 −2
@@ -1,8 +1,12 @@
11 import { expect, test } from "bun:test";
2 import { applyIntent, botThink, CARD, makeBrain, policy, think } from "./bots.ts";
2 import {
3 applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think,
4 } from "./bots.ts";
35 import * as map from "./map.ts";
46 import { COMPETITIVE, SHORT } from "./match.ts";
5 import { aimAt, bodyCentre, createWorld, NO_INPUT, update, type Player } from "./world.ts";
7 import {
8 aimAt, bodyCentre, createWorld, NO_INPUT, setDifficulty, startRound, update, type Player,
9 } from "./world.ts";
610
711 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
812 const world = createWorld(COMPETITIVE, "you", "ct", 1, seed);
@@ -81,6 +85,67 @@ test("a bot that has only just seen you does not shoot yet", () => {
8185 expect(immediate.fire).toBe(false);
8286 });
8387
88 // --- difficulty -----------------------------------------------------------
89
90 test("easy hands are strictly slower and shakier than hard's", () => {
91 const easy = createWorld(SHORT, "you", "ct", 5, 999, "easy");
92 const hard = createWorld(SHORT, "you", "ct", 5, 999, "hard");
93 for (const easyBot of easy.players) {
94 if (!easyBot.bot) continue;
95 const hardBot = hard.players.find((p) => p.id === easyBot.id)!;
96 const easyBrain = easyBot.brain!;
97 const hardBrain = hardBot.brain!;
98 expect(easyBrain.aimSpeed).toBeLessThan(hardBrain.aimSpeed);
99 expect(easyBrain.aimError).toBeGreaterThan(hardBrain.aimError);
100 expect(easyBrain.reaction).toBeGreaterThan(hardBrain.reaction);
101 }
102 });
103
104 test("createWorld gives every bot a skill inside its difficulty's band", () => {
105 for (const difficulty of DIFFICULTIES) {
106 const { lo, hi } = skillBand(difficulty);
107 const world = createWorld(SHORT, "you", "ct", 5, 555, difficulty);
108 const bots = world.players.filter((p) => p.bot);
109 expect(bots.length).toBeGreaterThan(0);
110 for (const bot of bots) {
111 expect(bot.brain).toBeDefined();
112 expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo);
113 expect(bot.brain!.skill).toBeLessThanOrEqual(hi);
114 }
115 }
116 });
117
118 test("a difficulty change waits for the next startRound", () => {
119 const world = createWorld(SHORT, "you", "ct", 5, 777, "normal");
120 const bot = world.players.find((p) => p.bot)!;
121 const before = bot.brain!.skill;
122 setDifficulty(world, "hard");
123 // Queued, not applied: the round already running keeps the hands it started with.
124 expect(world.difficulty).toBe("normal");
125 expect(bot.brain!.skill).toBe(before);
126 startRound(world);
127 expect(world.difficulty).toBe("hard");
128 const { lo, hi } = skillBand("hard");
129 expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo);
130 expect(bot.brain!.skill).toBeLessThanOrEqual(hi);
131 });
132
133 test("restyleBrain changes the hands but keeps the plan it already made", () => {
134 const brain = makeBrain(0.9, 5);
135 brain.path = [[100, 200, 0]];
136 brain.step = 1;
137 brain.site = "B";
138 brain.targetId = 4;
139 restyleBrain(brain, 3, "easy");
140 const { lo, hi } = skillBand("easy");
141 expect(brain.skill).toBeGreaterThanOrEqual(lo);
142 expect(brain.skill).toBeLessThanOrEqual(hi);
143 expect(brain.path).toEqual([[100, 200, 0]]);
144 expect(brain.step).toBe(1);
145 expect(brain.site).toBe("B");
146 expect(brain.targetId).toBe(4);
147 });
148
84149 test("the card describes a real training run", () => {
85150 expect(CARD.envSteps).toBeGreaterThan(1_000_000);
86151 expect(CARD.updates).toBeGreaterThan(0);
src/client/bots.ts +42 −2
@@ -36,6 +36,33 @@ export function policy(): Policy {
3636 /** The policy decides at 16 Hz and holds it, exactly as it was trained. */
3737 const SKIP = 4;
3838
39 /**
40 * How hard the opposition is. It is a band of skills, not one number — a side
41 * still runs from fodder to uncomfortable at every setting, just a different
42 * stretch of the same 0..1 scale `makeBrain` turns into hands.
43 */
44 export type Difficulty = "easy" | "normal" | "hard";
45 export const DIFFICULTIES: readonly Difficulty[] = ["easy", "normal", "hard"];
46 export const DEFAULT_DIFFICULTY: Difficulty = "normal";
47 const DIFFICULTY_LABELS: Record<Difficulty, string> = { easy: "Easy", normal: "Normal", hard: "Hard" };
48 export const difficultyLabel = (difficulty: Difficulty): string => DIFFICULTY_LABELS[difficulty];
49
50 const BANDS: Record<Difficulty, { lo: number; hi: number }> = {
51 easy: { lo: 0, hi: 0.35 },
52 normal: { lo: 0.25, hi: 1 },
53 hard: { lo: 0.55, hi: 1 },
54 };
55
56 /** A bot's place in its band, from its id — the same spread `brainOf` always used. */
57 function skillFor(id: number, difficulty: Difficulty): number {
58 const { lo, hi } = BANDS[difficulty];
59 const frac = ((id * 37) % 100) / 100;
60 return clamp(lo + frac * (hi - lo), 0, 1);
61 }
62
63 /** The band itself, for anything — a test, a hint on the page — that needs its edges. */
64 export const skillBand = (difficulty: Difficulty): { lo: number; hi: number } => BANDS[difficulty];
65
3966 export type BotBrain = {
4067 skill: number; // 0 = fodder, 1 = uncomfortable
4168 reaction: number; // seconds between seeing and shooting
@@ -110,12 +137,25 @@ export function botBuy(world: World, bot: Player): void {
110137
111138 function brainOf(world: World, bot: Player): BotBrain {
112139 if (bot.brain === undefined) {
113 const skill = clamp(0.25 + ((bot.id * 37) % 100) / 130, 0, 1);
114 bot.brain = makeBrain(skill, 0x1234 + bot.id * 2654435761);
140 bot.brain = makeBrain(skillFor(bot.id, world.difficulty), 0x1234 + bot.id * 2654435761);
115141 }
116142 return bot.brain;
117143 }
118144
145 /**
146 * Re-skill a brain for a new difficulty in place: the hands change, but the
147 * path, the plan and the memory it already built up this match do not. Called
148 * once a round, from `startRound`, so a change queued mid-fight cannot hand a
149 * bot new hands between one shot and the next.
150 */
151 export function restyleBrain(brain: BotBrain, id: number, difficulty: Difficulty): void {
152 const skill = skillFor(id, difficulty);
153 brain.skill = skill;
154 brain.reaction = 0.5 - skill * 0.33;
155 brain.aimSpeed = 180 + skill * 620;
156 brain.aimError = 4.5 - skill * 3.6;
157 }
158
119159 /** This bot's place in its side's list, so the plan can hand out two sites. */
120160 function slotOf(world: World, bot: Player): number {
121161 let slot = 0;
src/client/hud.ts +10 −4
@@ -451,21 +451,27 @@ function drawScoreboard(
451451 ): void {
452452 const w = Math.min(width - 40, Math.round(760 * scale));
453453 const rowH = Math.round(26 * scale);
454 const h = rowH * (world.players.length + 4);
454 const h = rowH * (world.players.length + 5);
455455 const x = width / 2 - w / 2, y = height / 2 - h / 2;
456456 ctx.fillStyle = "rgba(10, 12, 16, 0.88)";
457457 ctx.fillRect(x, y, w, h);
458458 ctx.font = `${Math.round(15 * scale)}px system-ui, sans-serif`;
459 // Which band the fight is being played at, so a screenshot of the scoreline
460 // still means something once the round it was taken in is long over.
461 ctx.textAlign = "center";
462 ctx.fillStyle = "#9aa4b4";
463 const difficulty = world.difficulty.charAt(0).toUpperCase() + world.difficulty.slice(1);
464 ctx.fillText(`Difficulty: ${difficulty}`, x + w / 2, y + rowH);
459465 ctx.textAlign = "left";
460466 ctx.fillStyle = "#c8ccd4";
461 ctx.fillText("player", x + 16, y + rowH);
467 ctx.fillText("player", x + 16, y + rowH * 2);
462468 ctx.textAlign = "right";
463469 for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries())
464 ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH);
470 ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH * 2);
465471
466472 const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage);
467473 order.forEach((p, row) => {
468 const ty = y + rowH * (row + 2.4);
474 const ty = y + rowH * (row + 3.4);
469475 ctx.textAlign = "left";
470476 ctx.fillStyle = p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR;
471477 ctx.globalAlpha = p.alive ? 1 : 0.45;
src/client/main.ts +22 −4
@@ -22,8 +22,8 @@ import { nextWatched, watched } from "./spectate.ts";
2222 import { summarize, type MatchSummary } from "./summary.ts";
2323 import { buyable } from "./weapons.ts";
2424 import {
25 buy, canBuy, canBuyItem, createWorld, eyePos, flinch, priceOf, update,
26 type BuyItem, type Player, type SoundName, type World,
25 buy, canBuy, canBuyItem, createWorld, DIFFICULTIES, eyePos, flinch, priceOf, setDifficulty, update,
26 type BuyItem, type Difficulty, type Player, type SoundName, type World,
2727 } from "./world.ts";
2828
2929 /**
@@ -50,6 +50,8 @@ function boot(): void {
5050 const nameField = $<HTMLInputElement>("name");
5151 const lengthField = $<HTMLSelectElement>("length");
5252 const sideField = $<HTMLSelectElement>("side");
53 const difficultyField = $<HTMLSelectElement>("difficulty");
54 const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty");
5355 const sensField = $<HTMLInputElement>("sens");
5456 const pauseSensField = $<HTMLInputElement>("pause-sens");
5557 const volumeField = $<HTMLInputElement>("volume");
@@ -84,6 +86,18 @@ function boot(): void {
8486 pauseSensField.value = String(next);
8587 saveSettings(settings);
8688 };
89 // Same pairing as sensitivity: the menu's field picks it before a match, the
90 // pause overlay's changes it mid-match — but `setDifficulty` only queues the
91 // change for the next round, so a bot already in a fight keeps its hands.
92 const syncDifficulty = (value: string): void => {
93 const next: Difficulty = (DIFFICULTIES as readonly string[]).includes(value)
94 ? (value as Difficulty) : DEFAULT_SETTINGS.difficulty;
95 settings = { ...settings, difficulty: next };
96 difficultyField.value = next;
97 pauseDifficultyField.value = next;
98 saveSettings(settings);
99 if (world !== null) setDifficulty(world, next);
100 };
87101 const updateMuteButton = (): void => {
88102 muteButton.textContent = settings.muted ? "Unmute" : "Mute";
89103 muteButton.setAttribute("aria-pressed", String(settings.muted));
@@ -91,12 +105,16 @@ function boot(): void {
91105 sensField.value = String(settings.sensitivity);
92106 pauseSensField.value = String(settings.sensitivity);
93107 volumeField.value = String(Math.round(settings.volume * 100));
108 difficultyField.value = settings.difficulty;
109 pauseDifficultyField.value = settings.difficulty;
94110 updateMuteButton();
95111
96112 // "change" rather than "input": a number field mid-edit (cleared, or "0.")
97113 // is not a value to clamp and snap back on every keystroke.
98114 sensField.addEventListener("change", () => syncSensitivity(Number(sensField.value)));
99115 pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value)));
116 difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value));
117 pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value));
100118 volumeField.addEventListener("input", () => {
101119 const next = Math.min(1, Math.max(0, Number(volumeField.value) / 100));
102120 settings = { ...settings, volume: Number.isFinite(next) ? next : settings.volume };
@@ -111,7 +129,7 @@ function boot(): void {
111129 });
112130 // The pause dialog sits over a live match: a key typed into one of its
113131 // fields must never reach the window-level handler that drives the game.
114 for (const field of [pauseSensField, volumeField, muteButton, fullscreenPauseButton])
132 for (const field of [pauseSensField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton])
115133 field.addEventListener("keydown", (event) => event.stopPropagation());
116134
117135 let world: World | null = null;
@@ -283,7 +301,7 @@ function boot(): void {
283301 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
284302 const team = sideField.value === "t" ? "t" : "ct";
285303 const name = nameField.value.trim().slice(0, 24) || "you";
286 world = createWorld(rules, name, team, 5);
304 world = createWorld(rules, name, team, 5, undefined, settings.difficulty);
287305 matchId = crypto.randomUUID();
288306 posted = -1;
289307 watching = world.me.id;
src/client/settings.test.ts +23 −3
@@ -24,10 +24,10 @@ test("a JSON value that isn't an object is the default", () => {
2424
2525 test("out-of-range numbers are clamped, not rejected", () => {
2626 expect(parseSettings('{"volume":5,"sensitivity":9999}')).toEqual({
27 muted: false, volume: 1, sensitivity: 12,
27 muted: false, volume: 1, sensitivity: 12, difficulty: "normal",
2828 });
2929 expect(parseSettings('{"volume":-3,"sensitivity":-1}')).toEqual({
30 muted: false, volume: 0, sensitivity: 0.2,
30 muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal",
3131 });
3232 });
3333
@@ -43,6 +43,26 @@ test("muted is only ever true for exactly true", () => {
4343 });
4444
4545 test("a whole, valid settings object round-trips", () => {
46 const stored = { muted: true, volume: 0.4, sensitivity: 3.5 };
46 const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard" };
4747 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
4848 });
49
50 test("a missing difficulty is the default", () => {
51 expect(parseSettings('{"muted":true}').difficulty).toBe("normal");
52 });
53
54 test("a garbage difficulty is the default", () => {
55 expect(parseSettings('{"difficulty":123}').difficulty).toBe("normal");
56 expect(parseSettings('{"difficulty":null}').difficulty).toBe("normal");
57 expect(parseSettings('{"difficulty":["hard"]}').difficulty).toBe("normal");
58 });
59
60 test("an unknown difficulty name is the default", () => {
61 expect(parseSettings('{"difficulty":"impossible"}').difficulty).toBe("normal");
62 expect(parseSettings('{"difficulty":"EASY"}').difficulty).toBe("normal");
63 });
64
65 test("a valid difficulty is kept exactly", () => {
66 expect(parseSettings('{"difficulty":"easy"}').difficulty).toBe("easy");
67 expect(parseSettings('{"difficulty":"hard"}').difficulty).toBe("hard");
68 });
src/client/settings.ts +16 −5
@@ -1,20 +1,30 @@
11 /**
2 * Settings a player can reach mid-match: mute, volume and look sensitivity.
3 * Stored in localStorage beside `cs.name`. `parseSettings` is the boundary —
4 * a missing, malformed or out-of-range value falls back to the default and
5 * never puts NaN into the game.
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.
68 */
79
10 import { DEFAULT_DIFFICULTY, DIFFICULTIES, type Difficulty } from "./bots.ts";
11
812 export type Settings = {
913 muted: boolean;
1014 volume: number;
1115 sensitivity: number;
16 difficulty: Difficulty;
1217 };
1318
14 export const DEFAULT_SETTINGS: Settings = { muted: false, volume: 1, sensitivity: 2.2 };
19 export const DEFAULT_SETTINGS: Settings = {
20 muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY,
21 };
1522 export const SENSITIVITY_MIN = 0.2;
1623 export const SENSITIVITY_MAX = 12;
1724
25 const isDifficulty = (value: unknown): value is Difficulty =>
26 typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value);
27
1828 const KEY = "cs.settings";
1929
2030 const bounded = (value: unknown, lo: number, hi: number, fallback: number): number => {
@@ -37,6 +47,7 @@ export function parseSettings(raw: string | null): Settings {
3747 muted: obj.muted === true,
3848 volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume),
3949 sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity),
50 difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty,
4051 };
4152 }
4253
src/client/world.ts +26 −2
@@ -23,9 +23,11 @@ import {
2323 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
2424 type WeaponId, type Weapon,
2525 } from "./weapons.ts";
26 import { botBuy, botThink, type BotBrain } from "./bots.ts";
26 import { botBuy, botThink, DEFAULT_DIFFICULTY, restyleBrain, type BotBrain, type Difficulty } from "./bots.ts";
2727
2828 export type Team = rules.Team;
29 export type { Difficulty } from "./bots.ts";
30 export { DEFAULT_DIFFICULTY, DIFFICULTIES, difficultyLabel } from "./bots.ts";
2931
3032 export type Ammo = { mag: number; reserve: number };
3133
@@ -142,6 +144,10 @@ export type World = {
142144 boxes: typeof map.boxes;
143145 bombPlanted: boolean; // in this round, for the economy
144146 pendingBuy: boolean;
147 /** How hard the bots are. What that means is bots.ts's business, not this file's. */
148 difficulty: Difficulty;
149 /** Queued by `setDifficulty`; `startRound` applies it and clears it. */
150 pendingDifficulty: Difficulty | null;
145151 };
146152
147153 const BOT_NAMES = [
@@ -175,6 +181,7 @@ export function createWorld(
175181 playerTeam: Team = "ct",
176182 teamSize = 5,
177183 seed = (Date.now() & 0xffff) >>> 0,
184 difficulty: Difficulty = DEFAULT_DIFFICULTY,
178185 ): World {
179186 const match = rules.newMatch(ruleSet, playerTeam);
180187 const players: Player[] = [];
@@ -199,11 +206,21 @@ export function createWorld(
199206 pickups: [], grenades: [], bodies: [],
200207 effects: [], feed: [], messages: [],
201208 random, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
209 difficulty, pendingDifficulty: null,
202210 };
203211 startRound(world);
204212 return world;
205213 }
206214
215 /**
216 * Queue a difficulty change. It does not touch a bot mid-fight — `startRound`
217 * picks it up and re-skills every brain already in play, so the person being
218 * crushed sees it from the next round rather than having to finish this one.
219 */
220 export function setDifficulty(world: World, difficulty: Difficulty): void {
221 world.pendingDifficulty = difficulty;
222 }
223
207224 export const teammates = (world: World, team: Team): Player[] => world.players.filter((p) => p.team === team);
208225 export const alive = (world: World, team: Team): Player[] =>
209226 world.players.filter((p) => p.team === team && p.alive);
@@ -223,6 +240,10 @@ export function startRound(world: World): void {
223240 // rather than a draw from `world.random`: the training arena steps this same
224241 // world, and a stream that shifted would move numbers that mean something.
225242 world.plan = roundPlan(world.seed, world.match.round);
243 if (world.pendingDifficulty !== null) {
244 world.difficulty = world.pendingDifficulty;
245 world.pendingDifficulty = null;
246 }
226247 const spots = { t: [...map.spawns.t], ct: [...map.spawns.ct] };
227248 for (const p of world.players) {
228249 const list = spots[p.team];
@@ -247,7 +268,10 @@ export function startRound(world: World): void {
247268 p.ammo = { knife: { mag: 0, reserve: 0 }, [p.secondary]: emptyAmmo(p.secondary) };
248269 if (p.primary !== null) p.ammo[p.primary] = emptyAmmo(p.primary);
249270 p.weapon = p.primary ?? p.secondary;
250 if (p.brain !== undefined) p.brain.reset = true;
271 if (p.brain !== undefined) {
272 p.brain.reset = true;
273 restyleBrain(p.brain, p.id, world.difficulty);
274 }
251275 }
252276 const ts = teammates(world, "t");
253277 const carrier = ts[Math.floor(world.random() * ts.length)];
src/pages/game.ts +11
@@ -193,6 +193,11 @@ export function gamePage(db: Database, options: PageOptions): string {
193193 <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"><small>in the match only</small></label>
194194 <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>
195195 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
196 <label>Difficulty<select id="difficulty">
197 <option value="easy">Easy</option>
198 <option value="normal" selected>Normal</option>
199 <option value="hard">Hard</option>
200 </select></label>
196201 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
197202 </div>
198203 <div class="row"><button type="button" class="primary" id="play">Play</button>
@@ -215,9 +220,15 @@ export function gamePage(db: Database, options: PageOptions): string {
215220 <div class="row">
216221 <label>Sensitivity<input id="pause-sens" type="number" min="0.2" max="12" step="0.1"></label>
217222 <label>Volume<input id="volume" type="range" min="0" max="100" step="1"></label>
223 <label>Difficulty<select id="pause-difficulty">
224 <option value="easy">Easy</option>
225 <option value="normal">Normal</option>
226 <option value="hard">Hard</option>
227 </select></label>
218228 <button type="button" id="mute" aria-pressed="false">Mute</button>
219229 <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button>
220230 </div>
231 <p class="hint">Difficulty takes effect from the next round.</p>
221232 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
222233 </div>
223234 <div class="overlay" id="summary" hidden>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs