Forge is primarily an editor. Multiplayer for playtests and shipped games should follow Grudge live-servers patterns — not a WebSocket on the Vercel SPA.
Hard rule: Vercel static SPA (and the Forge web Worker that only proxies HTML/JS) cannot upgrade WebSockets. Realtime lives on Railway Node, a CF Worker that proxies upgrades, or Durable Objects.
Editor (forge.grudge-studio.com)
├── SPA: Vercel prebuilt via CF Worker (no WS upgrade here)
├── Scene/scripts: Puter or local project store
└── "Publish playtest" → ephemeral room ticket (L7) optional
Play clients
├── Auth: id.grudge-studio.com
├── State: Railway grudge-api (characters / bag)
├── Assets: assets.grudge-studio.com (R2)
└── Realtime: one of L2 / L3+L4 / L6 below
| Pattern | Stack | When | Deploy how |
|---|---|---|---|
| L2 Carrier co-located | One Node process: HTTP /api/* + WS /api/carrier |
Fastest ops; game API already on Railway | railway up / existing api-server service |
| L3 + L4 | CF Worker upgrades WS → Railway room | Custom domain in front of Vercel SPA | wrangler deploy edge + Railway room |
| L6 Durable Objects | CF DO per roomId |
Multi-tenant browser rooms at edge | Worker + DO class in wrangler |
| L7 Ephemeral | Short-lived room URL + TTL | Forge/Studio “playtest” deploys | Ticket API + auto teardown |
Default for a new browser multiplayer game: L0 assets (CDN) + L2 (or L3+L4 if the front door is Vercel-only).
Is the play origin a long-lived Node host (Railway Express)?
YES → L2: attach WebSocketServer { noServer: true } on same process
NO → Is the browser domain Cloudflare DNS (Worker can own upgrade)?
YES → L3 Worker upgrade → L4 Railway room (or L6 DO)
NO → Point play clients at a dedicated WS host (L4 URL env)
or move play under a CF-owned domain
Forge “playtest” only (no permanent rooms)?
→ L7 ephemeral ticket + TTL teardown
Mirror / uMMORPG-style (client intent, server truth):
| Concern | Client | Server / room |
|---|---|---|
| Movement | Send move / look intent |
Integrate pose at 20–30 Hz |
| Combat | Send fire / skill id |
Validate CD, range, damage |
| Remote avatars | Interpolate last snapshots | Broadcast poses ~20 Hz |
| Physics | Local predict (Rapier) | Authoritative hits / loot |
| Seed | Same WORLD_SEED |
Same sim step (no wall clock) |
Do not put multiplayer truth in Puter KV, localStorage, or dual physics engines.
wasd-character-controllerthird-person-cameranetwork-manager-mirror — emits playerPose / expects ctx.netremote-player-interpolatorInject transport at play start (not in scene JSON):
function wsUrl(path = "/api/carrier") {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}${path}`;
}
// ctx.net = { playerId, send, on } over that socket
Never hardcode wss://some-railway-host into saved scenes.
One Railway (or VPS) Node process serves REST + WS:
Browser
HTTP /api/* → Express
WS /api/carrier → WebSocketServer (noServer) → room
# Typical monorepo api-server / game room
pnpm run build
# Railway: set PORT, ALLOWED_ORIGINS, DATABASE_URL as needed
railway up
# Smoke
curl -sS https://<host>/api/health
# WS: connect wss://<host>/api/carrier (or same-origin via reverse proxy)
Client: wsUrl() from location.host when DNS points at this host (or CF proxies both HTTP and WS).
Use when SPA is on Vercel and only the Worker owns *.grudge-studio.com:
Browser → CF Worker
├─ Upgrade /api/carrier|space|brawl → Railway room
├─ /api/characters|… → Railway game-data
└─ /* HTML/JS → Vercel ORIGIN
# Edge (example GRUDOX-style worker)
cd <repo>/infra/cloudflare/<game-worker>
npx wrangler deploy
# Room
cd artifacts/pvp-server # or Colyseus / carrier room
railway up
# Env: ALLOWED_ORIGINS includes play origin; TICK_HZ, MAX players
Do not re-wrap HTTP 101 responses in the Worker (breaks handshakes).
Worker fetch → idFromName(roomId) → DO Hibernation WebSockets
Good for many short rooms without one Railway process per room. Prefer when CPU is light (pose relay, not full physics server).
roomId (Carrier / DO / Railway)https://<game>/play?scene=<cdn>&room=<id>player.html?scene=…)| Artifact | Mechanism | Multiplayer? |
|---|---|---|
| Forge SPA | GHA → Vercel prebuilt → CF web Worker | No realtime |
| free-ai / catalog / agent | CF Worker + D1 | No |
| Scene / project | Puter or local | No |
| Character / bag | Railway Postgres | Shared identity only |
| Game room | Railway L2/L4 or CF DO L6 | Yes |
| Assets | R2 + D1 index | L0 SSOT for all clients |
[ ] Assets on R2 + D1 index (no multi-GB git)
[ ] Grudge ID + CORS allowlist for play origin
[ ] Room host: Railway L2/L4 OR CF DO L6
[ ] If domain is CF Worker + Vercel SPA: WS paths owned by Worker (L3)
[ ] Client uses same-origin wsUrl() or ticket-provided URL (never baked scene host)
[ ] Smoke: HTTP health + WS upgrade + one join
[ ] Open library card if player-facing (L9)
[ ] Rapier only — no second physics stack
colyseus.js required until you ship a Colyseus client in a play build.pvp-server / Carrier / Colyseus on Railway.colyseus.js to a player package, not the editor shell.@gltf-transform/*, meshoptimizer) are offline editor tooling — unrelated to room hosting.grudge-live-servers (L0–L9)forge-gameplay-scripts (templates + Mirror-style net)