Files
Aurora release bot 8a5971c97d feat(playground): nameplate portrait chips + CF Worker WS hub + online HUD
- Replace floating PNG portraits with portrait-chip nameplates above heads
  (NPCs, player, bots, remote players). Voxel body becomes the character;
  PNG identifies them at a glance without the chimera-y face hover.
- Add Cloudflare Workers + Durable Objects port of scripts/playground-ws.mjs
  in playground-ws-worker/ — same wire protocol so client connects unchanged.
  Includes /stats endpoint with online + byWorld + peakToday.
- Add PlaygroundOnlineChip HUD component that polls /stats and renders a
  live 'N agents online' badge. Hidden when VITE_PLAYGROUND_STATS_URL unset.
- Drop unused Billboard / useTexture imports from world-3d.

Build: pnpm build clean. No new deps. Worker deps install separately.
2026-05-03 08:45:46 -04:00

3.3 KiB
Raw Permalink Blame History

Hermes Playground multiplayer hub (Cloudflare Worker)

Drop-in port of scripts/playground-ws.mjs to Cloudflare Workers + Durable Objects. Free tier covers a hackathon and well beyond. Zero cold starts. Edge-deployed.

Why CF + DO over Fly.io / Render / Railway

  • Free tier: 100k req/day on Workers, 1M+ DO req/mo. WebSocket connections count per-message, not per-connection — a presence broadcast every 200 ms across 20 players is well under the limit.
  • Zero cold starts (vs Fly.io free tier which idles VMs after inactivity, and Render free which has a 30-50s cold start that kills demos).
  • One Durable Object instance is the canonical "lobby/room" pattern — strong consistency, no Redis needed.
  • Globally edge-deployed: a player in Tokyo and a player in NYC both connect to the closest edge, then route to the single DO holding game state.

Files

  • src/worker.ts — entry + PlaygroundHub Durable Object class
  • wrangler.toml — DO binding + migration
  • package.json / tsconfig.json — build deps

Endpoints

  • GET /playground — WebSocket upgrade (presence + chat fan-out, mirrors the Node sidecar protocol)
  • GET /stats — JSON { online, byWorld, peakToday, peakDay, ts } for the HUD badge
  • GET /health — JSON { ok: true, online, ts }

Deploy (~10 min, requires Eric's CF account)

cd playground-ws-worker
pnpm install                 # installs wrangler
pnpm wrangler login          # one-time browser auth
pnpm deploy                  # publishes to <name>.<your-subdomain>.workers.dev

Then in workspace .env.production:

VITE_PLAYGROUND_WS_URL=wss://hermes-playground-ws.<your-subdomain>.workers.dev/playground
VITE_PLAYGROUND_STATS_URL=https://hermes-playground-ws.<your-subdomain>.workers.dev/stats

(Custom domain optional via Workers Routes — wss://hub.hermes-playground.app.)

Local dev

pnpm wrangler dev            # hot-reload on http://localhost:8787
# In hermes-workspace:
VITE_PLAYGROUND_WS_URL=ws://localhost:8787/playground pnpm dev

Protocol parity

Wire format is identical to scripts/playground-ws.mjs. The client (src/screens/playground/hooks/use-playground-multiplayer.ts) connects unchanged.

Messages:

  • { kind: 'presence', id, x, y, z, yaw, name, color, worldId, avatar?, ... }
  • { kind: 'chat', id, text, ts }
  • { kind: 'leave', id }
  • Server-emitted: { kind: 'hello', server, ts }

State model

  • presence: Map<id, lastWire> — fan-out + bootstrap on connect
  • chatRing: array<chat> — last 50 messages, replayed to newcomers
  • peakToday — persisted in DO storage for stats endpoint

Stale presence is pruned every 1 s via DO alarms (cheaper than setInterval, and survives instance hibernation).

Cost ceiling

For 100 concurrent players sending presence at 5 Hz:

  • 100 × 5 × 60 × 60 × 24 = 43.2M msgs/day → still inside free tier for outbound (Workers count requests, not WS messages). If usage explodes:
    • Workers Paid: $5/mo for 10M req/day baseline.
    • DO storage: trivial (presence in memory, peak in storage).

Hardening (post-hackathon)

  • Add rate limiting per playerId (token bucket in DO state).
  • Multi-room: route by ?room= to idFromName(roomId).
  • Anti-spoof: require signed JWT for presence.id.
  • Replace presence ring with state.storage.transaction for crash recovery.