PBBG Architecture
Back
[ ARCHITECTURE ]Persistent browser RPGClosed alpha, live on Railway

One deterministic simulation, run on both sides of the wire

PBBG is a persistent idle RPG for desktop web, mobile web and, later, Steam. Players fight and gather around the clock, including while the game is closed. This page explains how the system keeps offline progress exact, stops two devices from overwriting each other, and treats every browser as untrusted.

~45klines of TypeScript across 4 packages
1,700+automated tests in sim, server and client
72 hof offline combat replayed exactly in tens of ms
12forward-only migrations against live accounts
[01]

The problem

In an idle game, offline progress is the product. A player starts a fight on their desktop, checks it on their phone at lunch, and expects the evening's totals to be right to the last coin. Three requirements fall out of that, and every architectural choice below serves one of them.

  • R1Offline equals online.Ten hours computed in one step must produce exactly the same state as ten hours computed in a thousand small steps. Any difference is a visible, exploitable bug.
  • R2No lost writes across devices.A phone and a desktop syncing in the same instant must both be credited once, and neither may erase the other.
  • R3The client is a hostile renderer.A modified browser can send anything. It may express intent ("equip this", "travel there"), never an outcome ("I earned 999,999 coins", "I was away 40 hours").
[02]

System map

A pnpm monorepo with four packages. The key decision is that the game's rules live in one pure package, sim, which both the server and the client import. The server runs it as the authority; the browser runs the same code to draw a smooth, predicted screen between syncs.

