LA.
Rally

technical write-up

Rally: a shared real-time game where the server does almost nothing

A match companion built with React, TypeScript and AWS, where every client derives the same truth instead of asking a server for it.

The 30-second version

Rally replays the 2019 FIBA World Cup bronze medal game (France vs Australia) as a live experience. Everyone who opens the page sees the exact same second of the match. You join with a nickname, you see who else is watching, you make predictions on key moments, and you climb a live leaderboard against other visitors and three CPU bots.

It looks like a classic client-server app. It isn't. The server never computes a score, never ticks a clock, and stores almost nothing. Two design rules drive the whole system:

  1. Derive, don't store. If a value can be computed from a fixed anchor, compute it. Never persist it.
  2. The server only carries what cannot be derived. Everything else, every client derives identically.

I build interactive brand experiences (instant-win contests, gamified campaigns) for a living. Rally is the same craft, applied to live sports, rebuilt from scratch on a stack I wanted to prove: React, TypeScript, and event-driven AWS.

Problem #1: everyone must see the same second

A replay loop is trivial on one machine: setInterval, next event, repeat. But I wanted a shared experience: if you and I open Rally at the same time, we must see the same basket scored at the same moment, like a real broadcast. And the loop has to keep running when nobody is watching.

The obvious answer is a server tick: a process that advances the match state every 1.5 seconds and pushes it out. I rejected it for two reasons. First, AWS EventBridge scheduled rules have a hard one-minute floor: you simply cannot tick a Lambda every 1.5 seconds with a scheduler. Second, a Lambda that keeps itself alive in a loop is fragile and wasteful: you pay compute to move a cursor forward, forever.

So there is no cursor. There is only one fixed timestamp in DynamoDB: baseStartedAt, the moment the very first loop started. Everything else is arithmetic:

cycleMs    = lastSequence × tickMs + pauseMs
elapsed    = now − baseStartedAt
cycleIndex = floor(elapsed / cycleMs)        // which loop are we on
within     = elapsed % cycleMs               // where inside the loop
sequence   = within < playMs
             ? floor(within / tickMs) + 1    // current match event
             : lastSequence                  // break between loops

The match "runs" 24/7 at zero cost, because it doesn't run at all: it's derived on demand. The loop wraps instantly, there's no drift, and there's nothing to crash.

Clients sync through a single public GET (a Lambda Function URL) that returns the anchor plus serverNow. The browser computes offset = serverNow − Date.now() once, then derives everything from Date.now() + offset. The client's setInterval doesn't own any state, it just repaints from the shared clock. I verified it the honest way: three browsers side by side, same sequence, every tick.

One guardian Lambda runs hourly via EventBridge, with a single job: make sure the anchor exists (an idempotent conditional write). In V1 it's a safety net. In V2, when Rally ingests real NBA feeds, that seam becomes the match-transition logic.

Problem #2: a live multiplayer leaderboard without a scoring server

The signature feature: your picks and everyone else's feed one shared leaderboard, updating live. The reflex architecture is server-authoritative: clients send picks, the server scores them, broadcasts standings. Correct, but heavy: the server must know the match, the predictions, the scoring rules.

I went the other way. The WebSocket layer (API Gateway WebSocket, one Lambda, DynamoDB) is a pure relay. It carries exactly two things that genuinely cannot be derived:

  • Presence: who is connected, under which name.
  • Picks: who chose what, on which prediction, in which loop.

Everything downstream (bot picks, correct answers, points, ranks), every client computes locally, and they all get the same result. Three details make that possible.

Identity is the connection. When you send a pick, the server never trusts a name inside the message. It looks up the nickname attached to your connectionId and stamps it on the broadcast. You physically cannot pick as someone else, because you can only speak through your own socket. That's V1 anti-cheat for free, by construction.

Nickname uniqueness is an atomic lock. Two people claiming "cobra" in the same millisecond is a textbook race. I don't solve it in code, DynamoDB does: the join route writes an item keyed by the name with attribute_not_exists. Conditional writes on the same key are serialized by the engine, so there are never two owners. The loser gets a name_taken message and picks another name. Disconnect deletes the lock.

Everything that touches shared rendering must be deterministic. This one bit me twice.

The CPU bots used to call Math.random(), which means every client saw different bots, and a shared leaderboard is impossible. The fix: seed a small PRNG (xmur3 → mulberry32, 32-bit integer ops only, so identical output on every JS engine) with (cycleIndex, predictionId, botId). Same seed everywhere → same bot decisions everywhere, with zero communication. The bots still vary between loops, because the loop index is in the seed.

Then ranking. Ties share a rank (10, 10, 10 → ranks 1, 1, 1, then 4, standard competition ranking), and tied players are ordered by name. My first tiebreak used localeCompare(), which depends on the runtime's locale. Two clients in different locales could render the same standings in different orders. Deterministic means deterministic: I switched to plain code-unit comparison.

The result is a leaderboard computed as a pure fold: (loopIndex, relayed picks, frozen event stream) → standings. Two clients with slightly different wall clocks still agree, because wall-clock time is not an input.

Honest limits (and what V2 looks like)

V1 trusts the client on one rule: picks are ignored after the lock, checked on send and on receive, but in the browser. A hand-crafted WebSocket client could still send a late pick and other well-behaved clients would reject it; a modified client could accept it. V2 moves enforcement into the Lambda, which can reuse the same TypeScript engine package to derive the current sequence and reject late picks server-side.

The relay also keeps no history: join mid-loop and you only see picks sent after you connected, so other players' points can be undercounted until the next loop resets everyone. Fixing it means storing picks per loop and replaying them on connect, same V2 hardening batch.

And it's a replay, labeled as such (REPLAY · 2019 FIBA WORLD CUP · LOOP #n). No fake "live". The seed is the real play-by-play of the bronze final: 73 events, every point attributed, quarter scores verified against the official box score. V2 swaps the frozen seed for real-time NBA feeds: webhook ingestion, push instead of derivation, idempotent writes, the part of the roadmap where the guardian Lambda gets its real job.

Also out of scope, permanently: real money. Rally is a fan-engagement game. No betting, ever.

Stack & bill

  • Frontend: Next.js (App Router), React, TypeScript. Deployed on Vercel.
  • Engine: a framework-free TypeScript package (@rally/engine): event stream, state folds, prediction resolution, seeded bots, leaderboard. Pure functions, no I/O, the same code can run in the browser today and in a Lambda tomorrow.
  • Backend: AWS SAM, eu-west-3, Node 22 on arm64. Three Lambdas (state read, guardian, WebSocket), two DynamoDB tables for connections and name locks, one table with a single item for the anchor. API Gateway WebSocket routes written as raw ApiGatewayV2 resources.
  • Monthly bill: approximately zero. One item read on page load, one hourly guardian invocation, and WebSocket messages when people actually play. Derive-don't-store is also a pricing strategy.

If you're building fan engagement, second-screen experiences, or anything real-time in sports media, this is the kind of system I love designing.