obOB STUDIO
Networking

My WebSocket Architecture — One Backend for All Games

6 min read

Three games, one WebSocket endpoint

Garden Gnomes, Meccha Chameleon, and Oliver's Racers all have online multiplayer. They all connect to the same backend. The same Lambda functions. The same DynamoDB table. The same API Gateway WebSocket URL.

This wasn't always the plan. I originally built the backend specifically for Garden Gnomes. Then when I added multiplayer to Meccha Chameleon, I realized the architecture was already game-agnostic. The backend doesn't know or care what game is using it. It just relays messages between players in a room.

The core protocol

Every message between client and server follows this structure:

// Client → Server
{
  "action": "createRoom" | "joinRoom" | "broadcast" | "startGame",
  "roomCode": "AB12",         // for joinRoom
  "playerName": "Oliver",     // for createRoom/joinRoom
  "type": "gnomePos",         // for broadcast - game-specific
  "payload": { ... }          // for broadcast - game-specific
}

// Server → Client
{
  "type": "roomCreated" | "playerJoined" | "playerLeft" | "gnomePos" | ...,
  "roomCode": "AB12",
  "players": ["Oliver", "Emma"],
  "payload": { ... }
}

The key insight: the action field routes the message on the server side. There are only four actions: createRoom, joinRoom, broadcast, and startGame. The broadcast action is the workhorse — it relays whatever type and payload the game sends to all other players in the room. The server never inspects the payload. It doesn't validate it. It doesn't transform it. It just passes it along.

This means adding multiplayer to a new game is trivially simple on the backend side. I don't touch a single Lambda function. The game just needs to:

1. Connect to the WebSocket 2. Create or join a room 3. Send and receive JSON

How games connect

The portfolio site is a Next.js static export. The actual games live in /public/games/<slug>/ and are embedded in iframes. The game files are plain HTML/JS — they can't read NEXT_PUBLIC_* environment variables.

So I inject the WebSocket URL via a query parameter. The embedSrc function in lib/projects.ts handles this:

const ONLINE_GAMES = new Set(["garden-gnomes", "meccha-chameleon"]);

export function embedSrc(p) {
  if (ONLINE_GAMES.has(p.slug) && GAME_WS_URL) {
    return `${p.src}?ws=${encodeURIComponent(GAME_WS_URL)}`;
  }
  return p.src;
}

The game reads the ws parameter on load and uses it to establish the WebSocket connection:

// Inside the game's JavaScript
const params = new URLSearchParams(window.location.search);
const wsUrl = params.get("ws");

if (wsUrl) {
  const ws = new WebSocket(wsUrl);
  ws.onopen = () => console.log("Connected to multiplayer backend");
  ws.onmessage = (event) => handleServerMessage(JSON.parse(event.data));
}

If the ws parameter is missing (someone opens the game HTML directly), multiplayer simply isn't available and the game runs in single-player mode. No crashes, no errors — just a graceful fallback.

Message flow: what happens when you broadcast

Let's trace a message through the entire system. Say a player in Meccha Chameleon moves their character:

1. Game client sends: { action: "broadcast", type: "playerMove", payload: { x: 5.2, y: 1.0, z: -3.1, colorIndex: 2 } } 2. API Gateway receives the WebSocket frame, invokes the $default Lambda 3. Lambda parses the message, looks up the sender's room in DynamoDB 4. Lambda finds all other connectionIds in that room 5. Lambda posts the message to each connection via API Gateway's @connections endpoint 6. Other players' browsers receive the message via their WebSocket onmessage handler 7. Their game interprets the playerMove type and updates the remote player's position

The Lambda function that does step 4-5 looks like this:

async function broadcast(senderConnectionId, body) {
  const room = await findRoomByConnection(senderConnectionId);
  if (!room) return { statusCode: 404 };

  const apiGw = new ApiGatewayManagementApi({
    endpoint: process.env.APIGW_ENDPOINT,
  });

  const promises = room.players
    .filter(p => p.connectionId !== senderConnectionId)
    .map(p =>
      apiGw.postToConnection({
        ConnectionId: p.connectionId,
        Data: JSON.stringify({
          type: body.type,
          payload: body.payload,
          from: senderConnectionId,
        }),
      }).promise().catch(err => {
        // Connection gone — clean it up
        if (err.statusCode === 410) {
          return removePlayerFromRoom(room.roomCode, p.connectionId);
        }
      })
    );

  await Promise.all(promises);
  return { statusCode: 200 };
}

Notice the 410 error handling. If a player's connection is gone (they closed the tab without a clean disconnect), postToConnection returns a 410 Gone status. I catch that and remove them from the room. This is my "garbage collection" for stale connections.

What each game does with it

Garden Gnomes sends gnome positions during flight and final scores on landing. The spectator view renders remote gnomes on a canvas overlay. Message types: gnomePos, finalScore, roundStart.

Meccha Chameleon sends full 3D transforms — position, rotation, scale, and the player's current body color. Remote players are rendered as colored chameleon meshes. Message types: playerMove, colorChange, seekerTag, gamePhase.

Oliver's Racers sends car positions and lap progress. The ghost cars on your track are other real players. Message types: carTelemetry, lapComplete, raceResult.

Each game implements its own handleServerMessage function that switches on the type field. The backend doesn't know or care about any of these types.

Why this works so well for indie games

The game-agnostic relay pattern has one massive advantage: zero backend changes per game. When I want to add multiplayer to a new game, I just:

1. Add the game's slug to the ONLINE_GAMES set 2. Write a handleServerMessage function in the game 3. Broadcast whatever data the game needs

No new Lambda functions. No new API routes. No backend deployment. The backend is done. It's been done since Garden Gnomes. Every new game just builds on top.

The trade-off is that the backend can't validate game-specific payloads. A malicious client could send garbage data and other clients would receive it. For a competitive multiplayer game, that's a problem. For casual browser games with friends? It's fine. The room code is the security — if you don't share it, strangers can't join.

The cost reality

I run this backend for three games with occasional players. My AWS bill for the game WebSocket infrastructure has never exceeded $2/month. Most months it's under $0.50. Lambda's pay-per-invocation model is perfect for this — when nobody's playing, the cost is literally zero.

Compare that to a $5/month VPS running a Node WebSocket server 24/7, most of the time serving zero players. Serverless wins by a mile for bursty, low-traffic workloads like indie game multiplayer.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.