Leave width and height off a TileLayer and let a ChunkStreamer keep only the chunks near the camera resident — procedurally sampled on the main thread, off-thread in a Worker, or re-sliced from a Tiled infinite map.
Advanced~7 min read
What you'll learn
create unbounded tile maps and stream chunks near the camera with ChunkStreamer
generate deterministic procedural terrain with createSampledChunkSource
move expensive sampling off the main thread with createWorkerSampledChunkSource
Most tilemaps have edges: a fixed width and height, a grid you can walk to the end of. An unbounded map has none. You construct a TileLayer (and its TileMap) with no width or height, and chunk storage stops being clamped to a fixed grid — any signed chunk coordinate becomes valid. Chunks exist only where something writes them; the rest of the plane is simply empty until you populate it.
Everything else is the ordinary tilemap machinery you already know. Rendering, culling, band composition, parallax, and pixel snapping all work exactly as they do on a bounded map — an unbounded layer is not a special renderer, just a layer that never says “no” to a coordinate. What is new is the piece that decides which chunks are worth keeping in memory as the camera moves, and where their tiles come from: a ChunkStreamer driven by a ChunkSource.
Unbounded layers
Omit width and height together (they are paired — provide both or neither) and the layer is unbounded. Two properties report the difference:
pixelWidth and pixelHeight are undefined — notInfinity. There is no finite extent to report, so there is no number to give.
import { TileLayer, TileMap } from '@codexo/exojs-tilemap';// No width/height: the layer (and map) are unbounded. Chunk storage now// accepts any signed chunk coordinate instead of a fixed 0..N grid.const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [] });const map = new TileMap({ name: 'infinite-world', tileWidth: 64, tileHeight: 64, layers: [terrain] });terrain.bounded; // falseterrain.pixelWidth; // undefined — never Infinitymap.pixelHeight; // undefined
Because there is no finite extent, there is nothing to clamp a camera to. Do not call setBounds on the View that follows your player — an unbounded world has no edges, and clamping to a bound it does not have would just pin the camera at the origin. Let the view follow freely.
Streaming chunks with ChunkStreamer
A ChunkStreamer watches a View and keeps the chunks near it resident: chunks that scroll into range are requested from a ChunkSource and installed, chunks that scroll far enough away are evicted. It touches only the layer’s data — the tilemap scene node reacts to adopt/evict on its own — so it has no rendering dependency. Construct one with the layer, a source, and the view, then tick it once per frame from your update loop:
examples/guides/infinite-maps/chunk-streaming.ts
const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [] });const view = new View(0, 0, 1280, 720);// Any ChunkSource works; the next section builds a real procedural one. This// placeholder leaves every chunk empty.const source = createSampledChunkSource(terrain, { sample: () => 0, mapValueToTile: () => null,});// loadRadius / unloadRadius are a hysteresis band (defaults 1 / 2). The gap// between them prevents load/unload thrashing when the view sits on a chunk// boundary. unloadRadius must be >= loadRadius or the constructor throws.const streamer = new ChunkStreamer(terrain, source, view, { loadRadius: 2, unloadRadius: 3, maxChunkLoadsPerFrame: 8,});// Tick from update(). The very first call loads the whole initial wanted set// unbudgeted, so the starting screen never pops in; every later call is// capped at maxChunkLoadsPerFrame (default 8).streamer.update();// On teardown, destroy() evicts exactly the chunks this instance loaded -// nothing that predated it or another source installed. Idempotent.streamer.destroy();
Chunks within loadRadius chunk-units (Chebyshev distance) of the visible range are loaded; own-resident chunks beyond unloadRadius are evicted. The maxChunkLoadsPerFrame budget spreads the cost of a fast camera across frames rather than loading an unbounded burst in one — with the deliberate exception of the first update(), which loads everything the starting view wants at once. The streamer tracks only its own resident set, so several sources can coexist on one layer without fighting over each other’s chunks.
It works on bounded layers too. A large finite map that you do not want fully resident benefits from the same eviction; there the wanted range is simply clamped to the layer’s chunkRange() so the streamer never asks for chunks outside the grid.
Procedural terrain with createSampledChunkSource
createSampledChunkSource turns a per-tile sampling function into a ChunkSource. It takes two callbacks, and both are required on purpose — the engine has no opinion on which noise algorithm you use or how your tilesheet’s ids map to values:
sample(tx, ty) returns a scalar for one tile.
mapValueToTile(value, tx, ty) turns that scalar into a resolved tile (a tileset + localTileId + transform), or null for an empty cell.
Both must be pure and deterministic, for the reason the callout above spells out. Here is a complete provider built on value-noise fBm and elevation-style biome bands:
import { View } from '@codexo/exojs';import { ChunkStreamer, createSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, type TileSet } from '@codexo/exojs-tilemap';// The tileset you built from your tilesheet (see the Tilesets guide).declare const tileset: TileSet;const FEATURE_SIZE = 28; // tiles per base-octave noise cell// Deterministic integer-lattice hash → [0, 1). Any change here changes every// world.function hash2D(seed: number, x: number, y: number): number { let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0; h = Math.imul(h ^ (h >>> 15), 0x85ebca6b); h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); h ^= h >>> 16; return (h >>> 0) / 4294967296;}function valueNoise(seed: number, x: number, y: number): number { const x0 = Math.floor(x); const y0 = Math.floor(y); const fx = x - x0; const fy = y - y0; const sx = fx * fx * (3 - 2 * fx); const sy = fy * fy * (3 - 2 * fy); const n00 = hash2D(seed, x0, y0); const n10 = hash2D(seed, x0 + 1, y0); const n01 = hash2D(seed, x0, y0 + 1); const n11 = hash2D(seed, x0 + 1, y0 + 1); const nx0 = n00 + (n10 - n00) * sx; const nx1 = n01 + (n11 - n01) * sx; return nx0 + (nx1 - nx0) * sy;}// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94).function fbm(seed: number, x: number, y: number): number { let value = 0; let amplitude = 0.5; let frequency = 1; for (let octave = 0; octave < 4; octave++) { value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency); amplitude *= 0.5; frequency *= 2; } return value;}// Elevation bands → solid terrain-center tile ids in the tilesheet.const TILE_DEEP_WATER = 203;const TILE_WATER = 186;const TILE_SAND = 18;const TILE_GRASS = 23;const TILE_ROCK = 28;const TILE_SNOW = 86;function biomeTileId(value: number): number { if (value < 0.34) return TILE_DEEP_WATER; if (value < 0.42) return TILE_WATER; if (value < 0.5) return TILE_SAND; if (value < 0.68) return TILE_GRASS; if (value < 0.8) return TILE_ROCK; return TILE_SNOW;}const terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: 64, tileHeight: 64, tilesets: [tileset] });const view = new View(0, 0, 1280, 720);const seed = 1337;const source = createSampledChunkSource(terrain, { sample: (tx, ty) => fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE), mapValueToTile: value => ({ tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }),});const streamer = new ChunkStreamer(terrain, source, view);
The noise here is illustrative. Swap sample for a Simplex generator, a third-party noise library, or a heightmap lookup — anything pure and deterministic works, because the provider makes no assumptions about it beyond that contract.
import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Spritesheet, type SpritesheetData, TextureRegion, View } from '@codexo/exojs';
import { ChunkStreamer, createSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, type TileMapView, TileSet } from '@codexo/exojs-tilemap';
import { mountControlPanel, mountControls } from '@examples/runtime';
// An infinite, procedurally generated world: the TileLayer has NO width/height
// (unbounded), and a ChunkStreamer keeps only the chunks near the camera
// resident. Terrain comes from a deterministic value-noise fBm sampler fed
// through createSampledChunkSource - same seed, same world, every visit.
const TILE = 64;
const FEATURE_SIZE = 28;
const MOVE_SPEED = 420;
// Deterministic integer-lattice hash → [0, 1). Any change here changes every
// world; the worker copy in the worker example must stay byte-identical.
function hash2D(seed: number, x: number, y: number): number {
let h = (seed ^ Math.imul(x, 0x27d4eb2d) ^ Math.imul(y, 0x165667b1)) | 0;
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function valueNoise(seed: number, x: number, y: number): number {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const fx = x - x0;
const fy = y - y0;
const sx = fx * fx * (3 - 2 * fx);
const sy = fy * fy * (3 - 2 * fy);
const n00 = hash2D(seed, x0, y0);
const n10 = hash2D(seed, x0 + 1, y0);
const n01 = hash2D(seed, x0, y0 + 1);
const n11 = hash2D(seed, x0 + 1, y0 + 1);
const nx0 = n00 + (n10 - n00) * sx;
const nx1 = n01 + (n11 - n01) * sx;
return nx0 + (nx1 - nx0) * sy;
}
// 4 octaves, persistence 0.5, lacunarity 2 → result in ~[0, 0.94).
function fbm(seed: number, x: number, y: number): number {
let value = 0;
let amplitude = 0.5;
let frequency = 1;
for (let octave = 0; octave < 4; octave++) {
value += amplitude * valueNoise(seed + octave, x * frequency, y * frequency);
amplitude *= 0.5;
frequency *= 2;
}
return value;
}
// Biome mapping (elevation-style bands; localTileId values are solid
// full-square terrain-center tiles read off mapPack_tilesheet.png - 17
// columns, index = row * 17 + column).
const TILE_DEEP_WATER = 203; // patterned blue (row 11, col 16)
const TILE_WATER = 186; // plain light blue (row 10, col 16)
const TILE_SAND = 18; // beige center (row 1, col 1)
const TILE_GRASS = 23; // green center (row 1, col 6)
const TILE_ROCK = 28; // gray center (row 1, col 11)
const TILE_SNOW = 86; // white center (row 5, col 1)
function biomeTileId(value: number): number {
if (value < 0.34) return TILE_DEEP_WATER;
if (value < 0.42) return TILE_WATER;
if (value < 0.5) return TILE_SAND;
if (value < 0.68) return TILE_GRASS;
if (value < 0.8) return TILE_ROCK;
return TILE_SNOW;
}
class InfiniteTerrainScene extends Scene {
private camera!: View;
private explorer!: Sprite;
private worldRoot!: Container;
private mapView!: TileMapView;
private terrain!: TileLayer;
private tileset!: TileSet;
private streamer!: ChunkStreamer;
private seed = 1337;
private moveX = 0;
private moveY = 0;
private hudTimer = 0;
private hud!: ReturnType<typeof mountControls>;
override async load(): Promise<void> {
const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image));
this.tileset = new TileSet({
name: 'biomes',
texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }),
tileWidth: TILE,
tileHeight: TILE,
tileCount: 204,
columns: 17,
});
// No width/height: the layer (and map) are unbounded - chunks exist
// only where something writes them.
this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] });
const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] });
this.mapView = map.createView({ bands: { terrain: ['terrain'] } });
const characters = new Spritesheet(
await this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)),
(await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData,
);
this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5);
this.explorer.setPosition(0, 0);
this.explorer.setScale(1.25);
const actorLayer = new Container();
actorLayer.addChild(this.explorer);
this.worldRoot = new Container();
this.worldRoot.addChild(this.mapView.band('terrain'), actorLayer);
// Camera follows the explorer - no setBounds: an unbounded map has no
// edges to clamp the camera to.
const { width, height } = app;
this.camera = new View(this.explorer.x, this.explorer.y, width, height);
this.camera.follow(this.explorer, { lerp: 0.12 });
this.rebuildStreamer();
this.setupInput();
this.setupHud();
}
private rebuildStreamer(): void {
// destroy() evicts every chunk this streamer loaded; the next update()
// of the replacement streamer loads the whole initial wanted set
// unbudgeted, so a seed change repopulates the screen in one frame.
this.streamer?.destroy();
const seed = this.seed;
const source = createSampledChunkSource(this.terrain, {
sample: (tx, ty) => fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE),
mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }),
});
this.streamer = new ChunkStreamer(this.terrain, source, this.camera);
}
private setupInput(): void {
this.inputs.onActive(Keyboard.A, () => (this.moveX = -1));
this.inputs.onStop(Keyboard.A, () => {
if (this.moveX < 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.D, () => (this.moveX = 1));
this.inputs.onStop(Keyboard.D, () => {
if (this.moveX > 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.W, () => (this.moveY = -1));
this.inputs.onStop(Keyboard.W, () => {
if (this.moveY < 0) this.moveY = 0;
});
this.inputs.onActive(Keyboard.S, () => (this.moveY = 1));
this.inputs.onStop(Keyboard.S, () => {
if (this.moveY > 0) this.moveY = 0;
});
}
private setupHud(): void {
this.hud = mountControls({
title: 'Infinite Procedural Terrain',
controls: [
{ keys: 'WASD', action: 'fly across the endless world' },
{ keys: 'panel', action: 'reroll the seed' },
],
status: '',
hint: 'The map has no width or height. A ChunkStreamer loads chunks around the camera and evicts the ones you leave behind — revisited terrain is regenerated identically from the seed.',
});
const panel = mountControlPanel({ title: 'World' });
panel.addButton({
label: 'New seed',
onClick: () => {
this.seed = (Math.random() * 0x7fffffff) | 0;
this.rebuildStreamer();
},
});
}
override update(delta: Seconds): void {
if (this.moveX !== 0 || this.moveY !== 0) {
const length = Math.hypot(this.moveX, this.moveY) || 1;
this.explorer.move((this.moveX / length) * MOVE_SPEED * delta, (this.moveY / length) * MOVE_SPEED * delta);
}
this.streamer.update();
this.hudTimer += delta;
if (this.hudTimer >= 0.25) {
this.hudTimer = 0;
const tx = Math.floor(this.explorer.x / TILE);
const ty = Math.floor(this.explorer.y / TILE);
this.hud.setStatus(`${this.streamer.residentCount} chunks resident · tile ${tx}, ${ty} · seed ${this.seed}`);
}
}
override draw(context: RenderingContext): void {
context.render(this.worldRoot, { view: this.camera });
}
}
const app = new Application({
scenes: { InfiniteTerrainScene },
canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() },
clearColor: new Color(38, 82, 128), // deep-water blue behind unloaded chunks
extensions: [tilemapExtension],
});
await app.start(InfiniteTerrainScene);
Off the main thread with createWorkerSampledChunkSource
A sampling function that is expensive per tile can stall the main thread while a chunk generates — the frame hitches, the camera stutters. createWorkerSampledChunkSource is the async counterpart that runs the sampler on a Web Worker instead.
You cannot hand it a live function: functions cannot cross a postMessage boundary. Instead you pass workerSource — a complete, self-contained worker script as a string (a template literal in your own module). It is Blob-URL’d into a real Worker at construction, the same technique the audio package uses to spin up AudioWorklet processors from a source string. Because it shares no scope with your module, the worker must carry its own copy of the sampling code. The script implements a small request/response protocol:
import { createWorkerSampledChunkSource, TILE_TRANSFORM_IDENTITY, type TileLayer, type TileSet } from '@codexo/exojs-tilemap';declare const terrain: TileLayer;declare const tileset: TileSet;// A complete worker script. It shares no scope with this module, so it must// carry its own copy of the sampling code — nothing outside this string exists// inside the worker.const workerSource = `"use strict";// Your sampler, transcribed to plain JS (kept byte-identical to the main-thread// copy so both render the same world for the same seed).function sample(tx, ty) { return (Math.sin(tx * 0.1) + Math.cos(ty * 0.1)) * 0.25 + 0.5;}self.onmessage = (event) => { const { requestId, cx, cy, chunkWidth, chunkHeight } = event.data; try { const values = new Float64Array(chunkWidth * chunkHeight); for (let localTy = 0; localTy < chunkHeight; localTy++) { for (let localTx = 0; localTx < chunkWidth; localTx++) { const tx = cx * chunkWidth + localTx; const ty = cy * chunkHeight + localTy; values[localTy * chunkWidth + localTx] = sample(tx, ty); } } // Reply once, transferring the buffer for a zero-copy handoff. self.postMessage({ requestId, values }, [values.buffer]); } catch (error) { // The error path must ALSO reply, with the same requestId. self.postMessage({ requestId, error: String(error) }); }};`;// mapValueToTile always stays on the main thread — a ResolvedTile references a// TileSet/Texture, neither of which can cross a postMessage boundary.const source = createWorkerSampledChunkSource(terrain, { workerSource, mapValueToTile: value => ({ tileset, localTileId: value < 0.5 ? 186 : 23, transform: TILE_TRANSFORM_IDENTITY }),});// The returned source owns a real Worker — you MUST call destroy() when done// (e.g. alongside ChunkStreamer.destroy()). ChunkSource has no lifecycle hook,// so this is not automatic; the Worker leaks otherwise.source.destroy();
The worker receives { requestId, cx, cy, chunkWidth, chunkHeight }, computes one value per tile in row-major order (localTy * chunkWidth + localTx), and replies with { requestId, values } — transferring values.buffer so the array moves without a copy. The main-thread mapValueToTile then turns each value into a tile, exactly as in the sync provider.
If your deployment sets a Content-Security-Policy, it must permit blob: in worker-src (or script-src as a fallback) — that is how the source string becomes a Worker.
import { Application, Asset, Color, Container, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Spritesheet, type SpritesheetData, TextureRegion, View } from '@codexo/exojs';
import { type ChunkSource, ChunkStreamer, createSampledChunkSource, createWorkerSampledChunkSource, TILE_TRANSFORM_IDENTITY, TileLayer, TileMap, tilemapExtension, type TileMapView, TileSet } from '@codexo/exojs-tilemap';
import { mountControlPanel, mountControls } from '@examples/runtime';
import { fbm } from '@examples/terrain-noise';
import terrainWorkerSource from './worker-streamed-terrain.worker.ts?worker';
// The same infinite, procedurally generated world as "Infinite Procedural
// Terrain", but the noise sampling can run off the main thread via
// createWorkerSampledChunkSource. Toggle "Provider" between sync/worker and
// raise "Sample cost" to make each tile artificially expensive to sample -
// on the sync path the main thread stalls and the spinning marker + camera
// motion visibly hitch; on the worker path they stay smooth.
//
// Both providers call the same fbm from @examples/terrain-noise: the worker
// gets it bundled into its source string at build time, which is what keeps
// the two worlds identical.
const TILE = 64;
const FEATURE_SIZE = 28;
const MOVE_SPEED = 420;
// Biome mapping (elevation-style bands; localTileId values are solid
// full-square terrain-center tiles read off mapPack_tilesheet.png - 17
// columns, index = row * 17 + column).
const TILE_DEEP_WATER = 203; // patterned blue (row 11, col 16)
const TILE_WATER = 186; // plain light blue (row 10, col 16)
const TILE_SAND = 18; // beige center (row 1, col 1)
const TILE_GRASS = 23; // green center (row 1, col 6)
const TILE_ROCK = 28; // gray center (row 1, col 11)
const TILE_SNOW = 86; // white center (row 5, col 1)
function biomeTileId(value: number): number {
if (value < 0.34) return TILE_DEEP_WATER;
if (value < 0.42) return TILE_WATER;
if (value < 0.5) return TILE_SAND;
if (value < 0.68) return TILE_GRASS;
if (value < 0.8) return TILE_ROCK;
return TILE_SNOW;
}
class WorkerStreamedTerrainScene extends Scene {
private camera!: View;
private explorer!: Sprite;
private marker!: Sprite;
private worldRoot!: Container;
private mapView!: TileMapView;
private terrain!: TileLayer;
private tileset!: TileSet;
private streamer!: ChunkStreamer;
private seed = 1337;
private providerMode: 'worker' | 'sync' = 'worker';
private extraCost = 200;
private workerSourceHandle: (ChunkSource & { destroy(): void }) | null = null;
private moveX = 0;
private moveY = 0;
private hudTimer = 0;
private frameMs = 0;
private hud!: ReturnType<typeof mountControls>;
override async load(): Promise<void> {
const tilesTexture = await this.loader.load(Asset.type('texture', assets.demo.tilesets.map.image));
this.tileset = new TileSet({
name: 'biomes',
texture: new TextureRegion(tilesTexture, { x: 0, y: 0, width: tilesTexture.width, height: tilesTexture.height }),
tileWidth: TILE,
tileHeight: TILE,
tileCount: 204,
columns: 17,
});
// No width/height: the layer (and map) are unbounded - chunks exist
// only where something writes them.
this.terrain = new TileLayer({ id: 1, name: 'terrain', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset] });
const map = new TileMap({ name: 'infinite-world', tileWidth: TILE, tileHeight: TILE, tilesets: [this.tileset], layers: [this.terrain] });
this.mapView = map.createView({ bands: { terrain: ['terrain'] } });
const characters = new Spritesheet(
await this.loader.load(Asset.type('texture', assets.demo.spritesheets.platformerCharacters.image)),
(await this.loader.load(Asset.type('json', assets.demo.spritesheets.platformerCharacters.data))) as SpritesheetData,
);
this.explorer = characters.getFrameSprite('character_green_front').setAnchor(0.5);
this.explorer.setPosition(0, 0);
this.explorer.setScale(1.25);
// Jank indicator: this sprite spins at a constant rate every frame
// regardless of provider mode - any hitch on the main thread (the
// sync provider under a high sample cost) is immediately visible as
// a stutter in its rotation.
this.marker = characters.getFrameSprite('character_beige_front').setAnchor(0.5);
this.marker.setScale(0.75);
this.marker.setPosition(96, -96);
const actorLayer = new Container();
actorLayer.addChild(this.explorer, this.marker);
this.worldRoot = new Container();
this.worldRoot.addChild(this.mapView.band('terrain'), actorLayer);
// Camera follows the explorer - no setBounds: an unbounded map has no
// edges to clamp the camera to.
const { width, height } = app;
this.camera = new View(this.explorer.x, this.explorer.y, width, height);
this.camera.follow(this.explorer, { lerp: 0.12 });
this.rebuildStreamer();
this.setupInput();
this.setupHud();
}
private rebuildStreamer(): void {
// destroy() evicts every chunk this streamer loaded; the next
// update() of the replacement streamer loads the whole initial
// wanted set unbudgeted, so a mode/cost/seed change repopulates the
// screen in one frame.
this.streamer?.destroy();
// Terminate the previous Worker on every rebuild - it leaks
// otherwise, since createWorkerSampledChunkSource has no lifecycle
// hook of its own beyond the destroy() it returns.
this.workerSourceHandle?.destroy();
this.workerSourceHandle = null;
const seed = this.seed;
const cost = this.extraCost;
if (this.providerMode === 'worker') {
this.workerSourceHandle = createWorkerSampledChunkSource(this.terrain, {
// The worker runs the same fbm this file imports - bundled into
// its source string, not restated - so both providers render an
// identical world for a given seed.
workerSource: terrainWorkerSource,
// Seed and cost travel as data, so changing either does not mean
// rebuilding a source string.
initMessage: { type: 'terrain-init', seed, featureSize: FEATURE_SIZE, extraCost: cost },
mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }),
});
this.streamer = new ChunkStreamer(this.terrain, this.workerSourceHandle, this.camera);
} else {
const source = createSampledChunkSource(this.terrain, {
sample: (tx, ty) => {
let value = fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE);
for (let i = 0; i < cost; i++) {
value = fbm(seed, tx / FEATURE_SIZE, ty / FEATURE_SIZE);
}
return value;
},
mapValueToTile: value => ({ tileset: this.tileset, localTileId: biomeTileId(value), transform: TILE_TRANSFORM_IDENTITY }),
});
this.streamer = new ChunkStreamer(this.terrain, source, this.camera);
}
}
private setupInput(): void {
this.inputs.onActive(Keyboard.A, () => (this.moveX = -1));
this.inputs.onStop(Keyboard.A, () => {
if (this.moveX < 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.D, () => (this.moveX = 1));
this.inputs.onStop(Keyboard.D, () => {
if (this.moveX > 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.W, () => (this.moveY = -1));
this.inputs.onStop(Keyboard.W, () => {
if (this.moveY < 0) this.moveY = 0;
});
this.inputs.onActive(Keyboard.S, () => (this.moveY = 1));
this.inputs.onStop(Keyboard.S, () => {
if (this.moveY > 0) this.moveY = 0;
});
}
private setupHud(): void {
this.hud = mountControls({
title: 'Worker-Streamed Terrain',
controls: [
{ keys: 'WASD', action: 'fly across the endless world' },
{ keys: 'panel', action: 'switch provider / raise sample cost' },
],
status: '',
hint: 'createWorkerSampledChunkSource runs the noise sampler on a Worker thread; createSampledChunkSource runs it on the main thread. Raise the sample cost and switch providers to see which one keeps the frame time flat.',
});
const panel = mountControlPanel({ title: 'Provider' });
panel.addCycle({
label: 'Provider',
options: ['worker', 'sync'],
index: 0,
onChange: (_, mode) => {
this.providerMode = mode as 'worker' | 'sync';
this.rebuildStreamer();
},
});
panel.addSlider({
label: 'Sample cost',
min: 0,
max: 500,
step: 50,
value: 200,
onChange: value => {
this.extraCost = value;
this.rebuildStreamer();
},
});
}
override update(delta: Seconds): void {
if (this.moveX !== 0 || this.moveY !== 0) {
const length = Math.hypot(this.moveX, this.moveY) || 1;
this.explorer.move((this.moveX / length) * MOVE_SPEED * delta, (this.moveY / length) * MOVE_SPEED * delta);
}
this.marker.rotation += 2 * delta;
this.streamer.update();
// Exponential moving average smooths out single-frame noise so the
// readout reflects sustained jank rather than every GC blip.
this.frameMs = this.frameMs * 0.9 + delta * 1000 * 0.1;
this.hudTimer += delta;
if (this.hudTimer >= 0.25) {
this.hudTimer = 0;
const tx = Math.floor(this.explorer.x / TILE);
const ty = Math.floor(this.explorer.y / TILE);
this.hud.setStatus(
`${this.providerMode} · ${this.frameMs.toFixed(1)} ms/frame · ${this.streamer.residentCount} chunks · tile ${tx}, ${ty} · cost ${this.extraCost}`,
);
}
}
override draw(context: RenderingContext): void {
context.render(this.worldRoot, { view: this.camera });
}
}
const app = new Application({
scenes: { WorkerStreamedTerrainScene },
canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() },
clearColor: new Color(38, 82, 128), // deep-water blue behind unloaded chunks
extensions: [tilemapExtension],
});
await app.start(WorkerStreamedTerrainScene);
Streaming a Tiled infinite map
You do not have to generate terrain procedurally — you can stream a hand-authored infinite map made in Tiled. TiledMap.toTileMap() accepts infinite: true maps: each chunked tile layer converts to an unbounded runtime TileLayer with no tiles populated eagerly. Its data streams in on demand through a ChunkSource that toTileMap() builds as a side effect, retrievable with getChunkSource(layerId). That source re-slices Tiled’s on-disk chunks onto the runtime chunk grid lazily, one requested chunk at a time.
import { View } from '@codexo/exojs';import { ChunkStreamer } from '@codexo/exojs-tilemap';import type { TiledMap } from '@codexo/exojs-tiled';declare const tiled: TiledMap; // loaded through the @codexo/exojs-tiled Loader// Build the runtime map. An infinite: true map's chunked layers become// unbounded TileLayers; this call is also what builds the chunk sources.const map = tiled.toTileMap();const terrain = map.getTileLayer('terrain');const view = new View(0, 0, 1280, 720);if (terrain) { // getChunkSource returns undefined for a finite (data-based) layer — only // chunked infinite-map layers get one. Call toTileMap() before this. const source = tiled.getChunkSource(terrain.id); if (source) { const streamer = new ChunkStreamer(terrain, source, view); streamer.update(); }}
From there it is the same ChunkStreamer loop as the procedural providers — the only difference is where the tiles come from.
import { Application, Asset, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, View } from '@codexo/exojs';
import { tiledExtension } from '@codexo/exojs-tiled';
import { ChunkStreamer, TileMapNode } from '@codexo/exojs-tilemap';
import { mountControls } from '@examples/runtime';
// A hand-authored Tiled `.tmj` infinite map, streamed the same way the
// procedural-terrain examples stream generated worlds: TiledMap.toTileMap()
// converts every chunked ("infinite") tile layer to an unbounded runtime
// TileLayer and builds a ChunkSource for it as a side effect; getChunkSource
// hands that source to a ChunkStreamer - one per chunked layer - ticked from
// a free-flying WASD camera with no bounds.
//
// drift-fields.tmj (examples/assets/json/maps/) is a small island cluster: a
// "Ground" tile layer (8 on-disk 16x16 chunks - a sand beach ring, a rock
// outcrop near the origin, a snow patch tucked into the interior, and an open
// "bay" where one corner chunk was left unauthored) and a sparser "Props"
// tile layer (6 chunks of scattered boulder/gem decoration) stacked on top.
// Everywhere neither layer has an on-disk chunk, the map shows nothing -
// the clear color behind it reads as open water.
const TILE = 64;
const MOVE_SPEED = 480;
class TiledInfiniteMapScene extends Scene {
private camera!: View;
private mapNode!: TileMapNode;
private groundStreamer!: ChunkStreamer;
private propsStreamer!: ChunkStreamer;
private moveX = 0;
private moveY = 0;
private hudTimer = 0;
private hud!: ReturnType<typeof mountControls>;
override async load(): Promise<void> {
const source = await this.loader.load(Asset.type('tiledSource', 'json/maps/drift-fields.tmj'));
const runtimeMap = source.toTileMap();
this.mapNode = new TileMapNode(runtimeMap);
const ground = runtimeMap.getTileLayer('Ground');
const props = runtimeMap.getTileLayer('Props');
if (!ground || !props) {
throw new Error('drift-fields.tmj is missing its "Ground" or "Props" tile layer');
}
// getChunkSource is a side effect of the toTileMap() call above - it
// returns undefined for a finite (data-based) layer, and one
// ChunkSource per chunked ("infinite") layer otherwise.
const groundSource = source.getChunkSource(ground.id);
const propsSource = source.getChunkSource(props.id);
if (!groundSource || !propsSource) {
throw new Error('drift-fields.tmj\'s "Ground"/"Props" layers are not chunked — is "infinite" true?');
}
const { width, height } = app;
// Free camera: moved directly by WASD, not following any actor - no
// setBounds, since an unbounded map has no edges to clamp to.
this.camera = new View(0, 0, width, height);
this.groundStreamer = new ChunkStreamer(ground, groundSource, this.camera);
this.propsStreamer = new ChunkStreamer(props, propsSource, this.camera);
this.setupInput();
this.setupHud();
}
private setupInput(): void {
this.inputs.onActive(Keyboard.A, () => (this.moveX = -1));
this.inputs.onStop(Keyboard.A, () => {
if (this.moveX < 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.D, () => (this.moveX = 1));
this.inputs.onStop(Keyboard.D, () => {
if (this.moveX > 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.W, () => (this.moveY = -1));
this.inputs.onStop(Keyboard.W, () => {
if (this.moveY < 0) this.moveY = 0;
});
this.inputs.onActive(Keyboard.S, () => (this.moveY = 1));
this.inputs.onStop(Keyboard.S, () => {
if (this.moveY > 0) this.moveY = 0;
});
}
private setupHud(): void {
this.hud = mountControls({
title: 'Tiled Infinite Map Streaming',
controls: [{ keys: 'WASD', action: 'fly the free camera across the streamed map' }],
status: '',
hint: '"Ground" and "Props" each stream through their own ChunkStreamer, sourced from TiledMap.getChunkSource(layer.id) — Tiled\'s on-disk 16x16 chunks are re-sliced onto the runtime chunk grid on demand, one requested chunk at a time.',
});
}
override update(delta: Seconds): void {
if (this.moveX !== 0 || this.moveY !== 0) {
const length = Math.hypot(this.moveX, this.moveY) || 1;
this.camera.move((this.moveX / length) * MOVE_SPEED * delta, (this.moveY / length) * MOVE_SPEED * delta);
}
this.groundStreamer.update();
this.propsStreamer.update();
this.hudTimer += delta;
if (this.hudTimer >= 0.25) {
this.hudTimer = 0;
const tx = Math.floor(this.camera.center.x / TILE);
const ty = Math.floor(this.camera.center.y / TILE);
this.hud.setStatus(`ground ${this.groundStreamer.residentCount} chunks · props ${this.propsStreamer.residentCount} chunks · tile ${tx}, ${ty}`);
}
}
override draw(context: RenderingContext): void {
context.render(this.mapNode, { view: this.camera });
}
}
const app = new Application({
scenes: { TiledInfiniteMapScene },
canvas: { width: 1280, height: 720, mount: document.body, sizing: new FixedResolutionCanvasSizing() },
clearColor: new Color(38, 82, 128), // deep-water blue behind unauthored/unloaded chunks
// tiledExtension depends on tilemapExtension, so registering it alone is
// enough for both loading (.tmj) and rendering (TileMapNode).
extensions: [tiledExtension],
loader: {
basePath: 'assets/',
},
});
await app.start(TiledInfiniteMapScene);
Performance notes
Chunk loads are budgeted. A camera moving faster than maxChunkLoadsPerFrame can keep up with will show brief pop-in at the frontier before the band fills. That is the budget doing its job — spreading load cost across frames instead of spiking one. For fast movers, raise loadRadius so chunks are requested further ahead of the visible edge, and raise the per-frame budget to fill them faster.
Mind the retained tier. A streamed layer mutates its structure every time a chunk is adopted or evicted. If it lives inside a RetainedContainer shared with unrelated static content, every chunk boundary the camera crosses drops and re-captures the whole group — you pay the retention bookkeeping for content that never changed. Give a streamed layer its own RetainedContainer, or none at all. See the Retained containers chapter for the full rule. A repeating or parallax ImageLayerNode re-sizes its wrapped sprite as the camera crosses period boundaries too, content-dirtying an enclosing retained group the same way — give it the same treatment.
Where to go next
The Infinite Procedural Terrain example is the sync provider end to end — fly an endless value-noise world and reroll the seed.
The Worker-Streamed Terrain example toggles between the sync and worker providers under a tunable sample cost, so you can watch the main thread hitch or stay smooth.
The Tiled Infinite Map Streaming example streams a hand-authored Tiled map instead of procedural terrain — a free-flying camera with two independently chunked layers.
Retained containers covers the group-invalidation rule a streamed layer interacts with.
Tiled maps covers loading .tmj maps and toTileMap() in full.