CASE · 052025 — ONGOINGSOLOEDGE · MULTIPLAYER
◦ DEFORBIT · deforbit.com

Co-op arcade. One URL. Zero origin.

Browser 2D shooter with waves, bosses, campaign and a global leaderboard — fully on the Cloudflare edge. No download. No account. No ad-roll.

§ 01Problem · motivation

Why this exists.

Browser arcade without ad-rolls, without forced accounts, without a store detour.

Browser arcade shooters today are either ad-funded portal games (Poki, Crazy Games) with two-second ad-rolls before every run — or native mobile apps that demand a store detour and account signup.

DefOrbit delivers a full 2D arcade experience — waves, bosses, co-op, campaign progression, global leaderboard — straight from a single URL. No download, no ads, no forced account for the solo run. Target audience: casual players who want to drop into "break mode" on desktop or mobile.

§ 02Constraints · operating box

The box it had to fit in.

Free-tier, solo, zero-ops. The architecture had to bend to that.
C/01 · OPS
Solo dev, no team — everything deployable with one command, zero ops overhead.
C/02 · BUDGET
Cap: ~€0/month in normal operation. Must stay inside CF free tiers (Pages, Workers, D1, DO).
C/03 · BUILD
Zero-build frontend — static index.html + ESM from CDN. No Webpack, no Vite.
C/04 · REALTIME
60 Hz locally, co-op with <100 ms perceived lag — without a dedicated game server.
C/05 · MOBILE
Touch controls, 60 fps on iPhone Safari, PWA-installable, landscape + offline (leaderboard excluded).
C/06 · TRAFFIC
Spiky and mostly zero — rooms sit empty for minutes, occasional viral peaks. Scaling has to fall to zero.
C/07 · ANTI-CHEAT
Hobby-grade: has to cover console injections and replay — without a DRM server.
C/08 · AUTH
Solo without login. Campaign progression only with Google OAuth — no homegrown password stack.
§ 03Architecture · system topology

How it runs.

