All posts

Inside Plankton: a small game with a shared world

How Plankton uses irt.io rooms, spatial visibility, server authority, and local prediction to turn a canvas full of blobs into a multiplayer game.

Plankton starts with a simple instruction: eat, grow, and avoid becoming somebody else’s lunch. You steer a blob around a world full of pellets and other blobs. Growing makes you harder to eat, but slower. Getting caught sends you back into the water after a short wait.

Underneath that small game is a useful example of how to build on irt.io. The browser draws and collects input. A room runs the world. A shared schema describes the state between them, and spatial visibility decides how much of that world each player needs to receive.

The interesting work is in where those responsibilities meet.

Each Plankton room contains a 3,000-by-3,000-unit world, 600 pellets, and 20 scripted grazers, with up to 32 connected clients. These are separate room instances, not one global ocean that every visitor automatically joins. The client keeps a room code in the URL hash so sharing the link brings another player into the same world.

The room runs at 20 ticks per second. On each tick it steers the grazers, moves living blobs, handles respawns, resolves eating, and applies mass decay. Once a second it updates a shared top-five scoreboard. irt.io supplies the room lifecycle and synchronized state; Plankton supplies the rules about what happens in that state.

That separation is especially important when two players disagree about who ate whom.

A player owns their blob record, but ownership does not give them control over its mass or position. The only ordinary writes the server accepts are the two steering fields, dx and dy. The validator clamps that direction and rebuilds everything else from the previous authoritative record:

validate: {
  blobs(prev, next) {
    const { dx, dy } = clampIntent(next.dx, next.dy);
    return { ...prev, dx, dy };
  },
},

The room calculates speed from mass, moves the blob, and decides whether a collision qualifies as eating. A modified client can request a direction. It cannot make itself heavier by submitting a larger number. The room also refuses ownership requests, so a player cannot claim a pellet or a grazer.

This is a concrete use of irt.io’s authority model: clients control intent, and the server controls outcomes. Renaming goes through a separate validated RPC because it is an occasional action with its own rules. Steering uses batched state writes rather than a new RPC on every frame.

The other big decision is how much state to send.

Plankton does not broadcast the entire ocean to every player. Its blobs collection uses spatial-grid visibility, with 150-unit cells and a radius of three cells around the player’s anchor. That produces a seven-by-seven block of cells: a neighborhood within the larger world. A spectator role can receive the whole collection for comparison.

visibility: 'spatial-grid',
grid: {
  x: 'x',
  y: 'y',
  cell: CELL,
  radius: RADIUS_CELLS,
  wideRoles: ['spectator'],
},

One slightly surprising consequence is that pellets and blobs share this collection. The spatial filter locates a player’s anchor using their record in the collection being filtered. A separate pellet collection without those anchors would not produce the intended player views. A kind field distinguishes food from moving blobs while keeping them under the same filter.

Steering fields live on those spatially filtered blob records too. Putting every player’s direction into a separate globally visible collection would reintroduce traffic from distant players. The scoreboard is deliberately global, but it is a small, server-owned singleton updated once a second.

The benefit depends on local density. If everyone crowds into one neighborhood, players receive more state. Spatial filtering reduces unnecessary updates about distant entities; it does not make a crowded arena free. The camera’s view limits are derived from the grid settings as well, because the view you draw must account for the neighborhood you receive.

Responsiveness is handled separately from authority.

The client draws other entities from room.render, using irt.io’s interpolation to smooth movement between updates. Your own blob gets a small custom predictor. Each animation frame, the browser integrates your steering using the same speed and world-boundary functions the server imports from src/world.ts.

The predictor gently pulls that position toward the latest authoritative position in room.state. Large differences snap instead of easing across the map, and the integration step is capped so returning to a backgrounded tab does not produce a huge jump.

This particular game uses simple movement prediction and correction smoothing. It does not enable irt.io’s Rapier prediction or replay a history of buffered inputs. It also leaves mass gains, deaths, and eating decisions entirely to the room. A responsive steering position is useful; prematurely showing that you ate another player would create a different kind of disagreement.

The grazers taught us another infrastructure lesson.

An earlier version represented them as NPC client sessions. That gave each grazer its own filtered view and input path, but also meant paying to filter and encode updates for those sessions. For 20 simple background characters, that was unnecessary work.

Today, grazers are server-owned records driven inside the room’s tick. Their decision function examines nearby entities, and decisions are staggered across ticks to spread the work. They consume no client seats and need no individual network feed. The room reads tracked values into a plain snapshot for those calculations, then writes back changes.

An NPC session is useful when a character needs to behave like a client with its own view and restrictions. A server-simulated entity fits a grazer whose job is simply to keep an active world busy. Grazers are saved room state, so they survive hibernation; a sleeping room is not continuously simulating them. On wake, the game resets their temporary wandering memory and continues from the stored world.

Reconnection follows the same separation of responsibilities. irt.io manages session resumption, while Plankton’s join handler checks ctx.reconnecting before creating a blob. A resumed player keeps their existing record instead of receiving an accidental second spawn. The browser shows a reconnecting banner, and the room removes the blob when the leave lifecycle callback runs.

The architecture has checks behind it. Plankton’s tests exercise forged writes, ownership requests, eating rules, respawns, and grazer behavior. Its spatial tests compare 30 players with a wide-view spectator, check for visibility leaks, and measure traffic across early and late windows of movement. Tick-cost checks cover simulation work too.

The browser also counts incoming protocol-frame bytes through onFrame and displays the rate. Opening the same room as a spectator gives you a practical comparison between a neighborhood feed and a whole-world feed. Those counts measure application frames, not every byte of transport overhead, but they make the filtering behavior observable while you play.

The project keeps the deployment boundary straightforward: a bundled canvas client and static HTML on one side, and the room implementation on the other. Both share the schema, while server simulation code stays out of the browser bundle. The irt.io project configuration identifies the project and the static output directory, alongside the platform’s deployment workflow.

Plankton leaves plenty of platform features unused. Its top-five list belongs to the current room; it is not a persistent leaderboard. The client shares room codes rather than using quick match, and the room does not currently enable replays or historical hit queries.

That focus is what makes it a useful example. A few game-specific files define steering, growth, eating, and drawing. irt.io provides the rooms, state synchronization, visibility, interpolation, and session lifecycle that let those rules operate across browsers. The result is a small game whose multiplayer behavior can be explained, measured, and tested alongside the gameplay itself.