@codexo/exojs-tilemap
v0.19.1
Published
Generic, format-independent tilemap runtime for ExoJS.
Readme
@codexo/exojs-tilemap
Generic, format-independent tilemap runtime and WebGL2/WebGPU chunk renderer for ExoJS. No Tiled (or any other on-disk format) vocabulary leaks into this package - adapters such as @codexo/exojs-tiled parse their format and hand this runtime fully resolved tiles.
Installation
npm install @codexo/exojs @codexo/exojs-tilemap@codexo/exojs is a peer dependency, so install it explicitly. This package is itself a peer of the format adapters (@codexo/exojs-tiled, @codexo/exojs-ldtk) rather than something they pull in transitively — install it whether you use an adapter or build procedural / custom-format maps directly.
What this package provides
Runtime (data model) — pure data, no scene graph:
TileMap— a finite, chunk-first map: dimensions, tile size,tilesets, orderedlayers.TileLayer— one tile layer; packedUint32Arraychunk storage,visible/opacity/offsetX/Y, queries, and revision tracking.TileSet— a resolved tileset grid over a CoreTextureRegion(Loader-owned texture).ResolvedTile/TileTransform— value-typed tile references withflipX/flipY/diagonalorientation.
Worlds and level lifetime — format-neutral, driven by whichever adapter loaded the data:
MapWorld/MapLevel— where levels sit: stable id, world bounds, neighbour graph, externality. Metadata only; no tiles, no textures.MapWorldRuntime— explicitloadLevel/unloadLevel, one childLoaderScopeper level, at most one live level per id. No camera policy: the game decides what to load.MapLevelRuntime— one loaded level's map, scope and spawn session, with a singledestroy()that tears all three down in a safe order.
Map object spawning:
MapObjectSpawner— a local (never global) dispatch table from object class to a factory. Deterministic order, atomic rollback,AbortSignalcancellation.MapObjectDescriptor— the format-neutral view of one authored object handed to a factory.MapSpawnSession— owns what one spawn produced; constant-time lookup by stable source id, reverse-order teardown.
Scene / rendering (@advanced):
TileMapNode— convenience root that renders a wholeTileMap.TileLayerNode— renders oneTileLayer; generated per layer byTileMapView(or construct directly).TileMapView— groups a map's layers into independently placeable layer nodes and namedTileMapBands for actor interleaving (a helper, not a scene node).TileMapBand— aContainerof tile-layer nodes produced byTileMapView.tilemapExtension— extension descriptor carrying the WebGL2/WebGPU renderer bindings.
Usage — procedural map
import { Application, Scene, type Texture, TextureRegion } from '@codexo/exojs';
import { TileLayer, TileMap, TileMapNode, TileSet, tilemapExtension, TILE_TRANSFORM_IDENTITY } from '@codexo/exojs-tilemap';
class WorldScene extends Scene {
private atlas: Texture | null = null;
public override async load(): Promise<void> {
// A Loader-owned atlas texture: the tilemap runtime never destroys it.
this.atlas = await this.loader.load('tiles.png');
}
public override init(): void {
const atlas = this.atlas!;
const terrain = new TileSet({
name: 'terrain',
texture: new TextureRegion(atlas, { x: 0, y: 0, width: atlas.width, height: atlas.height }),
tileWidth: 16,
tileHeight: 16,
tileCount: 256,
});
const ground = new TileLayer({
id: 1,
name: 'ground',
width: 64,
height: 64,
tileWidth: 16,
tileHeight: 16,
tilesets: [terrain],
});
ground.setTileAt(0, 0, { tileset: terrain, localTileId: 5, transform: TILE_TRANSFORM_IDENTITY });
const map = new TileMap({
name: 'world',
width: 64,
height: 64,
tileWidth: 16,
tileHeight: 16,
tilesets: [terrain],
layers: [ground],
});
// Render the whole map: a scene draws its root without a draw() override.
this.addChild(new TileMapNode(map));
}
}
const app = new Application({ extensions: [tilemapExtension] /* canvas, … */ });
await app.start(WorldScene);Interleaving actors between layers — TileMapView
TileMapNode owns only the map's layer nodes and renders them back-to-front — use it when nothing renders between layers. To draw application actors between tile layers, create a TileMapView: it generates one TileLayerNode per map layer (stable identity, map document order) and groups them into named TileMapBands that you parent yourself, as siblings of your own actor containers:
const view = map.createView({
bands: {
ground: ['background', 'ground'],
roof: ['roofs', 'foreground'],
},
});
worldRoot.addChild(
view.band('ground'),
actors, // app-owned actor container — drawn between ground and roof
view.band('roof'),
);Or, without bands, place the generated per-layer nodes directly:
const view = map.createView();
worldRoot.addChild(view.getLayerNodeById(groundId)!, actors, view.getLayerNodeById(roofId)!);Actors are application-owned siblings. TileMapView never adopts or destroys actors.
- A band definition selects layers (by id, or by unique layer name); rendering order within a band always follows map document order — definitions never reorder layers.
- Layers not listed in any band stay reachable via
view.getLayerNodeById(id)/view.getLayerNodesByName(name); they are not auto-added to any band. - Destroying a view or a band destroys only the tile nodes it generated — never actors, the
TileMap, itsTileLayers, or tileset textures. - There is no map-replacement API: to swap maps, destroy the old view and create a new view from the new map — the actor tree is untouched.
- After layers are structurally added to or removed from the map, call
view.refreshLayers(): unchanged layer nodes keep their identity and bands keep their placement in your scene graph.
Isometric grids
Supply a shared TileProjection({ orientation: 'isometric', tileWidth, tileHeight, originX, originY }) to the map and each tile layer. Orthogonal is the default. tileToPixel() projects the cell's top diamond vertex; pixelToTile() inversely projects and floors both grid axes, including negative coordinates. Cell boundaries are half-open. projection.logicalToPixel() and pixelToLogical() preserve fractional coordinates. Isometric logical cells are squares of side tileHeight; display diamonds may have any positive integer width and height.
Layer offsets are display translations. TileLayer.logicalOffset exposes the corresponding logical vector for simulation. A map's pixel dimensions describe the projected grid AABB, while projection.getBounds() also supplies its possibly negative origin. Scene-node bounds include isometric artwork overhangs and tileset offsets. Picking describes cells, not opaque pixels of tall artwork; apply inverse scene and camera transforms before calling the map/layer helpers.
Isometric rendering splits resident storage chunks into diagonal draw sections, sorted across chunks by increasing tx + ty, then increasing tx. Only consecutive tiles with the same texture are batched, so tall tiles preserve painter order across texture and chunk boundaries. Geometry stays revision-cached. All sections share a layer-grid snap origin, including odd tile sizes. More draw sections and texture changes can cost more than orthogonal batching; no throughput improvement is promised.
Object layers remain data-only. With a projection their objects use logical coordinates; layer.getDisplayObject(object) returns display geometry with the layer offset applied. Rectangles may become polygons and ellipses retain their exact affine shape. Tile/text dimensions stay in display pixels. Actors remain application-owned; bands interleave them between layers, not automatically among individual terrain tiles.
Renderer model
- Chunk-first. A
TileLayerNodeis a container of per-chunkTileChunkNodedrawables (one per non-empty loaded chunk). The renderer batches tiles by(shader, tileset texture)and issues one instanced draw per batch — draw calls scale with visible chunks × layers, not total tile count. - Revision-cached geometry. Each chunk's quad geometry is built once and cached against the source chunk's
revision. Unchanged chunks never rebuild; a camera pan rebuilds nothing — off-screen chunks are culled by their local bounds before any geometry is touched. - Orientation.
flipX/flipY/diagonalare baked into the chunk geometry / resolved in the shader (all 8 combinations), with no per-tile matrix or per-frame cost. - Multiple tilesets with differing tile sizes are first-class; tiles taller than the map grid are bottom-left aligned (Tiled orthogonal convention).
- WebGL2 and WebGPU share one CPU geometry builder and produce identical output (golden parity tested on both backends).
- Sampling. Tile UVs are exact (no half-texel inset), which assumes nearest atlas filtering - the typical pixel-art case. Under linear or mipmap filtering, author tilesets with extruded tile margins to avoid neighbour bleed at tile edges; the tilemap renderer does not apply an extrusion-aware inset.
Ownership & lifecycle
- Tileset textures are Loader-owned. The runtime and renderer never destroy them.
- A
MapWorldRuntimeowns the scope it creates under the one you give it, plus one child scope per loaded level - never the scope you passed in. AMapLevelRuntimeowns theTileMapits provider returned, its spawn session, and its scope;destroy()releases them in that order. TileMapNode/TileMapView/TileMapBand/TileLayerNodereference — but never own — theTileMap. Destroying a map node, view, or band frees only the tile nodes it generated (detaching them from their application parents) and their cached geometry; application actors, theTileMapdata, itsTileLayers, and textures all survive. Free those viaTileMap.destroy()andLoader.destroy()respectively.- A
TileMapNodeorTileMapViewreflects the layer set at construction time. After layers are structurally added to or removed from the map callnode.refreshLayers()/view.refreshLayers(); after tiles are written into previously-empty chunks calllayerNode.refresh(). In-place edits to existing chunks are picked up automatically.
Core compatibility
This package follows the Core lockstep release line and declares the compatible @codexo/exojs minor as a peer dependency. Install matching package versions.
Links
- API reference
@codexo/exojs-tiled— load Tiled.tmjmaps into this runtime
License
MIT
