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) ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Deploy locally:
cd ui npm run dev # Starts Vite (8788) + Worker via cloudflare() pluginYour vite.config.ts has
cloudflare()plugin which auto-starts the worker. It also runs ComfyUI and RunPod viaprocessRunner()plugins (you can ignore those).Open test page:
Navigate to
http://localhost:8788/test-cursors.htmlClick "Connect"
Move your mouse around!
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
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 syntaxMake 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
/cursorspath in browser Network tabCheck 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 CursorTrackerMake 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 withnpm run devMulti-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
Add kid name labels:
Show text above each kid sprite
Use PixiJS Text
Smooth movement:
Add simple interpolation for remote kids
Lerp between old and new positions
Connection status:
Show "Connecting..." indicator
Show kid count
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:
Tag: One player is "it", touch to tag
Race: First to reach a marker wins
Collect: Spawn items, collect most to win
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:
Add client prediction (reduce input lag)
Add proper physics on server
Add persistence for reconnection
Add rooms/lobbies UI
Migrate to one-room-per-DO for scale
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


![[WIP] Digital Being - Texture v1](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F682665f051e3d254b7cd5062%2F0a0b4f8e-d369-4de0-8d46-ee0d7cc55db2.webp&w=3840&q=75)

