All nine parts
Naijapolis is a 3D city builder. Most people who play it do so on a mid-range Android phone, often on a patchy connection. That one fact shaped almost every engineering decision in this series, and it starts here: the whole game is one HTML file.
Why one file?
A normal web game is a page plus dozens of requests: scripts, stylesheets, fonts, textures. On a good connection that is fine. On a bad one, every extra request is another chance to stall, and a half-loaded game is worse than a slow one. So the build produces a single, self-contained page. Once it has arrived, nothing else is needed to start playing. It can even be saved and opened with no connection at all (we come back to that in Part 6).
The trade is that the file must stay small enough to download quickly. We treat size like a budget, not a hope.
The stack, in plain terms
- three.js draws the world. It is a well-known WebGL library, vendored into the project so the game never depends on a CDN being up.
- esbuild bundles our own code. It is very fast, which keeps the loop between changing a line and seeing the result short.
- Plain JavaScript modules, no UI framework. The interface is a handful of sheets and cards built from strings. For a game where the 3D scene is the star and the panels are simple, a framework would add weight without adding much.
- Node for the build, the tests and the server. The game server has exactly one runtime dependency (a database driver).
How the build works
The game began life as a single large script and was split by concern into numbered files: the island, the water, the camera, tools, the simulation, the frame loop. The build joins those files in filename order into one closure. That is why the names start with numbers. It sounds old fashioned, and it is, but it kept a very large amount of working code intact while we moved the pure parts out into real ES modules (more on that in Part 2).
The recipe, simplified:
// 1. join the engine files, in order, into one scope
const parts = fs.readdirSync('src/legacy').filter(f => f.endsWith('.js')).sort();
const engine = parts.map(p => read('src/legacy/' + p)).join('');
// 2. hand the pure modules to that scope by name
const names = Object.keys(await import('./src/kernel/index.js'));
// 3. bundle everything for old and new phones alike
const out = await build({
entryPoints: ['src/main.js'], bundle: true,
format: 'iife', minify: true, target: 'es2020', write: false,
});
// 4. inline css, fonts, three.js and the bundle into one page
fs.writeFileSync('dist/index.html', page(css, three, out));
Two details are worth stealing:
- Fonts travel inside the page. The two typefaces are embedded as base64 and split by
unicode-range: the basic Latin set, plus the extended Latin that Yoruba needs for its tone marks. The browser only decodes the half it actually uses, and the game looks identical online, offline and on every phone. - A script-closing tag inside a script breaks the page. The build escapes any literal
</scriptin the bundle before inlining it. A tiny line of code that prevents a baffling blank screen.
The size budget
A script reads the finished file's size and fails if it is past a fixed number. The number is only ever raised on purpose, in a commit that says why. This single check has stopped more accidental bloat (a forgotten debug table, a duplicated font) than any amount of good intentions.
const BUDGET = { 'dist/artifact.html': 1975 * 1024 };
for (const [file, max] of Object.entries(BUDGET)) {
const size = fs.statSync(file).size;
console.log(file + ': ' + (size / 1024).toFixed(0) + ' KB of ' + (max / 1024).toFixed(0) + ' KB');
if (size > max) process.exitCode = 1; // CI goes red
}
Speaking the player’s language
The interface can be switched between English, Nigerian Pidgin, Yoruba, Hausa and Igbo. The mechanism is deliberately boring: a lookup table of exact phrases, plus a few patterns for sentences with numbers in them ("3 months left" has a different shape in each language). Anything not found is shown in English. A missing translation is a slightly less friendly screen, never a broken one. The table is exported to a spreadsheet so that translators can work without touching code, and imported back with a script.
What we learned
- Choose your constraint first (here: a slow phone), then let it make decisions for you.
- Measure the thing you care about in CI. A budget that nobody checks is a wish.
- Do not rewrite working code just to make it prettier. Move it, a piece at a time, behind tests.
Next, how those pieces are separated so that most of the game can be tested without a browser at all.