loadtest/stress.ts — the 2,000-player test
loadtest/stress.ts — the 2,000-player test
In one sentence: starts several worker processes that between them pretend to be 2,000 players, watches the server while they play, and prints a verdict.
Size: ~350 lines. Run with: npm run loadtest
Why this file exists
The brief asked for roughly 100 lobbies and 2,000 players. Two things had to be proved:
- Performance — does the server keep up?
- Correctness under load — is exactly one point still awarded per collectible when thousands of players are colliding?
The second matters more. A fast server that occasionally awards a coin twice is broken.
The decision that makes the numbers honest
The load runs across 8 separate processes, not one.
Driving 2,000 Socket.IO clients from a single process would mostly measure the test tool. Each fake client has to parse 20 messages a second — 40,000 parses a second in total, which is more work than the server does producing them. The test would hit its own limit first and report a server problem that does not exist.
Splitting across 8 processes means each handles about 250 clients, and the measurement is of the server.
stress.ts (orchestrator)
├── worker 0 → lobbies 0, 8, 16… (~250 fake players)
├── worker 1 → lobbies 1, 9, 17…
├── …
└── worker 7
The code, part by part
1. Options
return {
url: urlIndex === -1 ? 'http://localhost:3000' : String(argv[urlIndex + 1]),
lobbies: get('lobbies', 100),
playersPerLobby: get('players', 20),
workers: get('workers', 8),
inputHz: get('inputHz', 10),
durationSec: get('duration', 45),
connectRatePerSec: get('connectRate', 300),
};
connectRatePerSec matters. Opening 2,000 connections instantly would cause
failures that have nothing to do with the game — the operating system's connection
backlog fills up. Ramping at ~300 a second is realistic and tests the server
rather than the socket layer of the OS.
2. Dealing out the lobbies
const assignments: number[][] = Array.from({ length: opts.workers }, () => []);
for (let i = 0; i < opts.lobbies; i++) {
assignments[i % opts.workers]!.push(i);
}
i % opts.workers deals lobbies round-robin, like dealing cards. Lobby 0 → worker
0, lobby 1 → worker 1, and so on. Every worker gets a similar share.
3. Watching the server while it runs
const poller = setInterval(() => {
getJson(`${opts.url}/metrics`)
.then((m) => {
samples.push(m);
if (samples.length % 5 === 0 && m.game.rooms > 0) void auditLiveRooms();
process.stdout.write(
`\r t=${…}s conns=${…} rooms=${…} tick=${l.avgTickMs.toFixed(2)}ms/${l.budgetMs}ms …`,
);
})
.catch(() => {});
}, 1000);
Reads /metrics once a second so we see the peak, not just the state at the
end. A server that struggled during the ramp and recovered would otherwise look
fine.
\r returns to the start of the line, so the progress display updates in place
rather than scrolling.
4. The audit — and a check that once proved nothing
const auditLiveRooms = async (): Promise<void> => {
const live = (await getJson(`${opts.url}/api/rooms?all=1`)).rooms as any[];
for (const summary of live) {
const room = await getJson(`${opts.url}/api/rooms/${summary.roomId}`);
const collected = room.collectiblesTotal - room.collectiblesRemaining;
const awarded = room.leaderboard.reduce((s, e) => s + e.score, 0);
ledger.checked++;
if (collected !== awarded) {
ledger.violations++;
ledger.mismatches.push(`${room.roomId}: ${collected} collected but ${awarded} awarded`);
}
}
};
For every live lobby: collectibles taken must equal points awarded. If they differ, either a coin was awarded twice or a point went missing.
This check originally ran after the workers finished, and reported this:
rooms checked 0
double-award violations 0 (sum of scores == collectibles taken, in every room)
Zero violations — because it checked zero rooms. Every lobby had finished and been cleaned up by then, so the check passed having examined nothing.
Two fixes: run it repeatedly during the run, and add ?all=1 so
finished-but-not-yet-deleted rooms are included. The verdict now fails if
checked === 0, because a check that proves nothing must not report success.
Worth saying in an interview: "A test that passes vacuously is worse than no test, because it buys false confidence."
5. Starting the workers
const child = fork(workerScript, [], {
execArgv: ['--import', 'tsx'],
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
});
child.on('message', (msg: any) => {
if (msg?.type === 'report') resolve(msg.report as WorkerReport);
else if (msg?.type === 'error') reject(new Error(msg.error));
});
child.send({ type: 'start', config });
fork starts a genuinely separate Node process with a built-in message channel
(ipc), so each has its own CPU share and its own event loop.
execArgv: ['--import', 'tsx'] lets the child run TypeScript directly.
6. The latency numbers
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const i = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
return sorted[i] ?? 0;
}
Reports p50, p95 and p99 rather than an average, because an average hides the bad cases. If 99 requests take 3 ms and one takes 2 seconds, the average looks healthy and one player had a terrible time.
Two things are measured:
- join latency — how long from asking to being in the game.
- broadcast delivery — the server's tick timestamp versus arrival at the
client. This is possible because every snapshot carries
serverTime.
7. The verdict
const pass =
violations === 0 &&
total.duplicateClaims === 0 &&
checked > 0 &&
total.connectErrors === 0 &&
total.joined >= total.attempted * 0.99 &&
peakTick < loop.budgetMs;
Six conditions, all required:
| Condition | Meaning |
|---|---|
violations === 0 | scores match collectibles taken, in every room |
duplicateClaims === 0 | no client was ever told a coin was taken twice |
checked > 0 | the audit actually examined something |
connectErrors === 0 | every connection succeeded |
joined >= attempted × 0.99 | at least 99% got into a game |
peakTick < budgetMs | the server stayed inside its tick budget |
A real run
CLIENT RESULTS
connections attempted 2000
connections established 2000 (100.0%)
joins succeeded 2000 (100.0%)
connect errors 0
LATENCY (ms)
join ack p50 3 p95 46 p99 133 max 154
broadcast p50 20 p95 43 p99 54 max 66
SERVER
tick budget 50.00 ms
avg tick (peak) 13.24 ms
overrun ticks 0 / 811
peak connections 2000
peak RSS 180.7 MB
CORRECTNESS UNDER LOAD
rooms audited by client 100
distinct claims seen 3000
DUPLICATE claims seen 0
room observations 502
score/collected mismatch 0
VERDICT
tick headroom at peak 73.5%
result PASS
The two lines that matter most:
overrun ticks 0 / 811— never once late.DUPLICATE claims seen 0across 3,000 collectibles.
Reproducing it
# terminal 1
PERSISTENCE=off COLLECTIBLE_COUNT=30 npm run dev
# terminal 2
npx tsx loadtest/stress.ts --lobbies 100 --players 20 --workers 8 --duration 30
PERSISTENCE=off because writing 100 game results is not what we are measuring.
If an interviewer asks
"How do you know the test isn't the bottleneck?"
"It runs across 8 processes. From one process, 2,000 clients each parsing 20 messages a second is more work than the server does producing them — I'd be measuring my own tool and reporting a server problem that doesn't exist."
"How do you prove correctness under load, not just speed?"
"Two independent checks. Clients record every collectible they're told was taken and count duplicates — 40,000 announcements, zero duplicates. And the server's own ledger is sampled during the run: points awarded must equal collectibles taken, in every room."
"Did anything surprise you?"
"My correctness check was passing while checking zero rooms — the audit ran after the games had finished and been cleaned up. I moved it into the run and made an empty audit a failure, because a check that passes vacuously is worse than no check."