All nine parts
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
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
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
- A guard rail beats a guideline. The extraction tool that refuses to move impure code does more than a style document ever could.
- 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.
- Prefer a slow, certain approach (reload on switch) to a fast, subtle one when state is complicated.
Next, what the pure rules actually compute.