System map: browser client and Fastify server both import the pure sim package and the shared schema package; the server persists to PostgreSQL with compare-and-swap. sim pure rules engine · integrate() · seeded RNG · data/*.json shared Zod schemas · command union · Checkpoint wire type 0 deps · 0 I/O Browser client React · Vite · Tailwind sim as prediction frame loop: 250 ms, local sync loop: 10 s, network min-RTT server clock offset API server Node 22 · Fastify · Drizzle sim as authority advance(): read, mutate, integrate, compare-and-swap session auth · rate limits PostgreSQL one per environment character_state sim_state jsonb command_keys append-only audit imports imports intent checkpoint CAS PLATFORM Railway: production, qa, dev, dev-small, each with its own Postgres · one Docker image · Sentry + pino from day one
FIG 1 The client sends intents with an idempotency key and receives whole checkpoints. Server and client compute with the same sim build, so a disagreement between them is a bug to surface, not noise to smooth over.
PackageResponsibilityRule it keeps
simEvery formula, every roll, the event engine, content dataNo I/O, no clock, no runtime dependencies. Enforced by a test that scans the source.
sharedZod schemas for every request and response, the command unionOne definition parsed by both ends; types are derived, never hand-written twice.
serverAuth, authority, persistence, chat, leaderboardsEvery mutating request goes through one function, advance().
clientRendering, prediction, inputNever computes against its own wall clock; never claims an outcome.
[03]

A deterministic, event-stepped simulation

integrate(state, ticks) advances a character through combat, abilities, loot, gathering droids, crafting and radiation. It does not loop once per tick. It pops the next scheduled event from a priority queue held in the state, resolves it at its exact tick, and jumps to the next one. That is why 72 hours of offline play resolves in tens of milliseconds on the server.

The whole engine serves one property, tested over a thousand random split points per run:

// packages/sim/test/invariance.test.ts (shape of the property)
expect(integrate(s, 100)).toEqual(integrate(integrate(s, 60), 40));
// including splits that land mid-swing, on a death, on a pack wipe, and on a tick where events collide

Split invariance is what makes R1 true: a sync every 10 seconds, a sync after 9 hours, or a sync every frame on the client all produce the same totals. It is also fragile, because each way of breaking it passes most tests most of the time. The rules that keep it true:

  1. Randomness is addressed, not streamed. Every roll is a hash of its coordinates, roll(seed, tick, actionId, slot, range). Splitting a run cannot shift which number a later roll draws, because no roll depends on how many came before it.
  2. Integer arithmetic only. Fractions are fixed point (per-mille) with the remainder carried in state. A float would diverge in the last bits and look like a scheduler bug.
  3. A total order on simultaneous events. The queue sorts on (tick, priority, slot) and never ties, so two events on one tick resolve the same way on every machine.
  4. Every scheduled time is absolute. Nothing stores "40 ticks from now", so the queue survives being serialised mid-fight.
  5. Purity is mechanical. purity.test.ts fails the build on Date.now, Math.random, crypto.randomUUID, any node:* import, process.env or a runtime dependency.
  6. Floors stop hangs. Damage is at least 1, intervals at least one tick, reduction at most 99%, so no input can make offline resolution loop forever.
// packages/sim/src/rng.ts: coordinates mixed one at a time,
// because summed coordinates collide, as (tick 5, slot 3) with (tick 3, slot 5)
export function roll(seed, tick, actionId, slot, range) {
  let h = mix32(seed);
  h = mix32(h ^ mix32(tick >>> 0));
  h = mix32(h ^ mix32(actionId >>> 0));
  h = mix32(h ^ mix32(slot >>> 0));
  // ... reduced to [0, range) without modulo bias
}

Content is data, not code. Monsters, zones, items, abilities and progression curves are JSON records validated by Zod. Adding the tenth monster is a record plus a balance run; a test walks every record against the content rules (spawn weights sum to 1000, a rarer species is never weaker, and so on) and is itself guarded against passing on zero entries.

[04]

Server authority without lost writes

The server is the only thing that decides what happened. Both mutating endpoints, POST /sync and POST /command, are the same function with an optional mutation in the middle:

// packages/server/src/game/checkpoint.ts (condensed)
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
  const row     = await readCheckpoint(characterId);
  const mutated = mutate ? mutate(row.state) : row.state;     // pure; reruns on retry
  const elapsed = tickAt(serverNow) - row.lastTick;          // never from the request
  const next    = integrate(mutated, elapsed);

  // UPDATE character_state SET ..., state_version = state_version + 1
  //  WHERE character_id = $id AND state_version = $read
  if (await compareAndSwap(next, row.stateVersion)) return toCheckpoint(next);
}
throw new ConcurrencyError();   // returned as 503 + Retry-After: contention is not a fault
Sequence: phone and desktop sync at once; both read version 41; the phone's write wins; the desktop's write matches zero rows, so it re-reads version 42, integrates again and writes version 43; both receive the same state. Phone Desktop API server Postgres POST /sync POST /sync both read state_version = 41 phone: UPDATE ... WHERE v = 41 1 row, v = 42 desktop: UPDATE ... WHERE v = 41 0 rows retry: re-read v = 42, integrate desktop: UPDATE ... WHERE v = 42 1 row, v = 43 checkpoint v42 checkpoint v43, same totals
FIG 2 Optimistic concurrency. Zero rows updated is the mechanism working, not an error. Because the sim is split-invariant, the retried integration credits exactly the time not yet credited: nothing twice, nothing lost (R2).

Idempotent commands

Each command carries a client-generated key. The claim is one INSERT ... ON CONFLICT DO NOTHING RETURNING against a primary key on (character_id, key), so a retried request on a flaky mobile connection applies once, across any number of server processes. A duplicate still syncs and returns current truth rather than a cached old response.

Nothing to forge

/sync takes no body. /command accepts only a closed, .strict() Zod union, so an injected "coins": 999999 is a 400 naming the key, not a silently ignored field. Elapsed time comes from the server clock, so calling /sync more often credits exactly what one call does (R3).

Writes that touch two players

Sending items between characters runs both advance() calls inside one transaction, after SELECT ... FOR UPDATE on both rows in character-id order. One commit means an item never exists on neither side or both; the fixed lock order means two players sending to each other cannot deadlock. Every transfer is also written to an append-only table, so a duplication exploit is a query, not an investigation.

[05]

Client prediction and reconciliation

The browser holds the last server checkpoint and re-runs integrate() from it four times a second to draw the fight. That is what allowed syncs to drop from every 2 seconds to every 10: fewer database writes and a smoother screen at once.

  • Two loops that never mix. The 250 ms frame loop never touches the network; the 10 s sync loop never draws. Smoothness does not depend on latency.
  • The server's clock, not the browser's. Each checkpoint carries the server tick. The client keeps the offset from its fastest round trip seen, the NTP approach, because error is bounded by the best RTT and averaging would weight slow samples equally. A laptop clock minutes off is common, and mispredicting against it looks exactly like a formula bug.
  • Predict from the checkpoint, never from the last frame. Re-predicting the whole span is cheap with an event-stepped engine and self-healing after a suspended tab; an incremental chain lets one bad frame poison every later one.
  • Reconciliation is an equality check, not a merge. On each sync the client predicts to the checkpoint's exact tick and deep-compares the whole state, pending event queue included. A mismatch is surfaced as drift and the server state replaces the prediction. Interpolating would hide the very determinism bug this layer exists to catch.
  • Display data never enters the state. Floating damage numbers come from an observer callback passed to integrate(). A test proves integrating with or without the observer is byte-identical, so presentation cannot break split invariance.
[06]

Persistence and migrations

Real accounts exist, so the schema, the save format and the tick epoch are one-way doors. The persistence layer is built around changing safely rather than changing easily.

  • The whole sim state is one versioned jsonb column. Nothing reads half a checkpoint, and the sim gains a field most weeks; per-field columns would mean a migration per feature. Additive fields are defaulted on read. A save written by a newer server is refused with a 503 rather than silently reset, so a bad rollback locks players out instead of wiping them.
  • Migrations are forward-only SQL, applied at boot, idempotent. Drizzle generates them; each is read and hand-edited, and a test proves existing rows survive. Never drizzle-kit push.
  • No Postgres extensions. UUIDs come from Node and case-insensitive names from a lowered column. That constraint is what lets the entire server suite run in-process on PGlite (embedded Postgres) with no Docker, while CI also runs the real image against Postgres 17.
  • Closed sets are text checked in shared/, not Postgres enums, which cannot grow inside the transaction that first uses a new value.
  • Audit tables are append-only: transfers between players, registrations by invite key, purchases. Never updated, never deleted.

Authentication

Hand-rolled sessions, about 150 fully understood lines: argon2id passwords, 256-bit random tokens with only their SHA-256 hash stored, a first-party SameSite=Lax cookie (one origin serves API and client, so no CSRF token is needed). "No such email" and "wrong password" are byte-identical in body and timing, so login is not an enumeration oracle. Invite-key redemption is a conditional UPDATE inside the registration transaction, so two sign-ups racing on one key cannot both succeed.

[07]

Delivery: CI, environments and releases

The project deployed a hello-world on day one, before any game code, so deploy problems were found while the app was ten lines long. Every push runs the full verification; nothing counts as verified by clicking through the game.

  1. Typechecktsc -b across every package
  2. Clean buildfrom a tree with no dist/
  3. Test suitessim, server on PGlite, client
  4. Containerthe real Docker image
  5. Bootagainst Postgres 17, migrations, register
  6. BrowserPlaywright through every screen

The container job exists because of a real failure: a new workspace package built fine locally (a stale dist/ persisted) but the image booted into a module-not-found loop. The Dockerfile now builds with pnpm -r in topological order instead of a hand-maintained chain.

dev→qa→ pull request →master hotfixes:dev-small→master

Each branch deploys to its own Railway environment with its own database. Before a release, a script restores a production dump into the QA database (never a connection to production) so the candidate's migrations run against real row shapes. The scripts refuse to run unless they can prove, by Postgres system_identifier, that the two databases are different clusters. Non-production environments fail closed behind a password gate and refuse to boot without one.

Releases are written by CI. Opening a pull request to master collects the player-facing notes from every dev build since the last release into the in-game changelog, bumps the semantic version and commits it to the branch. Railway waits for CI to pass before deploying.

When the private repo outgrew GitHub's free Actions minutes, CI moved to a self-hosted runner in a WSL2 distro rather than a paid plan: the same workflow, no per-minute cost.

[08]

Keeping a solo codebase correct

One person maintains this, much of the code written with an AI pair (Claude Code), so the guardrails are tests rather than discipline.

  • Tests assert rules, not tuned numbers. "A miniboss pays strictly more than a rare", never "a miniboss pays 340". Balance changes weekly and should not become a test-editing pass.
  • Balance is measured against the real engine. pnpm balance runs integrate() over fixed seeds and grades each design target, so the report shows what was built, not what a spreadsheet meant. Monster stats are solved from a baseline power curve rather than hand-tuned.
  • Every formula has a graph. Each number-returning export in the sim has a Desmos graph; a test fails when a formula changes without its graph.
  • The docs are under test. Size caps on the process file, system guides and design notes, a comment budget, and no em dashes in player-facing copy are all CI checks. Each system has one guide covering its invariants, how to extend it and what breaks.
[09]

Trade-offs and what is next

ChoiceWhyCost accepted
Polling, not websocketsPrediction makes a 10 s sync feel live; chat polls by monotonic idAnother device's action shows up within a sync, not instantly. ws push is the next phase.
One replicaBoot-time migrations and an in-process rate limiter are simple and correct at this scaleHorizontal scaling needs a migration lock and a shared limiter first
Exact replay offlineOne code path for online and offline; nothing to driftPast the 72 h cap a statistical forecast path will be needed
CAS, no device leaseCorrect with zero extra stateTwo active devices can contend over intent; a control lease is designed but not built
Tauri for SteamAbout 5 MB against Electron's 150 MB+Deferred until the web game is settled