Zero-origin Cloudflare setup: Pages · Workers · D1 · Durable Objects — all behind one domain.
topology.live·ws ok·rtt
rooms 0·players 0·msgs/s 0
PAGES · 01
index.html · static
edge cache · 0 origin
cache hit99%
CLIENT · A · 02
Canvas · Three.js
ESM · bundler-free · 60fps
fps60
CLIENT · B · 03
Canvas · Three.js
co-op · same seed
fps60
PWA · 04
Service Worker
install · offline · landscape
scopedeforbit.com
SEED · 05
Mulberry32 RNG
SeededRNG.js · deterministic
scopeper-room
DO · 06
GameRoom · WS relay
stateful · hibernation
LOCKSTEP · 07
_inputDelay = 3
tick-aligned · 30s reconnect
inputs3-tick
CHECKSUM · 08
desync detection
score · enemies · earthHp
WORKER · 09
/api/* · scores · auth
JWT · rate-limit · campaign
req/s0
OAUTH · 10
Google · inline JWT
crypto.subtle · JWKS 1h
verifystateless
ANTI-CHEAT · 11
SHA-256 + SERVER_SALT
5m window · per-mode p99
nonceco-op skip
D1 · 12
SQLite · users · scores
campaign · earn_nonces
rows0
EVENT LOG · /api/* · DO messages · D1 writes
21:08:42doroom MX-92 · ws relay · 2 clients · tick 4812
21:08:41lockstepinput applied · _inputDelay=3 · seq 4815
21:08:39workerPOST /api/scores · jwt ok · 24ms
21:08:37anticheatsha256 ok · mode=solo · within p99 · accepted
21:08:34checksumdesync 0 · score 14820 · enemies 12 · earthHp 87
21:08:32clientA reconnect · 4.2s · resync from DO snapshot
21:08:29oauthjwks cache hit · google · sub:1108… · verified
21:08:26dohibernate · room MX-71 · idle 14m · state serialized
21:08:22d1campaign.purchase · WHERE currency>=cost · 1 row
21:08:42doroom MX-92 · ws relay · 2 clients · tick 4812
21:08:41lockstepinput applied · _inputDelay=3 · seq 4815
21:08:39workerPOST /api/scores · jwt ok · 24ms
21:08:37anticheatsha256 ok · mode=solo · within p99 · accepted
21:08:34checksumdesync 0 · score 14820 · enemies 12 · earthHp 87
21:08:32clientA reconnect · 4.2s · resync from DO snapshot
21:08:29oauthjwks cache hit · google · sub:1108… · verified
21:08:26dohibernate · room MX-71 · idle 14m · state serialized
21:08:22d1campaign.purchase · WHERE currency>=cost · 1 row
§ 04Decisions · trade-offs

Four deliberate choices.

Edge-native not as a buzzword, but as a cost decision.
D/01

Durable Object per room, instead of Redis + socket server.

chosen
One GameRoom DO instance per co-op room (room code as stable ID). WebSocket straight on the DO.
instead of
Redis Pub/Sub + a dedicated Node socket server on a VPS
reason
Zero-origin — one wrangler deploy. DO hibernation keeps idle rooms at ~0 cost. State (player list, seed, started flag, disconnect timer) lives in DO memory — no external KV. Auto-expire after 15 min idle.
D/02

Lockstep with seeded RNG, instead of an authoritative server.

chosen
The server (DO) only relays inputs + checksums. Both clients simulate in parallel using the same Mulberry32 seed (SeededRNG.js).
instead of
Authoritative server runs the simulation, clients only render
reason
Saves compute in the DO (free-tier friendly), still allows desync detection (periodic checksum comparison of score / enemies / earthHp) and a 30-second reconnect window.
D/03

Static frontend + Worker/D1, instead of SPA + backend.

chosen
index.html as the root of CF Pages. The Worker attaches only under /api/* on the same domain.
instead of
Next.js or SvelteKit with API routes
reason
No-build deployment, shared origin avoids CORS in production. D1 is plenty for scores / users / campaign (< 1 GB for the foreseeable future). One wrangler deploy + git push and everything is live.
D/04

Verify Google OAuth inline, instead of an OAuth library.

chosen
auth.js verifies Google JWTs manually via crypto.subtle.verify + a JWKS cache (1 h TTL).
instead of
Passport, NextAuth or a library from npm
reason
No npm dependencies in the Worker bundle (size limit). No session store needed — the client sends the token on every campaign call, the server validates statelessly.
§ 05Highlights · interesting bits

Things that were not obvious.

Network, cheating, rendering — the details that only became clear while building.

Reconnect logic with tick delay

H/01
The co-op network model uses _inputDelay = 3 ticks — no client is allowed to apply its own input instantly. It sends the input, both sides apply it three ticks later at the earliest.

That smooths the simulation and lets the DO hold the 30-second reconnect timer on a disconnect without kicking the partner out immediately. Only once the timer expires does partner-left fire.

Anti-cheat with mode scope

H/02
Score submit computes SHA-256(score:kills:time:...:mode + SERVER_SALT), timestamp window 5 min.

For campaign purchases: WHERE currency >= cost guard in the UPDATE to stop double-click / multi-tab races. earn_nonces prevents replay. Outlier detection (p99-based) is per-mode scoped and skipped entirely for co-op — two players naturally blow past the solo p99 and shouldn't get flagged for it.

Single draw call for 512 bullets

H/03
All up to 512 bullets across six weapons (pulse, scatter, lance, homing, plasma-burst, ricochet) are rendered in one InstancedBufferGeometry with a line primitive. Per-instance attributes (iOff, iRot, iOn) become rotated line-segment positions in the vertex shader. Off bullets get pushed out of clip space via z = -9999 rather than real culling.

Result: one draw call no matter how full the scene is — lets UnrealBloom + 4 shader passes still hit 60 fps on mobile (with a reduced bloom radius).

Scaling to zero

H/04
Durable Object hibernation is the silent hero of this architecture: an empty room costs nothing, a viral spike scales isolated per room (the DO instance is the scaling unit).

The DO decides via heartbeat when to hibernate — state gets serialized, the next message wakes it back up. No cron job needed to kill empty rooms. Auto-expire after 15 min idle is a pure safety net, not a cost mechanism.
§ 06Stack · in production

What's running.

Cloudflare end-to-end. No npm runtime dependencies in the Worker bundle.
Vanilla JS · ESMHTML5 CanvasThree.jsInstanced RenderingUnrealBloom PassCloudflare PagesCloudflare WorkersDurable ObjectsD1WebSocketsMulberry32 RNGLockstep SimGoogle OAuth (JWT inline)PWA · Service Workercrypto.subtle
§ 07Reflection · takeaways

What I learned.

Edge-native on a solo budget. What I'd plan differently next time.

Lockstep is cheaper than you'd think.

Deterministic simulation sounds like a lot of implementation work at first. In practice, seeded RNG + checksum comparison was less code than an authoritative server including interpolation would have been — and free-tier compatibility was a nice bonus on the side. For asymmetric gameplay modes the trade-off would look different.

Draw-call budget first, shaders after.

The instinct with bullet hell is to cut shader cost. Wrong front. 512 bullets × 512 draw calls kills mobile instantly; one draw call + expensive fragment shaders runs smooth. Next game I'll plan the instanced pipeline as the very first architectural element, not as a later optimization.

◦ NEXT CASE · 06 / 11
ToolPrime
← all projects