Two limes, one cave: fun with WebSockets
How Lime cave's 2 player mode works. A tiny WebSocket server, a shared seed, a live ghost of your friend and their notes in your mix. Plus some ideas for co-op in Luna Chris.
Lime cave started as a one-thumb flyer and slowly turned into a rhythm game. The last thing it needed was someone to fly it with. So now there’s a 2 player mode: one of you makes a game, sends the other a four-letter code, and you both fly the same cave to the same song at the same moment. You see your friend as a faint ghost lime beside you, and you hear the notes they hit mixed softly into the track. Last lime flying wins.
It was one of the most fun things I’ve built for the site, mostly because the whole thing is smaller than you’d expect.
A WebSocket server with no dependencies
The leaderboards already run on a little Node service on my server, so the live games live there too, at /api/scores/live. I didn’t want to pull in a WebSocket library for something this small, so the service does the protocol by hand. It turns out that’s not much code:
- The upgrade handshake is one SHA-1 of the browser’s key plus a fixed GUID, sent back in a
101 Switching Protocolsreply. - Every message is a short text frame. Browsers always mask what they send, so reading a frame is a couple of length bytes, a four-byte mask, and an XOR.
- Anything unmasked, or bigger than 4 KB, closes the socket. Nothing in the game comes close.
const accept = createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
There are guard rails: only the site’s own origin can connect, each IP gets a handful of connections a minute, each socket gets at most 80 messages a second, and a socket that goes quiet for a minute is dropped.
Rooms and codes
A room is just an object in a Map, keyed by a four-letter code. The letters skip anything easy to misread, so there’s no O and 0, or I and 1, to argue about over the phone. The room holds the two players, their names and which tune you’re flying to. Nothing is saved: when both players leave, the room is gone.
The server barely knows it’s running a game. It understands make, join, leave and go. Everything else that happens during a run (positions, notes, crashes) is passed straight through to the other player without being looked at.
The trick: don’t send the cave
The obvious way to do multiplayer is to have the server run the game and stream the world to everyone. I didn’t do that. When either player presses Go, the server picks two numbers and sends them to both:
- a seed, which the cave generator uses instead of
Math.random(), so both games build exactly the same cave, block for block; - a start time four seconds in the future.
That’s the whole world, in one small message. Each game then builds the cave locally, counts down 3, 2, 1 and starts the song.
The start time is the fiddly bit. Your phone’s clock and mine don’t agree, so “start at 22:41:07.250” means different moments on each device. When the socket opens, the game sends a quick burst of pings. Each one carries the page’s own time, and the server replies with its time. Half the round trip gives an estimate of how far apart the two clocks are, and the game keeps the estimate from the quickest round trip, since that’s the one least muddied by the network. Then it carries on pinging every few seconds to keep it fresh.
The music engine already scheduled everything against one page clock (it had to, to keep blocks on the beat), so starting the song “at server time X” is just a conversion and a restart(0, at). In practice both phones drop into the song together, and if you sit them side by side the kicks line up.
In a solo run the cave’s speed depends on the width of your screen. In 2 player it doesn’t: the cave is scaled to the height of the stage only, so a narrow phone and a wide laptop see the same cave arrive at the same time.
Seeing the other lime
Twenty times a second, each game sends one tiny message:
{ "t": "p", "r": 12.35, "h": 640, "s": 87 }
That’s the time into the run, how high the lime is within the gap (0 at the floor, 1000 at the ceiling), and the score. Sending a fraction of the gap rather than a pixel position means it doesn’t matter what size the other screen is. The receiving game looks up the cave walls at that point and places the ghost in the same spot in its own gap.
The ghost is drawn about 150 milliseconds behind. The game keeps a short buffer of recent positions and draws a point between the two either side of “now minus 150 ms”. That small delay is the difference between a ghost that glides and one that stutters every time a packet arrives late. You’re never racing it to the millisecond anyway; it just has to feel like someone is there.
It’s the same drawing code as the ghost runs on the leaderboard, where you can race a saved run. A live friend is just a ghost whose path is still being written.
Hearing them
This is my favourite bit. In Lime cave, every block you pass plays a note, and how high you fly through the gap picks which note. So two people flying the same cave are, in a small way, playing the same song together.
When you hit a note, the game sends which sixteenth of the bar it landed on and which of the five pitch bands it was. The other game schedules that same note on its own copy of the beat grid, slightly quieter, with a little flash of colour from the ghost. Because both songs started on the same moment, a note sent a fraction late still lands in time. If it’s too late for this beat it plays straight away, which is close enough that you don’t notice.
The result is that you hear your friend flying. If they go high through a gap you went low through, you get a little harmony.
Crashing isn’t the end
The first version just showed “Game over” when you crashed, which was dull when your friend was still going. Now, if you go down first, the cave keeps scrolling and you watch their lime finish the run, with the song still playing. When they crash too, both games show who won. If they leave mid-run, you win by default, which seems fair.
There’s also a bot, for when nobody’s around. It flies the same cave with a bit of planning and a bit of wobble, and it’s very good for testing without bothering anyone.
The Apache gotcha
Everything worked locally and then failed on the real server with the least helpful error in the browser: close code 1006, which means “the connection went away and I won’t tell you why”.
The site sits behind Apache, and Apache was proxying /api/scores as normal HTTP. A WebSocket starts as an HTTP request asking to upgrade, and plain mod_proxy_http doesn’t pass that through. The fix is mod_proxy_wstunnel and one extra line, and the order matters:
ProxyPass /api/scores/live ws://127.0.0.1:9001/api/scores/live
ProxyPass /api/scores http://127.0.0.1:9001/api/scores
Apache uses the first ProxyPass that matches, so the WebSocket line has to sit above the general one, or it never gets a look in. Two lines and a sudo a2enmod proxy_wstunnel, after an evening of staring at 1006.
Ideas: co-op in Luna Chris
Luna Chris is my other game: a run-and-gun on tiny round planets, with a space cow to rescue and a pasta gun. Lime cave’s 2 player mode is a race, but Luna feels like it wants to be co-op: two astronauts on the same little moon, covering each other. Here’s how I’d think about it, using what I learned from the cave.
Keep the server dumb. The same rooms, codes and relay would work as they are. The server makes a room, picks a seed and a start time, and passes messages along. No game logic on the server means nothing to keep in sync between two codebases.
Send angles, not positions. In Luna you’re always on a circle, so a player is basically an angle around the planet, a height above it and which way they’re facing. Like the cave’s height-in-the-gap, that’s small, and it’s the same on any screen size. A second Chris (maybe in a different coloured suit) would be drawn from that, smoothed with the same short delay.
One player owns the enemies. This is where it gets harder than the cave, because the mice and the Gouda-Golems react to what players do. If both games ran the enemies themselves, they’d drift apart within seconds. The simple answer is to make whoever started the room the host: their game runs the waves and the enemies, and sends the other player a short list of where everything is a few times a second. The other game just draws what it’s told, and sends “I hit enemy 14 for 2 damage” back to the host to decide on. A seeded wave plan means the second player can at least know what’s coming before the host tells them.
Bullets are cheap to fake. The Mac-Laster fires a lot of macaroni, and sending every shell would be silly. The gun already aims and fires itself, so each game can draw the other player’s shots from their position and aim, purely for show. Only hits need to be agreed on, and the host decides those.
Make co-op actually co-op. Two players doing the same thing side by side is just single player twice. Some things only work with two:
- The planet is round, so you can split up and hold opposite sides, with enemies coming from both directions.
- A downed player floats in place and can be revived if the other one runs over to them before the timer runs out, a bit like watch mode in the cave but with something to do.
- Sharing jetpack fuel, or one player boosting the other on a jump to reach something high.
- Boss fights with two weak points on opposite sides of the boss, so you have to be in two places at once.
- Bessie the cow, once rescued, as something you both have to escort around the planet.
Watch mode still makes sense. If one player is down for good, the other carries on, and the first one watches and cheers from the sidelines, exactly like the cave.
It’s a bigger job than the cave was, mostly because of the enemies. But the plumbing is there now, and having heard two people flying the cave together, I’d like to see two Chrises on one cheese moon.