# Driving with a friend: a ghost, a room and a clock that never jumps

> How the multiplayer of Threejs-Punk works: ghost cars as holograms, a Cloudflare room that relays a pose 15 times a second, and the smoothing that hides the network.

Canonical page: https://andersonmancini.dev/blog/threejs-punk-multiplayer

Published: 2026-09-27 · 5 min read · Tags: multiplayer, game-dev, webgpu, three.js, networking

When I added multiplayer to [Threejs-Punk](https://andersonmancini.dev/blog/threejs-punk-racing-game.md), the first decision was about what *not* to do. The game has a physics simulation, AI traffic, missions and a save. Making all of that shared would have meant a server that runs the simulation, a lot of state to keep in sync and a game that stops working when one player has a bad connection.

So I made the opposite choice. Up to four people drive the same city, each on their own machine, and they see each other as **ghosts**: the friend's real car, drawn as a cyan hologram, with its wheels turning, its lamps lit and its nitro burning. Ghosts pass through each other. Traffic, missions and saves stay each player's own. What travels between machines is one small pose per driver, about 15 times a second, plus a few small events.

## The room is a link

A room is a code in the URL: `?room=CODE`, eight symbols out of 32, so 40 bits. The invite button makes one, joins it and copies the link, or opens the share sheet on a phone. Opening a link joins on load, and because the page's URL always carries the room, a reload rejoins it.

The server is a Cloudflare Worker with **one Durable Object per room**. Each message is validated and relayed, nothing else: the server does not simulate, does not log and does not store anything. An idle room costs nothing, because the WebSocket connections hibernate.

Every pose is checked before it is relayed: finite numbers, a position on the map, a unit quaternion and a plausible speed. Then it is tagged with the sender's id. A `NaN` never reaches another player's renderer. Each connection is also limited, to 40 messages a second, to 400 characters a message and to four drivers a room, and the server only accepts the origins on an allow-list.

## What travels

A pose is the position, the rotation, the velocity and a set of flags (brake, reverse, nitro, drift, horn, lights, on foot). It is sent every 1/15 of a second while the car moves, and every half a second when it stands still, which also keeps the request count low. It is the pose of the **physics body**, not of the model.

Everything else is an **event**, one small message each: a quick chat phrase, a ping on the map, a car swap, a race start. The pose is the only stream.

## Hiding the network

At 15 poses a second, the raw positions would be a car that jumps every 66 milliseconds. The smoothing has three rules.

**Draw the car in the past.** A ghost is drawn 130 ms behind the newest snapshot, which is two packets, and is placed between the two snapshots around that moment: a Hermite curve for the position, using the velocity that comes in the snapshot, and a slerp for the rotation. Past the newest snapshot, it coasts for up to 250 ms and then holds. A jump of more than 35 m, such as a respawn, empties the buffer, so the car appears there and does not sweep across the map.

**The sender's clock is not yours.** To place a snapshot in time, the receiver keeps an offset between the two clocks. It follows the smallest "arrival minus sent" of the last three seconds, and moves toward it by at most half a millisecond per packet.

**That offset must never jump.** My first version simply snapped to the lowest sample. It moved the timeline by 1 to 8 ms at a time, which is 1 to 11 cm of car at 50 km/h, and the car's speed was wrong by about 20%. A test with simulated jitter, packet loss and clock skew caught it. With the offset easing, the speed error is below 4%.

## Drawing a ghost

A ghost is the friend's **real garage car**. When a friend picks a car, the id travels with the handshake, and each ghost is baked once, on demand, into a single vertex layout. All the ghosts share one additive material: a pool of four meshes, one shader. The look is the same vocabulary as the mission markers, a Fresnel rim, scanlines climbing the body and a pulse running out from the center of the car.

Because the material and the layout are built at startup and compiled by the warm-up, a friend joining, or swapping to a car nobody has drawn yet, is a buffer rebind and never a shader compile.

## What it costs

The room server runs on the free plan, and the numbers are in the docs: Cloudflare counts incoming WebSocket messages 20 to 1, so two drivers moving are about 5,400 requests an hour, which is about 18 room-hours a day within the free limit, and more with parked cars.

There is also a dark-launch switch: while it is on, only a browser that opened `?multiplayer` or an invite link sees any of it. For everyone else the game is exactly as it was, with no button, no connection and no ghost mesh.

## Together, but not too much

Once the room existed, the cheapest features were the ones that only needed an event: a horn, a headlight flash, a GPS that follows a friend, a camera that watches them, eight quick chat phrases, a ping on the map and street races started together. The chat is presets only, with no free text, so there is nothing to moderate and it works in any language.

Two are automatic and use the poses: a **slipstream** when you are right behind a friend, between 5 and 30 meters, both above 72 km/h, which cuts up to 55% of the drag, and a **tandem drift**, which gives 50% more drift points when a friend within 45 meters is drifting too.

On a phone, the rule is that what two thumbs on a wheel can do stays, in one place, and the rest is not offered. A phone still *receives* a desktop friend's horn and headlight flash. An early version put a row of buttons over the steering, and it was a mess.

The lesson I keep from it: a multiplayer game does not need a shared simulation. It needs one small, validated stream, a buffer that draws a little in the past, and a clock offset that never jumps.

## Related projects

- [Threejs-Punk](https://andersonmancini.dev/projects/threejs-punk.md) — Fully featured Racing Game
