src/realtime/rateLimiter.ts — one player, one budget

In one sentence: stops a single connection sending thousands of messages a second, using a technique called a token bucket.

Size: 41 lines — the smallest file in the project. Depends on: nothing.


Why this file exists

A normal browser sends about 20 movement messages a second. A modified one could send 10,000.

The game itself would survive that — setInput just overwrites the stored direction, so extra messages cost no simulation work at all. But they still cost:

  • JSON parsing for every message,
  • event loop turns, which is time not spent ticking the game,
  • bandwidth.

So a flood is throttled at the door.

Worth being precise about: this protects performance, not correctness. Correctness is already safe because input is coalesced. That distinction is a good thing to state — it shows you know what each defence is actually for.


What a token bucket is

Picture a bucket that holds coins. Every message costs one coin. Coins drip back in at a steady rate.

  • Capacity — how many coins fit. This allows a burst: if you have been quiet, the bucket is full and you can send several messages at once.
  • Refill rate — how fast coins return. This sets your sustained rate.

Our settings: capacity 20, refill 40 per second. So a player can fire off 20 messages instantly after being idle, then settles into 40 a second.

The two-number design matters. A simple "max 40 per second" counter would punish normal play, because real input arrives in bursts — you press three keys at once, the browser sends three messages in the same millisecond. The bucket absorbs that naturally.


The code, line by line

1. The fields (lines 15–24)

export class TokenBucket {
  private tokens: number;
  private lastRefill: number;

  constructor(
    private readonly capacity: number,
    private readonly refillPerSec: number,
    now: number,
  ) {
    this.tokens = capacity;
    this.lastRefill = now;
  }

Only two changing values: how many coins are left, and when we last topped up.

this.tokens = capacity — start full, so a player's very first action is never throttled.

Note there is no timer here, and that is the whole point of the design. With 2,000 connected players, 2,000 repeating timers would be its own performance problem. Instead the refill is calculated from a timestamp whenever someone asks. No timer, no background work, no cost while idle.

2. tryConsume (lines 26–40)

tryConsume(now: number): boolean {
  if (now > this.lastRefill) {
    const refill = ((now - this.lastRefill) / 1000) * this.refillPerSec;
    if (refill > 0) {
      this.tokens = Math.min(this.capacity, this.tokens + refill);
      this.lastRefill = now;
    }
  }
  if (this.tokens >= 1) {
    this.tokens -= 1;
    return true;
  }
  return false;
}

Step 1 — work out how many coins came back.

const refill = ((now - this.lastRefill) / 1000) * this.refillPerSec;
  • now - this.lastRefill — milliseconds since the last top-up, say 250.
  • / 1000 — convert to seconds: 0.25.
  • * this.refillPerSec0.25 × 40 = 10 coins earned.

Step 2 — add them, but never overflow.

this.tokens = Math.min(this.capacity, this.tokens + refill);

Math.min caps it at capacity. Without this, a player idle for an hour would accumulate 144,000 coins and could then send 144,000 messages in one burst — which is exactly the flood we are preventing.

Step 3 — spend one if there is one.

if (this.tokens >= 1) {
  this.tokens -= 1;
  return true;
}
return false;

true means "allowed", false means "throttled". The caller decides what to do about it; this class only answers the question.

>= 1 rather than > 0 because tokens is a decimal. With 0.4 coins there is not a whole message's worth available.

if (now > this.lastRefill) guards the clock going backwards, which would produce a negative refill and remove coins.


How it is used

In socketServer.ts, each connection gets its own bucket at join time:

bucket: new TokenBucket(
  this.config.inputRateLimitBurst,   // 20
  this.config.inputRateLimitPerSec,  // 40
  now,
),

And on every input message:

if (!session.bucket.tryConsume(now)) {
  this.metrics.inputsRateLimited++;
  return;   // dropped silently
}

Two things worth noticing:

One bucket per connection. One player flooding cannot affect anyone else's budget.

Silent rejection. No error is sent back. Replying to a flooder means sending one message for every message they send, doubling the traffic they are already causing. The count goes to /metrics instead, where an operator can see it without feeding the attacker.


Proved by test

it('allows a burst then throttles', () => {
  const bucket = new TokenBucket(5, 10, 0);
  for (let i = 0; i < 5; i++) expect(bucket.tryConsume(0)).toBe(true);
  expect(bucket.tryConsume(0)).toBe(false);   // burst exhausted
});

it('refills over time at the configured rate', () => {
  const bucket = new TokenBucket(5, 10, 0);   // 10 per second
  for (let i = 0; i < 5; i++) bucket.tryConsume(0);
  expect(bucket.tryConsume(0)).toBe(false);

  expect(bucket.tryConsume(100)).toBe(true);  // 100 ms -> 1 coin
  expect(bucket.tryConsume(100)).toBe(false);

  // A long idle period refills to capacity but never beyond it.
  for (let i = 0; i < 5; i++) expect(bucket.tryConsume(10_000)).toBe(true);
  expect(bucket.tryConsume(10_000)).toBe(false);
});

Because now is a parameter rather than a real clock, these tests run instantly. Ten seconds of behaviour is verified by passing 10_000 — no waiting.

The last two lines are the important ones: after being idle for ten seconds the bucket refills to exactly capacity, not more.


If an interviewer asks

"Why a token bucket rather than a simple counter?"

"A fixed counter punishes normal play, because real input arrives in bursts — press three keys and three messages leave in the same millisecond. A bucket allows a short burst while still capping the sustained rate."

"Why is there no timer refilling the buckets?"

"It's calculated from a timestamp on demand. With 2,000 players, 2,000 repeating timers would be its own performance problem. This way an idle connection costs nothing at all."

"What does rate limiting actually protect here?"

"Bandwidth and parsing, not correctness. Input is coalesced — only the latest direction before a tick is used — so a flood already costs no simulation work. The limiter stops it costing JSON parsing and event loop time."

Built with LogoFlowershow