Couple months ago we have developed Quinn — a prompt-to-app application running natively inside Telegram (we also recently got into Telegram App Store! try it out). When we were collecting feedback from early users, it turned out that they wanted to be able to create multiplayer online games. It was kind of natural for them — “I created this game, I want to play it with friends, why can’t I?”

Well, Quinn had persistent storage as well as access to on-device storage, but our generated apps run entirely on client, so there was no easy way to connect users between each other (in fact there were no way to do that, but we were developing solution for cross-user storage at the time). So we have decided to make it possible.

Supabase

The first natural solution would be to go with Supabase. It’s pretty much what everyone is doing (Lovable, Bolt) and is a very popular solution nowadays, as depicted here:

However, the integration to our app wasn’t quite clear. There were basically 2 ways to do that:

  1. Let people bring their own Supabase key, connect to their database, and give Quinn access to it
  2. Create a multi-tenant database on Supabase with complicated RLS policies so that people don’t interfere with each other and it remains secure

Solution 1 is somewhat okay, but we didn’t want our approach to be: “Yes, so you should go to this other website, sign up there, and authorize our app to make changes on your behalf.” We don’t want people to leave the platform! The philosophy was to attract complete non-coders, so introducing the complexities that come with Supabase was a no-go. Everything can and should be done inside one chat, and we don’t want the most valuable part of the business — hosting — to be at someone else’s disposal. And it was boring.

Solution 2 with multi-tenancy was interesting, but we thought of it as too complicated. For that, one would basically have to create 3 separate tables: representing users, data, and access policies. Then we would have to write RLS policies:

  • RLS would parse out user_id (at least we are lucky we have it by design as we issue JWT ourselves every time you open generated app)
  • on each database read check if user has access to a given row
  • and only then get the key. Many joins and network requests would be required. For real-time functionality, there is also now Supabase Realtime.

Cloudflare

Another solution we considered and ultimately chose was Cloudflare Durable Objects. We already host everything on Cloudflare (I am a huge fan), and the whole Durable Object abstraction looked like exactly what we needed.

Brief note on what is Durable Object: Its a stateful “object” which is created on-demand, close to where the first request comes from. It is unique per-instance and keeps state per-instance as well.

Durable Object elegantly solves multi-tenancy — for each lobby we create a unique “server” which then becomes the authority for any content happening in the lobby. All clients playing a single game are connected to the same object via WebSocket.

I also recommend using PartySocket as a drop-in replacement for standard WebSocket. It brings no downsides but has nice additional benefits like auto-reconnect, timeout handling, and buffering!

There was one caveat with Durable Objects though — Durable Objects require a certain type set on deployment, and we didn’t want our bot to create a template Durable Object on the spot but rather launch an instance of it for that game lobby. This required creating an abstraction — a generic lobby abstraction. Here we wanted to laser-focus on one use case — games.

Luckily, we also had the privilege to choose the interface.

Claude Experience

The entity interacting the most with such an API would be Claude as it powers our app. Thus, the API should be Claude-centric instead of developer-centric.

So I naturally started chatting with Claude about what they thought should be present in such an API.

To avoid Claude being overly optimistic, I created a benchmark! The benchmark consisted of only 5 apps to create:

  • sample app showing names of participants — just to make sure it’s working
  • private chat app — to see if we can connect 2 people
  • public chat app — to check if we can connect more than 2 people
  • tic-tac-toe — how easy it is to build complicated logic on top
  • air hockey — test for latency

So there was an iterative process: we create an interface, make sure it works on one app, then we run all 5 to see how Claude is performing. Fortunately, after 3 iterations we converged, and Claude in Telegram one-shotted working apps with all 5 prompts.

What was interesting about the process is that the spec and the prompt for how to use the spec are developed together, so successfully doing one solves the other. So what Claude and I converged on:

Basically, what we ended up with is a Storage object (which we called KVStore — it’s a pun on both Quinn and Key-Value) that acts as a bridge between your app and Cloudflare. The whole object is built around WebSockets and it’s pretty much a wrapper that makes multiplayer just work. We expose 2 entities: kv and GameState. kv is being used to manage everything around lobby creation — it is intended for creating sessions, where is GameState is an object to share what is happening inside the single game. They could have been one, but this separation of concerns eased the code generation involving them for AI.

The architecture is simple: you have sessions (which are your game lobbies), and each session is just a WebSocket connection with metadata. When someone creates a session, they get a access code back. Others can join using that code. Once connected, everyone in the session can send messages to each other in real-time.

