Vol. 07 · Dispatch2026-07-14

How Infinitown fakes an infinite city with 81 chunks and mod 9

The infinite-scrolling town in our Infinitown gamecenter port isn't procedurally generated — it's a Möbius carpet. A 9×9 pool of pre-built chunks maps onto a 9×9 grid of fixed container slots through modulo arithmetic, and camera drags rebind slots to different pool entries instead of spawning new geometry. This post walks the four moving parts (pool, containers, mapping, drag event) and explains why the whole system holds together with zero allocations at runtime.

by MetaWorldOS Engineering
Three.jsWebGLGame DesignObject PoolingInfinite World

TL;DR — Infinitown’s infinite tiling city is not an open world. It’s a fixed carpet of 81 pre-built 3D chunks, mapped onto a fixed grid of 81 container slots via chunks[gridX mod 9][gridY mod 9]. When the camera drags across a chunk boundary, a raycast fires a chunkmove event, gridCoords increments, and every container rebinds to a different pool entry — same 81 objects, new positions. No loaders, no allocations, no GC pressure. Cost: a subtle 9-tile visual repeat if you drag far in one direction, and a chunkScene.position that accumulates unbounded (float precision degrades after tens of thousands of units, unhandled upstream). The design is a classic bucket-vs-content separation and it’s easier to reason about once you see all four pieces at once.

Code: src/features/gamecenter/games/infinitown/engine/core/{AppScene,CityChunkTbl,SceneManager}.ts, constrol/SceneMoveController.ts.

Our gamecenter has a port of Infinitown — a bird’s-eye 3D toy town you drag around, cars weaving between the buildings, clouds drifting overhead. The advertised effect is “infinite city.” The camera scrolls in any direction forever and there’s always more town.

The natural assumption is that the city is procedurally generated on-demand: new blocks streamed in as the player nears the edge, old blocks freed as they slide out of view. That’s how you’d build an actual open-world game. It’s also expensive — you need a serialization format, a loader, a memory budget, a spawn/despawn scheduler, and a way to hide the seams.

Infinitown does none of that. It ships 81 chunks total, uses 81 container slots total, and gets the “infinite” feel from a single line of modulo arithmetic. Once you see all four moving parts at once, the whole trick collapses into something you could sketch on a napkin. Here it is.

Part 1: the pool — 81 chunks, built once, kept forever

CityChunkTbl is the object pool. At construction it generates a 9×9 grid of unique chunks and stashes them in this.chunks[x][y]. GVar.TABLE_SIZE = 9 is the magic number:

// CityChunkTbl.ts — one entry per (x, y) in [0, 9) × [0, 9).
private _generate(): void {
    for (let x = 0; x < GVar.TABLE_SIZE; x++) {
        for (let y = 0; y < GVar.TABLE_SIZE; y++) {
            const node = this._genRandomChunk(x, y)
            this.chunks[x] = this.chunks[x] || []
            this.chunks[x][y] = { node, tableX: x, tableY: y }
        }
    }
}

Each entry is a THREE.Object3D holding a building (from our gltf loader), four merged road lane segments, a corner intersection, some cars, maybe a cloud. _getRandomBlockAt uses _getNeighboringBlocks to avoid picking the same building type as an in-pool neighbour, so the 81 chunks look visually varied even though they’ll get reused constantly.

These 81 objects live forever. They are never released, never regenerated, never garbage-collected. The pool is finite and immutable after boot. That’s the whole memory story.

The lookup method is where the trick starts:

public getChunkData(x: number, y: number): ChunkData | null {
    x = x % GVar.TABLE_SIZE
    y = y % GVar.TABLE_SIZE
    if (x < 0) x = GVar.TABLE_SIZE + x
    if (y < 0) y = GVar.TABLE_SIZE + y
    return this.chunks[x]?.[y] ?? null
}

Ask for chunk at world coordinate (13, 4) — you get chunks[4][4]. Ask for (22, 4) — same thing, chunks[4][4]. Every 9 steps in any direction, the pool loops. The player is walking on a torus without knowing.

Part 2: the containers — a fixed 9×9 grid of slots

AppScene builds a matching 9×9 grid of THREE.Object3D containers. Their positions are set once and never touched again:

// AppScene.createChunkAt(x, z)
const halfSize = (GVar.CHUNK_COUNT - 1) / 2 * -GVar.CHUNK_SIZE
chunkInsContainer.position.x = halfSize + x * GVar.CHUNK_SIZE
chunkInsContainer.position.z = halfSize + z * GVar.CHUNK_SIZE

With CHUNK_COUNT = 9 and CHUNK_SIZE = 60, this lays a 540×540 world-unit carpet, centered on the parent (chunkScene) origin. Each cell is a bucket at a fixed spot. Their userData carries centeredX/centeredY — the container’s own grid index, needed for the raycast trick in Part 4.

These containers are buckets. Not content. They hold whichever chunk from the pool we’ve assigned them at this moment. The distinction between the container and its contents is the pivot of the whole design — it’s what lets us “move the world” without moving any geometry.

Part 3: the binding — refreshChunkScene

SceneManager keeps a running world coordinate: gridCoords: THREE.Vector2. It’s the player’s position on the infinite (well, “infinite”) map, measured in whole chunks. refreshChunkScene() is called at boot and again after every chunk crossing. It walks all 81 containers, and for each one asks the pool what chunk should live there right now:

