loadtest/worker.ts — the fake players

In one sentence: one process that creates hundreds of pretend players, connects them, walks them toward collectibles, and records what they were told.

Size: ~290 lines. Started by stress.ts — not run directly.


Why the bots are greedy

Every fake player walks toward the nearest collectible.

That is not laziness — random wandering would be easier to write. It is chosen because greedy bots naturally converge on the same target. Twenty players in a lobby all heading for the closest coin means constant crowding, which is exactly the contention the "exactly one winner" rule has to survive.

Random movement would almost never produce a genuine collision. This produces thousands.


The code, part by part

1. What each bot remembers

interface Bot {
  socket: Socket;
  playerId: string | null;
  roomId: string | null;
  targetId: string | null;
  targetX: number;
  targetY: number;
  selfX: number;
  selfY: number;
  lastRetarget: number;
  collectibles: Map<string, { id: string; x: number; y: number }>;
}

Each bot keeps its own collectible list, exactly as the real browser does — because the server only sends the full list occasionally. The load test therefore exercises the same delta-message path real players use.

2. Connecting gradually

const connectDelayMs = 1000 / Math.max(1, config.connectRatePerSec);

for (const lobbyIndex of config.lobbies) {
  const roomId = `LOAD${String(lobbyIndex).padStart(4, '0')}`;
  for (let seat = 0; seat < config.playersPerLobby; seat++) {
    report.attempted++;
    const isAuditor = seat === 0;
    // …
    bots.push(bot);
    await sleep(connectDelayMs);
  }
}

A pause between each connection. Opening hundreds at once causes failures in the operating system's connection queue that have nothing to do with our server.

padStart(4, '0') makes LOAD0007 rather than LOAD7, so every room code is the same length.

3. The exactly-once audit

const isAuditor = seat === 0;
if (isAuditor) {
  claimAudit.set(roomId, new Set());
  report.auditedRooms++;
}

Seat 0 in each lobby is the auditor. All 20 seats receive identical claim events, so having all of them check would be twenty times the work for no extra information.

socket.on('claim', (payload: any) => {
  report.claimsReceived++;
  if (payload?.collectibleId) bot.collectibles.delete(payload.collectibleId);
  if (!audit || typeof payload?.collectibleId !== 'string') return;
  if (audit.has(payload.collectibleId)) {
    report.duplicateClaims++;
  } else {
    audit.add(payload.collectibleId);
    report.auditedClaims++;
  }
});

A Set of every collectible id this room announced. Seeing an id twice means the server awarded the same collectible twice.

Why this is the strongest evidence in the project: it proves what players were actually told, not what the server believes about itself. Asking the server "did you double-award?" trusts the thing under test. This checks the messages that went out over the wire.

Result across 100 lobbies: 40,000 announcements, zero duplicates.

4. Measuring delivery latency

if (nextSample() % 20 === 0 && typeof snapshot.serverTime === 'number') {
  report.deliveryLatencies.push(Date.now() - snapshot.serverTime);
}

snapshot.serverTime is the server's clock at the tick that produced it. Subtracting gives how long delivery took.

% 20 === 0 samples one message in twenty. Recording all of them would allocate 40,000 numbers a second inside the measuring tool, which would distort the very thing being measured.

Both processes run on one machine, so the clocks agree. Across machines this would need clock-skew handling.

5. Driving movement

const inputInterval = setInterval(() => {
  const now = Date.now();
  for (const bot of bots) {
    if (!bot.playerId || bot.targetId === null) continue;

    const dx = bot.targetX - bot.selfX;
    const dy = bot.targetY - bot.selfY;
    const mag = Math.hypot(dx, dy);
    if (mag < 1) continue;

    bot.socket.emit('input', { dx: dx / mag, dy: dy / mag, seq: now });
    report.inputsSent++;
  }
}, 1000 / config.inputHz);

One timer for every bot in the process, not one per bot. With 250 bots, 250 timers would be its own performance problem — the same reasoning as the server's single game loop.

dx / mag, dy / mag normalises to length 1 before sending. The server would do this anyway; sending it correctly means the test exercises the normal path rather than the anti-cheat path.

if (mag < 1) continue; — already on top of the target, no need to move.

6. Choosing a target

const targetGone = bot.targetId === null || !bot.collectibles.has(bot.targetId);
if (!targetGone && now - bot.lastRetarget < 1500) return;

bot.lastRetarget = now;
let best: any = null;
let bestDist = Infinity;
for (const c of bot.collectibles.values()) {
  const d = (c.x - bot.selfX) ** 2 + (c.y - bot.selfY) ** 2;
  if (d < bestDist) { bestDist = d; best = c; }
}

Recalculate only when the target is gone, or every 1.5 seconds.

Why not every message? 2,000 bots × 20 messages a second × 30 collectibles would be 1.2 million distance calculations a second — inside the test tool. The tool would become the bottleneck.

(c.x - bot.selfX) ** 2 — squared distance again, avoiding a square root. Same trick as the server: we only need to know which is nearest, not the actual distance.

7. Reporting back

if (require.main === module) {
  process.on('message', (msg: any) => {
    if (msg?.type !== 'start') return;
    runWorker(msg.config as WorkerConfig)
      .then((report) => {
        process.send?.({ type: 'report', report });
        process.exit(0);
      })
      .catch((err) => {
        process.send?.({ type: 'error', error: String(err) });
        process.exit(1);
      });
  });
}

require.main === module means "only run this when started directly, not when imported". The orchestrator imports the types from this file without wanting the worker to start.

process.send is the channel back to the orchestrator, which aggregates all eight reports into the final numbers.


What each worker reports

export interface WorkerReport {
  attempted: number;
  connected: number;
  joined: number;
  connectErrors: number;
  joinErrors: Record<string, number>;
  statesReceived: number;
  claimsReceived: number;
  inputsSent: number;
  auditedClaims: number;      // distinct collectibles seen claimed
  duplicateClaims: number;    // ← must be zero
  auditedRooms: number;
  joinLatencies: number[];
  deliveryLatencies: number[];
}

duplicateClaims is the one that matters. Anything above zero means the exactly-once guarantee was violated on the wire.


If an interviewer asks

"Why do the bots chase the nearest collectible?"

"To create contention deliberately. Twenty players all heading for the closest coin means constant crowding on the same target, which is exactly what the exactly-once rule has to survive. Random wandering would almost never produce a real collision."

"How do you know the server didn't award a coin twice?"

"One client per lobby records every collectible id it's told was claimed, in a Set. A repeat would be caught. That's stronger than asking the server, because it proves what players were actually told rather than trusting the thing under test."

"Why sample latency instead of recording every message?"

"Recording all of it would allocate 40,000 numbers a second inside the measuring tool and distort what I'm measuring. One in twenty is plenty for a percentile."

Built with LogoFlowershow