10,975 words · 2 diagrams
How InertialRef gets from a dist/ directory to a URL, and what has to exist
behind that URL before the persistent universe is possible.
Static hosting, the local authority port, metadata, reference media, and the optional Planetarium guide are implemented. Remote simulation authority and persistent mutations remain planned. The client is live at https://inertialref.app and the retained
inertialref.jonjaques.comorigin.apps/serverserves the static bundle,/api/health, the account verdict at/api/account, and the/api/tourservice that creates a GPT Live session for an account granted the guide. The Worker implements no Durable Object; the browser holds the guide conversation over WebRTC (ADR-0042). The multiplayer/wspath still returns a deliberate 501; partition authorities and D1 are planned, and are where the first Durable Object will land.ADR-0008 is the decision it implements, modes is what each tier owes the player, and sustainability is who pays.
Legend — ✅ done · 🟡 partial · ⬜ not started · ⛔ deliberately deferred
The private Planetarium guide
The Guide is an optional cloud service within the Planetarium, for signed-in
accounts whose private metadata in Clerk says admin: true or tour: true
(ADR-0048).
The Planetarium offers the Guide panel only to such an account, and the Worker
refuses a session to anybody else. It answers only where accounts do:
inertialref.app in production, and every preview and development host.
Missing secrets or TOUR_GUIDE_ENABLED=false
make cloud capabilities unavailable; the ordinary scene and local tour controls
do not require provider credentials. See
ADR-0041 for the execution boundary.
A grant is set by hand: in the Clerk dashboard, Users → the user →
Metadata → Private, { "tour": true }. Private metadata is not in the
session token and not in the user object the browser sees, so the Worker reads
it with the secret key and holds the answer for a minute per user: a grant set
or removed in the dashboard reaches the Worker within that minute.
Local pnpm dev starts the server through scripts/tour/dev.mjs. That adapter
loads the repository's gitignored .env.local into its own process, which
starts nothing but cf dev; cf dev reads each declared secret from that
environment. Use OPENAI_API_KEY and CLERK_SECRET_KEY without a VITE_ or
PUBLIC_ prefix. Vite never sees the file, so the client bundle, preference store, and
public build variables never receive them.
Production uses Worker secrets, entered at a prompt so the value never reaches
shell history. Wrangler's prompt needs the Worker name, because the platform
configuration is cloudflare.config.ts and Wrangler does not read it:
pnpm --filter @inertialref/server exec wrangler secret put OPENAI_API_KEY --name inertialrefd
pnpm --filter @inertialref/server exec wrangler secret put CLERK_SECRET_KEY --name inertialrefdcf workers secrets update <NAME> --worker inertialrefd --type secret_text
is the cf form; without --text it prompts for the value the same way.
cf deploy --secrets-file <path> (JSON or .env) uploads secrets with a
version instead.
The Worker implements no Durable Object. ADR-0042
moved the conversation into the browser, and the deployed Worker's tour-v2
migration has deleted the TourSession and TourAdmission classes the earlier
design bound, so cloudflare.config.ts declares no exports and no migration.
Regenerate host declarations with pnpm --filter @inertialref/server run types
when the compatibility date or flags change; Env follows the bindings without
it. Deployment requires no simulation or D1 migration.
| Endpoint | Purpose |
|---|---|
GET /api/tour/capabilities |
The verdict: granted with its voices, or refused as unavailable, signed out or not granted |
POST /api/tour/sessions |
Creates one GPT Live session: writes the configuration, exchanges the WebRTC SDP |
Both shapes, their decoders, the paths, the refusal sentences and the session
limits — 65,536 bytes of SDP and 1,500 bytes of opening scene, both in UTF-8 —
are packages/protocol/src/guideWire.ts. The Worker's answers satisfy those
types and the browser decodes them with the same decoders.
The browser holds the session over its own WebRTC peer connection and data
channel; there is no application socket, no /speech, /close, /status, or
/usage route, and no controlling tab. Tour responses are uncached. Session
creation validates the origin and the account: the browser sends its Clerk
session token as a bearer header, the Worker verifies it and reads the user's
grant, and answers 401 to somebody signed out, 403 to an account without the
grant, and 503 when Clerk cannot be reached.
The Worker keeps no session ledger and no session timer. The spending bound is the OpenAI project's own limit and the duration bound is the provider's session expiry, two hours out; a browser that vanishes ends its session within three seconds without the Worker's help, so there is nothing to time. These are configured bounds on an experiment, not a measured cost per tour.
The Worker has no conversation log, because it holds no conversation: it never
sees a prompt, a response, a transcript fragment or a tool call. What it does
record is every answer that is not a plain success, and why. Workers Logs keeps
a record per refused request naming the check that refused it, and one per
provider failure carrying the provider's status, error type, error code,
message and request id — the browser sees only the 503, so this record is the
one place a revoked key or a spent project is readable. Workers Traces holds a
span per route with the provider round trip as a child. Both are unsampled and
persisted; apps/server/cloudflare.config.ts says why. Read them in the dashboard
under the Worker's Logs and Traces tabs, or live:
pnpm --filter @inertialref/server run tailThe script is wrangler tail inertialrefd --format pretty: cf 1.0.0-beta.5
has no live-log command.
The message log lives where the messages do. ir.guideTrace(true)
enables the browser's application-message log and ir.guideTrace() returns its
last 200 entries; it carries no credentials, cookies, SDP or audio. The
conversation replay reads a
recorded one back.
Live audio uses browser WebRTC. The server writes the Live configuration —
one voice, Astra as the delegated backend, and a data-channel allow list that
omits session.update — and the browser runs the tool loop over that channel.
There is one voice and no controlled clips; the browser's own clock, reading
the remote audio track, decides when a stop has been heard.
Guide admission and operation are separate from public hosting. Do not claim a
provider gate from a successful page build or a session.started event. Real
spoken replies, delegation, closure, and repeated listening need explicit
verification. Because the Worker implements no Durable Object, Cloudflare
serves previews for it, and deployed guide verification uses a Worker Preview —
any branch push, or pnpm --filter @inertialref/server run previews:deploy —
under the account's workers.dev subdomain, where the guide and accounts are
on and sign-in is the development Clerk instance. The origin rule and the
account check both admit that subdomain, so a preview can create a session.
The one idea
The server's job is small, and the architecture's job is to keep it small.
Because the universe is a pure function of (seed, catalog version, address),
the simulation server never has to store, serve or simulate the galaxy. Its
authority holds what a client cannot derive — other entities and persistent mutations — which is
the same set the save format represents. That is
ADR-0007 and ADR-0008
agreeing with each other, and it is the reason a non-commercial project can
credibly promise a persistent universe at all.
The guide adds session ordering, provider calls, and a spending ledger outside that simulation contract. It sends bounded object records for an admitted request and has no authority to create a discovery or mutate a ship.
Every simulation hosting decision below is downstream of that. Where a choice would let the server grow a responsibility the client could have discharged itself, the choice is wrong even when it is convenient.
flowchart TB
subgraph CLIENT["what the client derives — free, offline, forever"]
DER["galaxy · systems · bodies · orbits<br/>terrain · frames · physics"]
end
subgraph SERVER["what a server must hold — the only thing that costs money"]
ENT["entity states<br/><i>other players' ships</i>"]
MUT["persistent mutations<br/><i>discovered · destroyed · placed · terrain</i>"]
end
DER -.->|"never crosses<br/>the wire"| SERVER
style CLIENT fill:#065f46,stroke:#064e3b,color:#fff
style SERVER fill:#5b21b6,stroke:#3b0764,color:#fffThe topology
One Worker is the whole front door. It serves the client's static assets, it
answers /api/*, and upgrades the guide socket under /api/tour. The
multiplayer topology below remains planned: /ws will select a Durable Object
by partition key.
flowchart TB
B["<b>browser</b><br/>apps/game · service worker · IndexedDB"]
subgraph CF["Cloudflare"]
W["<b>Worker</b> — apps/server<br/>static assets · /api/* · /ws routing"]
A["static assets<br/><i>free, unlimited requests</i>"]
DO1["<b>DO</b> partition s:SOL<br/>SQLite · hibernating sockets"]
DO2["<b>DO</b> partition s:HIP71683"]
DO3["<b>DO</b> partition c:12,-3,7"]
D1["<b>D1</b><br/>accounts · discovery credit<br/>catalog revisions · sync"]
R2["<b>R2</b><br/>inertialrefd-storage<br/><i>reference audio · material sets later</i>"]
end
B -->|"GET /"| W
B -->|"GET /api/*"| W
B -->|"WS /ws?partition=…"| W
W --> A
W --> D1
W --> DO1
W --> DO2
W --> DO3
DO1 -.->|"writes needing a global<br/>uniqueness guarantee"| D1
W --> R2
B -.->|"biome textures"| R2
style W fill:#0369a1,stroke:#0c4a6e,color:#fff
style DO1 fill:#5b21b6,stroke:#3b0764,color:#fffWhy one Worker and not three services. Same origin means no CORS preflight
on every API call, no second hostname for the WebSocket, and no SameSite
gymnastics around whatever token identifies a player. Static asset requests are
free and unlimited and do not invoke the Worker at all, so the shared origin
costs nothing. The alternative — an api. subdomain — was considered and is
noted under things that will bite, because it would
have sidestepped one real problem for free.
What each Cloudflare primitive is for
| Primitive | Holds | Why this one and not another |
|---|---|---|
| Workers | The client bundle, the API, WebSocket upgrade routing | Static asset requests are free and never reach the script. One deploy, one origin, one artifact. |
| Durable Objects | ⛔ nothing today; partition authorities are planned | A DO is a single-threaded, addressable, consistent island with its own SQLite. That is precisely the shape of a star system under patched conics — the guide needed none, so the first one lands with multiplayer. |
| DO SQLite | Per-partition durable state, co-located with the authority | Transactional with the code that owns it. No network round trip. 10 GB per object, which is four orders of magnitude more than a partition will ever need. |
| D1 | Account-scoped and globally-unique data | Cross-partition queries and global uniqueness — "who discovered this first" — need one writer for the whole galaxy, not one per system. |
| R2 ✅ | What the repository will not carry; biome material sets later | Zero egress fees. Today one bucket, inertialrefd-storage, holding the cutscene's reference audio (H-8). Material sets are the planned second tenant. |
| Workers KV | ⛔ nothing | The catalog is 366 KB brotli across two files and ships in the bundle (spike 3). There is no eventually-consistent read tier to fill. |
| Queues / Workflows | ⛔ nothing yet | No asynchronous fan-out exists. Revisit if catalog revision publishing becomes a batch job. |
| Cloudflare Pages | ⛔ nothing | Workers static assets is the same capability inside the Worker that already has to exist. Two deploy targets for one site is one too many. |
Numbers, with their source
Every figure below was read from Cloudflare's documentation on 2026-08-20. They move; re-check before relying on one.
| Limit | Value |
|---|---|
| Static asset files per Worker version | 20,000 (Free) / 100,000 (Paid); 25 MiB each |
| Static asset requests | Free and unlimited; no storage cost |
| Worker script size | 3 MB gzip (Free) / 10 MB gzip (Paid) |
| Worker CPU per invocation | 10 ms (Free) / minutes, configurable (Paid) |
| Worker memory per isolate | 128 MB |
| DO storage per object | 10 GB |
| DO storage per account | 5 GB (Free) / unlimited (Paid) |
| DO objects (instances) per account | Unlimited, within an account or a class |
| DO classes (types) per account | 100 (Free) / 500 (Paid) |
| DO CPU per request | 30 s default, configurable to 5 min |
| DO soft request ceiling | ~1,000 requests/second per object |
| DO outgoing connections per request | 6 |
| WebSocket message received | 32 MiB max |
serializeAttachment per connection |
16,384 bytes |
| D1 free tier | 5M rows read/day, 100k rows written/day, 5 GB |
The class/object row is the one people misread, so read it twice. A
Durable Object class is a type — a blueprint, declared once in
cloudflare.config.ts with exports.durableObject({ storage: 'sqlite' }). A Durable Object object is a live instance of that class,
addressed by name. This entire plan defines exactly one class,
PartitionAuthority, and instantiates it once per partition key. Five hundred
players in five hundred different star systems is 500 objects of 1 class,
and Cloudflare states plainly that the number of objects is "unlimited (within
an account or of a given class)". The 500 ceiling would only bind if the design
grew 500 distinct kinds of authority, which would be a bizarre thing to want.
The bundle is 736.0 KB gzip measured 2026-08-25, so the 3 MB script limit is irrelevant — the bundle is an asset, not part of the script.
Decisions
H-1 · One Worker serves the client and the API
✅ Built. apps/server owns cloudflare.config.ts and the Worker entry
point. wrangler.config.ts beside it points assetsDirectory at
apps/game/dist. runWorkerFirst sends /api, /ws and /media/* to the
script; everything else is served as a static asset without invoking it.
The platform configuration is apps/server/cloudflare.config.ts, read by cf
(excerpt; the bindings and observability are in the file):
export default defineConfig({
worker: {
name: 'inertialrefd',
compatibilityDate: '2026-08-20',
entrypoint: 'src/index.ts',
// Version previews identify one build; workers.dev does not track production.
workersDev: false,
previewUrls: true,
assets: {
// Astro emits route HTML; an unknown address receives a real 404.
notFoundHandling: '404-page',
htmlHandling: 'drop-trailing-slash',
// `/api` is listed as well as `/api/*` because the glob does not match
// the bare path. Media names go through the R2 allow-list.
runWorkerFirst: ['/api', '/api/*', '/ws', '/media/*'],
},
// Custom domains, not routes: each provisions the DNS record and the
// certificate and points the hostname at this Worker.
domains: ['inertialref.app', 'inertialref.jonjaques.com'],
env: {
MEDIA: bindings.r2({ name: 'inertialrefd-storage' }),
CF_VERSION_METADATA: bindings.versionMetadata(),
ASSETS: bindings.assets(),
// …the guide's text, secret and rate-limit bindings
},
},
})cf bundles this project with Wrangler, so the bundler's half —
assetsDirectory, uploadSourceMaps, the dev inspector port and
types: { generate: false } — is apps/server/wrangler.config.ts
(defineWranglerConfig from wrangler/experimental-config). The toolchain is
cf 1.0.0-beta.5 with wrangler ^4.142.0 beside it as a devDependency of
apps/server; cf requires Wrangler ≥ 4.136.0.
main builds every API reference page as HTML. Review builds keep prose HTML
and a shared /docs/api loading shell, with exact _redirects proxies for
known API pages. The browser fetches their articles from doc-content; unknown
addresses still return 404. TypeDoc validation runs in both builds.
IR_PRERENDER_API=1 pnpm build verifies the full production output from another
branch. See the development guide and
ADR-0039.
The checked-in configuration binds no Durable Object and has no exports
block. The guide's original TourSession and TourAdmission classes were
created by the tour-v1 migration and deleted by tour-v2 once the browser
took over the conversation
(ADR-0042); the deployed Worker has
applied both, so there is no class lifecycle left to declare. Multiplayer
partition objects are the next exports.durableObject({ storage: 'sqlite' })
entry in cloudflare.config.ts, and they and the D1 bindings are future
milestones.
version_metadata was not in the original sketch and earns its place: the
health record reports the deployment's version id, so "am I talking to the
build I just shipped" is a question the client answers instead of a log dive.
H-2 · A Durable Object per partition key, with hibernating sockets
The DO name is the string partitionForAddress / partitionForPosition already
returns. This is the binding ADR-0008
anticipated, and it is a one-line mapping precisely because the seam was built
first:
const stub = env.PARTITION.getByName(partitionKey) // "s:SOL", "c:12,-3,7"Hibernation is not an optimization here, it is the cost model. With
ctx.acceptWebSocket() instead of ws.accept(), Cloudflare keeps the client
sockets attached to its network while evicting the object from memory, and
duration charges stop accruing. That is the property
sustainability already promised
in public, so it is a requirement rather than a tuning knob.
Be precise about what it buys, though, because it is easy to overclaim. Cloudflare's documentation is explicit that "events such as alarms, incoming requests, and scheduled callbacks prevent hibernation", and that an object is evicted only after receiving no events "for a short period" — the threshold is not published. A partition with players actively flying in it is receiving intent at 10–20 Hz and therefore never hibernates; it bills duration for every wall-clock second they are there. What hibernation actually buys is:
| State | Hibernating? | Duration cost |
|---|---|---|
| Nobody in the partition | evicted | none |
| Connected but idle — alt-tabbed, docked | yes | none |
| Connected and flying, 20 Hz intent | no | full |
And duration is per object, not per player — a partition with fifty people in it bills the same wall-clock second as a partition with one. That single fact is what makes tall cheap and wide expensive, and it is why H-6 exists.
The first row is the one that carries the promise, and it is the common case: most of the galaxy is empty at any moment. The second row is what makes a half-attentive player free instead of expensive. The third is the real bill, and it is priced in cost model rather than waved at.
Two consequences that shape the code:
- No instance fields survive hibernation. Per-connection state goes in
ws.serializeAttachment()(16 KiB), everything else inctx.storage.sql. A DO that keeps aMapof players in memory works perfectly in development and loses everyone the first time it sleeps. - Heartbeats must not wake it.
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"))answers keepalives in the runtime, and the docs are explicit that auto-responses accrue no wall-clock time and are not charged. - A socket outlives the token that opened it. Clerk's session tokens last a
minute. The upgrade carries one,
verifySessionadmits it, and itsexpiresAtgoes in the attachment; after that a fresh token rides on the client's first message past the deadline, and a lapsed socket is refused everything but a heartbeat and asked for a token before it is sent state. Nothing wakes the object to check, and each check is a signature against the key set its isolate holds (ADR-0048). Messages on a socket never reach the Worker'sfetch, so the Worker's rate limits see only the upgrade; the object keeps its own allowance per connection.
H-3 · Two databases, and the rule for choosing
There is exactly one question, and it is not "which is faster":
Does this write need an ordering guarantee relative to one partition's simulation, or a uniqueness guarantee across the whole galaxy?
| Data | Where | Because |
|---|---|---|
| Live entity states in a system | DO SQLite | Ordered against that partition's tick; meaningless anywhere else |
placed / destroyed / terrain mutations |
DO SQLite | Scoped to an address inside one system; the authority that validates them owns them |
| Which players are connected | DO SQLite | Same |
| First-discovery claims | D1 | "First in the galaxy" is a global uniqueness claim. One writer, one unique index. |
| Accounts, tokens | D1 | Cross-partition by definition |
| Catalog revisions | D1 | Read by every client, written by nobody at runtime |
| Almanac / bookmark sync | D1 | Per player, not per place |
Discovery credit is the interesting one because it is tempting to put it in the
DO that witnessed it. It cannot live there: two players in two different systems
can claim the same address if the catalog ever lets one system's contents be
referenced from another, and more importantly the query — "show me everything
I discovered first" — spans every partition a player has ever visited. In D1 it
is INSERT … ON CONFLICT DO NOTHING against a unique index on the address, which
is a genuine atomic first-write-wins because D1 has a single primary.
If claim write rate ever outgrows D1's 50M rows/month, the escape hatch is a
sharded arbiter DO keyed by an address prefix, mirroring into D1 for reads. That
is a change of implementation behind the same API, which is why the API should be
claimDiscovery(address) and not insertDiscoveryRow(...).
H-4 · The vendor stays in apps/, and a new package holds the port
✅ The port and the local implementation are built (H3). remote.ts and
channel.ts are not, and deliberately: they have no caller until there is a
socket, and shipping an implementation of a transport nothing transports over is
how a seam becomes fiction. What exists is below; what is missing is marked.
packages/* may not import a Cloudflare SDK, and pnpm graph now genuinely
enforces it by rejecting any third-party runtime dependency below apps/.
Nothing on this page changes that. The pattern is the one
packages/workers already uses for Web
Workers, and the analogy is exact:
| Web Workers (built ✅) | Networking (to build ⬜) |
|---|---|
WorkerInbound / WorkerOutbound in protocol |
ClientToServer / ServerToClient in protocol |
WorkerPort — post / subscribe / terminate |
ChannelPort — post / subscribe / close |
WorkerPool owns dispatch and cancellation |
RemoteAuthority owns the session and reconnect |
Browser passes a real Worker |
Browser passes a real WebSocket |
| Node tests pass an in-process fake | Node tests pass an in-process fake |
apps/game/src/engine/browserWorker.ts is the only place new Worker appears |
one file is the only place new WebSocket appears |
New package, mirroring the existing layout:
packages/net/ layer 5 — depends on shared, spatial, universe, simulation, protocol
authority.ts ✅ AuthorityPort, its messages, clientHello, partitionOfEntity
local.ts ✅ LocalAuthority — the single-player case, not a stub
remote.ts ⬜ RemoteAuthority over a ChannelPort
channel.ts ⬜ ChannelPort — the host boundaryLayer 5 is deliberate and slightly awkward: net cannot depend on
persistence, which is also layer 5, even though both encode entities. They do
not need to — the shared encoding is already in protocol (encodeFrameState,
SaveEntity), which sits at layer 4 under both. If you find yourself wanting
net to import persistence, the thing you actually want belongs in
protocol.
/* packages/net/src/authority.ts — sketch */
export interface AuthorityPort {
/** Enter a partition. Resolves once the authority has accepted the client. */
join(
partition: PartitionKey,
hello: ClientHello,
): Promise<Result<Joined, string>>
leave(): void
/** Control input and mutation proposals. Fire-and-forget; never awaited in the loop. */
submit(intent: Intent): void
/** Authoritative state for entities this client does not own. */
subscribe(handler: (update: AuthorityUpdate) => void): () => void
}LocalAuthority implements all four against the in-process World and is what
runs in solo offline — the normal case, not a
mock. Offline-first is the requirement, so the local implementation is the one
that must never be allowed to rot.
The mechanism that keeps it from rotting turned out to be simpler than a
discipline: openSession takes an AuthorityPort and defaults to a
LocalAuthority over its own world. There is no flag and no branch anywhere, so
the local path is what every host — the browser client, the headless runner, the
capability checks, every test — exercises by default.
Two things about the built version that the sketch did not anticipate, both worth keeping:
joinreturns aResult, and refusal is an answer rather than a failure.LocalAuthorityruns the sameincompatibility()check a remote one will, so the two cannot drift into applying different rules to the same question. It additionally refuses a hello carrying a different seed, because the seed is the universe — a position replicated between two of them refers to a planet only one of them has.status().partitionis recomputed, never remembered. A remembered one is correct until the first frame transition and quietly wrong afterward. Flying from Sol to Alpha Centauri moves the reported authority froms:SOLtos:HIP71683with nothing driving it, which makes the handoff question something you can watch on the overlay a milestone before it has to be answered.
H-5 · The wire protocol is versioned, and the handshake refuses a mismatch
modes states it as a design constraint: all clients in a
partition must run the same catalog version; it becomes a protocol handshake.
It is stronger than that. A client whose GENERATION_VERSIONS differ derives a
different universe — different planets, different terrain — so replicating a
position into it is meaningless.
The handshake therefore carries seed, galaxy, GENERATION_VERSIONS and a
NET_PROTOCOL_VERSION, and a mismatch is refused with a reason, not
best-effort accepted. This is the same rule the save loader already applies to a
newer schema (ADR-0007) and for the same reason:
silently proceeding loses data that looks like it was preserved.
Decoders go in packages/protocol/src/net.ts, built from the existing codec
combinators. The network is a trust boundary, so every inbound message is
decoded rather than cast — exactly as save files and worker messages already are.
🟡 Half built. net.ts holds NET_PROTOCOL_VERSION, the shared paths, the
ServerHealth record with its decoder, and incompatibility() — the rule that
compares two version manifests and returns a sentence or null. The healthcheck
already refuses on a mismatch, so the rule is exercised in production by every
client on every probe rather than waiting for a socket to be written against it.
One decision inside it is worth recording, because the tempting default is the wrong one: an algorithm present on one side and absent on the other is a mismatch, not a default. A generator the server runs and the client does not is a universe the client cannot derive, and the reverse is the same statement. Ignoring unknown keys would make the handshake pass in exactly the case it exists to catch.
H-7 · The front door is a public surface, not just a bundle
✅ Built. Everything a person or a machine meets before the WebGPU canvas does: the share card, the install manifest, the crawler files, the analytics gate, and the one asset the repository will not carry.
One canonical hostname. inertialref.app is what
<link rel="canonical"> names, what the sitemap lists, and the only host
src/analytics.ts will load a tag on. Every version preview URL is the same
deployment under a different name — useful for checking a build, and wrong to
count as visits or to let a crawler index as a duplicate site. The Worker's own
workers.dev route is off (workersDev: false in cloudflare.config.ts), so there
is no additional workers.dev address tracking production. Both custom
origins remain live so installed apps keep their storage; a preview URL names
one version.
Every public route arrives as HTML. Astro prerenders the React shell,
documentation body and navigation at build time. src/documentHead.ts renders
the title, description, canonical URL, robots policy, Open Graph, Twitter and
JSON-LD tags from src/site.ts and the article's title and lead. A scraper sees
the page it requested before JavaScript runs. pages/DocumentMeta.tsx uses the
same metadata resolver after React Router navigation.
@astrojs/sitemap enumerates the rendered routes, including every published
documentation page. robots.txt points at /sitemap-index.xml; account stubs,
settings and missing pages carry noindex and stay out of the sitemap.
Request-time rendering remains an adapter choice. The build uses Astro's static output. A route that needs request data can opt out of prerendering once an Astro server adapter and its Worker routing are configured. Public HTML and hashed game assets can keep their static delivery. There is no request-time renderer deployed by this configuration.
The simulation stays in the browser. Hydration starts the optional visuals; React Router owns subsequent navigation and the persistent runtime keeps its canvas. Astro does not run the engine or add work to the simulation loop.
Offline HTML is scoped to a route. The service worker fetches navigations from the network first and stores successful HTML by pathname. Camera and seed queries keep their meaning in the browser while sharing the route's document. An uncached article receives a clear offline response rather than home-page markup that cannot hydrate at that address. Installation precaches the home, solo flight, planetarium and cinema shells, plus the currently open documents. Hashed assets remain cache-first, and activation carries them forward before deleting an earlier build's cache.
The brand is generated. design/brand/brandmark.svg supplies the mark.
pnpm brand renders the favicon, the .ico, the apple-touch and PWA icons, the
maskable variant, the 1200×630 share card, the install manifest, robots.txt and
the <Logomark> module. pnpm brand:check also validates the shared head
renderer and the service worker's precache paths without requiring a build.
The card's background is the one artifact with a second source:
design/brand/og-plate.png, a frame of the real renderer — Earth's limb at
sunrise, with the flare the flight camera actually produces — captured once and
committed. The build composites the type over it with sharp and never touches
a GPU, and the picture cannot drift because it is a file in the tree rather
than a screenshot taken at build time. That was the objection to screenshots,
and it is an objection about the build; the drawing it replaced was six bezier
continents on a cyan disk.
Agents are welcome and are told so. robots.txt allows everything and
points at /llms.txt, which is the short prose version of what this project is
— written on the premise that a reader arriving at a WebGPU canvas with no
JavaScript has otherwise been handed nothing. The <noscript> block is the same
courtesy for a person.
H-8 · R2 holds what the repository will not carry
✅ Built. The cutscene is cut against a piece of music that is somebody
else's. Its use here is a fair-use claim this project is willing to make in a
deployment and not in a git history — which is permanent, mirrored by every fork
and indexed. So the track lives in r2://inertialrefd-storage/dropbox/ and
reaches the browser two ways, both of which start from one table in
apps/server/src/media.ts:
pnpm media:pull, run bypnpm buildwith--optional, copies it intoapps/game/public/media/(gitignored as a directory) so it ships in the bundle as an ordinary static asset. Free, never wakes the script, andRangeis the asset server's problem.- The
MEDIAbinding, when the bundle does not have it — a build that ran without R2 credentials, which is what a fork gets.
They are not two sources. It is one object under one key, reached by two transports, and the fallback is what makes a credential-less build a slower first byte rather than a missing feature.
runWorkerFirst covers /media/*. The script asks env.ASSETS first
and reaches R2 on a miss. The response is immutable, so a cache can reuse it
on later plays. Unlisted names return 404 before any bucket read.
A 404 or an HTML response is a miss. An absent asset can have a custom HTML error page or an empty body. Successful HTML cannot be media either, so the handler rejects it if a proxy or a stale response supplies it.
env.ASSETS does not serve ranges, which is the second reason the binding
is here rather than only the fallback one. Measured against a deployed review
app: Range: bytes=0-1023 on this file comes back 200 with all 2.7 MB. A
browser copes — it buffers the whole track and seeks locally — but the cutscene
overlay drives currentTime against a reference clock, so on a slow connection
every seek waits for a download a 206 would have made unnecessary. So when a
range is asked for and the asset store ignores it, R2 answers instead. That is
the one case where the second transport is not a fallback but the better path.
An allow-list, not a key prefix. inertialrefd-storage is the site's general
storage, not a public directory. Mapping /media/* onto a prefix would make
everything under it world-readable and would turn /media/../ into a bucket
read; mediaFor answers for exactly the names in the table.
Two things bit here and are worth reading before touching the handler.
R2Rangeis published as a union of three exclusive shapes, so the obvious implementation narrows with'suffix' in range. The object workerd actually hands over has all three keys present with two of themundefined, so that test is true for a range with no suffix and the arithmetic runssize - undefined. Everything came outNaN, the runtime quietly replacedContent-Lengthfrom the real body size, and the only visible symptom wasContent-Range: bytes NaN-NaN/2747091on a response whose bytes were correct. Narrow on the value.routes.test.tshas the regression.
stored.rangeis populated whether or not the request carried aRangeheader — an unranged get reports the whole object as its range — so keying the status off it answers every plain GET with206 Partial Content. Browsers mostly cope; caches are entitled not to.
Workers Builds needs R2 read on the CLOUDFLARE_API_TOKEN its build runs cf
with — scripts/media.mjs fetches through cf r2 objects get — for the
bundled copy to exist. If it does not have one, the deploy still succeeds, the
build log says so in one line, and the Worker serves the track from the bucket
instead — which is the whole point of having both.
The seams that already exist
Worth stating plainly, because most of this plan is wiring rather than invention:
| Seam | Status | Where |
|---|---|---|
| Partition keys as opaque strings | ✅ | packages/universe/src/partition.ts |
| Authority follows the frame chain, not the address | ✅ | devtools/inspect.ts via systemOfFrameId |
| Storage behind a port, with a memory implementation | ✅ | packages/persistence/src/store.ts |
| Host capabilities behind a port, with an in-process fake | ✅ | packages/workers/src/transport.ts |
| Versioned, validated, decoded-not-cast wire schemas | ✅ | packages/protocol |
| Replication set == save set | ✅ | SaveGame.entities + SaveGame.mutations |
No vendor SDK below apps/, mechanically enforced |
✅ | scripts/check-graph.mjs |
| Session assembled in exactly one place | ✅ | packages/devtools/src/session.ts |
| A versioned handshake that refuses a mismatch | ✅ | packages/protocol/src/net.ts |
AuthorityPort + LocalAuthority |
✅ | packages/net |
A session built around a port, with no if (online) |
✅ | openSession({ authority }) |
| An account verdict the server owns, not the browser | ✅ | GET /api/account, apps/server/src/account.ts — ADR-0048 |
| Input log for prediction and replay | ⬜ | roadmap |
One of those was a lie by coincidence, and H0 fixed it. inspect.ts
computed the authority key by scanning the frame chain for an s: prefix
itself rather than calling partitionForAddress, and the two agreed only
because the frame-id grammar and the partition-key grammar happen to spell a
system the same way. That was one rename away from a bug in which the debug
overlay and the router disagree about which Durable Object owns a ship — with
the overlay being the tool you would reach for to diagnose it.
The frame grammar's owner now supplies the inverse (systemOfFrameId in
packages/universe/src/frames.ts), the overlay composes it with
partitionForAddress, and partition.test.ts asserts the two agree rather than
asserting a literal — because a literal passes for both the right answer and
the coincidence.
What has to be built
apps/server/ ✅ the only place Cloudflare appears
cloudflare.config.ts ✅ the platform configuration `cf` reads
wrangler.config.ts ✅ the bundler's half: assets directory, dev inspector
tsconfig.json ✅ the fourth typecheck project
src/index.ts ✅ fetch handler: /api/health, /ws (501), assets
src/routes.ts ✅ pure routing, so it is testable in plain Node
.cloudflare/types/index.d.ts ✅ generated by `cf workers types`, committed
src/partition.ts ⬜ class PartitionAuthority extends DurableObject
src/api/ ⬜ discovery, catalog, sync
migrations/ ⬜ D1 schema, one file per change
packages/protocol/src/net.ts 🟡 paths, NET_PROTOCOL_VERSION, ServerHealth,
and the compatibility rule — no messages yet
apps/game/src/net/health.ts ✅ the only place the client fetches /api
apps/game/public/sw.js ✅ /api and /ws bypassed; see below
apps/game/src/net/socket.ts ⬜ the only `new WebSocket` in the client
packages/net/ ✅ layer 5, no vendor import
authority.ts ✅ the port, its messages, clientHello
local.ts ✅ LocalAuthority
remote.ts · channel.ts ⬜ H4, when there is something to transport
packages/devtools/src/session.ts ✅ accepts an AuthorityPort, defaults to local
packages/universe/src/partition.ts 🟡 gained `partitionForFrames`, now shared
by the overlay and the authorityThat last line is the only change H1 and H3 together forced below apps/, and
it is a deduplication rather than a feature: the overlay and the authority both
have to know which partition owns a ship, and two open-codings of the same
coincidence is precisely what H0 had just finished removing.
apps/server is an app, so it may depend on cf, wrangler,
@cloudflare/workers-types and cloudflare:workers. pnpm graph reads
packages/ only, which is correct and deliberate: the vendor is allowed at the
edge of the graph and nowhere else.
Naming
apps/server over apps/edge or apps/api, because the repo names apps for
what they are — game is the browser client, headless is the Node runner —
and this one serves. It is also the name a self-hoster would expect, and
sustainability commits to the
authority server being runnable by anyone.
The deployed Worker is inertialrefd, with the daemon suffix, so the
running service and the repository are never the same name in a sentence. The
directory keeps the plain name; only the deployment carries the d.
Things that will bite
Ordered by how expensive they are to discover late.
Service-worker storage and updates
Both production domains serve the same application and /sw.js directly.
inertialref.app is canonical. A redirect between hosts cannot move a service
worker, an installed app, preferences or IndexedDB saves; each origin keeps its
own browser storage. Existing installations can continue using the legacy host.
The service worker uses the following policy:
/api,/ws,/sw.js, source maps, range requests and explicitreloadorno-storerequests bypass Cache Storage.- Navigation tries the network and caches successful HTML by pathname. Network failures and server errors fall back to that route's document. A 404 remains a 404, and an uncached offline route returns 503 rather than another page's HTML.
/assets/*contains content-hashed scripts, fonts, catalogs, models and textures. These are cache-first. The current and previous build caches remain; requested assets from the previous build are copied into the current one. Older build caches are deleted. Unused files are not copied across every deploy./media/*uses cache-first within the current build. Its unhashed paths never inherit a previous build's contents. Range requests continue to use the network.- Other public files use stale-while-revalidate in the current build's cache.
The build id arrives on the registration URL and names inertialref-${build}.
Registration bypasses the HTTP script cache. Installation refreshes a short
route/icon list and open documents, then claims clients without forcing a reload.
The page reports loaded hashed assets from buffered resource timing, including
requests completing after claim. The worker warms these serially, so first-visit
scripts, fonts and models can enter Cache Storage without downloading every
available asset. An offline launch requires that this caching has finished;
unvisited modes and unloaded assets are not promised offline.
Only complete, successful, same-origin responses without private or no-store
are stored. HTML never occupies a data-file key. Storage denial and quota errors
leave online requests usable. Fetch events retain background work from dispatch
through the last cache write, including stale revalidation.
This policy manages downloaded files, not generated terrain or sky data.
Terrain and active sky caches live in the runtime and GPU. Completed sky cubes
also persist in the separate inertialref-galaxy-sky IndexedDB database, whose
archive policy retains two entries. Saves use the inertialref IndexedDB
database. Service-worker cleanup touches neither database. Cache
Storage can be evicted by the browser, and the two-build retention policy is not
a fixed byte quota. The previous cache protects assets used during a deployment;
it cannot provide an old tab's lazy chunk that neither the tab nor its worker
ever downloaded.
apps/game/src/net/serviceWorker.test.ts executes the shipped script against
storage and fetch adapters. Startup-resource and hosting tests cover the browser
handoff and both domain bindings.
An
api.subdomain would have avoided this for free, since the handler already returns early for cross-origin requests. It was not chosen because CORS preflights, a second deploy target and a second hostname for the socket are a permanent cost, and this is a three-line one. Recorded here so the trade-off is visible rather than implied.
Wall clock does not drive simulation ticks
It is already banned in canonical code by
ADR-0006 — generation derives from seeds and
simulation depends on the integer tick, and wall clock enters at exactly one
call, clock.plan. In a Worker it is also not what you think it is. From
Cloudflare's security model:
"the time value returned is not the current time. Date.now() returns the time
of the last I/O. It does not advance during code execution." That is a Spectre
mitigation, not a bug — a Worker is deliberately denied the ability to time its
own execution. Two reads with no await between them return the same value.
The guide uses wall clock in its host adapter for admission, operation expiry, and session leases. Those are application deadlines evaluated across I/O, not simulation time or a benchmark of CPU execution.
So a server-side authority cannot drive a tick from wall clock even if the rules
allowed it. It advances the same way the client does — from a fixed cadence — and
the cadence source is ctx.storage.setAlarm().
One alarm per simulation tick is not viable
The simulation runs at 64 Hz. An alarm per tick is 64 billable requests per second per partition and is nowhere near that punctual anyway. The shape that works, and the one the roadmap already anticipates:
| Rate | Who | What |
|---|---|---|
| 64 Hz | client | Full simulation, locally, exactly as it runs today |
| 10–20 Hz | client → DO | Batched intent: control input, not per-frame position |
| 5–10 Hz | DO → client | Authoritative snapshots of entities the client does not own |
| on demand | DO alarm | Persistence flush, presence timeout, partition teardown |
This is why replay recording is listed as
a multiplayer prerequisite: the input log (tick, entityId, controlInput) is
the intent stream, and building it is multiplayer work brought forward cheaply
rather than deferred.
Incoming WebSocket messages are billed, outgoing ones are not
Cloudflare counts HTTP requests, RPC sessions, WebSocket messages and alarm invocations as requests. Two details change the design:
- There is no charge for outgoing WebSocket messages. Broadcasting state to every connected client is free of request cost. Fan-out is cheap.
- Incoming messages are billed at a 20:1 ratio — 100 inbound messages bill as 5 requests.
So the cost driver is client→server message rate, and the mitigation is batching intent at a fixed cadence rather than sending on input change. At 20 Hz inbound with 10 players in a partition, that is 200 messages/s → 10 billable requests/s → ~26M billable requests/month for one continuously-busy system, which is about $4/month of request cost. Duration is the number to watch instead, and hibernation is what keeps it near zero.
A Durable Object is single-threaded, with a soft ceiling around 1,000 req/s
Per object. A partition is a star system, and a star system with a thousand
requests per second is a design problem long before it is an infrastructure one.
Worth knowing because it is the number that decides whether "partition by star
system" survives contact with a popular system — and
ADR-0008 is explicit that
PARTITION_ENTRY_RADIUS and the entry rule are guesses to be validated against
real latency and real player density, not decisions.
A Durable Object lives in one place, and never moves
Cloudflare creates an object in a data center near the first get() for that
name and states plainly that "Durable Objects do not currently change locations
after they are created". locationHint biases creation only, and is best-effort.
The consequence for a partition-per-star-system model is not obvious and is not mentioned in ADR-0008: the first player to enter Sol decides where Sol's authority lives, for everyone, forever. A player in Sydney joining a Sol object created in Frankfurt eats ~250 ms round trip to the authority.
For what H4 builds, that is genuinely fine — ships do not collide, there is no entity-to-entity physics, and a ghost 250 ms behind where it really is looks exactly like a ghost. It stops being fine the moment anything is contested, because then the same latency decides who shot first.
Two things follow, and neither is work for today:
- The partition key may eventually need a region component (
s:SOL@apac), which shards a busy system by locality and is a change topartitionForAddress— a package-level change, not an infrastructure one. Worth knowing before the key grammar hardens. - It is another reason the relay-only posture in H4 is not merely a stopgap. A DO's pinned location is a poor foundation for authoritative combat arbitration, and the design has already decided the client knows everything anyway (modes).
Three tsconfig projects become four ✅ done
pnpm typecheck runs four projects now, one per real environment, and
AGENTS.md explains why that is deliberate rather than
accidental. apps/server is the fourth.
One correction to the plan: @cloudflare/workers-types is not involved.
cf workers types (the package's types script) emits the runtime types and
an Env inferred from cloudflare.config.ts into a single
apps/server/.cloudflare/types/index.d.ts — about 600 KB of it — so the project
has types: [] and includes src and that file. It is committed while the rest
of .cloudflare/ (the Build Output a cf deploy stages) is ignored, and
.cloudflare/ is in .prettierignore: reformatting generated output produces a
diff nobody wrote.
Tests run in Node, on purpose, and DO tests cannot
vitest.config.ts registers no browser environment deliberately — that is the
check that the core stays free of DOM, React and WebGL. Durable Object tests need
@cloudflare/vitest-pool-workers, which runs inside workerd.
Do not change the existing project. Add a second Vitest project scoped to
apps/server/**, so pnpm test still proves the core is environment-free and
additionally proves the adapter works. runInDurableObject and
runDurableObjectAlarm let an alarm be triggered without waiting for one, which
is what makes hibernation and teardown testable at all. Note that the pool wants
vitest@^4.1.0 and the repo pins ^4.0.5 — that is a bump, in its own commit.
The dev loop is two servers, and that is the cheaper option
pnpm dev starts Astro on 5173 and cf dev on 8787. Astro's Vite server
proxies /api and /ws to the Worker. scripts/dev.mjs gives both processes a
shared lifetime; pnpm dev:client and pnpm dev:server run either half alone.
Astro owns the page build, while the React Compiler and Tailwind remain Vite
plugins inside that build.
pnpm preview is the production emulation. It builds and runs
cf dev against the resulting static assets. That exercises the real
runWorkerFirst, per-route HTML and 404 behavior, and registers the service
worker because the assets are a production build.
A property worth keeping rather than fixing: with the Worker not running,
the proxy fails and the client reports no server. The offline path is
therefore easy to exercise in development, which is the right way round for a
game whose normal case is solo offline — pnpm dev:client is now the way to get
it deliberately.
Cross-origin isolation is a door that is currently open
SharedArrayBuffer is listed as unstarted in the
roadmap and needs COOP/COEP headers. A Worker can
set them, but require-corp breaks every cross-origin resource that does not
opt in — which would include R2-hosted material sets unless they carry
Cross-Origin-Resource-Policy. Nothing needs it yet; the note exists so the
requirement is discovered before the material sets are, not after.
Milestones
Each one ends in something demonstrable. The point of the ordering is that
every component is stood up and testable before any of them is load-bearing,
which is the same discipline partitionForPosition already got: build the seam,
put it on the debug overlay, and look at it for a phase before trusting it.
| # | Milestone | Ends when |
|---|---|---|
| H0 ✅ | Fix the coincidence | inspect.ts calls partitionForAddress; a renamed frame grammar breaks a test rather than production |
| H1 ✅ | The client is on a URL | apps/server exists, serves apps/game/dist, prerendered route HTML and 404s work, service worker excludes /api and /ws, custom domain live, 12/12 capability checks pass in production |
| H2 🟡 | The API exists and is empty | /api/version returns seed, galaxy and GENERATION_VERSIONS; D1 bound with one migration; cf workers types output committed; fourth tsconfig project green |
| H3 ✅ | The port exists, still offline | packages/net with AuthorityPort + LocalAuthority; openSession takes one and defaults to local; no behavioral change, proven by an unchanged stateHash |
| H4 | The socket exists, carrying presence | One DO per partition with hibernating sockets; two browser tabs in Sol see each other's ship; closing one drops presence within the timeout; state survives an eviction |
| H5 | The first real mutation | A discovered claim written through the API, atomic in D1, visible to the other tab, and present in a save round trip |
H4 introduces remote presence; the current solo runtime uses local authority.
Current implementation. H0, H1 and H3 are complete. Both custom domains serve
the client, prerendered route HTML and 404s work, and the service worker excludes
the live API and socket paths. H2 is partial: generated Worker types and type
checks exist, and /api/health reports protocol, generation and catalog
identity. There is no /api/version endpoint or D1 binding. H4 presence and H5
discovery mutations remain unbuilt. Public metadata and R2 media delivery are
implemented separately, as described in H-7 and H-8 above.
The client shows the result in the telemetry tab under network, in five
states: checking, online, offline (the browser says there is no network),
no server (the request did not complete) and mismatch (something answered,
but not with a health record this build can use). mismatch is the one that
earns its place — it is H-5
arriving early, and it also catches a captive portal, which answers every
request with a cheerful 200 and is otherwise indistinguishable from a healthy
server.
H3 added an authority section above it, and they are deliberately separate
questions. You can be online and alone, which is the normal case and the one
H-6
exists to keep free. partition appears there and on the player, which is not
redundancy: one is the overlay's own derivation from the frame chain and the
other is what the authority believes, and the whole reason partitionForFrames
exists is that those two once agreed only by coincidence.
The smoke test that proves all four components at once
Worth designing before the code, because it is what makes the whole thing a capability check rather than a demo:
- Tab A and tab B both
ir.goTo('SOL'). Both reportauthority: s:SOLon the telemetry overlay. Proves partition routing agrees with the debug field. - Tab B's ship appears in tab A and moves. Proves the socket, the DO, the protocol and the replication set.
- Close tab B. Presence drops in tab A within the timeout. Proves the alarm.
- Leave both idle for longer than the hibernation threshold, then move. State is intact. Proves nothing important was living in an instance field.
pnpm sim --connect wss://…joins as a third client from Node. Proves the protocol has no browser dependency — the same claimapps/headlessmakes for the simulation core, made again for the network.
(5) is the one worth building deliberately. Node 26 has a global WebSocket, so
the headless runner can be the second player, which means a multiplayer
regression is reproducible in CI without a browser.
Environments, deployment and secrets
Workers Builds is the repo-connected deployment path. main is production;
every other branch deploys a Worker Preview. That removes the API token from
GitHub entirely, which is why it won out over a deploy workflow in Actions. Both
commands run cf in apps/server, so the Workers Builds configuration is:
| Setting | Command |
|---|---|
| Build command, both | pnpm install --frozen-lockfile && pnpm build |
| Deploy command, Production | pnpm --filter @inertialref/server run deploy |
| Preview command, Previews Base | pnpm --filter @inertialref/server run previews:deploy |
deploy is cf deploy --mode production, which stages its Build Output — the
Worker bundle and a copy of apps/game/dist — under the ignored
apps/server/.cloudflare/output/. The mode there is a statement, not the
switch: cloudflare.config.ts evaluates anything that is neither a preview nor
--mode development as production, so a deploy command that loses the flag
still ships production's variables. pnpm dev:server and pnpm preview pass
--mode development.
previews:deploy runs cf previews deploy, which evaluates the config with
isPreview and deploys a preview named for the branch, through
scripts/previews-deploy.mjs. The wrapper exists for the pull request's
preview link: Workers Builds reads what a deploy produced from an output file,
not the log, and cf previews deploy only prints its preview record, so the
wrapper writes that record where wrangler preview would. cf authenticates with
CLOUDFLARE_API_TOKEN in a build and with cf auth login on a developer's
machine.
Production and Previews Base each have their own secrets and build variables,
and nothing else: plain-text variables are declared in cloudflare.config.ts,
per environment, because cf has no keep_vars — a deploy replaces the
variables with the declared ones.
The production Worker is never changed from the dashboard — not a variable,
a secret, nor anything else. While the Worker's latest change came from the
dashboard, cf deploy compares the dashboard's configuration with
cloudflare.config.ts before it uploads. It compares in Wrangler's strict mode,
which cf deploy always uses and has no flag to relax, and in a build a
difference that would remove anything aborts the upload — "Aborting the upload
operation because of conflicts", with advice to drop a --strict that
cf deploy does not take. There always is such a difference: the dashboard
reports each custom domain's route with zone_name, enabled and
previews_enabled, and no local configuration has them. One dashboard change
therefore refuses every production build after it, however closely the rest
matches.
The comparison follows the newest version, not the live deployment. A
variable or a secret edited in the dashboard uploads a version without
deploying it, so cf workers deployments list can show cf's deployment
serving traffic while cf workers versions list --worker-id inertialrefd
shows the dashboard's version on top and every build is refused.
Redeploying the live version from the command line does not end it. The
deployment is recorded as cf's, and the Worker still counts as changed from
the dashboard: the next build is refused on the same diff. A deploy from a
terminal, pnpm run deploy:worker, runs the same comparison but asks instead
of aborting. It ships a local build, with whatever build variables that
machine holds rather than those of the Production build settings — and a
production build still reads apps/game/.env.local, so a machine set up for
pnpm dev ships the development instance's pk_test_ key to production. Set
the Production build settings' values in the environment, which wins over any
file, or PUBLIC_CLERK_PUBLISHABLE_KEY= to ship a client with no accounts.
A production build of main runs again without a push through
cf builds create <trigger> --body '{"branch":"main","commit_hash":"<sha>"}'.
The body is required though the command's schema omits it, and without one the
API answers 12002 Invalid request body.
cf builds triggers list --external-script-id <tag> names the trigger, and
cf workers get inertialrefd has the Worker's tag.
| Concern | Approach |
|---|---|
| Production | Push to main → cf deploy. One Worker, inertialrefd, on both inertialref.app and inertialref.jonjaques.com, with the former canonical — workersDev is false, so there is no additional workers.dev address tracking the tip. |
| Review apps | A branch push deploys a Worker Preview with its own URL and the Previews Base secrets and build variables: the development Clerk instance, and the guide and accounts switched on. That holds while the Worker implements no Durable Object; see below. |
| The gate | pnpm check stays in .github/workflows/check.yml. Cloudflare cannot see a GitHub status check, so branch protection on main is what actually prevents a red merge from deploying. |
| Build command | pnpm build — an optional R2 media pull, the documentation build, typecheck across five projects, then astro build into apps/game/dist, which is what assetsDirectory in wrangler.config.ts points at. pnpm docs:build stages apps/game/public/doc-content/, which is gitignored, so the deploy carries the documentation only because the build regenerates it. See H-8. |
| Node version | .node-version, read by Cloudflare's build image and by the Actions workflow, so the two cannot disagree about the runtime. |
| Build identity | WORKERS_CI_COMMIT_SHA and WORKERS_CI_BRANCH become __BUILD_ID__, so a review app's HUD names the branch it was built from. |
| Migrations | None declared: the deployed Worker's tour-v2 migration has deleted the guide's former classes. H4's PartitionAuthority is declared in cloudflare.config.ts with exports.durableObject({ storage: 'sqlite' }). D1 migrations remain future multiplayer work. |
| Secrets | Declared with bindings.secret(), never bindings.text(), and not Workers Builds' build variables — those exist only during the build. Production's values are set from the command line, never the dashboard (above): at a prompt with wrangler secret put <NAME> --name inertialrefd or cf workers secrets update <NAME> --worker inertialrefd --type secret_text, or uploaded with a version by cf deploy --secrets-file. Nothing in cloudflare.config.ts may be a credential; it is committed. The Worker's are OPENAI_API_KEY for the guide and CLERK_SECRET_KEY for accounts and the guide's grant — one name, set per environment: the production Clerk instance's key under Production, the development instance's under Previews Base. Every declared secret is required: an environment's upload fails while one is unset. pnpm dev:server reads them from the root .env.local. |
| Build variables | VITE_GA_MEASUREMENT_ID, set in Workers Builds. Not a secret — it ships in the bundle — but this repository is public, and an id committed in it is an id every fork measures into. A build run from a developer's machine reads the same name out of the gitignored apps/game/.env.production; a real environment variable wins over the file. apps/game/.env.example is the committed documentation. PUBLIC_CLERK_PUBLISHABLE_KEY is the same kind of variable, for the same reason, and set per environment: the production instance's pk_live_… in the Production build settings and the development instance's pk_test_… in Previews Base, matching each environment's CLERK_SECRET_KEY. Without a usable key the build has no accounts (ADR-0048). |
| Rollback | From the command line: cf workers deployments create with the previous version at 100% (cf workers deployments list names it), or wrangler rollback --name inertialrefd, never the dashboard (above). DO SQLite migrations are not rolled back by either; write them additively. |
| Manual deploy | pnpm run deploy:worker still works and is the escape hatch when CI is the thing that is broken. |
| Observability | Workers Logs and Workers Traces, unsampled and persisted. apps/server/src/log.ts writes one object per record in the scope / message / fields shape packages/shared uses, with the level as the console method; the guide section above says what is recorded and how to read it. |
Durable Objects require a different review environment
Cloudflare's preview URL documentation
states that Workers implementing Durable Objects do not receive version preview
URLs. The Worker implements none, so cf workers versions create produces a
preview URL. H4's PartitionAuthority ends that, and previewUrls: true
remaining in cloudflare.config.ts does not remove the platform limitation.
From then on, local review uses cf dev, which runs workerd and the Worker's
Durable Objects. A deployed test needs a separately configured staging Worker
and its own namespaces and secrets. That environment is an operational setup
step; the source configuration and a versions upload do not prove it exists.
Cost model
Restating sustainability with the 2026-08-20 numbers attached.
| Mode | What runs | Monthly cost shape |
|---|---|---|
| Solo offline | Nothing. Assets only. | $0. Static asset requests are free and unlimited. |
| Solo online | Worker + D1 | Workers Paid floor of $5. D1's free allowance (5M rows read/day) covers a long way. |
| Persistent universe | + one DO per occupied partition | Scales with concurrency, not with galaxy size. An empty partition bills nothing. |
The floor is the $5 Workers Paid plan and nothing else, and the free plan (100,000 DO requests/day, 13,000 GB-s/day, SQLite-backed DOs included) is enough to build and test all of H0–H5 without paying anything.
Two properties are worth protecting because they are what make the promise credible:
- No world state is stored or served. Storage cost does not grow with the size of the galaxy or the number of places visited. It grows only with mutations, which are deliberate player acts.
- Empty and idle are free. Most of the galaxy is empty at any moment, and hibernation extends "free" to cover connected-but-not-flying. Neither covers connected-and-flying — see below.
Both are consequences of decisions already made, not of anything on this page. The job here is to avoid spending them.
What "busy" means, precisely
"Busy" was doing too much work in an earlier draft of this page. The billable state has an exact definition and it is not "has players in it":
A partition is awake for any wall-clock second in which it has received an event — a message, an alarm, a request — recently enough not to have been evicted. Duration bills per awake object-second, at 128 MB.
The consequence that changes every number below: duration is charged per object, not per player. Cloudflare bills wall-clock time that is "shared across all requests active on an Object at once". A partition with fifty players in it costs exactly the same duration as a partition with one. Only requests scale with players, and only inbound ones, at 20:1.
So the cost of a persistent universe is not driven by how many players are online. It is driven by how spread out they are.
Tall and wide: 500 concurrent players, two ways
Both scenarios below assume 500 players online continuously for a 30-day month — which nobody ever is, so treat these as ceilings rather than forecasts — each sending intent at 10 Hz. The only thing that differs is their distribution.
Constants. An always-awake partition costs
2,592,000 s × 0.128 GB = 331,776 GB-s per month, or $4.15 at $12.50/M
GB-s. The Paid plan includes 400,000 GB-s, so the allowance covers roughly one
and a fifth always-awake partitions.
Tall — all 500 in one system
| Component | Working | Monthly |
|---|---|---|
| Awake objects | 1 | |
| Duration | 331,776 GB-s, inside the 400,000 allowance | $0 |
| Inbound requests | 500 × 10 Hz = 5,000 msg/s ÷ 20 = 250 billable/s → 648M/month | ~$97 |
| Outbound broadcast | Not billed | $0 |
| Total | $0.19 per player per month | ~$97 |
This case is throughput-bound, not cost-bound. 5,000 inbound messages/s is five times the ~1,000 requests/second soft ceiling for a single object, and the naive fan-out — every input echoed to every other player — is 2.5M outbound messages/s, which is free to bill and impossible to execute. The levers are aggregation (one snapshot per tick carrying every entity, not one message per input) and interest management (replicate only what is near you). With both, the honest expectation is order 100–200 concurrent players in one partition, and that number wants measuring at H4, not modeling here.
Wide — 500 players in 500 different systems
| Component | Working | Monthly |
|---|---|---|
| Awake objects | 500 | |
| Duration | 500 × 331,776 = 165.9M GB-s, less the allowance, at $12.50/M | ~$2,069 |
| Inbound requests | Identical total message rate — distribution does not change it | ~$97 |
| Total | $4.33 per player per month | ~$2,166 |
Same 500 players. Twenty-two times the cost, and every object is idling at 10 requests/second — 1% of what it could handle. Nothing is working hard; you are simply paying rent on 500 mostly-empty rooms.
That is the real ceiling, and it is the one worth designing away.
H-6 · An authority streams only when it has someone to replicate to
The wide case is expensive for a silly reason: a solo player in an empty system is paying for an authority that has nothing to tell them. There is no second client, so there is nothing to replicate — which is this page's one idea applied to itself.
The rule that follows:
A partition streams when it holds two or more players. With one, it tells the client so, the client falls back to
LocalAuthority, and the object hibernates behind a 30-second heartbeat.
A solo occupant then costs about what an empty partition costs — roughly 550
GB-s and 4,300 billable requests per month, which rounds to zero — and the wide
scenario above collapses from $2,166 to inside the free allowance. Mutation
writes do not need the socket either; they are an HTTP POST to the API, which
is what makes the fallback complete rather than degraded.
Two things recommend this beyond the money. It needs no new infrastructure — the
heartbeat is the hibernation mechanism working as designed. And it means the
single-player path is exercised continuously in production by every player who
is alone, which is the surest way to keep LocalAuthority from rotting into a
stub. Offline-first is the requirement, so the local
implementation is the one that must always work.
The realistic middle
Neither ceiling is a forecast. A plausible shape at 500 peak concurrent — 300 players alone in their own systems, 200 clustered into fifteen popular ones, under H-6:
| Component | Monthly |
|---|---|
| Duration — 15 awake objects | ~$57 |
| Requests — only the 200 clustered stream | ~$39 |
| Total, at sustained peak | ~$96 |
Real average concurrency is a fraction of peak, so the lived number is plausibly $25–40/month for a 500-player peak. That is a donations-scale bill, and it stays that way because of H-6 rather than by luck.
What to instrument at H4, since all six of these numbers are arithmetic rather than measurement: awake object-seconds, inbound messages per player per second, and the distribution of players across partitions. The third one is the cost driver and it is the one nobody would think to log.
Open questions
Named rather than answered, because guessing at them in a document is how a guess becomes a citation.
| Question | How it gets settled |
|---|---|
Is PARTITION_ENTRY_RADIUS (4e12 m) right? |
Measure at H4 with real latency and two real clients. ADR-0008 already calls it a guess. |
| What does handoff between partitions look like when a ship leaves Sol? | The frame-transition machinery is the natural home; it already does the local equivalent. |
| Where does the client's authoritative-vs-predicted split live? | Needs the input log first — replay. |
| Does the DO ever simulate, or only relay and persist? | The load-bearing one. H4 relays. Everything cheap about this plan depends on it staying that way — see below. |
| Does a busy system need a region-sharded partition key? | Only if contested gameplay arrives. Decide before the key grammar hardens. |
| How many concurrent players actually fit in one partition? | Measure at H4. The arithmetic says 100–200 against a ~1,000 req/s ceiling, and arithmetic is not a measurement. |
What happens to a client whose GENERATION_VERSIONS are older than the partition's? |
Refused with a reason, per H-5 — but "what the player sees" is a UX question, not solved. |
The one that decides whether any of this is cheap
Everything on this page is feasible at a hobby project's budget and a single maintainer's time because the server never simulates. A relay that holds entity states and arbitrates mutation writes is a well-understood, boring thing that Cloudflare's primitives fit exactly. An authoritative simulation server with client prediction, reconciliation, lag compensation and partition handoff of live authority is a different project, with a different cost curve and a different skill requirement.
The design has already decided in favor of the cheap one, and for a good reason rather than a budgetary one: modes observes that the universe is derivable, so a client knows everything anyway — there are no secrets to protect. What must be authoritative is mutation writes. That is a genuinely coherent position, not a compromise, and it is what makes a Durable Object the right tool instead of a load-bearing approximation of a game server.
If that decision ever reverses, this document does not survive it. Re-derive the hosting plan from scratch at that point rather than extending this one.
Related
- ADR-0008 — the partition topology this implements
- ADR-0007 — why the replication set and the save set are the same set
- modes — what solo offline, solo online and the persistent universe each owe the player
- sustainability — the promise this has to keep
- roadmap — the engineering gap list, unchanged by this page
- architecture — the layering that keeps the vendor at the edge