Naijapolis

Engineering blog · Part 2 of 9 · Architecture

A kernel you can test

Pure rules on the inside, the messy browser on the outside, and the small API between them.

4 min read · 21 October 2026

All nine parts
  1. One file, no framework
  2. A kernel you can test
  3. The city’s heartbeat
  4. Ground, water and light
  5. Highlife from maths
  6. Sharing without trust
  7. Shipping and staying up
  8. Growing trees, flowers and water
  9. Waste and the politics of rubbish

A city builder has a lot of rules: how far a clinic reaches, what a house needs to be happy, when a council opens. If those rules are tangled up with 3D objects and buttons, you can only test them by playing the game. So Naijapolis is built the other way round: the rules live in pure functions, and the browser is just one place they run.

Three layers

Feature systems (one folder each)EconomyGovernanceAudioGraphics …LG API + event busthe only door between systems and the engineKernelEnginethree.js objects, DOM,live game statePure rule modulespower, services, layout,placement, save formatServer (Node)runs the same pure codesame files, no copies
Systems never reach into the engine directly. They go through a small API object and a hook bus. The pure rule modules at the bottom right know nothing about the browser, which is why the server can run them too.

The kernel is the original engine: renderer, camera, tools, input, and the base simulation. It is the oldest part of the code and still shares one scope. Over time we have been moving its pure pieces out into ES modules in a separate folder. A helper script does the moving, and it refuses to move anything that touches game state, THREE objects or the DOM. That guard rail is the whole point: it is impossible to "accidentally" make a pure module impure.

Systems are the features: economy, governance, markets, audio, graphics, interiors and about fifty more. Each lives in its own folder and exposes a single install function.

The server is the third layer, and it reuses kernel modules directly. We come back to this in Part 6.

The shape of a system

data.jsnumberslogic.jspure rulesruntime.jsthe live cityui.jsDOM onlyindex.jsinstallstested without a browserthe parts that touch the world
Every feature folder has the same five files. The left two are pure and are unit tested directly. The right three are thin glue.

The discipline is the order of work. When a new feature is added, the data tables and the rules are written first, with tests, before any interface exists. The interface then just shows what the rules say, including what every choice costs and what it will do. A shared helper formats those costs and effects, so a player is never asked to commit to something without seeing the consequence.

The API and the bus

A system receives one object, LG, and touches nothing global. It can read the city, ask whether the player is allowed to do something, store its own data, and subscribe to events such as "a month has passed" or "the city was reset". That is the whole surface. It looks like this:

export function installMarkets(LG, deps) {
  // persist this system's data with the city; load() must clean what it is given
  LG.ext('markets', { save: () => state, load: d => { state = sanitize(d) }, reset: () => { state = fresh() } });

  // react to time, never poll
  LG.bus.on('month', () => {
    if (!LG.bus.canGovern()) return;     // not before the council exists
    state = stepMonth(state, LG.city());   // pure function: state in, state out
  });
}

Because stepMonth is pure, a test does not need a browser, a canvas or a fake DOM. It needs a fake LG and some numbers.

Testing a pure rule

import test from 'node:test';
import assert from 'node:assert/strict';
import { powerRatio } from '../src/kernel/simcore.js';

test('supply that cannot meet demand gives a partial ratio', () => {
  assert.equal(powerRatio(50, 100, 0, 0), 0.5);
});
test('batteries help, but never above full', () => {
  assert.equal(powerRatio(90, 100, 100, 0), 1);
});
test('no generator means no power, whatever the demand', () => {
  assert.equal(powerRatio(0, 100, 0, 0), 0);
});

Node’s built-in test runner is enough. There are roughly nine hundred such tests, and the whole suite runs in seconds, which means it runs before every commit.

Nothing happens to a city that has nothing for it to happen to

One design rule deserves its own heading because it came from watching real players. A settlement of two people should not get a strike, a council meeting or a neighbouring town asking for aid. Early builds did, and it felt absurd. The fix is a single table of stages:

  • Gates: local government opens at 60 residents, neighbouring towns at 120, trade and its shocks at 60 residents or three shops.
  • Event needs: a stall-fee complaint needs a market stall, a teachers’ pay dispute needs a school, a strike needs fifteen people in work.

Every system asks the same two questions ("is this gate open?" and "does this event make sense here?") through one small function. The interface says what is missing ("opens at 60 people"), so a locked feature reads as something to look forward to, not a broken menu. A test fails if someone adds an event without an entry in the table.

Several cities on one device

A device can hold up to six cities. Switching between them reloads the page. That feels heavy-handed, but the game’s state lives in one closure and many systems, and a reload is the only way to be certain that nothing leaks from one city to the next. We chose the boring guarantee over the clever optimisation. The first city keeps the storage key the game has always used, so nobody’s existing town moved or changed when the feature arrived.

What we learned

  1. A guard rail beats a guideline. The extraction tool that refuses to move impure code does more than a style document ever could.
  2. Keep the surface between layers tiny. One object and one bus meant fifty systems could be written without any of them knowing about the others.
  3. Prefer a slow, certain approach (reload on switch) to a fast, subtle one when state is complicated.

Next, what the pure rules actually compute.

Now go and build something.

It takes about a minute to lay your first road.

Play Game