I rebuilt my 2018 agar.io clone around real orbital physics, with no game server
In 2018 I spent a weekend making an agar.io clone: a planet that followed the mouse with acceleration and almost no friction, three files (planeta.js , vector.js , functions.js ) and a comment that said F = G * m1 * m2 / r² . Eight years later that comment is the whole game. Lagario 3: Órbita is a free multiplayer browser game where your planet has momentum, stars pull with inverse-square gravity, and there is no game server. Play it here: https://lagario.thomasar.dev (desktop or phone, no account). Here are three things I learned building it. Multiplayer without a server I host on Vercel, and serverless functions (WebSockets included) don't keep every player of a room on the same instance. So I don't use them for the game at all. Whoever creates a room hosts the match in their own browser: the simulation runs in a Web Worker at 30 ticks per second, and everyone else connects over WebRTC. Signalling goes through public Nostr relays via Trystero, so the site stays fully static. The Worker matters more than I expected. Browsers throttle main-thread timers in background tabs, but a Worker's timer keeps going, so the host can alt-tab without freezing the room. The loop is a plain fixed-step accumulator that catches up after hiccups without spiralling: let n = 0 while (acc >= step && n step * 4) acc = 0 Each Trystero room gets three channels: binary game messages, small JSON control messages, and checkpoints. Snapshots are binary and area-of-interest filtered, so each client only gets what is on its screen. Small rooms get 30 snapshots/s (about 3 KB/s per player), big rooms 15/s. Host migration The hard part of "one player is the server" is that players leave. Every 2 seconds the host sends a checkpoint of the world to the next two peers on its roster. When a client hears nothing from the host for 5 seconds, it doesn't promote itself right away. First it asks every peer who they follow: // Before promoting anyone, ask the other peers what they see: if they // still have the host, only our link broke and taking over would split the room. const oldStillServing = hints.some((h) => h.host === old && h.epoch >= this.epoch) If nobody still sees the old host, the next peer in roster order (the ones holding checkpoints) restores the latest checkpoint in its own Worker, bumps an epoch number and announces itself. Everyone reconnects keeping their planets. If two peers both end up hosting, the higher epoch wins, then the lower peer id, and the loser steps down. An end-to-end script drives three real browsers through a room, chat and a host migration. Gravity you can play with All movement rules live in one shared module, physics.ts , used by both the host and the client. Stars, black holes and planets above a mass threshold are gravity sources. The pull is inverse-square outside a source's radius, fades linearly inside it, and is ignored once it drops below a small floor, which bounds each source's reach: const a = d2 >= r2 ? (s.g * r2) / d2 : s.g * (d / s.r) Realistic gravity was not fun on its own. Two tweaks made it a game: - Gravity is scaled by how hard you can push back. Big planets are slower, so real physics would make every star a death trap for them. Instead, the pull on a planet is multiplied by its thrust relative to a starting planet's, and a star's pull is capped at 80% of that. Steering straight out of any star always works (you just burn on the way). - Slingshots keep their speed. Normally your velocity eases toward where you're steering. But if you're already faster than your top speed in the direction you're steering, that extra speed fades at only 15% of the normal rate. Dive past a star, keep pointing forward, and you come out much faster, capped at 3.2× top speed. The dotted line in the screenshot below is the trajectory prediction. The client replays exactly the host's movement rules for 60 small steps (1.4 to 4 seconds ahead), using the entities it can see, and turns the line red where it crosses into a star's burn zone. Sharing one module keeps the prediction honest. Collisions instead of eating In agar.io, same-size blobs just overlap. In Órbita, planets less than 25% apart in mass collide: an impulse with a restitution of 0.45. The knock each side takes is compared with its own top speed, and past a threshold it loses up to 25% of its mass as chunks (up to 8) that fly off the surface. The planet they came from can't re-swallow them for 1.5 seconds, so a hard crash means a scramble for the debris. Every old version is still playable I didn't want v3 to erase v1 and v2, so one deploy serves all of them: the current game at / , and frozen builds at /v1/ and /v2/ . A script (npm run archive -- v2.0.0 ) checks the tag out into a git worktree, installs that tag's dependencies, builds it with Vite's base set to /v2/ , and writes the result to archive/v2/ , which gets committed. Then it patches the build to live alongside the current one: - A tiny localStorage shim prefixes the frozen version's keys. It reads through to the current version's data on first visit (you keep your name and level), but only writes to its own copy. - The web manifest's start_url andscope point at/v2/ , source maps and the old service worker are removed, and links to the old standalone deployments are rewritten. The netcode is versioned too: each version joins the P2P network under its own app id, so a v2 client can never land in a v3 room. Try it It's TypeScript with a WebGL2 renderer (and the old Canvas 2D one as a fallback), with bots filling empty slots. I'd love feedback on how the physics feels, especially the slingshot, and I'm happy to answer questions about the P2P setup in the comments. Play free: https://lagario.thomasar.dev (compare with https://lagario.thomasar.dev/v1/ and /v2/) Disclosure: this post was written by an AI assistant on my behalf, from the game's source code and changelog, and checked against the code. The snippets come from the repo, lightly trimmed. Top comments (0)
Comments
No comments yet. Start the discussion.