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:

  1. Performance — does the server keep up?
  2. 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:

ConditionMeaning
violations === 0scores match collectibles taken, in every room
duplicateClaims === 0no client was ever told a coin was taken twice
checked > 0the audit actually examined something
connectErrors === 0every connection succeeded
joined >= attempted × 0.99at least 99% got into a game
peakTick < budgetMsthe 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 0 across 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."

Built with LogoFlowershow