GameStack runs on Cloudflare Workers and Durable Objects. There are no servers to manage, no containers to orchestrate, no load balancers to configure. A single Durable Object is the game room — it holds the state, manages the WebSocket connections, runs the game logic, and stores the results.

This is a great architecture for multiplayer games, and it has some sharp edges.

Why Durable Objects

A multiplayer game room has a simple requirement: multiple clients need to share mutable state in real time. Every architecture for this involves some process that holds the authoritative game state. Traditionally that’s a game server — a Node.js process, a Go binary, an Elixir GenServer.

Durable Objects are Cloudflare’s version of this: a single-threaded JavaScript execution context with persistent storage and WebSocket support. Each game room gets its own DO instance, automatically placed close to the first player who creates it. The DO handles all WebSocket messages, runs the game logic, and persists state to its built-in key-value store.

The key advantages:

  • No server management. We don’t provision anything. Cloudflare creates and destroys DO instances on demand.
  • Automatic placement. The DO runs in the datacenter closest to the room creator, minimizing latency for the host.
  • Built-in persistence. Game state survives disconnections and hibernation. A player can close their browser and rejoin.
  • Scale to zero. When nobody’s playing, nothing’s running. We pay for compute only during active games.

What breaks

The free tier has limits. Durable Objects allow 100,000 storage operations per day across all DOs in the account. Each put, get, delete, and list counts. A busy game room doing rapid state updates can burn through thousands of operations per session.

We hit this wall during development. A single deploy-and-test cycle during heavy iteration could exhaust the daily quota by mid-afternoon. The error message is blunt: “Exceeded allowed rows written in Durable Objects free tier.”

The fix was discipline: test locally first, deploy once. We also reduced storage writes by batching state updates and only persisting at phase transitions rather than on every action.

The plugin isolation model

Every game in GameStack is a separate npm workspace package. At build time, the Worker bundles all game plugins into a registry. When a room is created for a specific game type, the DO loads the corresponding plugin and delegates all game logic to it.

This means the DO code itself is generic — it handles rooms, connections, and message routing. The game-specific logic lives entirely in the plugin. Adding a new game never touches the DO code.

The tradeoff is bundle size. With 30+ games, the Worker bundle grows. We use dynamic imports to lazy-load plugins, keeping the initial load fast and only pulling in the game code when a room of that type is created.

What we’d do differently

If we were starting over, we’d use the paid tier from day one. The free tier’s storage limits create artificial pressure to minimize writes, which fights against the natural pattern of “persist state on every meaningful change.” With the paid tier, the cost per million storage operations is low enough to not think about.

We’d also consider splitting the DO into two: one for the real-time WebSocket handling and game logic (which needs low latency), and one for analytics and history (which can be eventually consistent). Right now both responsibilities live in the same DO, and the analytics writes compete with game state writes for the daily quota.

But for a two-person lab shipping games for fun? The free tier and a single DO pattern gets you remarkably far.