loadtest/worker.ts — the fake players
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."