Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Tile maps

A tile map draws a 2D game’s ground and walls from a tile set: one image cut into a grid of tiles. defineTileSet declares the tile set, and TileMapLayer draws a grid of its tiles and stands its solid tiles up as colliders. readTiledMap reads a map made in Tiled, the editor every engine reads, with its object layers. Godot calls the same pieces TileSet and TileMapLayer, and Unity calls them a tile palette and a Tilemap.

A tile layer plays on the plane of the 2D frame. It shares the frame’s pixels per metre, so a 16 by 16 tile at 16 pixels a metre is one metre square.

Tile maps come from the 2D layer’s own entry, which a 3D game never downloads:

import { defineTileSet, readTiledMap, TileMapLayer } from "@spawnite/engine/2d";

Declare each tile set once, at module scope, in a module the room and every page import. This yard declares one and draws a layer of it:

packages/engine/test/outside/Yard.tsx
import { defineTileSet, TileMapLayer } from "@spawnite/engine/2d";
export const grass = defineTileSet({
image: "/assets/grass.png",
tileSize: 16,
solid: [12, 13, 14],
});
// At module scope, so the layer builds its colliders once.
const yard = [
[12, 12, 12, 12],
[12, 0, 1, 12],
[12, 2, -1, 12],
[12, 12, 12, 12],
];
export function Yard() {
return <TileMapLayer tileSet={grass} tiles={yard} position={[-2, 0, -2]} />;
}

image is the image’s URL: a path in the game’s public folder, as here, or what an import of the PNG gives. Tile 0 is the image’s top-left tile, and the count runs left to right, then top to bottom, as Tiled counts. solid lists the tiles a body cannot pass. Give margin and spacing where the image has pixels round its edge or between its tiles, as many packs do.

A tile set saved from Tiled as a .tsj file carries its tile size, its spacing, its animations, its collision shapes and its tiles’ properties. Pass it as data, and leave solid out to take the tiles the file gives collision. Where you give both and they disagree, a development build throws naming the difference.

The asset library holds two tile sets at 16 pixels a metre, each a .tsj with its walls solid. dungeon-tiles is 0x72’s dungeon walls, banners, fountains, floors and spikes, and town-tiles is Kenney’s Tiny Town: grass, paths, trees, houses and fences. Both are CC0. spawnite add asset town-tiles copies the image and its .tsj into src/tiles/ and prints the defineTileSet lines to paste into src/tiles/index.ts. The room builds the tile set’s colliders, so import that module in the scene the room loads.

TileMapLayer takes the tile set and rows of tile indexes, as the yard above does, the first row at the top of the screen. A cell of -1 is empty, as in Godot.

Cell [0, 0]’s top-left corner stands at position, as Godot, Unity and Phaser place their first cell. On the ground plane the columns run along +x and the rows along +z.

A cell can also be { tile, set, flipX, flipY, flipDiagonal }. A layer that draws from several tile sets takes a list as tileSet, and set names one by its place in the list. The flips are Tiled’s, applied in its order: the diagonal flip first, then x, then y.

TileMapLayer is a scene component, like Entity. It spawns the layer’s entity with its colliders in the room and on every page, and draws its tiles on a page alone. A page draws each 16 by 16 chunk of a tile set’s cells as one mesh, so a 128 by 128 layer takes 64 draws.

Save the map from Tiled as JSON, a .tmj file, and its tile sets as .tsj files. The engine’s Vite plugin loads both as data, in the page, the room and a test. readTiledMap returns the map’s tile layers, ready to spread on a TileMapLayer, and its object layers:

packages/engine/test/outside/Courtyard.tsx
import { CharacterSpawn } from "@spawnite/engine";
import courtyardMap from "./courtyard.tmj";
import courtyardTileData from "./courtyard.tsj";
import {
defineTileSet,
readTiledMap,
TiledObjectShape,
TileMapLayer,
} from "@spawnite/engine/2d";
// The tile set as Tiled drew it: its wall solid, and its lava animated,
// with a `damage` property of 5.
export const courtyardTiles = defineTileSet({
image: "/assets/courtyard.png",
data: courtyardTileData,
});
// The map, read once at module scope: 16 m across, its top-left at
// -8, -8, so it sits in the middle of the flat map under it.
export const courtyard = readTiledMap(courtyardMap, {
tileSets: { courtyard: courtyardTiles },
position: [-8, 0, -8],
});
// Where the player starts: the point whose type is spawn in Tiled.
const start = courtyard.objectLayers
.flatMap((layer) => layer.objects)
.find(
(object) =>
object.type === "spawn" && object.shape === TiledObjectShape.Point,
);
export function Courtyard() {
return (
<>
{courtyard.layers.map((layer) => (
<TileMapLayer key={layer.id} {...layer} />
))}
<CharacterSpawn position={start?.position ?? [0, 0, 0]} />
</>
);
}

Pass each tile set the map names under its name in Tiled, or under its file’s name, as courtyard for courtyard.tsj. Tiled’s (0, 0), the map’s top-left corner, stands at position. Every tile layer keeps its place in Tiled’s order as its sortingOrder, and a hidden layer keeps its colliders and draws nothing.

