Architecture

Roughly 5,800 lines of plain JavaScript on top of Three.js. No framework, no build step for the game itself, no runtime dependencies, and no binary assets.

5,842

lines across 17 source files

0

runtime dependencies

1 MB

the whole game, one HTML file

  1. The constraints
  2. Module map
  3. Entities and state
  4. Rendering and crowds
  5. World generation
  6. Simulation and pathfinding
  7. Save format
  8. Build and packaging
  9. Testing and releases

The constraints

Three rules shaped everything else.

Module map

Load order matters, because it is plain classic scripts: utilities first, data second, then the systems that need them.

FileLinesResponsibility
util.js265Seeded PRNG, value noise, maths, spatial hash, the A* nav grid, hex maths
data.js200Every tuning number: 24 buildings, 9 miracles, 22 unlocks, ages, adjacency, biomes, the four campaign lands
terrain.js307Island heightfield, chunked level-of-detail mesh, water, biome colouring, ray picking
gfx.js897Renderer, lights, sky, procedural mesh kits, instanced crowds, particles
world.js357Entity store, towns, resource accounting, mesh synchronisation, save and load
camera.js144God camera: zoom to cursor, edge scroll, rotate, touch
audio.js211Generative ambience and score, synthesised in the browser
people.js526Villager needs, jobs, skills, walking, fear, illness and death
buildings.js323Placement rules, construction, upgrades, adjacency, fire, towers
towns.js322Economy, births, ages, loyalty and conversion, the rival-tribe AI
miracles.js165Miracle effects and their consequences
creature.js387Creature AI, learning weights, commands, combat
army.js153Platoons, unit types, morale, raids
director.js135Seasons, weather, pacing events, the two advisors
hand.js460The divine hand, picking up and throwing, placement modes, gestures
ui.js606HUD, tabs, panels, charts, overlays
game.js384Bootstrap, land generation, the main loop, campaign flow

Entities and state

There are no classes for game objects. Every entity — villager, building, tree, rock, creature, platoon, town — is a plain object with a numeric id, held in a Map and in a per-type array. Systems are free functions over that state.

The one rule that makes this work: anything prefixed with an underscore is transient. A building's Three.js mesh lives at _mesh, a cached adjacency result at _adj, a dirty flag at _dirty. Serialisation strips every key beginning with _, so the save format is whatever the simulation actually needs and nothing else — no manual list of fields to keep in sync.

// world.js — the entire save filter
const strip = (k, v) => (k.startsWith('_') ? undefined : v);
return JSON.stringify(data, strip);

Rendering and crowds

A town at full tilt has dozens of villagers and tens of thousands of trees. Drawing each as its own mesh would be hopeless, so almost everything goes through InstancedMesh crowds that are rebuilt per frame from state:

CrowdCapacityNotes
People2,500Twelve parts each, with per-part local transforms, so legs swing, tools chop and worshippers raise their arms
Conifers / broadleaf20,000 eachRebuilt only when the forest changes
Need icons1,000The little symbols above an unhappy villager
Rocks, items400 each
Particles8,000One points system for smoke, sparks, rain and dust

Buildings are the exception: each is a small Group of boxes, cones and cylinders assembled by a buildingMesh function. Upgrading rebuilds the group with extra storeys and ornaments, which is why a level-three building visibly fills more of its hex.

Simulation has its own level of detail. Villagers far from the camera tick every third frame with a tripled delta, which keeps a large town cheap without changing its behaviour.

A wide view of the island showing terrain, forests and the town
Terrain is a chunked mesh with level of detail, built nearest-first so the ground under the camera appears immediately.

World generation

Every island comes from one integer seed, through a mulberry32 PRNG and value-noise fbm. Nothing is stored; the same seed rebuilds the same world exactly, which is also what makes save files small.

Simulation and pathfinding

Two spatial structures carry the load. A uniform grid hash (cell size 8) answers "what is near this point" for the thousands of nearest-neighbour queries each second. A separate navigation grid (cell size 3) holds walkability and movement cost.

Villagers try a straight line first and only run A* when it is blocked. Roads cost less, so people prefer them and visibly move 40% faster. Buildings stamp themselves into the grid by cell centre, gates are passable to their owner only, and anyone who somehow ends up inside a blocked cell is allowed to walk out — without that escape rule a single mis-stamped cell can strand a villager forever, which is exactly the bug it was written to fix.

The grid is rebuilt lazily: a navDirty flag plus a version counter, so paths recompute only when the world actually changed under them.

Save format

A save is JSON in local storage: the god, the towns, the people, the buildings, the creatures, the effects, the camera, the PRNG cursor and the weather. Trees are the one hand-packed structure — tens of thousands of them become compact arrays rather than objects.

Loading tolerates older saves through a chain of migrations: a resource that was renamed, a field that did not exist, levels added to buildings. Each is a couple of lines in loadState, which is cheap insurance against a format that is still moving.

Build and packaging

build.js inlines the stylesheet and all eighteen scripts into dist/index.html — about 1 MB, completely self-contained, works from file:// with no network. That single file is the browser build, the download on the releases page, and the page this site serves at /play/.

The desktop app is an Electron shell around exactly that file. The renderer is sandboxed: no Node integration, context isolation on, external links handed to the real browser, and one instance at a time so two copies cannot fight over the same saves. The packaged archive contains three files — the main process, the bundled game, and a manifest.

electron-builder produces an NSIS installer and a portable executable for Windows, dmg and zip for both Mac architectures, and an AppImage and deb for Linux.

Testing and releases

Three suites run on every pull request.

Static

About a second, no browser. Every file parses, and the data tables are checked against each other: no building missing from the menu, no category claiming one twice, worker slots matching the upgrade levels, no unlock that nothing defines.

Smoke

Loads the portable build from disk in headless Chromium, plays five minutes of simulated time, opens every panel, and round-trips a save.

Packaged app

Builds the Electron app and launches it for real, checking it loads the game, keeps Node out of the renderer, and can write a save.

Releases need no approval and no release pull request. Every pull request carries one label — release:patch, release:minor, release:major or release:none — and merging a versioned one bumps the version, dates the changelog, commits and tags on main, builds all three platforms, and publishes the release with those changelog entries as its notes. The labels create themselves on first run, so the repository needs no setup.

The static suite is where cheap invariants go. It has already caught a build menu entry pointing at a building that did not exist, a packaging option that was not in electron-builder's schema, two installer scripts drifting from the filenames the build actually produces, and a .gitignore pattern that silently excluded the release script.