protected refreshChunkScene(): void {
    this.chunkScene!.forEachChunk((chunkContainer, xOffset, yOffset) => {
        const xcor = this.gridCoords.x + xOffset   // absolute world coord
        const ycor = this.gridCoords.y + yOffset
        const v = this.cityChkTbl!.getChunkData(xcor, ycor)   // ← mod-9 lookup
        if (!v) return
        chunkContainer.remove(chunkContainer.getObjectByName("chunk"))
        chunkContainer.add(v.node)   // hot-swap
    })
}

Read that carefully. xcor is unbounded (world coordinate — grows as player walks). getChunkData does % 9 internally, so it always returns one of the 81 pool entries. chunkContainer.add(v.node) doesn’t create anything — it just reparents an object that already exists.

If gridCoords.x = 0 and we look at container (3, 5), we bind pool entry chunks[3][5]. When gridCoords.x = 1, that same container gets chunks[(1+3) % 9][5] = chunks[4][5]. Every drag across a boundary, every container rebinds to a different pool entry. Same 81 objects, new positions on the carpet.

Here’s the subtle bit that makes it feel infinite instead of a barrel: at any snapshot, forEachChunk fills all 81 containers with 81 distinct pool entries (the mapping (gridCoords + offset) mod 9 is a bijection over the 9×9 offset range). So the visible field is always a complete, non-repeating 9×9 arrangement. It only starts repeating if you drag more than 9 chunks in the same direction — then you see the same building you saw 9 tiles ago slide back into view.

Part 4: the trigger — dragging the world

Chunks only rebind when refreshChunkScene runs. What runs it? A raycast from the camera’s look-at target, straight down, into the container carpet:

// SceneMoveController.raycast()
this._raycaster.set(castVec, new THREE.Vector3(0, -1, 0))
const hits = this._raycaster.intersectObjects(this._scene!.getPickables())
if (hits.length > 0) {
    const cx = hits[0].object.userData["centeredX"]   // which grid cell
    const cy = hits[0].object.userData["centeredY"]
    this._sceneOffset.x += cx * GVar.CHUNK_SIZE
    this._sceneOffset.z += cy * GVar.CHUNK_SIZE
    EventMgr.getins().trigger("chunkmove", cx, cy)
}

The pickables are the container floor tiles. Each carries its own grid coord in userData. As the user drags, chunkScene.position shifts (buildings slide under a stationary camera), and the raycast start point (camera lookAt) starts hitting a different container’s floor. When it does, we’ve crossed a chunk boundary — fire chunkmove, bump gridCoords, refresh.

SceneManager listens:

EventMgr.getins().on("chunkmove", (xoff: number, yoff: number) => {
    this.gridCoords.x += xoff
    this.gridCoords.y += yoff
    this.refreshChunkScene()
})

That’s the entire feedback loop. Drag → position drift → raycast hits new cell → event → gridCoords advances → all containers rebind. From the player’s perspective the world just kept scrolling. From the engine’s perspective 81 objects got 81 new parents.

Why this works so well

Three properties fall out for free:

Zero-allocation scrolling. Once boot is done, no new calls, no loader traffic, no texture decompression, no GC pauses. The frame budget is entirely rendering. You could scroll for a week without touching the heap.

Zero seam risk. The 81 chunks were built once, at boot, by a generator that already knew about adjacency (_getNeighboringBlocks). The road tiles at chunk edges are always the same road tiles — they can’t disagree because they came from the same table. This is a strictly stronger guarantee than “streaming generation with consistent seams,” which is a hard problem people write papers about.

Trivial memory ceiling. 81 chunks. Whatever a chunk’s geometry costs (few hundred KB after gltf load + merge), multiply by 81. That’s your budget forever. You can put a hard number in the perf spreadsheet.

What breaks if you push it

Two things.

Repetition after 9 chunks. If the player drags in a straight line, the 10th chunk they see is the same one as chunk 1. This is visible if you’re looking for it — the same tall apartment block, same corner store, same intersection layout. In the bird’s-eye demo it doesn’t matter because you’re not treating any building as a landmark. In a game with quests or NPC memory it would. The fix is either a much bigger pool (say 32×32 = 1024 chunks, which is still cheap in modern VRAM but makes the boot generator slower), or actual streaming — but you lose the zero-alloc scroll.

chunkScene.position drifts unbounded. _sceneOffset += cx * CHUNK_SIZE is the accumulator. Every chunk boundary crossed, it grows by 60 world units. It’s never subtracted. After a few thousand chunks of dragging, chunkScene.position is in the hundreds of thousands. Float32 precision starts to hurt around 10^5, which is why we ended up snap-tracking the directional light to Math.round(chunkScene.position.x) to fight sub-texel shadow jitter (that’s a different debugging story). The clean fix would be to snap chunkScene.position back into [-CHUNK_SIZE/2, CHUNK_SIZE/2] after every chunkmove — the visible field wouldn’t change because the container-carpet-inside-chunkScene is already toroidal. Upstream Infinitown doesn’t do this. Neither did we. Someone should.

What Infinitown teaches about “infinite” worlds

The instinct — mine, at least — is to reach for procedural generation the moment the word “infinite” shows up. Streaming loaders, chunked serialization, LOD hierarchies, all the machinery. Infinitown says: don’t. Not until you actually need it.

If the player will never see two chunks that are more than N apart, you only need N-many chunks. If the world can repeat every K units without breaking the fantasy, you only need K^2 unique tiles. The rest is bookkeeping. And bookkeeping is what a modulo operator is for.

The infrastructure Infinitown doesn’t have — a serialization format, a chunk loader, a memory budget, a spawn scheduler — is infrastructure you don’t have to write, maintain, or debug. chunks[x % 9][y % 9] is a stand-in for all of it. That’s a lot of complexity to avoid for one operator.

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.