src/config/gameConfig.ts — every tunable number
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:
- The domain layer never reads
process.env. Settings are handed toGameRoomas 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. - Every number has a name.
config.playerSpeedexplains itself where260does 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
| Setting | Value | Why |
|---|---|---|
worldWidth / worldHeight | 1600 × 900 | 16:9, so it fits any modern screen without letterboxing |
playerRadius | 14 | players are circles 28 pixels across |
collectibleRadius | 10 | smaller, so they read as items rather than players |
Capture distance is the two radii added: 24 pixels.
Movement, and why it is safe
| Setting | Value |
|---|---|
playerSpeed | 260 pixels per second |
tickRateHz | 20 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
| Setting | Value | Why |
|---|---|---|
collectibleCount | 30 | enough for a real race, few enough to finish |
maxPlayersPerRoom | 20 | the brief's requirement |
minPlayersToStart | 1 | so you can demo alone; set to 2 for a real deployment |
countdownMs | 3000 | everyone starts together, which is a fairness property |
Timing and cleanup
| Setting | Value | Why |
|---|---|---|
broadcastEveryNTicks | 1 | send on every tick. This is the load-shedding knob |
finishedRoomTtlMs | 20 s | long enough to read the final scoreboard |
emptyRoomTtlMs | 30 s | grace 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
| Setting | Value | Why |
|---|---|---|
inputRateLimitPerSec | 40 | double the normal 20/s, so honest players never hit it |
inputRateLimitBurst | 20 | absorbs the natural burst of pressing several keys at once |
usernameMaxLength | 20 | names go out in every broadcast — a huge one would be sent constantly |
usernameReleaseMs | 20 s | how 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")isNaN,Number.isFinitecatches 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.envdirectly 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."