src/config/gameConfig.ts — every tunable number

In one sentence: all the settings in one place, so no magic number is buried in the middle of the game logic.

Size: 101 lines. Depends on: nothing.


Why this file exists

Without it, 260 would sit in the movement code, 20 in the join code, 50 in the loop. Changing the world size would mean hunting through five files, and a test could not use a smaller board.

Two rules follow from having this file:

  1. The domain layer never reads process.env. Settings are handed to GameRoom as an object. That is part of what makes it testable — a test constructs a config with 5 collectibles and a zero-length countdown, and the game behaves accordingly.
  2. Every number has a name. config.playerSpeed explains itself where 260 does not.

The code, line by line

1. The interface

export interface GameConfig {
  worldWidth: number;
  worldHeight: number;
  playerRadius: number;
  collectibleRadius: number;
  playerSpeed: number;
  collectibleCount: number;
  maxPlayersPerRoom: number;
  minPlayersToStart: number;
  countdownMs: number;
  tickRateHz: number;
  broadcastEveryNTicks: number;
  finishedRoomTtlMs: number;
  emptyRoomTtlMs: number;
  inputRateLimitPerSec: number;
  inputRateLimitBurst: number;
  usernameMinLength: number;
  usernameMaxLength: number;
  usernameReleaseMs: number;
}

Every field is required. There is no ? anywhere, so TypeScript refuses to compile a config that is missing something — you cannot forget one by accident.

2. The defaults, and why each is what it is

export const DEFAULT_GAME_CONFIG: GameConfig = {
  worldWidth: 1600,
  worldHeight: 900,
  playerRadius: 14,
  collectibleRadius: 10,
  playerSpeed: 260,
  collectibleCount: 30,
  maxPlayersPerRoom: 20,
  minPlayersToStart: 1,
  countdownMs: 3000,
  tickRateHz: 20,
  broadcastEveryNTicks: 1,
  finishedRoomTtlMs: 20_000,
  emptyRoomTtlMs: 30_000,
  inputRateLimitPerSec: 40,
  inputRateLimitBurst: 20,
  usernameMinLength: 1,
  usernameMaxLength: 20,
  usernameReleaseMs: 20_000,
};

20_000 uses numeric separators — underscores JavaScript ignores. 20_000 is far easier to read at a glance than 20000.

The world

SettingValueWhy
worldWidth / worldHeight1600 × 90016:9, so it fits any modern screen without letterboxing
playerRadius14players are circles 28 pixels across
collectibleRadius10smaller, so they read as items rather than players

Capture distance is the two radii added: 24 pixels.

Movement, and why it is safe

SettingValue
playerSpeed260 pixels per second
tickRateHz20 ticks per second

These two interact, and the interaction matters:

260 pixels/second ÷ 20 ticks/second = 13 pixels moved per tick

Compare that with the 24-pixel capture distance. 13 is comfortably less than 24, so a player cannot jump over a collectible between two ticks and miss it.

That failure is called tunnelling, and it is a classic game bug. If speed were raised to 600, a player would move 30 pixels per tick and could skip straight past a collectible without ever touching it. If you change one of these numbers, check this relationship still holds.

Lobby rules

SettingValueWhy
collectibleCount30enough for a real race, few enough to finish
maxPlayersPerRoom20the brief's requirement
minPlayersToStart1so you can demo alone; set to 2 for a real deployment
countdownMs3000everyone starts together, which is a fairness property

Timing and cleanup

SettingValueWhy
broadcastEveryNTicks1send on every tick. This is the load-shedding knob
finishedRoomTtlMs20 slong enough to read the final scoreboard
emptyRoomTtlMs30 sgrace for a whole lobby reconnecting after a wifi blip

broadcastEveryNTicks deserves a note. Setting it to 2 halves the send rate while the simulation keeps running at 20 Hz. That knob is what proved where the bottleneck was: halving the sends cut broadcast cost from 29.6 ms to 18.5 ms while simulation was unchanged — showing the limit is the number of messages, not their size.

Anti-abuse

SettingValueWhy
inputRateLimitPerSec40double the normal 20/s, so honest players never hit it
inputRateLimitBurst20absorbs the natural burst of pressing several keys at once
usernameMaxLength20names go out in every broadcast — a huge one would be sent constantly
usernameReleaseMs20 show long a disconnected, unscored name stays reserved

usernameReleaseMs only applies to a player who never scored. A player who has scored keeps their name for the whole game, because a username is part of the leaderboard record and releasing it would corrupt the standings.

3. Reading environment variables

export function loadGameConfig(env: NodeJS.ProcessEnv = process.env): GameConfig {
  const num = (key: string, fallback: number): number => {
    const raw = env[key];
    if (raw === undefined || raw === '') return fallback;
    const parsed = Number(raw);
    return Number.isFinite(parsed) ? parsed : fallback;
  };

The little num helper reads one setting safely:

  • Not set, or set to empty → use the default.
  • Set to nonsense like "abc"Number("abc") is NaN, Number.isFinite catches it, use the default.

Why that matters: without the isFinite check, TICK_RATE_HZ=abc would make intervalMs become NaN, and setTimeout(fn, NaN) fires immediately, forever. One typo in an env file would spin the CPU to 100%. Three lines prevent it.

env: NodeJS.ProcessEnv = process.env — a default parameter, so a test can pass a fake environment instead of the real one.

  return {
    ...DEFAULT_GAME_CONFIG,
    worldWidth: num('WORLD_WIDTH', DEFAULT_GAME_CONFIG.worldWidth),
    collectibleCount: num('COLLECTIBLE_COUNT', DEFAULT_GAME_CONFIG.collectibleCount),
    tickRateHz: num('TICK_RATE_HZ', DEFAULT_GAME_CONFIG.tickRateHz),
    // …
  };
}

...DEFAULT_GAME_CONFIG spreads every default in first, then the listed fields override. So a setting with no environment variable still gets its default and the result is always complete.


Overriding settings

For a quick demo game:

COLLECTIBLE_COUNT=5 COUNTDOWN_MS=500 npm run dev

For the stress test, keeping lobbies alive longer:

COLLECTIBLE_COUNT=400 MAX_PLAYERS_PER_ROOM=20 npm run dev

To halve the broadcast rate:

BROADCAST_EVERY_N_TICKS=2 npm run dev

Not every field is wired to an environment variable — only the ones worth changing from outside. finishedRoomTtlMs, for instance, is a constant in the defaults. If you needed it configurable you would add one line to loadGameConfig.


If an interviewer asks

"Why not read process.env directly where each value is needed?"

"Then the game rules would depend on the environment and couldn't be tested without setting variables. Config is passed in as an object, so a test constructs one with 5 collectibles and a zero countdown."

"How did you choose the speed and tick rate?"

"They're related. 260 pixels a second at 20 ticks is 13 pixels per tick, against a 24-pixel capture distance — so a player can't tunnel past a collectible between ticks. Raising the speed without raising the tick rate would start losing collisions."

Built with LogoFlowershow