← Back to all posts

Going Multiplayer: Real-Time 1v1 with Durable Objects

The Biggest Engineering Challenge

The daily game was working great, but I kept thinking about multiplayer. What if two players could compete head-to-head in real time, racing to guess the same product's price? This feature would transform PriceGuess from a solo daily ritual into something you actively play with friends. It was also, by far, the most complex thing I'd ever built.

Why WebSockets Over Polling

In a 1v1 game, latency matters. If Player A submits a guess, Player B needs to see that update instantly. HTTP polling introduces delays and wastes bandwidth. WebSockets give you a persistent, bidirectional connection where messages arrive in milliseconds. For a real-time competitive game, there's really no alternative.

Why Durable Objects

Here's the problem with serverless: traditional Workers are stateless. Each request might hit a different instance with no shared memory. But a game room needs state -- who's in it, whose turn it is, what guesses have been made. Cloudflare's Durable Objects solve this perfectly. A Durable Object is a single instance with persistent state and the ability to handle WebSocket connections. Each game room is one Durable Object.

// workers/1v1/src/GameRoom.ts
export class GameRoom extends DurableObject {
  players: Map<string, WebSocket> = new Map();
  gameState: GameState | null = null;

  async fetch(request: Request) {
    if (request.headers.get('Upgrade') === 'websocket') {
      const pair = new WebSocketPair();
      const [client, server] = Object.values(pair);

      this.ctx.acceptWebSocket(server);
      this.handleConnection(server);

      return new Response(null, {
        status: 101,
        webSocket: client,
      });
    }
    // Handle HTTP requests for room info
    return new Response(JSON.stringify(this.gameState));
  }
}

The Room System

Players can create or join rooms. When you create a room, you get a 6-character code to share with a friend. When they enter the code, they join the same Durable Object instance. The flow is straightforward:

  1. Player A creates a room, gets code "ABC123"
  2. Player A's WebSocket connects to the Durable Object for that room
  3. Player B enters "ABC123", their WebSocket connects to the same DO
  4. The DO detects two players, starts the game, broadcasts the product
  5. Each guess is received by the DO, evaluated, and broadcast to both players
PriceGuess 1v1 screen in Hebrew with two options: play with a friend via a shared room link, or play a random opponent
The 1v1 entry screen: create a room to share with a friend, or get matched with a random opponent.

Game Variants

I implemented several game modes to keep things interesting:

Each variant uses the same underlying WebSocket protocol but with different game state machines. The DO keeps track of rounds, scores, and determines when the match is over.

The Architecture Challenge

Here's something that tripped me up for days: Cloudflare Pages cannot host Durable Objects. DOs must live in a separate Worker. So the architecture became:

// WebSocket message protocol
interface WSMessage {
  type: 'join' | 'guess' | 'result' | 'opponent_guess'
    | 'game_start' | 'game_end' | 'rematch';
  payload: {
    playerId?: string;
    guess?: number;
    evaluation?: GuessEvaluation;
    product?: Product;
    winner?: string;
    scores?: Record<string, number>;
  };
}

Matchmaking for Random Opponents

Beyond private rooms, I wanted players to find random opponents. The matchmaking system works by maintaining a queue of waiting players. When you click "Find Opponent," you join the queue. If someone's already waiting, the system creates a room and connects you both. If not, you wait until someone else joins. The queue itself is a separate Durable Object that acts as a coordinator.

// Matchmaking queue in a separate DO
async handleMatchRequest(ws: WebSocket, playerId: string) {
  if (this.waitingPlayer) {
    // Match found! Create a room
    const roomId = generateRoomId();
    const roomStub = this.env.GAME_ROOM.get(
      this.env.GAME_ROOM.idFromName(roomId)
    );
    this.broadcast(this.waitingPlayer.ws, {
      type: 'match_found', payload: { roomId }
    });
    this.broadcast(ws, {
      type: 'match_found', payload: { roomId }
    });
    this.waitingPlayer = null;
  } else {
    this.waitingPlayer = { ws, playerId };
  }
}

The "Aha" Moment

I'll never forget the first time multiplayer actually worked. I had two browser windows open side by side. I guessed a price in one window, and the other window instantly showed my opponent's guess appear. The WebSocket messages were flying back and forth in under 50 milliseconds. I sat there for a solid minute just sending guesses back and forth, watching the real-time updates. It felt like magic.

One important gotcha on Cloudflare's free plan: you must use new_sqlite_classes instead of new_classes in the wrangler config for Durable Objects. The paid plan uses new_classes, but the free plan requires the SQLite-backed variant. This took me an embarrassingly long time to figure out.

With multiplayer working, the game felt complete. But to make competitive play meaningful, I needed authentication and a ranking system. That's what I tackled next.