Naijapolis

Engineering blog · Part 1 of 9 · Build

One file, no framework

How a 3D city builder becomes a single page that opens on a cheap phone, and how the build keeps it that way.

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

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.

Engine filessrc/legacy, joined in orderPure kernel modulessrc/kernel, real ES modulesFeature systemssrc/systems, one folder eachStyles + two fontsfonts become base64 textthree.jsvendored, no CDNbuild.mjsesbuild bundlesand inlinesindex.htmlone file,about 2 MBSize budgetfails the buildif it grows
Everything that makes up the game funnels through one build step into one file. The last box matters as much as the first: the build refuses to finish if the file passes its size budget.

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 </script in 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

  1. Choose your constraint first (here: a slow phone), then let it make decisions for you.
  2. Measure the thing you care about in CI. A budget that nobody checks is a wish.
  3. 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.

Now go and build something.

It takes about a minute to lay your first road.

Play Game