wololo中文

Making io games without servers

How Inkloop runs entirely offline in the browser: bots in place of other players, plain JavaScript and Canvas 2D, a fixed 60 Hz loop, and how we test it.

An io game usually implies a real-time server: one big map, dozens of real people dropping in at any moment. The three io-style games we planned go the other way. They run entirely in the browser, offline, with no server and no account. Inkloop is the one you can play now; the other two from the same plan are still drafts and haven't been released. This piece collects the engineering and product decisions the three share, using Inkloop for examples. For how Inkloop's own rules came about, see Designing Inkloop.

What we mean by io

We broke the genre into six traits that every game in the plan has to have:

  1. Instant entry. Type a nickname (or don't) and click. No account, no tutorial.
  2. Start tiny and grow from what's on the map and from kills.
  3. One life. Dying ends the round; Enter starts the next.
  4. Everyone is on the leaderboard. A live top 10, with your own row added if you're not in it.
  5. Minimal controls. Mouse direction plus one action key.
  6. Big fish have a weakness, so small players can turn things around.

We borrow only these conventions; the rules themselves are original.

Bots fill the map, with a seam left for networking

No server. Each round is filled with roughly 20 to 28 AI bots wearing human-sounding nicknames; Inkloop has 24. The names mix Chinese and English the way an io lobby does: 小明, Kai, 不要吃我, xX_Nova_Xx.

There were two reasons. Every game in this repository is plain static files with no build step: double-click index.html and play. A WebSocket server would break that. And most of the fun of an io game comes from a map full of rivals growing alongside you, which good bots can largely provide.

We gave up real multiplayer and matchmaking, but left a seam. The simulation accepts exactly one kind of input, { ax, ay, act }: an aim direction and whether the action key is down. Players and bots drive the same kind of entity through it:

// simplified from game.js
for (const b of world.brushes) {
  inputs[b.id] = b.isPlayer
    ? playerInput(b)              // mouse, keyboard or touch -> { ax, ay, act }
    : Bots.input(world, b, dt);   // bot brain -> the same shape
}
world.step(dt, inputs);

To go online later, a bot's brain can be swapped for remote input without touching the rules.

Human-like, and beatable

Bots with perfect aim are boring and frustrating, so we gave them weaknesses on purpose:

  • They don't think every frame. A bot re-plans only about four to seven times a second and follows its current plan in between, which works like reaction time.
  • Their aim is off, by an amount that depends on personality. Low-skill bots wobble.
  • They have personalities. aggression, caution and skill values give you timid farmers, hunters, and opportunists who pick off the weakened. That's where stories like "that Kai keeps stealing from me" come from.
  • They can't see the whole map, only a limited range around them.
  • They respawn after 2 to 6 seconds. In Inkloop, a respawning bot has a one-in-four chance of returning as a mid-sized veteran, so the leaderboard isn't all newcomers.

The spec's numbers were only a starting point. In all three games, bots written to spec were too aggressive and needed higher attack thresholds and cooldowns. Inkloop also hit the opposite problem: its bots escaped the instant they were surrounded, so seals were rare until we added a detection delay.

Plain JavaScript and Canvas 2D, no framework

Plain HTML, CSS and JavaScript, loaded with ordinary <script> tags in order, and no ES modules. The reason is practical: browsers block ES modules opened from file:// under their CORS rules, and every game has to open with a double-click.

Drawing is Canvas 2D. Sound is synthesised live with WebAudio, so there are no audio files. Fonts come from Google Fonts with system fallbacks, so the game still works offline. We gave up TypeScript, bundlers and WebGL: Canvas 2D handles a thousand-odd small objects without WebGL's complexity.

One self-contained folder per game

Each game has its own copy of core.js; nothing is pulled out into a shared library. The file split is the same everywhere:

inkloop/
  index.html     layout, CSS, title and death screens
  DESIGN.md      the game's rules and decision log
  js/core.js     maths, RNG, localStorage, spatial hash grid, input
  js/world.js    simulation: entities, collisions, rules (no DOM, no drawing)
  js/bots.js     bot brains: read the world, produce {ax, ay, act}
  js/render.js   draws the world to the canvas
  js/audio.js    WebAudio sound synthesis
  js/game.js     state machine, main loop, HUD, title and death screens

Any folder can be deployed on its own. A shared library would tie the games together, so that changing one could break another, and the duplicated code is only a few hundred lines. We gave up DRY on purpose.

A fixed 60 Hz simulation, drawn on requestAnimationFrame

The simulation runs on a fixed 1/60-second step with an accumulator, and drawing happens once per requestAnimationFrame:

// simplified from game.js
const STEP = 1 / 60;
function frame(now) {
  requestAnimationFrame(frame);
  const dt = Math.min((now - last) / 1000, 0.1);
  last = now;
  if (!paused) {
    acc += dt;
    let n = 0;
    while (acc >= STEP && n < 5) {
      tick(STEP);
      acc -= STEP;
      n++;
    }
    if (n === 5) acc = 0;
  }
  Render.draw(world, cam);
}

A fixed step keeps collisions and bot behaviour the same on every display, so a 120 Hz screen doesn't make the game faster. Catching up is capped at 5 steps per frame, so returning to a background tab doesn't set off thousands at once.

Pausing is allowed

A real io game can't pause, because your opponents are people. Here they're bots, and dying because you glanced at another tab is just annoying, with no fairness at stake. So the game pauses itself when the tab is hidden or the window loses focus, and asks you to click to continue. In Inkloop, Esc pauses too.

Saving three things

The only saved data is your nickname, your personal best and the mute setting, kept in localStorage under keys prefixed with the game's name (such as inkloop.best). Every read and write is wrapped in try/catch. In private windows or with storage blocked, localStorage can throw, and the game must still be playable; it just won't remember anything.

Performance targets

The target is 60 fps on an ordinary laptop, with about 25 characters and about 1000 small objects on the map. The methods are plain:

  • Collisions and neighbour lookups go through a spatial hash grid instead of checking every pair. Inkloop keeps its stroke segments in the grid, so loop and cut checks only look at nearby cells.
  • devicePixelRatio is capped at 2.
  • Anything off screen isn't drawn.

Testing

  • Every game supports ?demo, which hands the player's slot to a bot and skips the title screen, for headless tests and for anyone who just wants to watch the bots play.
  • ?seed=<number> fixes the random number generator so problems can be reproduced.
  • Acceptance meant running ?demo in headless Chrome for a while, collecting console errors and screenshots, then actually playing in a real browser.

Two additions came out of building the games. Headless Chrome's virtual clock doesn't reliably fire requestAnimationFrame, so a ?test mode advances the simulation on a timer while drawing stays on rAF; Inkloop also logs a line of JSON every 10 simulated seconds in this mode. And testing in a real browser needs a local HTTP server (browser extensions can't open file://) with caching off, or an edited core.js can load stale next to a fresh game.js and throw.

Simulation also showed that an io economy needs a drain. Inkloop's cuts originally created ink out of nothing; now the spilled ink comes out of the victim, and the other two games each needed a drain of their own.

What we may revisit

  • Bot difficulty. For now it's controlled by the spread of personality values. If players generally find first place too easy or too hard, we may add adjustment based on how the player is doing.
  • A networked version. The seam is in place, but doing it properly would mean making the simulation server-authoritative, with client-side prediction.
  • Mobile. Touch already works, but on small screens the leaderboard and minimap get crowded, so a collapsible HUD may be needed.

Design notes

  • Designing Inkloop: loops, cuts and seals

    Why the only way to die in Inkloop is to be enclosed, why cutting never kills, how the bots are tuned to feel human, and what changed after simulation.

  • Game sound with no audio files

    Inkloop, Meme Brawl and Runekeep ship no audio files. Every effect and song is synthesized live with the Web Audio API. Here is how, and what it costs.

  • How Runekeep turns typing practice into tower defence

    Inside Runekeep's code: how targets lock, how difficulty ramps, why Letter Trial drills one row at a time, and how WPM and accuracy are counted.