What’s cool about this abstraction is that it handles all the annoying stuff:

  • JWT auth (pulls it from sessionStorage automatically, users are authed via Telegram’s mechanism)
  • WebSocket lifecycle (connection, reconnection, cleanup)
  • Message routing between the parent window and the sandbox (we can safely intefere here as well if we need)
  • State synchronization across all connected clients (thanks CloudFlare 🙏)

The API comes with two abstractions to separate concerns:

The KV Store - Infrastructure

The kv object handles everything infra-related: creating lobbies, managing connections, and persistent storage:

// Create a lobby with optional custom code
const result = await kv.createLobby({ 
  shareCode: 'chess-game-123',  // optional custom code
  metadata: { gameType: 'chess', maxPlayers: 2 }
});

// Join someone else's lobby
await kv.joinSession(result.shareCode);

// Get lobby info and list active sessions
const info = await kv.getLobbyInfo(result.shareCode);
const lobbies = await kv.listActiveLobbies();

GameState - Application Layer

GameState is initialized of connected kv. It is used by our system to manage the game itself, not infrastructure around. The main feature of GameState is delta updates — we send partially filled GameState dicts from client and later their are merged inside Durable Object and broadcasted to all clients:

// Initialize with your game's state structure
const gameState = new GameState(
  { board: [], players: {}, turn: 'white' }, 
  kv
);

// Connect to a session
await gameState.connect(sessionCode);

// Make changes - they're automatically batched and synced
gameState.set('turn', 'black');
gameState.increment('moveCount', 1);
gameState.push('history', { from: 'e2', to: 'e4' });

// React to changes from other players
gameState.subscribe((state) => {
  updateUI(state);
});

This separation is intentional (well, its more like it is the first one which worked for Claude 3.5-new): GameState for the game logic and kv for lobby creation.

Permission Management

The system also includes a simple permission model for persistent data (this can be applied to lobbies as well, since they are managed by kv):

// Save private data (only you can access)
await kv.set('user/profile', { name: 'Alice' });

// Save public data (anyone can read)
await kv.set('leaderboard', scores, { 
  canReadPublic: true 
});

// Create shareable links with specific permissions
const readOnlyLink = await kv.createShare('game/saves', { 
  access: 'read-only',
  expiresInDays: 7 
});

const editableLink = await kv.createShare('shared/document', { 
  access: 'read-write',
  expiresInDays: 30 
});

// Others can redeem these codes to get access -- kv object with redeemed this way shareCode can be passed to GameState and one would have access
await kv.redeemShareCode(readOnlyLink);

4 supported permissions are: read-only (post something select people can read), read-write (full access to everyone), public-read (everyone can read) and owner-only (nobody can touch).

Permissions are enforced at the infrastructure level (in the DO actually) with JWT validation, so generated apps can’t accidentally expose data (well unless it was the developer intention).

We also share the prompt about multiplayer:

Show full prompt

