Skip to main content

Command Palette

Search for a command to run...

SpriteDX - Playground - Multiplayer - POC - Implementation Plan

Updated
•18 min read
SpriteDX - Playground - Multiplayer - POC - Implementation Plan

Cloudflare Durable Objects - Simple Multi-Room Architecture

Goal: Build a playable multiplayer game in ~1 week that you can play with your kid Philosophy: Simple, pragmatic, event-driven, scale-to-zero Players: 4-8 per room No: Persistence, WebRTC, complex auth, cheat prevention


Phase 0: Practice POC - Shared Cursor Tracker (1-2 hours)

Goal: Validate the Durable Object + WebSocket stack works end-to-end with a fun, visual test that uses the exact patterns needed for the playground (position broadcasting, state per client, real-time updates).

0.1 Add Durable Object Binding

Update wrangler.jsonc (add to existing config):

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "sprite-dx",
  "compatibility_date": "2025-08-20",
  "assets": {
    "directory": "dist",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*", "/proxy/*"]
  },
  "main": "./worker/index.ts",
  "vars": {
    "RUNPOD_ENDPOINT": "https://api.runpod.ai/v2/bs3mmnuvhehfwd"
  },
  "observability": {
    "logs": {
      "enabled": true
    }
  },

  // ADD: Durable Objects for cursor test
  "durable_objects": {
    "bindings": [
      {
        "name": "CURSOR_TRACKER",
        "class_name": "CursorTracker",
        "script_name": "sprite-dx"
      }
    ]
  },

  // ADD: Migration for Durable Objects
  "migrations": [
    {
      "tag": "v1",
      "new_classes": ["CursorTracker"]
    }
  ]
}

0.2 Create Cursor Tracker Server

Create worker/CursorTracker.ts:

interface Cursor {
  id: string
  x: number
  y: number
  color: string
}

interface ClientMessage {
  type: 'move'
  x: number
  y: number
}

interface ServerMessage {
  type: 'welcome' | 'cursors' | 'cursor_left'
  myId?: string
  cursors?: Record<string, Cursor>
  cursorId?: string
}

export class CursorTracker {
  private state: DurableObjectState
  private cursors: Map<string, Cursor> = new Map()
  private sessions: Map<WebSocket, string> = new Map() // ws -> cursorId

  constructor(state: DurableObjectState) {
    this.state = state
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)

    // WebSocket upgrade
    if (request.headers.get('Upgrade') === 'websocket') {
      const pair = new WebSocketPair()
      const [client, server] = Object.values(pair)

      this.handleSession(server)

      return new Response(null, {
        status: 101,
        webSocket: client
      })
    }

    // Info endpoint
    if (url.pathname === '/info') {
      return Response.json({ 
        connections: this.sessions.size,
        cursors: this.cursors.size,
        message: 'Cursor tracker is running'
      })
    }

    return new Response('Cursor Tracker', { status: 200 })
  }

  private handleSession(ws: WebSocket) {
    ws.accept()

    // Generate unique ID and color for this cursor
    const cursorId = crypto.randomUUID()
    const color = this.randomColor()

    const cursor: Cursor = {
      id: cursorId,
      x: 0,
      y: 0,
      color
    }

    this.cursors.set(cursorId, cursor)
    this.sessions.set(ws, cursorId)

    // Send welcome with own ID and all existing cursors
    const welcome: ServerMessage = {
      type: 'welcome',
      myId: cursorId,
      cursors: Object.fromEntries(this.cursors)
    }
    ws.send(JSON.stringify(welcome))

    ws.addEventListener('message', (event) => {
      try {
        const msg: ClientMessage = JSON.parse(event.data as string)

        if (msg.type === 'move') {
          // Update cursor position
          cursor.x = msg.x
          cursor.y = msg.y

          // Broadcast to all other clients
          const update: ServerMessage = {
            type: 'cursors',
            cursors: Object.fromEntries(this.cursors)
          }

          for (const [session, id] of this.sessions) {
            if (id !== cursorId) {
              session.send(JSON.stringify(update))
            }
          }
        }
      } catch (err) {
        console.error('Message parse error:', err)
      }
    })

    ws.addEventListener('close', () => {
      this.cursors.delete(cursorId)
      this.sessions.delete(ws)

      // Notify others
      const leave: ServerMessage = {
        type: 'cursor_left',
        cursorId
      }

      for (const session of this.sessions.keys()) {
        session.send(JSON.stringify(leave))
      }

      console.log('Cursor left:', cursorId, 'remaining:', this.sessions.size)
    })

    ws.addEventListener('error', (event) => {
      console.error('WebSocket error:', event)
      this.cursors.delete(cursorId)
      this.sessions.delete(ws)
    })
  }

  private randomColor(): string {
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9']
    return colors[Math.floor(Math.random() * colors.length)]
  }
}

0.3 Update Worker to Route to Cursor Tracker

Update worker/index.ts (add to existing Hono app):

import { Hono } from 'hono'
// ... existing imports

// Export the Durable Object class
export { CursorTracker } from './CursorTracker'

type Bindings = {
  RUNPOD_ENDPOINT: string,
  RUNPOD_API_KEY: string,
  FAL_KEY: string,
  BFL_API_KEY: string,
  CURSOR_TRACKER: DurableObjectNamespace  // ADD THIS
}

const app = new Hono<{ Bindings: Bindings }>()

// ... existing routes (fal, bfl, etc.)

// ADD: Cursor tracker route
app.all('/cursors', async (c) => {
  const id = c.env.CURSOR_TRACKER.idFromName('global-cursor-room')
  const stub = c.env.CURSOR_TRACKER.get(id)
  return stub.fetch(c.req.raw)
})

export default app

0.4 Create Cursor Test Client

Create ui/test-cursors.html in the ui root (next to index.html, app.html, play.html):

<!DOCTYPE html>
<html>
<head>
  <title>Shared Cursor Test</title>
  <style>
    body {
      margin: 0;
      padding: 20px;
      font-family: system-ui;
      overflow: hidden;
      background: #1a1a1a;
      color: white;
    }

    #info {
      position: fixed;
      top: 10px;
      left: 10px;
      padding: 15px;
      background: rgba(0, 0, 0, 0.8);
      border-radius: 8px;
      z-index: 1000;
    }

    button {
      margin: 5px;
      padding: 8px 16px;
      background: #4ECDC4;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }

    button:hover {
      background: #45B7D1;
    }

    .cursor {
      position: absolute;
      width: 16px;
      height: 16px;
      border-radius: 50%;
      pointer-events: none;
      transform: translate(-50%, -50%);
      transition: all 0.1s ease;
      box-shadow: 0 2px 8px rgba(0,0,0,0.3);
    }

    .cursor::after {
      content: '';
      position: absolute;
      width: 4px;
      height: 4px;
      background: white;
      border-radius: 50%;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }

    #status {
      margin-top: 10px;
      color: #4ECDC4;
    }
  </style>
</head>
<body>
  <div id="info">
    <h2>šŸ–±ļø Shared Cursor Test</h2>
    <button id="connect">Connect</button>
    <button id="disconnect">Disconnect</button>
    <div id="status">Not connected</div>
    <div id="count">Cursors: 0</div>
  </div>

  <script>
    let ws = null
    let myId = null
    const cursors = new Map() // cursorId -> div element
    const status = document.getElementById('status')
    const count = document.getElementById('count')

    function updateStatus(msg, color = '#4ECDC4') {
      status.textContent = msg
      status.style.color = color
    }

    function updateCount() {
      count.textContent = `Cursors: ${cursors.size + (myId ? 1 : 0)}`
    }

    document.getElementById('connect').addEventListener('click', () => {
      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
      const wsUrl = `${protocol}//${window.location.host}/cursors`

      updateStatus('Connecting...', '#FFA07A')
      ws = new WebSocket(wsUrl)

      ws.addEventListener('open', () => {
        updateStatus('Connected! Move your mouse', '#4ECDC4')
      })

      ws.addEventListener('message', (event) => {
        const msg = JSON.parse(event.data)

        if (msg.type === 'welcome') {
          myId = msg.myId
          console.log('My cursor ID:', myId)

          // Render existing cursors
          for (const [id, cursor] of Object.entries(msg.cursors)) {
            if (id !== myId) {
              createOrUpdateCursor(cursor)
            }
          }
          updateCount()
        }

        if (msg.type === 'cursors') {
          for (const [id, cursor] of Object.entries(msg.cursors)) {
            if (id !== myId) {
              createOrUpdateCursor(cursor)
            }
          }
          updateCount()
        }

        if (msg.type === 'cursor_left') {
          const cursorDiv = cursors.get(msg.cursorId)
          if (cursorDiv) {
            cursorDiv.remove()
            cursors.delete(msg.cursorId)
          }
          updateCount()
        }
      })

      ws.addEventListener('close', () => {
        updateStatus('Disconnected', '#FF6B6B')
        ws = null
        myId = null
        // Remove all cursors
        for (const div of cursors.values()) {
          div.remove()
        }
        cursors.clear()
        updateCount()
      })

      ws.addEventListener('error', (err) => {
        console.error('WebSocket error:', err)
        updateStatus('Connection error', '#FF6B6B')
      })
    })

    document.getElementById('disconnect').addEventListener('click', () => {
      if (ws) {
        ws.close()
      }
    })

    // Send mouse position
    document.addEventListener('mousemove', (e) => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({
          type: 'move',
          x: e.clientX,
          y: e.clientY
        }))
      }
    })

    function createOrUpdateCursor(cursor) {
      let div = cursors.get(cursor.id)

      if (!div) {
        div = document.createElement('div')
        div.className = 'cursor'
        document.body.appendChild(div)
        cursors.set(cursor.id, div)
      }

      div.style.left = cursor.x + 'px'
      div.style.top = cursor.y + 'px'
      div.style.backgroundColor = cursor.color
    }
  </script>
</body>
</html>

0.5 Test End-to-End

What you'll see:

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  šŸ–±ļø  Shared Cursor Test                                │
│  [Connect] [Disconnect]                                 │
│  Connected! Move your mouse                             │
│  Cursors: 3                                             │
│                                                         │
│                    šŸ”“ (cursor 1)                        │
│                                                         │
│         šŸ”µ (cursor 2)                                   │
│                                                         │
│                             🟢 (cursor 3)               │
│                                                         │
│  (Your cursor is invisible to you, but others see it)  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
  1. Deploy locally:

     cd ui
     npm run dev  # Starts Vite (8788) + Worker via cloudflare() plugin
    

    Your vite.config.ts has cloudflare() plugin which auto-starts the worker. It also runs ComfyUI and RunPod via processRunner() plugins (you can ignore those).

  2. Open test page:

    • Navigate to http://localhost:8788/test-cursors.html

    • Click "Connect"

    • Move your mouse around!

  3. Validate (the fun part!):

    • āœ… Connection establishes (see "Connected! Move your mouse")

    • āœ… Move your mouse - no visible cursor yet (it's your local cursor)

    • āœ… Open second browser tab (or use your phone!), navigate to same URL

    • āœ… Click "Connect" in second tab

    • āœ… Move mouse in either tab - you should see a colored cursor dot appear in the OTHER tab!

    • āœ… Open 3rd, 4th tabs - see multiple colored cursors moving

    • āœ… Close a tab - its cursor disappears from others

    • āœ… "Cursors: N" count updates correctly

  4. Check Durable Object info:

     curl http://localhost:8788/cursors/info
     # Should show: {"connections": 2, "cursors": 2, "message": "Cursor tracker is running"}
    

0.6 Success Criteria

Before proceeding to Phase 1:

  • [ ] WebSocket connection establishes successfully

  • [ ] Mouse movements send position updates

  • [ ] Position updates broadcast to other connected clients

  • [ ] Multiple cursors visible simultaneously (different colors)

  • [ ] Cursor positions update smoothly in real-time

  • [ ] Disconnection removes cursor from other clients

  • [ ] Durable Object tracks state correctly

  • [ ] No console errors in browser or worker logs

  • [ ] Bonus: Show your kid and watch them be amazed! šŸŽ‰

0.7 Troubleshooting

"Durable Object not found":

  • The cloudflare() plugin should handle this, but if issues persist, check wrangler.jsonc syntax

  • Make sure CursorTracker is exported in worker/index.ts

WebSocket upgrade fails:

  • Check that routing in worker/index.ts is correct (app.all('/cursors', ...))

  • Verify request reaches /cursors path in browser Network tab

  • Check terminal logs from Vite/Wrangler for errors

Cursors not appearing:

  • Check browser console for WebSocket messages (should see JSON)

  • Verify ws.accept() is called in CursorTracker

  • Make sure you're moving mouse in one tab while watching another

  • Check that cursor divs are being created (inspect DOM)

Cursors jumping/lagging:

  • Normal for localhost - position updates are throttled by JavaScript

  • This validates the infrastructure works; real playground will be smoother

Port conflicts:

  • Vite is configured to use port 8788 (not default 5173)

  • If port is taken, check server.port in vite.config.ts

If everything works: Remove cursor tracker code (or keep for fun demos) and proceed to Phase 1!


Phase 1: Foundation (Day 1-2)

1.1 Project Setup

Important: Your vite.config.ts already has:

  • cloudflare() plugin - auto-starts worker with npm run dev

  • Multi-page build config (index.html, app.html, play.html)

  • Port 8788 for dev server

No changes needed to vite.config.ts or package.json!

Extend Existing Worker Structure:

/ui/
ā”œā”€ā”€ wrangler.jsonc          # Existing - add DO bindings
ā”œā”€ā”€ worker/
│   ā”œā”€ā”€ index.ts            # Existing Hono app - add routes
│   ā”œā”€ā”€ PlaygroundServer.ts # NEW: Durable Object (multi-room host)
│   └── types.ts            # NEW: Shared types
ā”œā”€ā”€ vite.config.ts          # Already has cloudflare() plugin
└── package.json            # Already has all dependencies

Note: All infrastructure already exists (Hono, Cloudflare plugin, Wrangler). Just adding multiplayer routes and Durable Objects.

Update wrangler.jsonc (replace ECHO_SERVER with PLAYGROUND_SERVER):

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "sprite-dx",
  "compatibility_date": "2025-08-20",
  "assets": {
    "directory": "dist",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*", "/proxy/*"]
  },
  "main": "./worker/index.ts",
  "vars": {
    "RUNPOD_ENDPOINT": "https://api.runpod.ai/v2/bs3mmnuvhehfwd"
  },
  "observability": {
    "logs": {
      "enabled": true
    }
  },

  // ADD: Durable Objects for multiplayer (replace echo server)
  "durable_objects": {
    "bindings": [
      {
        "name": "PLAYGROUND_SERVER",
        "class_name": "PlaygroundServer",
        "script_name": "sprite-dx"
      }
    ]
  },

  // UPDATE: Migration tag
  "migrations": [
    {
      "tag": "v2",
      "new_classes": ["PlaygroundServer"],
      "deleted_classes": ["CursorTracker"]
    }
  ]
}

1.2 Core Types

Create worker/types.ts:

// Basic types for POC
export interface Kid {
  id: string
  name: string
  x: number
  y: number
  animation: 'idle' | 'run' | 'jump'
  facing: 'left' | 'right'
  connectedAt: number
}

export interface RoomState {
  id: string
  kids: Map<string, Kid>
  createdAt: number
  lastActiveAt: number
}

export interface ClientMessage {
  type: 'join' | 'input' | 'leave'
  kidId?: string
  kidName?: string
  input?: {
    dx: number
    dy: number
    jump: boolean
    sprint: boolean
  }
}

export interface ServerMessage {
  type: 'welcome' | 'state' | 'kid_joined' | 'kid_left' | 'error'
  roomId?: string
  kidId?: string
  kids?: Record<string, Kid>
  kid?: Kid
  message?: string
}

1.3 Worker Entry Point (Extend Existing Hono App)

worker/index.ts (add to existing file):

import { Hono } from 'hono'
// ... existing imports

// Export the Durable Object class
export { PlaygroundServer } from './PlaygroundServer'

// ADD: Update Bindings type for Durable Objects
type Bindings = {
  RUNPOD_ENDPOINT: string,
  RUNPOD_API_KEY: string,
  FAL_KEY: string,
  BFL_API_KEY: string,
  PLAYGROUND_SERVER: DurableObjectNamespace  // ADD THIS
}

const app = new Hono<{ Bindings: Bindings }>()

// ... existing routes (fal, bfl, etc.)

// ADD: Helper function for region-based routing
function getPlaygroundServerId(env: Bindings, region: string) {
  // Simple: use region as name for consistent DO per region
  return env.PLAYGROUND_SERVER.idFromName(region)
}

// ADD: Matchmaking endpoint
app.post('/api/matchmake', async (c) => {
  const region = c.req.raw.cf?.colo || 'DEFAULT'
  const serverId = getPlaygroundServerId(c.env, region)
  const stub = c.env.PLAYGROUND_SERVER.get(serverId)

  // Ask PlaygroundServer for available room
  const response = await stub.fetch('http://internal/allocate-room')
  const { roomId } = await response.json() as { roomId: string }

  return c.json({
    roomId,
    wsUrl: `/connect?roomId=${roomId}`
  })
})

// ADD: WebSocket connection endpoint
app.all('/connect', async (c) => {
  const roomId = c.req.query('roomId')

  if (!roomId) {
    return c.text('Missing roomId', 400)
  }

  // Get PlaygroundServer for this region
  const region = c.req.raw.cf?.colo || 'DEFAULT'
  const serverId = getPlaygroundServerId(c.env, region)
  const stub = c.env.PLAYGROUND_SERVER.get(serverId)

  // Forward WebSocket to DO
  return stub.fetch(c.req.raw)
})

// Keep existing export
export default app

That's it! The existing worker structure already has Hono set up, so you're just adding routes.


Phase 2: PlaygroundServer Durable Object (Day 2-3)

2.1 PlaygroundServer Structure

PlaygroundServer.ts:

import { RoomState, Kid, ClientMessage, ServerMessage } from './types'

const MAX_KIDS_PER_ROOM = 8
const ROOM_CLEANUP_INTERVAL = 30000 // 30 seconds

export class PlaygroundServer {
  private state: DurableObjectState
  private rooms: Map<string, RoomState> = new Map()
  private sessions: Map<WebSocket, { roomId: string, kidId: string }> = new Map()
  private cleanupTimer?: number

  constructor(state: DurableObjectState) {
    this.state = state
    this.startCleanupTimer()
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)

    // Room allocation endpoint
    if (url.pathname === '/allocate-room') {
      const room = this.allocateRoom()
      return Response.json({ roomId: room.id })
    }

    // WebSocket connection
    if (request.headers.get('Upgrade') === 'websocket') {
      return this.handleWebSocket(request)
    }

    // Room info endpoint
    if (url.pathname === '/rooms') {
      const roomsInfo = Array.from(this.rooms.values()).map(r => ({
        id: r.id,
        kids: r.kids.size,
        lastActive: r.lastActiveAt
      }))
      return Response.json({ rooms: roomsInfo })
    }

    return new Response('Not found', { status: 404 })
  }

  private allocateRoom(): RoomState {
    // Find existing room with space
    for (const room of this.rooms.values()) {
      if (room.kids.size < MAX_KIDS_PER_ROOM) {
        return room
      }
    }

    // Create new room
    const roomId = crypto.randomUUID()
    const room: RoomState = {
      id: roomId,
      kids: new Map(),
      createdAt: Date.now(),
      lastActiveAt: Date.now()
    }
    this.rooms.set(roomId, room)
    return room
  }

  private async handleWebSocket(request: Request): Promise<Response> {
    const url = new URL(request.url)
    const roomId = url.searchParams.get('roomId')

    if (!roomId || !this.rooms.has(roomId)) {
      return new Response('Invalid room', { status: 400 })
    }

    const pair = new WebSocketPair()
    const [client, server] = Object.values(pair)

    server.accept()
    this.handleSession(server, roomId)

    return new Response(null, {
      status: 101,
      webSocket: client
    })
  }

  private handleSession(ws: WebSocket, roomId: string) {
    const room = this.rooms.get(roomId)!
    let kidId: string | null = null

    ws.addEventListener('message', (event) => {
      try {
        const msg: ClientMessage = JSON.parse(event.data as string)

        if (msg.type === 'join') {
          kidId = crypto.randomUUID()
          const kid: Kid = {
            id: kidId,
            name: msg.kidName || 'Kid',
            x: 400,
            y: 300,
            animation: 'idle',
            facing: 'right',
            connectedAt: Date.now()
          }

          room.kids.set(kidId, kid)
          this.sessions.set(ws, { roomId, kidId })
          room.lastActiveAt = Date.now()

          // Send welcome
          this.send(ws, {
            type: 'welcome',
            roomId,
            kidId,
            kids: Object.fromEntries(room.kids)
          })

          // Broadcast join to others
          this.broadcast(roomId, {
            type: 'kid_joined',
            kid
          }, kidId)
        }

        if (msg.type === 'input' && kidId) {
          this.handleInput(roomId, kidId, msg.input!)
        }
      } catch (err) {
        console.error('Message error:', err)
      }
    })

    ws.addEventListener('close', () => {
      if (kidId) {
        room.kids.delete(kidId)
        this.sessions.delete(ws)

        // Broadcast leave
        this.broadcast(roomId, {
          type: 'kid_left',
          kidId
        })

        // Cleanup empty room
        if (room.kids.size === 0) {
          this.rooms.delete(roomId)
        }
      }
    })
  }

  private handleInput(roomId: string, kidId: string, input: any) {
    const room = this.rooms.get(roomId)
    const kid = room?.kids.get(kidId)
    if (!kid) return

    // Simple movement - no physics, just update position
    const speed = input.sprint ? 12 : 8
    kid.x += input.dx * speed
    kid.y += input.dy * speed

    // Update animation state
    if (input.dy !== 0 || input.dx !== 0) {
      kid.animation = 'run'
    } else {
      kid.animation = 'idle'
    }

    if (input.dx !== 0) {
      kid.facing = input.dx > 0 ? 'right' : 'left'
    }

    room.lastActiveAt = Date.now()

    // Broadcast state to all kids
    this.broadcast(roomId, {
      type: 'state',
      kids: Object.fromEntries(room.kids)
    })
  }

  private send(ws: WebSocket, msg: ServerMessage) {
    ws.send(JSON.stringify(msg))
  }

  private broadcast(roomId: string, msg: ServerMessage, excludeKidId?: string) {
    for (const [ws, session] of this.sessions.entries()) {
      if (session.roomId === roomId && session.kidId !== excludeKidId) {
        this.send(ws, msg)
      }
    }
  }

  private startCleanupTimer() {
    this.cleanupTimer = setInterval(() => {
      const now = Date.now()
      for (const [roomId, room] of this.rooms.entries()) {
        // Remove inactive empty rooms
        if (room.kids.size === 0 && now - room.lastActiveAt > 60000) {
          this.rooms.delete(roomId)
        }
      }
    }, ROOM_CLEANUP_INTERVAL) as any
  }
}

Phase 3: Client Integration (Day 3-4)

3.1 Client File Structure

/ui/src/
ā”œā”€ā”€ network/
│   ā”œā”€ā”€ PlaygroundClient.ts # WebSocket client
│   └── types.ts            # Shared types (copy from worker)
└── playground.tsx          # Main playground (modify existing)

3.2 PlaygroundClient

network/PlaygroundClient.ts:

import { ServerMessage, ClientMessage, Kid } from './types'

type MessageHandler = (msg: ServerMessage) => void

export class PlaygroundClient {
  private ws: WebSocket | null = null
  private handlers: Map<string, MessageHandler[]> = new Map()
  private reconnecting = false

  async connect(wsUrl: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const baseUrl = window.location.origin.replace('http', 'ws')
      this.ws = new WebSocket(`${baseUrl}${wsUrl}`)

      this.ws.onopen = () => {
        console.log('Connected to playground server')
        resolve()
      }

      this.ws.onerror = (err) => {
        console.error('WebSocket error:', err)
        reject(err)
      }

      this.ws.onmessage = (event) => {
        try {
          const msg: ServerMessage = JSON.parse(event.data)
          this.emit(msg.type, msg)
        } catch (err) {
          console.error('Failed to parse message:', err)
        }
      }

      this.ws.onclose = () => {
        console.log('Disconnected from playground server')
        this.emit('disconnected', { type: 'error', message: 'Disconnected' })
      }
    })
  }

  on(event: string, handler: MessageHandler) {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, [])
    }
    this.handlers.get(event)!.push(handler)
  }

  private emit(event: string, msg: ServerMessage) {
    const handlers = this.handlers.get(event) || []
    handlers.forEach(h => h(msg))
  }

  send(msg: ClientMessage) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(msg))
    }
  }

  sendInput(dx: number, dy: number, jump: boolean, sprint: boolean) {
    this.send({
      type: 'input',
      input: { dx, dy, jump, sprint }
    })
  }

  disconnect() {
    this.ws?.close()
  }
}

3.3 Integrate with Playground

Modify playground.tsx:

Add multiplayer state and connection logic:

// Add to imports
import { PlaygroundClient } from './network/PlaygroundClient'
import { Kid } from './network/types'

// Add state for remote kids
const [remoteKids, setRemoteKids] = useState<Map<string, Kid>>(new Map())
const [client] = useState(() => new PlaygroundClient())
const [myKidId, setMyKidId] = useState<string>()

// Connect to server on mount
useEffect(() => {
  async function connectToServer() {
    try {
      // Get room from matchmaking
      const response = await fetch('/matchmake', { method: 'POST' })
      const { roomId, wsUrl } = await response.json()

      console.log('Joining room:', roomId)

      // Connect WebSocket
      await client.connect(wsUrl)

      // Join room
      client.send({
        type: 'join',
        kidName: 'Kid' + Math.floor(Math.random() * 1000)
      })

      // Handle messages
      client.on('welcome', (msg) => {
        setMyKidId(msg.kidId)
        setRemoteKids(new Map(Object.entries(msg.kids!)))
      })

      client.on('state', (msg) => {
        setRemoteKids(new Map(Object.entries(msg.kids!)))
      })

      client.on('kid_joined', (msg) => {
        console.log('Kid joined:', msg.kid)
      })

      client.on('kid_left', (msg) => {
        console.log('Kid left:', msg.kidId)
      })

    } catch (err) {
      console.error('Failed to connect:', err)
    }
  }

  connectToServer()

  return () => {
    client.disconnect()
  }
}, [])

// In loop, send inputs
const { dx, dy } = keyboard.getMovementVector()
const jump = keyboard.isJumpPressed()
const sprint = keyboard.isSprinting()
client.sendInput(dx, dy, jump, sprint)

// Render remote kids
remoteKids.forEach((kid, id) => {
  if (id !== myKidId) {
    // Create sprite for remote kid
    // Update position to kid.x, kid.y
    // Set animation to kid.animation
  }
})

Phase 4: Remote Kid Rendering (Day 4-5)

What you'll see:

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Multiplayer Playground                                 │
│                                                         │
│         šŸ§ Kid123                                       │
│        (remote)                                         │
│                                                         │
│                     šŸ§ You                              │
│                    (local)                              │
│                                                         │
│  šŸ§ Kid456                                              │
│ (remote)                                                │
│                                                         │
│                                    šŸ§ Kid789            │
│                                   (remote)              │
│                                                         │
│  Players: 4 | Latency: 65ms                             │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

All kids move in real-time. Your keyboard controls your kid, others see your movement synchronized.

4.1 Remote Kid Manager

Add to playground.tsx:

const remoteSpriteRefs = useRef<Map<string, AnimatedSprite>>(new Map())

// In useEffect with app initialization
function updateRemoteKids(kids: Map<string, Kid>) {
  for (const [id, kid] of kids.entries()) {
    if (id === myKidId) continue // Skip local kid

    let sprite = remoteSpriteRefs.current.get(id)

    // Create sprite if doesn't exist
    if (!sprite) {
      sprite = createAnimatedSprite(idleFrames) // Use same sprites
      sprite.anchor.set(0.5)
      app.stage.addChild(sprite)
      remoteSpriteRefs.current.set(id, sprite)
    }

    // Update sprite
    sprite.x = kid.x
    sprite.y = kid.y
    sprite.scale.x = kid.facing === 'left' ? -1 : 1

    // Update animation (simplified - just show idle/run)
    if (kid.animation !== 'idle') {
      sprite.textures = runFrames.map(f => f.texture)
    } else {
      sprite.textures = idleFrames.map(f => f.texture)
    }
    sprite.play()
  }

  // Remove sprites for disconnected kids
  for (const [id, sprite] of remoteSpriteRefs.current.entries()) {
    if (!kids.has(id)) {
      app.stage.removeChild(sprite)
      sprite.destroy()
      remoteSpriteRefs.current.delete(id)
    }
  }
}

// Call when state updates
client.on('state', (msg) => {
  const kids = new Map(Object.entries(msg.kids!))
  updateRemoteKids(kids)
})

Phase 5: Testing & Polish (Day 5-6)

5.1 Local Testing

# Start worker with Durable Objects locally
cd ui
npx wrangler dev

# In another terminal, start UI dev server
cd ui
npm run dev

# Open multiple browser tabs to test multiplayer
# Note: Local DO persistence may reset between restarts

5.2 Basic Improvements

  1. Add kid name labels:

    • Show text above each kid sprite

    • Use PixiJS Text

  2. Smooth movement:

    • Add simple interpolation for remote kids

    • Lerp between old and new positions

  3. Connection status:

    • Show "Connecting..." indicator

    • Show kid count

  4. Error handling:

    • Reconnect on disconnect

    • Show error messages

5.3 Deploy to Cloudflare

cd ui
npx wrangler deploy

# The multiplayer routes will be available at:
# https://your-worker.workers.dev/matchmake
# wss://your-worker.workers.dev/connect

Phase 6: Play with Your Kid! (Day 6-7)

What you'll see (example: Tag game):

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  šŸŽ® Multiplayer Playground - Tag!                       │
│                                                         │
│    šŸƒ Dad (YOU)                                         │
│      šŸ‘ˆ Running left                                     │
│                                                         │
│                        šŸ§ Kid1                          │
│                       (idle)                            │
│                                                         │
│  šŸƒā€ā™€ļø Your Kid                                            │
│   šŸ‘‰ "IT!" - Running right                              │
│                                                         │
│              šŸ§ Kid2                                     │
│             (idle)                                      │
│                                                         │
│  Your Kid is IT! Run away! šŸƒšŸ’Ø                          │
│  Players: 4 | Latency: 58ms                             │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Everyone sees the same game state. Actions happen in real-time. Perfect for simple games like tag, race, or follow-the-leader!

6.1 Final Checklist

  • [ ] Multiple kids can join same room

  • [ ] Kids see each other moving

  • [ ] Movement is responsive (< 100ms latency)

  • [ ] Kids can jump and it syncs

  • [ ] Disconnection doesn't crash

  • [ ] Works on multiple devices

6.2 Simple Game Ideas

Since you have multiplayer working, add simple gameplay:

  1. Tag: One player is "it", touch to tag

  2. Race: First to reach a marker wins

  3. Collect: Spawn items, collect most to win

  4. Follow leader: Simon says style


Configuration Reference

Worker Limits (POC)

const CONFIG = {
  MAX_KIDS_PER_ROOM: 8,
  MAX_ROOMS_PER_DO: 50, // Soft limit
  ROOM_CLEANUP_INTERVAL: 30000, // 30s
  KID_TIMEOUT: 60000, // 1 min
  UPDATE_THROTTLE: 50, // 50ms min between broadcasts
}

Performance Expectations

  • Latency: 50-150ms (region dependent)

  • Kids: 4-8 per room comfortable

  • Rooms per DO: 20-50 before creating new DO

  • CPU: Event-driven, very low when idle

  • Memory: ~100KB per kid, ~5MB per room


What This POC Doesn't Do

Intentionally omitted to ship fast:

  • āœ— Fixed-tick game loop

  • āœ— Client-side prediction

  • āœ— Server reconciliation

  • āœ— Physics engine

  • āœ— Collision detection

  • āœ— Persistence/reconnection

  • āœ— Authentication

  • āœ— Anti-cheat

  • āœ— Voice chat

  • āœ— Global matchmaking

  • āœ— Spectator mode

You have a playable multiplayer game in a week. That's the win!


Next Steps After POC

If the POC is fun and you want to continue:

  1. Add client prediction (reduce input lag)

  2. Add proper physics on server

  3. Add persistence for reconnection

  4. Add rooms/lobbies UI

  5. Migrate to one-room-per-DO for scale

  6. Add proper matchmaking

But first: play with your kid and validate the fun! šŸŽ®


End of POC Implementation Plan

Ready to ship in ~1 week of focused work