[ 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.
Nathanial Martin · sole engineer: design, backend, frontend, infrastructure, CI
~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
TypeScript
Node 22
Fastify
PostgreSQL
Drizzle
Zod
React
Vite
Tailwind
Vitest
Playwright
Docker
Railway
GitHub Actions
Sentry
pino
[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.
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.
Package
Responsibility
Rule it keeps
sim
Every formula, every roll, the event engine, content data
No I/O, no clock, no runtime dependencies. Enforced by a test that scans the source.
shared
Zod schemas for every request and response, the command union
One definition parsed by both ends; types are derived, never hand-written twice.
server
Auth, authority, persistence, chat, leaderboards
Every mutating request goes through one function, advance().
client
Rendering, prediction, input
Never 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:
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.
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.
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.
Every scheduled time is absolute. Nothing stores "40 ticks from now", so the queue survives being serialised mid-fight.
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.
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 retryconst elapsed = tickAt(serverNow) - row.lastTick; // never from the requestconst next = integrate(mutated, elapsed);
// UPDATE character_state SET ..., state_version = state_version + 1// WHERE character_id = $id AND state_version = $readif (await compareAndSwap(next, row.stateVersion)) return toCheckpoint(next);
}
throw new ConcurrencyError(); // returned as 503 + Retry-After: contention is not a fault
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.
Typechecktsc -b across every package
Clean buildfrom a tree with no dist/
Test suitessim, server on PGlite, client
Containerthe real Docker image
Bootagainst Postgres 17, migrations, register
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.
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
Choice
Why
Cost accepted
Polling, not websockets
Prediction makes a 10 s sync feel live; chat polls by monotonic id
Another device's action shows up within a sync, not instantly. ws push is the next phase.
One replica
Boot-time migrations and an in-process rate limiter are simple and correct at this scale
Horizontal scaling needs a migration lock and a shared limiter first
Exact replay offline
One code path for online and offline; nothing to drift
Past the 72 h cap a statistical forecast path will be needed
CAS, no device lease
Correct with zero extra state
Two active devices can contend over intent; a control lease is designed but not built