obOB STUDIO
Networking

Adding Online Multiplayer to a Solo Racing Game

6 min readolivers-racers

Three months into development, Oliver's Racers was a solo game. You raced the clock, you tried to beat your best lap. It was fine. But "fine" wasn't what I wanted. I wanted to see other people on the track. I wanted names floating above tiny coloured cars. I wanted that feeling of not being alone on the circuit.

So I added online multiplayer. In a weekend. Kind of.

The architecture: dumber than you think

There's no game server. There's no authoritative simulation. There's an AWS WebSocket relay — the same one I use for my other games — and it does exactly one thing: when client A sends a message, every other client in the same room gets it. That's it.

const WS_URL := "wss://7j2luxg3qi.execute-api.us-east-1.amazonaws.com/prod"
const SEND_INTERVAL := 0.1       # 10 Hz position frames
const PING_INTERVAL := 240.0     # keep the API Gateway idle timer fresh
const REMOTE_TIMEOUT := 6.0      # drop a ghost that stopped sending

Each client sends its car's position, rotation, velocity, and some visual state (drifting? boosting? braking?) ten times per second. Every other client receives that and renders a ghost car. No collision between players. No server-side physics. Just presence.

Rooms and seeds

One room per world. If you're racing Green Valley, your room is "cars-valley". Up to 30 racers can be in a room at once. The tricky part isn't the room — it's the track.

Since every track is procedurally generated from a seed, everyone in the room needs the same seed. Here's how that negotiation works:

## THE TRACK SEED: layouts are procedurally random, so the whole room must
## agree on one seed or players would appear to drive through the scenery.
##   * First racer in a room: their world's random seed becomes the room seed.
##   * Joining racer: asks the room ("sr"); anyone answers ("sd" + seed); the
##     joiner rebuilds its world with that seed (a ~1 s blip after joining).
##   * "New track" (N key): broadcast "nt" + fresh seed; every client rebuilds.

The first person in a room is king — their random seed becomes the room's seed. When a second player joins, they broadcast a "seed request" (sr). Anyone already in the room replies with the current seed. The new player receives it, stores it via _next_seed, and reloads the scene. Now both players have identical tracks.

func build_seed(_map_id: String) -> int:
    if _next_seed >= 0:
        var s := _next_seed
        _next_seed = -1
        return s
    randomize()
    return randi() & 0x3fffffff

If nobody replies within 3 seconds (SEED_REPLY_WAIT), the joiner keeps their own seed and races solo. Graceful fallback.

The tab-switch bug that haunted me

This one took me two days to track down. In a browser, when you switch tabs, the engine freezes — no _process, no _physics_process. But the WebSocket stays open, and messages keep piling into the inbound buffer. Godot's default WebSocket buffer is 64 KB. A room with 15 racers sending 10 Hz can overflow that in seconds. When the buffer overflows, the socket closes. The player comes back to their tab and they're offline.

The fix was embarrassingly simple:

const INBOUND_BUFFER := 4 * 1024 * 1024   # 4 MB
const OUTBOUND_BUFFER := 256 * 1024
const MAX_QUEUED := 16384
const BURST_PACKETS := 30   # more than this in one poll = we just woke up

4 MB inbound buffer. 16K packet queue. And when I detect a burst (more than 30 packets in one poll cycle), I snap every ghost car to its newest state instead of trying to smoothly interpolate through minutes of stale data. Comes back clean. No disconnect.

Ghost cars: the social contract

Players can't collide with each other. This was a deliberate choice, not a limitation. In a game with 30 people on a narrow procedural track, collision would be chaos. It'd be bumper cars. So everyone is a ghost — you see them, they see you, but you pass right through each other.

This also sidesteps the hardest problem in networked games: resolving collisions across unreliable connections with no authoritative server. Instead, I'm just rendering sprites. Or rather, 3D car meshes that happen to have no collision.

Offline is always fine

## Offline is always fine: if the socket can't connect you simply race alone,
## and it quietly keeps retrying in the background.

That comment is from the actual source, and I meant every word. If you can't reach AWS, the game works. If you're on a train with spotty signal, the game works. If my WebSocket backend goes down (it's happened), the game works. You just race alone. The socket retries in the background with exponential backoff, and when it connects, ghosts start appearing.

No login screen. No "connecting…" blocker. No "you must be online to play". You open the page and you're driving. That's the whole pitch.

The leaderboard hook

The social magic isn't just seeing ghost cars — it's seeing their lap times. Every racer's best lap on the current track is tracked and ranked in a live leaderboard on the HUD. Hold first place in a room where at least one other racer has set a lap? You earn the Champion Gold car. Forever. It's the only car you can't buy, and it's surprisingly motivating.

I'll write more about the leaderboard and champion system in a future post. But the seeds of competition live right here, in this dumb little relay.

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.