MULTIPLAYER = """
<multiplayer-capabilities>
Telegram mini apps support real-time multiplayer experiences through the GameState API. This system enables synchronized state management across clients, allowing users to interact in shared sessions.

Core Components:
- window.kv: Lobby management and session creation
- window.GameState: Real-time state synchronization between clients

Setup and Initialization:
```
// Initialize with default state and kv reference
const gameState = new window.GameState(initialState, window.kv);

// Set up event listeners
gameState.on('connectionChange', handleConnectionStatus);
gameState.on('error', handleError);

// Subscribe to state updates
const unsubscribe = gameState.subscribe(handleStateUpdate);

// Connect to a session
await gameState.connect(sessionCode);
```

Cleanup is essential:
```
// In component unmount
if (gameStateRef.current) {
  gameStateRef.current.disconnect();
}
```
</multiplayer-capabilities>

<state-management>
Best Practices for State Management:

1. Always merge state updates instead of replacing:
```
// Good: Preserve existing data
if (state.players) {
  setPlayers({
    ...currentPlayers,
    ...state.players
  });
}

// Bad: Overwrites entire state object
setPlayers(state.players);
```

2. Refresh state before critical operations:
```
// Get latest state before making decisions
await gameStateRef.current.refreshState();
const state = gameStateRef.current.getState();
```

3. Use batch updates for related changes:
```
// Update multiple state properties atomically
gameStateRef.current.batchUpdate({
  currentPlayer: nextPlayer,
  board: newBoard,
  lastMoveTimestamp: Date.now()
});
```

4. Verify critical state changes after making them:
```
// Ensure assignment was successful
await gameStateRef.current.batchUpdate({ players: newPlayers });
await gameStateRef.current.refreshState();
const verifyState = gameStateRef.current.getState();
```
</state-management>

<session-management>
Creating and Joining Sessions:
1. Create a lobby with a share code:
```
await window.kv.createLobby({ 
  shareCode: gameCode,
  metadata: { gameType: 'my-game', createdBy: username }
});
```

2. Handle connection status changes:
```
gameState.on('connectionChange', (status) => {
  setConnectionStatus(status);
  if (status === 'disconnected') {
    // Show reconnection UI
  }
});
```

Player Assignment Strategy:
1. Check available roles before assignment:
```
// Refresh state first
await gameStateRef.current.refreshState();
const state = gameStateRef.current.getState();
const currentPlayers = state.players || { player1: null, player2: null };

// Assign only if role is available
if (!currentPlayers.player1) {
  currentPlayers.player1 = { id: userId, name: username };
}
```

2. Use the user's Telegram ID as unique identifier:
```
const userId = window.Telegram?.WebApp?.initDataUnsafe?.user?.id || 
              Math.floor(Math.random() * 10000);
```
</session-management>

<mini-app>
<title>TicTacToe Multiplayer</title>
<description>Simple multiplayer TicTacToe game demonstrating GameState usage</description>
<code>
import React, { useEffect, useState, useRef } from 'react';

const TicTacToe = () => {
  // Basic state
  const [gameCode, setGameCode] = useState('');
  const [username, setUsername] = useState('');
  const [gameStarted, setGameStarted] = useState(false);
  const [connectionStatus, setConnectionStatus] = useState('disconnected');
  const [error, setError] = useState('');
  const [isJoining, setIsJoining] = useState(false);

  // Game state
  const [board, setBoard] = useState([
    [null, null, null],
    [null, null, null],
    [null, null, null]
  ]);
  const [currentPlayer, setCurrentPlayer] = useState('X');
  const [players, setPlayers] = useState({ X: null, O: null });
  const [winner, setWinner] = useState(null);
  const [messages, setMessages] = useState([]);
  const [message, setMessage] = useState('');

  const gameStateRef = useRef(null);

  // Initialize Telegram WebApp
  useEffect(() => {
    if (window.Telegram?.WebApp) {
      const WebApp = window.Telegram.WebApp;
      WebApp.ready();
      WebApp.expand();

      // Try to get username from Telegram
      if (WebApp.initDataUnsafe?.user?.username) {
        setUsername(WebApp.initDataUnsafe.user.username);
      } else if (WebApp.initDataUnsafe?.user?.first_name) {
        setUsername(WebApp.initDataUnsafe.user.first_name);
      } else {
        setUsername(`Player${Math.floor(Math.random() * 1000)}`);
      }
    }

    // Cleanup
    return () => {
      if (gameStateRef.current) {
        try {
          gameStateRef.current.disconnect();
        } catch (err) {
          console.error("Error disconnecting:", err);
        }
      }
    };
  }, []);

  // Handle state updates
  const handleStateUpdate = (state) => {
    if (!state) return;

    // Board updates
    if (state.board) {
      setBoard(state.board);
    }

    // Current player updates
    if (state.currentPlayer !== undefined) {
      setCurrentPlayer(state.currentPlayer);
    }

    // Winner updates
    if (state.winner !== undefined) {
      setWinner(state.winner);
    }

    // Player updates - carefully merge to preserve assignments
    if (state.players) {
      const currentPlayers = players || { X: null, O: null };
      setPlayers({
        X: state.players.X || currentPlayers.X,
        O: state.players.O || currentPlayers.O
      });
    }

    // Message updates
    if (state.messages) {
      setMessages(state.messages);
    }
  };

  // Start or join a game
  const handleStartGame = async () => {
    if (!gameCode || !username) {
      setError('Please enter both game code and username');
      return;
    }

    setError('');
    setIsJoining(true);

    try {
      // Try to create a lobby (might already exist)
      try {
        await window.kv.createLobby({ 
          shareCode: gameCode,
          metadata: { gameType: 'tic-tac-toe', createdBy: username }
        });
      } catch (err) {
        console.log('Lobby might already exist, trying to join...');
      }

      // Initialize game state
      const initialState = {
        board: [
          [null, null, null],
          [null, null, null],
          [null, null, null]
        ],
        currentPlayer: 'X',
        winner: null,
        players: { X: null, O: null },
        messages: []
      };

      // Create new GameState instance
      const gameState = new window.GameState(initialState, window.kv);
      gameStateRef.current = gameState;

      // Set up event handlers
      gameState.on('connectionChange', (status) => {
        setConnectionStatus(status);
      });

      gameState.on('error', (error) => {
        setError(String(error));
      });

      // Subscribe to state changes
      gameState.subscribe(handleStateUpdate);

      // Connect to the session
      await gameState.connect(gameCode);

      // Add join message
      addSystemMessage(`${username} joined the game`);

      // Try to take a role
      const userId = window.Telegram?.WebApp?.initDataUnsafe?.user?.id || 
                     Math.floor(Math.random() * 10000);

      setTimeout(() => assignPlayerRole(userId), 1000);

      setGameStarted(true);
      setIsJoining(false);

    } catch (err) {
      setError(`Error joining game: ${String(err)}`);
      setIsJoining(false);
    }
  };

  // Assign player role (X or O)
  const assignPlayerRole = async (userId) => {
    if (!gameStateRef.current) return;

    try {
      // Get fresh state
      await gameStateRef.current.refreshState();
      const state = gameStateRef.current.getState();
      const currentPlayers = state.players || { X: null, O: null };

      // If already assigned, don't reassign
      if ((currentPlayers.X && currentPlayers.X.id === userId) || 
          (currentPlayers.O && currentPlayers.O.id === userId)) {
        return;
      }

      // Copy current players, preserving existing assignments
      const newPlayers = { ...currentPlayers };

      // Assign to X if available, otherwise O
      if (!newPlayers.X) {
        newPlayers.X = { id: userId, name: username };
        addSystemMessage(`${username} is playing as X`);
      } else if (!newPlayers.O) {
        newPlayers.O = { id: userId, name: username };
        addSystemMessage(`${username} is playing as O`);
      } else {
        // Both roles taken, user is spectator
        return;
      }

      // Update state with new players
      await gameStateRef.current.batchUpdate({ players: newPlayers });

      // Verify assignment worked
      await gameStateRef.current.refreshState();
    } catch (err) {
      console.error('Error assigning role:', err);
    }
  };

  // Add system message
  const addSystemMessage = (text) => {
    if (!gameStateRef.current) return;

    try {
      const newMessage = {
        id: Date.now().toString(),
        sender: 'System',
        text: text,
        isSystem: true,
        timestamp: new Date().toISOString()
      };

      const state = gameStateRef.current.getState() || {};
      const currentMessages = state.messages || [];

      gameStateRef.current.set('messages', [...currentMessages, newMessage]);
    } catch (err) {
      console.error('Error adding message:', err);
    }
  };

  // Send chat message
  const sendMessage = () => {
    if (!message.trim() || !gameStateRef.current) return;

    try {
      const newMessage = {
        id: Date.now().toString(),
        sender: username,
        text: message.trim(),
        isSystem: false,
        timestamp: new Date().toISOString()
      };

      const state = gameStateRef.current.getState() || {};
      const currentMessages = state.messages || [];

      gameStateRef.current.set('messages', [...currentMessages, newMessage]);
      setMessage('');
    } catch (err) {
      console.error('Error sending message:', err);
    }
  };

  // Handle cell click
  const handleCellClick = (row, col) => {
    if (!gameStateRef.current) return;

    try {
      // Check if cell is already filled or game is over
      if (board[row][col] || winner) return;

      // Check if it's the player's turn
      const userId = window.Telegram?.WebApp?.initDataUnsafe?.user?.id || 0;
      const playerSymbol = getPlayerSymbol(userId);
      if (playerSymbol !== currentPlayer) {
        addSystemMessage("It's not your turn");
        return;
      }

      // Make the move
      const newBoard = JSON.parse(JSON.stringify(board));
      newBoard[row][col] = playerSymbol;

      // Update state
      gameStateRef.current.batchUpdate({
        board: newBoard,
        currentPlayer: playerSymbol === 'X' ? 'O' : 'X'
      });

      // Add message about the move
      addSystemMessage(`${username} placed ${playerSymbol} at position [${row+1}, ${col+1}]`);

      // Check for winner
      const winResult = checkWinner(newBoard);
      if (winResult) {
        gameStateRef.current.set('winner', winResult);
        addSystemMessage(winResult === 'draw' 
          ? "Game over! It's a draw!" 
          : `Game over! ${winResult} wins!`);
      }
    } catch (err) {
      console.error('Error handling move:', err);
    }
  };

  // Get player symbol
  const getPlayerSymbol = (userId) => {
    if (players.X?.id === userId) return 'X';
    if (players.O?.id === userId) return 'O';
    return null;
  };

  // Check for winner
  const checkWinner = (board) => {
    // Check rows, columns, and diagonals
    // Implementation omitted for brevity
    return null; // Replace with actual implementation
  };

  return (
    <div className="tg-bg min-h-screen p-4">
      {!gameStarted ? (
        // Game setup UI
        <div>
          <h1 className="tg-section-header text-2xl font-bold mb-6 text-center">
            Tic Tac Toe
          </h1>

          {error && (
            <div className="tg-destructive p-2 rounded mb-4">
              {error}
            </div>
          )}

          <div className="mb-4">
            <label className="tg-hint block mb-1">Your Name</label>
            <input
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              className="tg-secondary-bg tg-text w-full p-2 rounded"
              placeholder="Enter your name"
              disabled={isJoining}
            />
          </div>

          <div className="mb-4">
            <label className="tg-hint block mb-1">Game Code</label>
            <input
              value={gameCode}
              onChange={(e) => setGameCode(e.target.value)}
              className="tg-secondary-bg tg-text w-full p-2 rounded"
              placeholder="Create or join with a code"
              disabled={isJoining}
            />
            <div className="tg-hint text-xs mt-1">
              Share this code with friends to play together
            </div>
          </div>

          <button
            onClick={handleStartGame}
            disabled={isJoining}
            className="tg-button w-full p-3 rounded font-medium"
          >
            {isJoining ? 'Connecting...' : 'Start Game'}
          </button>
        </div>
      ) : (
        // Game board UI - implementation omitted for brevity
        <div>
          {/* Game board implementation */}
          <div className="text-center tg-hint">
            {connectionStatus === 'connected' 
              ? `Connected to game: ${gameCode}`
              : 'Connecting...'}
          </div>
        </div>
      )}
    </div>
  );
};

export default TicTacToe;
</code>
</mini-app>

<multiplayer-user-experience>
Recommended UX Guidelines for Multiplayer Apps:

1. Game Creation/Joining:
   - Provide a simple "game code" input system
   - Auto-generate random codes for new games
   - Allow sharing game links directly via Telegram

2. Player Identification:
   - Use Telegram user data when available: 
     `window.Telegram?.WebApp?.initDataUnsafe?.user`
   - Fall back to manually entered names when needed
   - Display who's currently in the game/session

3. Real-time Feedback:
   - Show connection status clearly (connected/connecting/disconnected)
   - Indicate which player's turn it is
   - Use haptic feedback for gameplay actions

4. State Synchronization:
   - Implement optimistic UI updates
   - Show loading indicators during state updates
   - Handle offline modes gracefully

5. Chat Integration:
   - Provide in-game messaging for players
   - Include system messages for game events
   - Support both text and emoji communication

6. Error Handling:
   - Present connection issues in user-friendly terms
   - Offer reconnect options when disconnected
   - Implement error recovery strategies
</multiplayer-user-experience>

<common-pitfalls>
Common Multiplayer Implementation Issues:

Race Conditions:
- Always refresh state before critical operations
- Use atomic batched updates for related changes
- Implement conflict resolution strategies

Stale State:
- Don't assume local state is current
- Provide manual refresh options
- Check state after operations to confirm changes

Missing or Lost Updates:
- Merge rather than replace incoming state
- Handle partial updates properly
- Subscribe to state changes early in the component lifecycle

Player Role Conflicts:
- Implement verification after role assignment
- Build spectator mode for additional players
- Handle disconnects and reconnects gracefully

Excessive Updates:
- Batch related changes together
- Implement debounce for frequently changing values
- Only send necessary state changes

Cleanup Issues:
- Always disconnect from GameState on component unmount
- Remove all event listeners properly
- Handle browser refresh/navigation events
</common-pitfalls>
"""

The whole thing routes through a Cloudflare Worker (quinn-router) which handles the WebSocket upgrade and manages the Durable Object instances. Each session is its own Durable Object, which means:

  1. Automatic geographic distribution (players connect to the nearest edge)
  2. Built-in persistence
  3. Guaranteed single-threaded execution (no race conditions!)

Performance-wise, creating a new session is basically instantaneous since Durable Objects wake up in ~50ms, there is very little overhead on our side as well. Compare that to traditional game servers that need to spin up containers or VMs!

What I like about resulting architecture is that system generating code doesn’t need to know about any of the complexity beneath. It just calls simple methods and multiplayer games magically work. No server setup, no DevOps, no scaling concerns. Just createSession() and you’re live.

Side note: I was demoing Quinn at a meetup during a conference and was approached by folks who were developing a platform for creating games — it was an SDK that would allow you to create game servers on the fly. We chatted with them about possible usage, and I then asked what the SLAs were for their platform and how fast they create game servers. Their answer was around 1-2 seconds. I then created a multiplayer game to play with them on the spot. Luckily my system didn’t fail me and we played online air hockey with a 0.5-second boot time. I told them about Cloudflare and how it is the best company on earth.