src/loop/GameLoop.ts — the heartbeat
src/loop/GameLoop.ts — the heartbeat
In one sentence: one timer that wakes up 20 times a second, tells every lobby to advance, sends the updates out, and measures how long all of that took.
Size: 207 lines. Depends on: GameManager, gameConfig.
Why this file exists
A game has to move on its own. Even if nobody presses a key, time passes and the world updates. Something has to be the clock.
The design decision worth defending: one timer for the whole server, not one per lobby.
The obvious approach is to give each GameRoom its own setInterval. With 100
lobbies that means:
- 100 timers competing for one thread, all drifting independently.
- No way to answer "is the server keeping up?" — you would have to inspect 100 separate things.
- No single budget to measure against.
One loop gives you exactly one number that matters: drift, how late a tick fired compared to when it was due. If drift climbs, the server is falling behind. That is the whole health check.
The concept: a tick and its budget
A tick is one heartbeat. At 20 ticks per second:
1000 ms ÷ 20 = 50 ms per tick
Fifty milliseconds is the budget. Everything — moving every player in every lobby, checking every collision, sending every message — must finish inside it. Go over, and the next tick starts late.
Measured at 100 lobbies and 2,000 players: 13.24 ms used of 50 ms, and 0 of 811 ticks went over.
The code, line by line
1. What gets measured (lines 4–24)
export interface LoopMetrics {
ticks: number;
lastTickMs: number;
avgTickMs: number;
maxTickMs: number;
lastSimMs: number;
lastBroadcastMs: number;
avgSimMs: number;
avgBroadcastMs: number;
maxSimMs: number;
maxBroadcastMs: number;
p95TickMs: number;
lastDriftMs: number;
maxDriftMs: number;
overrunTicks: number;
roomsReaped: number;
budgetMs?: number;
}
| Field | Meaning |
|---|---|
ticks | how many heartbeats have happened |
lastTickMs | how long the most recent one took |
avgTickMs | average over the last 30 seconds |
maxTickMs | the worst one ever |
lastSimMs / lastBroadcastMs | the split — simulating vs sending |
p95TickMs | 95% of ticks were faster than this |
lastDriftMs | how late this tick fired |
overrunTicks | how many went over 50 ms — the alarm |
The sim / broadcast split is the important pair. It is what turned "the
server feels slow" into "sending is 1.6× more expensive than simulating, and the
message is mostly collectibles that never move".
2. Fields (lines 38–48)
private timer: NodeJS.Timeout | null = null;
private readonly intervalMs: number;
private tickCounter = 0;
private nextTickAt = 0;
private readonly samples = new Float64Array(600);
private sampleCount = 0;
private sampleIndex = 0;
private tickMsSum = 0;
new Float64Array(600) — a fixed-size array of 600 decimal numbers, allocated
once. At 20 ticks per second that holds the last 30 seconds of history.
A Float64Array (rather than a normal array) is a fixed block of memory that
cannot grow. That is the point: however long the server runs, this uses exactly
the same memory.
3. The constructor (lines 74–80)
constructor(
private readonly manager: GameManager,
private readonly config: GameConfig,
private readonly onBroadcast: () => void,
) {
this.intervalMs = 1000 / config.tickRateHz;
}
Writing private readonly manager in the parameter list is TypeScript
shorthand: it declares the field and assigns it in one go.
onBroadcast: () => void — a function passed in, taking nothing and returning
nothing. The loop does not know or care what it does. In production it is
"send every room's state to its players", but a test can pass an empty function.
This is why the loop has no idea Socket.IO exists.
this.intervalMs = 1000 / config.tickRateHz — 1000 / 20 = 50 ms.
4. Start and stop (lines 82–91)
start(): void {
if (this.timer) return;
this.nextTickAt = Date.now() + this.intervalMs;
this.schedule();
}
stop(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
if (this.timer) return; — calling start() twice must not create two loops
running at double speed.
nextTickAt records the absolute time the next tick is due. This is the key
to measuring drift, and it is explained in step 7.
5. Scheduling the next tick (lines 93–98)
private schedule(): void {
const delay = Math.max(0, this.nextTickAt - Date.now());
this.timer = setTimeout(() => this.runTick(), delay);
this.timer.unref?.();
}
this.nextTickAt - Date.now()— how long until the next tick is due. If the last tick took 12 ms, this is 38 ms, not a fixed 50.Math.max(0, …)— if we are already late, run immediately rather than passing a negative delay.setTimeoutschedules a single future run, not a repeating one. Each tick schedules the next. That is what allows the delay to adapt.
this.timer.unref?.() — tells Node "this timer alone should not keep the process
alive". Without it, pressing Ctrl+C would hang, because Node waits for pending
timers. The ?. is optional chaining: only call unref if it exists.
Why setTimeout and not setInterval? setInterval fires on a fixed
schedule regardless of how long the work takes, so a slow tick causes the next to
fire immediately, and they pile up. This pattern always leaves a full gap after
the work finishes.
6. runTick — one heartbeat (lines 100–152)
Measuring lateness
private runTick(): void {
const startedAt = Date.now();
this.metrics.lastDriftMs = startedAt - this.nextTickAt;
if (this.metrics.lastDriftMs > this.metrics.maxDriftMs) {
this.metrics.maxDriftMs = this.metrics.lastDriftMs;
}
Drift = when we actually ran − when we were supposed to run. A drift of 0 is perfect. A drift of 40 ms means the loop is badly behind.
The work, timed in two halves
try {
const simStart = performance.now();
const { reaped } = this.manager.update(startedAt);
const simEnd = performance.now();
this.metrics.roomsReaped += reaped.length;
this.tickCounter++;
if (this.tickCounter % this.config.broadcastEveryNTicks === 0) {
this.onBroadcast();
}
const broadcastEnd = performance.now();
this.metrics.lastSimMs = simEnd - simStart;
this.metrics.lastBroadcastMs = broadcastEnd - simEnd;
performance.now() rather than Date.now() — it is far more precise
(fractions of a millisecond) and cannot jump backwards if the system clock is
adjusted.
The two halves are timed separately on purpose. This turned a guess into a measurement, and is the most useful instrumentation in the project:
- Sending was more expensive than simulating.
- The message was mostly collectibles that never move → send them rarely.
- Re-measured: better, but sending was still the expensive half.
- Conclusion: the cost is the number of messages, not their size.
this.tickCounter % this.config.broadcastEveryNTicks === 0 — "every Nth tick".
The remainder after dividing is only 0 on ticks 1×N, 2×N, and so on. With N = 1
it broadcasts every tick; setting N = 2 halves the send rate, which is the knob
used to prove the conclusion above.
The averages
this.simMsSum += this.metrics.lastSimMs;
this.broadcastMsSum += this.metrics.lastBroadcastMs;
const tickNumber = this.metrics.ticks + 1;
this.metrics.avgSimMs = this.simMsSum / tickNumber;
this.metrics.avgBroadcastMs = this.broadcastMsSum / tickNumber;
if (this.metrics.lastSimMs > this.metrics.maxSimMs) {
this.metrics.maxSimMs = this.metrics.lastSimMs;
}
Running total divided by count — the simplest possible average. Plus the worst value ever seen, because when hunting a bottleneck the peak matters more than the average. A server that is fine on average but stutters every few seconds is still a bad server.
this.metrics.ticks + 1 because this tick has not been counted yet — that
happens in recordTickDuration below.
Catching errors
} catch (err) {
console.error('[GameLoop] tick failed', err);
}
Essential. With one loop driving every lobby, an uncaught error here would stop the entire server — all 100 lobbies, not just the one that broke. Catching means a bad tick is logged and the next one carries on.
Scheduling the next one
this.recordTickDuration(Date.now() - startedAt);
this.nextTickAt += this.intervalMs;
const now = Date.now();
if (this.nextTickAt < now) {
this.nextTickAt = now + this.intervalMs;
}
this.schedule();
}
this.nextTickAt += this.intervalMs — advance on an absolute schedule.
Adding 50 ms to the scheduled time (not to "now") means small delays do not
accumulate. Tick 100 is due at start + 5000 ms regardless of what happened
before.
if (this.nextTickAt < now) — if we fell so far behind that whole slots elapsed,
skip them rather than firing several ticks back to back trying to catch up. A
burst of catch-up ticks would make a struggling server struggle harder.
Skipping is safe because GameRoom calculates movement from real elapsed time.
Players move the correct distance whether they got one tick or three.
7. recordTickDuration — the ring buffer (lines 154–176)
private recordTickDuration(durationMs: number): void {
this.metrics.ticks++;
this.metrics.lastTickMs = durationMs;
if (durationMs > this.metrics.maxTickMs) this.metrics.maxTickMs = durationMs;
if (durationMs > this.intervalMs) this.metrics.overrunTicks++;
if (durationMs > this.intervalMs) this.metrics.overrunTicks++ — the alarm.
This tick took longer than its budget. A handful is fine; a rising count means
trouble.
if (this.sampleCount === this.samples.length) {
this.tickMsSum -= this.samples[this.sampleIndex]!;
} else {
this.sampleCount++;
}
this.samples[this.sampleIndex] = durationMs;
this.tickMsSum += durationMs;
this.sampleIndex = (this.sampleIndex + 1) % this.samples.length;
this.metrics.avgTickMs = this.tickMsSum / this.sampleCount;
}
This is a ring buffer — a fixed array written round and round in a circle, overwriting the oldest entry.
Picture 600 slots numbered 0 to 599. You write at slot 0, then 1, then 2… After 599 you wrap back to 0 and overwrite the oldest value.
if (this.sampleCount === this.samples.length)— the array is full, so subtract the value about to be overwritten from the running total.this.sampleIndex = (this.sampleIndex + 1) % this.samples.length— the wrap.599 + 1 = 600, and600 % 600 = 0, back to the start.
Keeping a running total means the average is one division, not a walk through 600 numbers on every tick.
Result: a rolling 30-second average using a fixed amount of memory forever.
8. getMetrics and percentile (lines 178–197)
getMetrics(): LoopMetrics {
return { ...this.metrics, p95TickMs: this.percentile(0.95), budgetMs: this.intervalMs };
}
{ ...this.metrics } — spread syntax, copying every field into a new object. The
caller gets a snapshot they cannot accidentally modify.
Note what is happening here: p95 is calculated when someone asks, not on
every tick. It needs a sort of 600 numbers, and doing that 20 times a second
would be pure waste. /metrics is requested perhaps once a second, so the sort
happens off the hot path.
private percentile(p: number): number {
if (this.sampleCount === 0) return 0;
const sorted = Array.from(this.samples.slice(0, this.sampleCount)).sort((a, b) => a - b);
const index = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
return sorted[index] ?? 0;
}
What a percentile is: sort the numbers smallest to largest, then step 95% of the way along the list and read that value. It answers "95% of ticks were faster than what?"
With 600 samples: 600 × 0.95 = 570, so read slot 570.
Why it matters more than the average. If 99 ticks take 5 ms and one takes 500 ms, the average is 10 ms and looks healthy. The p95 exposes the slow one. Real players notice the stutter, not the average.
Math.min(sorted.length - 1, …) — guards p = 1, which would otherwise index one
past the end of the array.
How it all fits together
GameLoop wakes up (every 50 ms)
│
├─ manager.update(now) ── every room moves, collides, scores
│ │
│ └─ each room returns events → passed to subscribers
│
├─ onBroadcast() ── send each room's state to its players
│
└─ measure, then schedule the next tick
If an interviewer asks
"Why one loop instead of a timer per room?"
"One timer gives me a single budget to measure against and one health number — drift. A hundred independent timers would compete on the same thread and give me nothing to watch."
"How do you know the server is keeping up?"
"
avgTickMsagainstbudgetMs, andoverrunTicks. At 2,000 players it used 13 ms of a 50 ms budget with zero overruns."
"Why
setTimeoutrather thansetInterval?""
setIntervalfires regardless of how long the work took, so slow ticks pile up. Rescheduling after each tick always leaves a full gap, and if we fall badly behind I skip slots instead of running a catch-up burst — which is safe because movement is derived from real elapsed time."