Naijapolis

Engineering blog · Part 4 of 9 · Graphics

Ground, water and light

A height-mapped island, a sky made of maths, soft shadows from tiny textures, and a game that lowers its own quality to stay smooth.

5 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 draws a living 3D town on phones that may be three or four years old. That is possible only because the game is honest about how much work it asks of the device, and willing to do less when the device cannot keep up.

Drawing the world

three.js sets the scene: a camera, lights, meshes. The interesting question is not what we draw but how much it costs. A town with a few thousand objects can easily mean thousands of draw calls, and on a phone, draw calls are the bottleneck. So the engine merges what it can: fences, roads and repeated props are built into combined meshes, and anything that appears many times (trees, vehicles, street lamps) shares geometry and materials.

Post effectsbloom, colour grade, grainBuildings, props, peoplemerged meshes, shared geometryContact shadowstiny soft textures under thingsGround and waterheight-mapped surface, shader waterSky domegradient shader, sun glowdrawn last at the top, first at the bottom
One frame is a stack of layers. Each is cheap on its own; the art is in keeping each one cheap enough that all five fit into a sixtieth of a second.

A sky made of maths

The sky is not a picture. It is a large sphere seen from the inside, painted by a tiny shader that blends a top colour into a horizon colour and adds a soft glow where the sun is. The colours come from the time of day, so one program produces the pale dawn, the white noon, the orange golden hour and the deep blue night, and every transition between them. Because the sky lives inside the scene, the final bloom and colour grade apply to it too, and it all feels like one photograph.

Soft shadows from a 64-pixel texture

Real shadow maps are expensive. For the soft darkening you see right under a house or a tree we cheat: at start-up the game paints two small textures on an offscreen canvas, one rectangular blob and one round one, using a smooth curve that fades from dark in the centre to transparent at the edge. Every building and tree gets a flat decal of the right shape under it. They are merged into just two meshes, so a thousand soft shadows cost two draw calls.

const smooth = t => { t = Math.min(1, Math.max(0, t)); return t * t * (3 - 2 * t) };   // smoothstep

function blob(shape) {
  const c = document.createElement('canvas'); c.width = c.height = 64;
  const g = c.getContext('2d'), img = g.createImageData(64, 64);
  for (let y = 0; y < 64; y++) for (let x = 0; x < 64; x++) {
    const u = x / 31.5 - 1, v = y / 31.5 - 1;                 // -1 .. 1 across the tile
    const a = shape === 'round'
      ? smooth((1 - Math.hypot(u, v)) / 0.9)                  // a soft disc
      : smooth((1 - Math.abs(u)) / 0.45) * smooth((1 - Math.abs(v)) / 0.45);   // a soft rectangle
    const i = (y * 64 + x) * 4;
    img.data[i + 3] = a * 255;                                // only the alpha varies
  }
  g.putImageData(img, 0, 0);
  return c;
}

Ground that is not flat

The first version of the game was a flat island. Players asked for coasts and hills, so the ground became a height map: a 128-by-128 grid where each number is a height. Roads, fences, houses, people and vehicles all ask the same question, "how high is the ground here?", and the answer comes from one function.

a grid of heightsthe same row, seen from the sidesea level
A grid of numbers becomes a landscape. Anywhere the ground is below the water level, there is water; anywhere the slope is too steep, nothing may be built.

Two decisions came out of early experiments:

  • Heights are 16-bit, not 8-bit. With eight bits, one step was visible as a terrace on a smooth hillside. Sixteen bits makes slopes look smooth, for the price of a slightly bigger save.
  • A value means "flat". A city with no terrain is simply a grid of the flat value, so old cities load unchanged and behave exactly as they did before. The save file only contains land when there is land.

The rules follow the ground too. Nothing may stand in water or on too steep a slope, and flooding is decided by height against the water level. Houses sit at the highest ground under their footprint, on a plinth that reaches down to the lowest, so a house on a slope looks planted, not floating.

Trees, flowers and rivers that sit on this ground are the subject of Part 8.

Staying smooth: the game lowers its own quality

Every device is different, so the game does not guess; it measures. It has four quality tiers, from Eco to High. The first tier is chosen from what the browser reports about the device (phones start lower than laptops, unless they report plenty of memory and cores). Then a frame-time monitor watches: when a full window of frames averages slower than the budget, the game drops one tier. It never climbs on its own, because bouncing up and down is worse than staying a little plain.

Eco30 fpsno effectsLow45 fpscontact shadowsMedium60 fpsbloom, env lightHigh60 fpsbig soft shadowsframes too slow: step down one tierit never climbs again by itself; the player can choose to
Each tier turns a few expensive things on or off: render resolution, shadow size, post effects, frame rate. The monitor only ever moves left.
const ORDER = ['eco', 'low', 'med', 'high'];
const lower = tier => ORDER[Math.max(0, ORDER.indexOf(tier) - 1)];

// the slowest a frame may take before we call it struggling
const budgetFor = tier => Math.max(36, 1000 / TIERS[tier].fps * 1.6);

function nextTier(tier, avgFrameMs) {
  return avgFrameMs > budgetFor(tier) && tier !== 'eco' ? lower(tier) : tier;
}

Two smaller kindnesses sit alongside it. When nothing has been touched for a while the game quietly drops to a low frame rate, so a phone left on the table does not heat up. And the long-running soak test (Part 7) plays a town for years and fails if GPU memory is not given back, because a leak that takes an hour to matter is exactly the kind a phone will find.

What we learned

  1. Cheap tricks, used everywhere, beat expensive tricks used once. A 64-pixel texture does the work of a shadow map for most scenes.
  2. Give old data a path: make "nothing new" look exactly like the old format.
  3. Do not trust a device’s self-description alone. Measure frame time and adapt.

Now for the part of a game people notice only when it is wrong: the sound.

Now go and build something.

It takes about a minute to lay your first road.

Play Game