Each layer carries its Tiled name, with its groups’ names before it, and its custom properties. Give a roof layer an int property sortingLayer of 1 in Tiled, and it draws over the characters, as Draw order says. To show the room under a roof while the player’s character stands in it, pass visible={false} over the spread for that layer: its colliders stay.

An object’s position is its anchor in Tiled, and its center is its middle after its rotation. Stand an entity at an object’s center, such as <Entity prefab={enemy} position={object.center} rotation={object.rotation} /> for each object whose type is enemy. Entity describes prefabs.

readTiledMap reads the following from the file:

  • Orthogonal maps, finite or infinite, with their layers in CSV or uncompressed base64.
  • The three flips of each tile, and several tile sets with their firstgids.
  • Tile animations, collision shapes and custom properties.
  • Group layers, whose names it joins as town/walls and whose offsets and visibility it adds to their layers’.
  • Objects: rectangles, points, ellipses, polygons, lines and tile objects, each with its position, its middle after its rotation, its rotation as an Entity takes it, and its properties.

It refuses, naming the setting to change, a map it would draw differently from Tiled: an isometric or hexagonal map, a compressed layer, a layer’s opacity, tint or parallax, an image layer, an object template and an XML file. It skips a text object with a development warning.

A tile object is how a tree taller than the grid stands in a map. Draw it as a sprite at the object’s position, which is its bottom-left, as Tiled anchors a tile object.

The solid tiles of a layer are one Rapier voxel grid, a voxel a tile. Rapier treats the grid as one surface, so a character or a pushed crate slides along a wall with no catch where two tiles meet. The character controller, the NPC navigation’s bake and a camera’s bounds read it as any fixed body’s.

Each voxel stands 4 m off the ground plane, taller than the character and its jump, so no default character jumps or shoots over a wall. Set colliderHeight on the tile set for a lower hedge. On the side plane the voxels are that deep along z.

A tile whose Tiled collision is a polygon, or a rectangle smaller than the tile, takes a convex hull of each shape instead. A concave polygon takes its hull.

TileMapLayer takes trigger, material, collisionLayer and collisionMask for every collider of the layer, as a collider does. Draw water a character cannot walk through but a shot crosses as its own layer, with its own collision layer.

Every tile lies in its plane, so its layers show in order:

  1. The map’s ground, which draws after the layers and models and loses every tie with a layer in its plane.
  2. The layers with a sortingLayer of 0 or below, lower first, and by sortingOrder within one.
  3. The sprites and the models.
  4. The layers with a sortingLayer above 0, such as roofs and treetops, which cover every sprite and model under them.

sortingLayer and sortingOrder take the whole numbers a sprite’s do, −4 to 3 and −64 to 63. readTiledMap numbers each band’s layers in the file’s order from −64. It refuses a band of more than 128 layers, naming the layer: merge layers in Tiled, or give some a sortingLayer of their own.

The map’s ground draws wherever a layer has no tile. A layer writes depth as the ground does, so the sky and a model standing behind a side-plane layer stay behind it.

findTileAt(layer, point) returns the tile under a point, with its tile set, its cell and its flips, or null where the cell is empty. A system reads it for a footstep sound, a damage floor or a field to dig. The result is one object the next call writes over, so read it at once.

The courtyard’s lava burns what stands on it: a room’s rule in a plugin, as Systems describes them. A tile’s properties are Tiled’s typed values, so read a number with a typeof check. Health says what dealDamage does:

packages/engine/test/outside/lava.ts
import {
createQuery,
dealDamage,
definePlugin,
defineSystem,
HealthTrait,
Phase,
readEach,
TransformTrait,
type AuthoritativeStep,
} from "@spawnite/engine/core";
import { courtyard } from "./Courtyard";
import { findTileAt } from "@spawnite/engine/2d";
// A room's rule: a body standing on a tile whose Tiled property `damage`
// is a number loses that much health on each second of the room's clock.
const standing = createQuery(HealthTrait, TransformTrait);
function burn(step: AuthoritativeStep) {
// Once a second: on every whole second of the room's ticks.
if (step.tick % Math.round(1 / step.deltaSeconds) !== 0) return;
readEach(step.world, standing, ([, transform], entity) => {
// Written in place by the next call, so read at once.
const under = findTileAt(courtyard.layers[0], transform.position);
const damage = under?.tileSet.properties.get(under.tile)?.damage;
if (typeof damage === "number")
dealDamage(step, entity, { amount: damage });
});
}
export const lava = definePlugin(() => ({
name: "lava",
description: "A tile with a damage property burns what stands on it.",
systems: [defineSystem({ name: "burn", phase: Phase.Rules, run: burn })],
}));

To change tiles while the game runs, keep the rows in a trait the game owns, and hand the layer new rows. The layer then builds its colliders again whole.

A tile layer sits on the engine’s flat map, the one add_map writes. Make the map as wide as the layer, and centre the layer on it. readTiledMap returns the map’s size in metres, and its bounds, the rectangle it covers in metres right and up on the screen, which Camera2D’s bounds takes as it is. A layer that reaches past the map’s edge warns in development, since its colliders there stand over no ground.