Island Evolution
Devlog

Making Island Evolution: 40,320 Orders, 3 Perfect

· 7 min read

The bare pixel art island before anything has been placed on it

Island Evolution is an order puzzle: you place 8 things on an empty island, everything already there evolves one level after each placement, and your order decides how far each thing gets. It runs on Godot 4 and exports to the browser. Here is what turned out to be interesting to build, including the bug that made the title music inaudible on the web and nowhere else.

Rules as data, not as code

The first real decision was refusing to write the rules as branches. Every item, every level, and every condition lives in a JSON file that looks roughly like this:

tree, max level 4: sprout, then sapling when water is at least 1, then big tree when water is at least 2, then forest when water is at least 3.

The engine reads that file and becomes a lookup-driven state machine. Nothing about the 8 specific items is compiled into the logic, which had two consequences I did not fully appreciate at the start.

The first is that rebalancing costs nothing. Changing when a lighthouse lights up is a one-line edit to data, not a hunt through conditionals.

The second is the good one: the rules become executable outside the game. Since a run is just a function from an order to a final state, you can run every possible order without rendering a single frame.

Brute forcing all 40,320 orders

Eight items means 8 factorial, which is 40,320 orderings. That is nothing for a computer, so every single one gets simulated and dumped to a table. The distribution is more lopsided than I expected:

  • 3 orders reach the perfect score of 27, with everything at maximum.
  • 35 orders, about 0.1 percent, get through without anything dying at all.
  • The most common outcome by far is a score of 6, which happens in 12,180 orders.

That table is the design document. When I changed a rule, I re-ran it, and if the number of perfect orders jumped from 3 to 60 I knew the puzzle had gone soft before anyone played it. A difficulty conversation that would otherwise be vibes becomes a number that either moved or did not.

It also settles arguments about fairness. A solution that only one ordering in 40,320 reaches would be cruel. Three is tight but findable, especially since 35 orders let you survive to the end and see a nearly-complete island, which is the feedback that teaches you what to fix.

Hazards were the hard part

Growth rules are monotonic and easy to reason about. Hazards are not. The rule that took three attempts to get right is when the danger check runs: originally it fired only on the turn the dangerous item was placed, which meant you could place it early and then safely build the thing it was supposed to threaten. Now the check runs after every turn against everything on the board, so a hazard placed on turn 2 still ruins what you add on turn 7. Re-running the 40,320 table after that change rewrote most of the results.

The art pipeline

The game runs on a 426 by 328 canvas at integer scaling, which means every sprite is genuinely small and every pixel is a decision. The pipeline has three stages and each tool only does what it is good at:

  1. Generation produces oversized reference images of each item at each level.
  2. Python does the mechanical part: crop, downsample, fit the palette, and split sheets into pieces by connected component.
  3. Aseprite is the source of truth. One file per item, one tag per animation. Hand-fixing frames happens here and nowhere else, and a script exports sheets plus JSON that Godot reads directly.

The rule that kept this from turning into a mess: never edit the exported PNG. The moment you fix a stray pixel downstream of Aseprite, the next export silently reverts it, and you lose an afternoon working out why your fix keeps disappearing.

Every one of the 27 surviving states has its own looping idle animation, 23 of which are hand-checked in engine for pixel crawl. Things that move in place, like a windmill or a shoal of fish, ended up as baked frames rather than code-driven transforms, because sub-pixel motion on a 426-wide canvas looks like the sprite is vibrating.

Audio: measure it, do not eyeball it

Mixing by ear at midnight produces a game that is too loud by morning. Instead the mix is matched against a loudness target: browser and handheld games sit around -18 LUFS integrated, with true peak below -1 dBFS. The final mix measures -17.6 LUFS with a true peak of -1.6 dBFS, which is close enough to stop touching it.

Two Godot specifics cost real time. Gain has to be applied on the individual players rather than the master bus, because bus volume is applied after the effect chain, so the analyser you attached to measure loudness never sees it. And the soft limiter is not good enough to hold a ceiling: at a 2 dB reduction the true peak still came out above the target. Switching to a hard limiter with the ceiling set to -2.5 dB finally held, because four-times oversampled true peak runs roughly 1 dB above the sample peak it is limiting.

The bug that ate the title music

My favourite failure. Browsers refuse to play audio until the user has interacted with the page, so the game keeps its own lock: on web, sound stays off until the player clicks the canvas. Sensible, and it worked in the standalone export.

Then the game got embedded in this site behind a Play button, and the title screen went silent. The reasoning took a minute to unpick:

  • Audio unlocks on the first click inside the game canvas.
  • The only clickable thing on the title screen is PRESS START.
  • PRESS START leaves the title screen, and the music fades out on the way into the game.

So the unlock and the exit were the same click, and the title theme was unreachable by construction. It had been that way for as long as the web build existed, and no automated test would ever have caught it, because from the engine's point of view everything worked exactly as designed.

The fix is a handshake. The page's Play button is itself a user gesture, so the shell sets a flag on the window before booting the engine, and the game checks that flag at startup and unlocks immediately if it is there. The standalone export sees no flag and keeps its old behaviour.

While wiring that up, a second one: Godot's web runtime looks up the canvas with a selector built from its id, so handing it a canvas without an id fails with '#' is not a valid selector, which is not the most helpful phrasing for "give the element an id."

What I would tell myself at the start

  • Make the rules data early. Everything good here followed from being able to run the whole game without the game.
  • If the design has a combinatorial space, enumerate it. Balance stops being an opinion.
  • Pick one source of truth per asset type and never edit downstream of it.
  • Test the web build as it will actually be embedded, not in isolation. The most interesting bug lived exactly in that gap.

Play the thing 8 items, 40,320 orders, 3 of them perfect. Free in your browser, no download.

▶ Play free