Skip to main content

2 posts tagged with "gamedev tilemap tiling autotiling tooling"

View All Tags

Dual Tilemap Autotiling Technique

· 14 min read
Justin Young
Excalibur Contributor
TitleImage

Intro

Autotiling is one of those topics every 2D gamedev bumps into sooner or later.
You want maps that look nice without hand-placing every edge and corner, but you don’t want to manage a monster tileset with 50+ variants just to blend one terrain type into another.

In this post, I’ll show you the dual Tilemap technique I use in ExcaliburJS with TypeScript.
This method cuts the needed art down to just 5 tiles, keeps your code simple, and separates gameplay logic from visual decoration.

Quick Review of Autotiling

Autotiling is about automating which tile sprite to draw based on a tile’s neighbors.
Instead of painting every edge by hand, the engine checks surrounding cells and picks the right graphic.

The most common approaches:

  • Blob / bitmask – looks at 8 neighbors; needs ~47–56 tiles.
  • Marching Squares – 4-bit mask (N/E/S/W); needs 16 tiles.
  • Wang tiles – edges/corners encode transitions; flexible, but still asset-heavy.

These work well but tend to require large tilesets and entangled rules.

My last dive into autotiling, Autotiling Technique, implemented a 47 tile tileset and bitmasking. While it worked and looked great, the algorithm forced me to do a lot of work mapping all the different bitmasks to the 47 tiles. It took a lot of manual effort.

Example from my last autotiling project:

old tileset

Traditional Autotiling Techniques

The classic techniques all share a common trade-off: flexibility vs. asset count.

  • Blob / Bitmask (47+ tiles)
    Each neighbor contributes to a binary mask (8 bits → 256 possibilities).
    Many cases overlap visually, so artists usually trim down to ~47 unique tiles.
    This means dozens of sprites to draw and maintain.

  • Marching Squares (16 tiles)
    Simpler 4-bit system that only considers N/E/S/W.
    It’s easier to code, but you still need 16 distinct tiles.

  • Wang Tiles
    Encode transitions on edges or corners. Very elegant mathematically, but each terrain type still multiplies your tile count.

These all work — but if you only want a clean outline between Ground and Void, they’re overkill.

What is the problem?

A ton of art

When I implemented a 47-tile bitmask system, the art looked great — but the cost was high:

  • Artist time: drawing 47 tiles for every new biome or terrain type.
  • Mapping complexity: bitmask → tile index lookups are tedious and brittle.
  • Coupling: logic and visuals are glued together; small rule changes break the whole set.

This complexity grows fast when you add multiple terrain types (grass, sand, snow, etc.).

The Ambiguous Tile

Visual: Part of the tile shows one terrain (e.g., grass), while the rest shows another (e.g., soil/ground).

Gameplay/Logic: The walkable area is determined by the world Tilemap (logic map), not the graphic overlay. So even if half the tile visually looks like grass, the character might still be able to walk over it if the underlying tile is marked as walkable ground.This leads to a detail that the game developer must manage: is the tile walkable or usable?

Ambiguous tile

Benefits of a dual Tilemap solution

Instead of one giant Tilemap that does everything, we split the work:

  • World Tilemap (logic)
    Just 2 states and one tile: the background tile. This is the world Tilemap and is used for game logic. For this demonstration we are manage the states of soil or grass.

  • Graphics Tilemap (overlay)
    Only 5 tiles: Edge, InnerCorner, OuterCorner, Filled, or Opposite Corners
    These can be rotated and stacked to create all the shapes we need.

Total: 6 tiles, the background tile plus 5 different grass tiles.

Removal of the ambiguous tile

With the offset 2nd Tilemap, we can better align the mesh textures to the actual world tiles, removing the ambiguous tile problem stated earlier.

Fixed ambiguous tile

Why this is powerful

  • Tiny tileset (easy for artists).
  • Composable overlays (T-junctions, crosses, islands all “just work”).
  • Separation of concerns (logic map stays clean).
  • No Ambiguous Tiles

A walk through the algorithm

For my demonstration, I created two Excalibur (Ex) Tilemaps. One I called worldMap and the other meshMap.

Getting started and setup

ts
const worldMap = new TileMap({
columns: 10,
rows: 10,
tileWidth: 16,
tileHeight: 16,
});
// Note: the meshMap needs to 'overlap' the world map by one tile, you'll see what later
const meshMap = new TileMap({
columns: 11,
rows: 11,
tileWidth: 16,
tileHeight: 16,
});
// Position the Tilemaps
worldMap.pos = vec(0, 0);
worldMap.z = 0;
// Note: the mesh Tilemap's position is half a tile offset; this is important
meshMap.pos = vec(-8, -8);
meshMap.z = 1;
// Load Assets and add Tilemaps to game
await game.start(loader);
game.add(worldMap);
game.add(meshMap);
//move camera and center on the Tilemap
game.currentScene.camera.pos = vec(16 * 5, 16 * 5);
game.currentScene.camera.zoom = 1.25;
ts
const worldMap = new TileMap({
columns: 10,
rows: 10,
tileWidth: 16,
tileHeight: 16,
});
// Note: the meshMap needs to 'overlap' the world map by one tile, you'll see what later
const meshMap = new TileMap({
columns: 11,
rows: 11,
tileWidth: 16,
tileHeight: 16,
});
// Position the Tilemaps
worldMap.pos = vec(0, 0);
worldMap.z = 0;
// Note: the mesh Tilemap's position is half a tile offset; this is important
meshMap.pos = vec(-8, -8);
meshMap.z = 1;
// Load Assets and add Tilemaps to game
await game.start(loader);
game.add(worldMap);
game.add(meshMap);
//move camera and center on the Tilemap
game.currentScene.camera.pos = vec(16 * 5, 16 * 5);
game.currentScene.camera.zoom = 1.25;

Assets

Tileset

The tileset i'm using has 6 tiles, the first is the 'base' soil tile that covers the world Tilemap. The next 5 are the 5 tiles needed for this autotiling technique. For the purposes of this demonstration I included a light border around the soil tile so the end result can more readily show how the Tilemaps line up.

Initializing Tile State

I loop through all the tiles initially to setup the intial state for each Tilemap.

ts
// Instead of using a TypeScript enum, I like to define my tile states as a const object with string literal values
const TileState = {
soil: "soil",
grass: "grass",
} as const;
// Setup the world map state
for (const tile of worldMap.tiles) {
tile.addGraphic(tileSS.getSprite(0, 0)); // This sets the default 'soil' tile from the spritesheet
tile.data.set("state", TileState.soil); // This is setting the data store of each tile to 'soil'
}
// Setup the Mesh map state
for (const tile of meshMap.tiles) {
tile.data.set("worldNeighbors", getWorldNeighbors(tile)); // Each tile in the meshMap needs to know its corresponding 'worldMap' neighbors, 4 for each tile (TL, TR, BL, BR)
tile.data.set("meshTile", null); // which index of the spritesheet do I access
tile.data.set("rotation", 0); // how do I rotate the graphic
}
ts
// Instead of using a TypeScript enum, I like to define my tile states as a const object with string literal values
const TileState = {
soil: "soil",
grass: "grass",
} as const;
// Setup the world map state
for (const tile of worldMap.tiles) {
tile.addGraphic(tileSS.getSprite(0, 0)); // This sets the default 'soil' tile from the spritesheet
tile.data.set("state", TileState.soil); // This is setting the data store of each tile to 'soil'
}
// Setup the Mesh map state
for (const tile of meshMap.tiles) {
tile.data.set("worldNeighbors", getWorldNeighbors(tile)); // Each tile in the meshMap needs to know its corresponding 'worldMap' neighbors, 4 for each tile (TL, TR, BL, BR)
tile.data.set("meshTile", null); // which index of the spritesheet do I access
tile.data.set("rotation", 0); // how do I rotate the graphic
}

Storing the neighbor data

ts
type TileList = {
TL: Tile | undefined;
TR: Tile | undefined;
BL: Tile | undefined;
BR: Tile | undefined;
};
const getWorldNeighbors = (currentMeshTile: Tile): TileList => {
let TL: Tile | undefined = undefined;
let TR: Tile | undefined = undefined;
let BL: Tile | undefined = undefined;
let BR: Tile | undefined = undefined;
// get vectors of four corners
const topLefMeshTile = currentMeshTile.pos.clone();
const topRightMeshTile = currentMeshTile.pos.clone().add(vec(16, 0));
const bottomLeftMeshTile = currentMeshTile.pos.clone().add(vec(0, 16));
const bottomRightMeshTile = currentMeshTile.pos.clone().add(vec(16, 16));
// for each corner, find mesh tile that contains that position
TL = worldMap.tiles.find(tile => topLefMeshTile.equals(tile.center));
TR = worldMap.tiles.find(tile => topRightMeshTile.equals(tile.center));
BL = worldMap.tiles.find(tile => bottomLeftMeshTile.equals(tile.center));
BR = worldMap.tiles.find(tile => bottomRightMeshTile.equals(tile.center));
return { TL, TR, BL, BR };
};
ts
type TileList = {
TL: Tile | undefined;
TR: Tile | undefined;
BL: Tile | undefined;
BR: Tile | undefined;
};
const getWorldNeighbors = (currentMeshTile: Tile): TileList => {
let TL: Tile | undefined = undefined;
let TR: Tile | undefined = undefined;
let BL: Tile | undefined = undefined;
let BR: Tile | undefined = undefined;
// get vectors of four corners
const topLefMeshTile = currentMeshTile.pos.clone();
const topRightMeshTile = currentMeshTile.pos.clone().add(vec(16, 0));
const bottomLeftMeshTile = currentMeshTile.pos.clone().add(vec(0, 16));
const bottomRightMeshTile = currentMeshTile.pos.clone().add(vec(16, 16));
// for each corner, find mesh tile that contains that position
TL = worldMap.tiles.find(tile => topLefMeshTile.equals(tile.center));
TR = worldMap.tiles.find(tile => topRightMeshTile.equals(tile.center));
BL = worldMap.tiles.find(tile => bottomLeftMeshTile.equals(tile.center));
BR = worldMap.tiles.find(tile => bottomRightMeshTile.equals(tile.center));
return { TL, TR, BL, BR };
};

It is important to understand how I approached storing the tile map data. My disclaimer: there is more than one right way to do this, and I'm certain there are more optimum means. This is simply my approach.

Not every tile will have 4 neighbors, if you consider the edge of the worldMap, there may only be one or two neighbors available which is why I allow undefined values for the tile positions as well. The majority of the tiles can/will be surrounded by 4 world tiles. The fact that the mesh Tilemap is offset by half of the tilesize, means that the corners of the mesh tile land on the centers of the four neighbors, and I use this to my advantage.

Tile Relationship

Managing the mouse input

While not specific to Tilemapping, controlling the mouse properly to set the worldMap tile states is important. When I click and drag the mouse, what I'm doing is using the mouse pointer positions to set/clear the world tile state. I use the Left Mouse Button (LMB) to set 'grass' state, and use the Right Mouse Button to clear the 'grass' state to 'soil'.

ts
let isDragging = false;
let lastTile: Tile | null = null;
let activeButton: PointerButton | null = null;
game.input.pointers.primary.on("down", e => {
if (e.button !== PointerButton.Left && e.button !== PointerButton.Right) return; // ignore other buttons
activeButton = e.button;
isDragging = true;
lastTile = null; // reset so first tile definitely triggers
setTileState(game.input.pointers.primary.lastWorldPos, activeButton);
});
game.input.pointers.primary.on("up", e => {
isDragging = false;
lastTile = null;
activeButton = null;
});
game.input.pointers.primary.on("move", e => {
if (isDragging && activeButton) {
setTileState(game.input.pointers.primary.lastWorldPos, activeButton); // <---- this manages the click and drag 'drawing' of tiles
}
});
ts
let isDragging = false;
let lastTile: Tile | null = null;
let activeButton: PointerButton | null = null;
game.input.pointers.primary.on("down", e => {
if (e.button !== PointerButton.Left && e.button !== PointerButton.Right) return; // ignore other buttons
activeButton = e.button;
isDragging = true;
lastTile = null; // reset so first tile definitely triggers
setTileState(game.input.pointers.primary.lastWorldPos, activeButton);
});
game.input.pointers.primary.on("up", e => {
isDragging = false;
lastTile = null;
activeButton = null;
});
game.input.pointers.primary.on("move", e => {
if (isDragging && activeButton) {
setTileState(game.input.pointers.primary.lastWorldPos, activeButton); // <---- this manages the click and drag 'drawing' of tiles
}
});

Changing Tile State

ts
const setTileState = (pPos: Vector, buttonState: PointerButton) => {
// get tile coords
const tx = Math.floor(pPos.x / worldMap.tileWidth);
const ty = Math.floor(pPos.y / worldMap.tileHeight);
// if tile coords are 'inside' the worldmap
if (tx >= 0 && tx < worldMap.columns && ty >= 0 && ty < worldMap.rows) {
// if we moved to a new tile
if (lastTile !== worldMap.getTile(tx, ty)) {
const state = buttonState === PointerButton.Left ? TileState.grass : TileState.soil;
worldMap.getTile(tx, ty)!.data.set("state", state);
lastTile = worldMap.getTile(tx, ty);
}
}
//update the mesh data and redraw
updateMeshMap();
redrawMeshTileMap();
};
ts
const setTileState = (pPos: Vector, buttonState: PointerButton) => {
// get tile coords
const tx = Math.floor(pPos.x / worldMap.tileWidth);
const ty = Math.floor(pPos.y / worldMap.tileHeight);
// if tile coords are 'inside' the worldmap
if (tx >= 0 && tx < worldMap.columns && ty >= 0 && ty < worldMap.rows) {
// if we moved to a new tile
if (lastTile !== worldMap.getTile(tx, ty)) {
const state = buttonState === PointerButton.Left ? TileState.grass : TileState.soil;
worldMap.getTile(tx, ty)!.data.set("state", state);
lastTile = worldMap.getTile(tx, ty);
}
}
//update the mesh data and redraw
updateMeshMap();
redrawMeshTileMap();
};

As the mouse moves with the LMB or RMB held down, the setTileState method is being called with the position and button state details. This method uses this data to set the worldMap tile states appropriately. Then redraws the Tilemap.

The Magic, selecting which tile and how to rotate

For this section, this is where one had to sit down and consider how each tile is drawn. This is my approach;

  1. Loop over world neighbors and 'count' grass tiles
  2. For each 'combination' of grass tiles, select the tile index
  3. For the couple of tiles where rotation is important, figure out 'where' the grass tiles are located

Let us walk through the code:

ts
const updateMeshMap = () => {
for (const tile of meshMap.tiles) {
// get the predetermined neighbor references
const worldNeighbors = tile.data.get("worldNeighbors");
const { spriteIndex, rotation } = calculateMeshSprite(worldNeighbors); // call the function that returns the index and rotation structure
// if no tile data needed, clear out the mesh data
if (spriteIndex === null || rotation === null) {
tile.data.delete("meshTile");
tile.data.delete("rotation");
continue;
}
// set the mesh data appropriately
tile.data.set("meshTile", spriteIndex);
tile.data.set("rotation", toRadians(rotation)); // this is where the rotation is converted to Radians for proper graphic rotation
}
};
ts
const updateMeshMap = () => {
for (const tile of meshMap.tiles) {
// get the predetermined neighbor references
const worldNeighbors = tile.data.get("worldNeighbors");
const { spriteIndex, rotation } = calculateMeshSprite(worldNeighbors); // call the function that returns the index and rotation structure
// if no tile data needed, clear out the mesh data
if (spriteIndex === null || rotation === null) {
tile.data.delete("meshTile");
tile.data.delete("rotation");
continue;
}
// set the mesh data appropriately
tile.data.set("meshTile", spriteIndex);
tile.data.set("rotation", toRadians(rotation)); // this is where the rotation is converted to Radians for proper graphic rotation
}
};

This is a straightforward utility method that loops through each tile and sets the data appropriately, there's still one more magical method to dive into.

ts
const calculateMeshSprite = (neighbors: TileList): { spriteIndex: number | null; rotation: number | null } => {
// 1. Count the grass tiles
let grassCount = 0;
Object.values(neighbors).forEach(tile => {
if (!tile) return;
if (tile.data.get("state") === TileState.grass) {
grassCount++;
}
});
// based on grasstile count, make a decision
let spriteIndex = 0;
let rotation = 0;
let isTLGrass = neighbors.TL?.data.get("state") === TileState.grass;
let isTRGrass = neighbors.TR?.data.get("state") === TileState.grass;
let isBLGrass = neighbors.BL?.data.get("state") === TileState.grass;
let isBRGrass = neighbors.BR?.data.get("state") === TileState.grass;
...
ts
const calculateMeshSprite = (neighbors: TileList): { spriteIndex: number | null; rotation: number | null } => {
// 1. Count the grass tiles
let grassCount = 0;
Object.values(neighbors).forEach(tile => {
if (!tile) return;
if (tile.data.get("state") === TileState.grass) {
grassCount++;
}
});
// based on grasstile count, make a decision
let spriteIndex = 0;
let rotation = 0;
let isTLGrass = neighbors.TL?.data.get("state") === TileState.grass;
let isTRGrass = neighbors.TR?.data.get("state") === TileState.grass;
let isBLGrass = neighbors.BL?.data.get("state") === TileState.grass;
let isBRGrass = neighbors.BR?.data.get("state") === TileState.grass;
...

So far in this function we've looped through the neighbors object and counted the grass tiles. Also, I've setup some helper flags for assisting with orientation.

ts
...
// No grass, return the nullish state
if (grassCount === 0) return { spriteIndex: null, rotation: null };
// one grass square, use the sprite with just a corner piece, index 1
else if (grassCount === 1) {
spriteIndex = 1;
//rotate the tile based on which of the corners is grass
if (isTLGrass) {
rotation = 180;
} else if (isTRGrass) {
rotation = -90;
} else if (isBLGrass) {
rotation = 90;
} else if (isBRGrass) {
rotation = 0;
}
}
...
ts
...
// No grass, return the nullish state
if (grassCount === 0) return { spriteIndex: null, rotation: null };
// one grass square, use the sprite with just a corner piece, index 1
else if (grassCount === 1) {
spriteIndex = 1;
//rotate the tile based on which of the corners is grass
if (isTLGrass) {
rotation = 180;
} else if (isTRGrass) {
rotation = -90;
} else if (isBLGrass) {
rotation = 90;
} else if (isBRGrass) {
rotation = 0;
}
}
...

The grassCount: 0 scenario is super simple, return nulls so that nothing is drawn. But let us look into the grassCount:1 quick to get an ideas of what we are working with.

tile 1 rotations

We can use the utility flags for us to set the appropriate rotations, and you can see this pattern show up in the next two scenarios as well. I won't draw the permutations but the commens walk you through each situation.

Below both 2 grass tile and 3 grass tile scenarios. The 3 grass tile scenario it just seemed easier to me to track which tile was not a grass tile.

ts
...
// two of the neighbors are grass, that could be two different tile indexes possibly
else if (grassCount === 2) {
// are they next to each other or cattycorner?
// first four are when they are next to each other
if (isTLGrass && isTRGrass) {
spriteIndex = 2;
rotation = -90;
} else if (isTLGrass && isBLGrass) {
spriteIndex = 2;
rotation = 180;
} else if (isTRGrass && isBRGrass) {
spriteIndex = 2;
rotation = 0;
} else if (isBLGrass && isBRGrass) {
spriteIndex = 2;
rotation = 90;
}
// next two are the 2 catty corner conditions
else if (isTLGrass && isBRGrass) {
spriteIndex = 3;
rotation = 90;
} else if (isTRGrass && isBLGrass) {
spriteIndex = 3;
rotation = 0;
}
}
// three grass, one soil, let's track the soil tile, its just easier
else if (grassCount === 3) {
spriteIndex = 4;
// to note, we're specifically looking for the tile that's NOT grass
if (!isTLGrass) {
rotation = 0;
} else if (!isTRGrass) {
rotation = 90;
} else if (!isBLGrass) {
rotation = -90;
} else if (!isBRGrass) {
rotation = 180;
}
}
// all grass tiles, rotation is irrelevant
else if (grassCount === 4) spriteIndex = 5;
return { spriteIndex, rotation };
};
ts
...
// two of the neighbors are grass, that could be two different tile indexes possibly
else if (grassCount === 2) {
// are they next to each other or cattycorner?
// first four are when they are next to each other
if (isTLGrass && isTRGrass) {
spriteIndex = 2;
rotation = -90;
} else if (isTLGrass && isBLGrass) {
spriteIndex = 2;
rotation = 180;
} else if (isTRGrass && isBRGrass) {
spriteIndex = 2;
rotation = 0;
} else if (isBLGrass && isBRGrass) {
spriteIndex = 2;
rotation = 90;
}
// next two are the 2 catty corner conditions
else if (isTLGrass && isBRGrass) {
spriteIndex = 3;
rotation = 90;
} else if (isTRGrass && isBLGrass) {
spriteIndex = 3;
rotation = 0;
}
}
// three grass, one soil, let's track the soil tile, its just easier
else if (grassCount === 3) {
spriteIndex = 4;
// to note, we're specifically looking for the tile that's NOT grass
if (!isTLGrass) {
rotation = 0;
} else if (!isTRGrass) {
rotation = 90;
} else if (!isBLGrass) {
rotation = -90;
} else if (!isBRGrass) {
rotation = 180;
}
}
// all grass tiles, rotation is irrelevant
else if (grassCount === 4) spriteIndex = 5;
return { spriteIndex, rotation };
};

Final section, drawing

ts
const redrawMeshTileMap = () => {
let tileindex = 0;
for (const tile of meshMap.tiles) {
// clear current tile graphics
tile.clearGraphics();
//grab sprite index and rotation
const spriteIndex = tile.data.get("meshTile");
const rotation = tile.data.get("rotation");
tileindex++;
// no sprite data, move on to next tile
if (!spriteIndex) continue;
// if there is tile data, grab appropriate sprite, and rotate it
let sprite = tileSS.getSprite(spriteIndex, 0);
let spritecopy = sprite.clone(); // <------ if you don't create a copy of the sprite, you'll end up rotating ALL of them in the Tilemap
spritecopy.rotation = rotation;
tile.addGraphic(spritecopy); // draw the graphic
}
};
ts
const redrawMeshTileMap = () => {
let tileindex = 0;
for (const tile of meshMap.tiles) {
// clear current tile graphics
tile.clearGraphics();
//grab sprite index and rotation
const spriteIndex = tile.data.get("meshTile");
const rotation = tile.data.get("rotation");
tileindex++;
// no sprite data, move on to next tile
if (!spriteIndex) continue;
// if there is tile data, grab appropriate sprite, and rotate it
let sprite = tileSS.getSprite(spriteIndex, 0);
let spritecopy = sprite.clone(); // <------ if you don't create a copy of the sprite, you'll end up rotating ALL of them in the Tilemap
spritecopy.rotation = rotation;
tile.addGraphic(spritecopy); // draw the graphic
}
};

The final thing left to do is simply managing the 'drawing' of the graphics to each tile location. We do this by looping through the mesh tiles, and if there is data present, redraw it to the tile.

The Demo

I place a small and quick demo application out on Itch.io. This demo can give you the sense of how smoothly this works, it is almost magical!

Link to demo: Link

Link to Source: link

Why Excalibur

ExcaliburJS

Small Plug...

ExcaliburJS is a friendly, TypeScript 2D game engine that can produce games for the web. It is free and open source (FOSS), well documented, and has a growing, healthy community of gamedevs working with it and supporting each other. There is a great discord channel for it JOIN HERE, for questions and inquiries. Check it out!!!

Summary

Autotiling is one of those problems that looks simple but can quickly balloon into complexity — traditional methods often demand dozens of tiles and intricate bitmask rules.

By splitting responsibilities between two Tilemaps, we drastically simplify the workflow:

  • World map handles logic with just 2 states (soil, grass) and a base tile graphic.
  • Graphics map overlays visuals with only 5 tiles.
  • Total: 5 tiles instead of 47+.

The dual Tilemap method keeps your code clean, your art requirements minimal, and your system flexible for new biomes or mechanics.

If you want to dig into the details, check out the demo on Itch.io and the source on GitHub.
It’s a simple idea that pays off big when your worlds start to grow.

For more information

This information was learned via a few YouTube videos, which I recommend if you want to dive deeper.

Autotiling Technique

· 16 min read
Justin Young
Excalibur Contributor
TitleImage

As a game developer, if the thought of hand crafting a level does not appeal to you, then you may consider looking into procedural generation for your next project. Even using procedural generation, however, you still need to be able to turn your generated map arrays into a tilemap with clean, contiguous walls, and sprites that match up cleanly, as if it was drawn by hand. This is where a technique called auto-tiling can come into play to help determine which tiles should be drawn in which locations on your tilemap.

In this article, I will explain the concept of auto-tiling, Wang Tiles, binary and bitmasks, and then walk through the process and algorithms associated with using this tool in a project.

What is auto-tiling

Auto-tiling converts a matrix or array of information about a map and assigns the corresponding tile texture to each tile in a manner that makes sense visually for the tilemap level. This uses a tile's position, relative to its neighbor tiles to determine which tile sprite should be used. Today we will focus on bitmask encoding neighbor data, although there are other techniques that can be used to accomplish this.

One can get exposed to auto-tiling in different implementations. If you're using a game engine like Unity or Godot, there are features automatically built into those packages to enabling auto-tiling as you draw and create your levels. Also, there are software tools like Tiled, LDTK, and Sprite Fusion, that are a little more tilemap specific and give you native tools for auto-tiling.

Auto-tiling has provided the most benefit when we think about how we can pivot from tilemap matrices or flat indexes representing the state of a tilemap, to a rendered map on the screen. Let us say you have a tilemap in the form of a 2d matrix with 1's and 0's in it representing the 'walkable' state of a tile. Let us assign a tile as a floor (0) piece or a wall (1) piece. Now, one can simply use two different tiles, for example:

a grass tile grass tile and a dirt path tile grass tile

We could take a tilemap matrix like this:

tilemap matrix

and use these two tiles to assign the grass to the 1's and the 0's to the path tile. It would look like this:

rudementary tilemap

This is technically a tile-map which has been auto-tiled, but we can do a little better.

What are Wang tiles?

Wang tiles do not belong or associate with game development or tile-sets specifically, but come from mathematics. So, why are we talking about them? The purpose of the Wang tiles within the scope of game development is to have a series of tile edges that create matching patterns to other tiles. We control which tiles are used by assigning a unique set of bitmasks to each tile that allows us reference them later.

Wang tiles themselves are a class of system which can be modeled visually by square tiles with a color on each side. The tiles can be copied and arranged side by side with matching edges to form a pattern. Wang tile-sets or Wang 'Blob' tiles are named after Hao Wang, a mathematician in the 1960's who theorized that a finite set of tiles, whose sides matched up with other tiles, would ultimately form a repeating or periodic pattern. This was later to be proven false by one of his students. This is a massive oversimplification of Wang's work, for more information on the backstory of Wang tiles you can be read here: Wang Tiles.

This concept of matching tile edges to a pattern can be used for a game's tilemap. One way we can implement Wang tiles in game development is to create levels from the tiles. We start with a tile-set that represents all the possible edge outcomes for any tile.

These tile assets can be found here: Wang Tile Set

wang blob

The numbers on each tile represents the bitmask value for that particular permutation of tile design. We then can see how you can swap these tiles for a separate texture below. In the image above, there are a couple duplicate tile configurations, and they are shown in white font.

wang texture

The magic of Wang tiles is that it can be extended out and create unique patterns that visually work. For example:

wang example

A bitmask is a binary representation of some pattern. In the scope of this conversation, we will use a bitmask to represent the 8 neighbors tiles of an given tile on a tilemap.

What is a bitmask?

A bitmask is a binary representation of some pattern. In the scope of this conversation, we will use a bitmask to represent the 8 neighbors tiles of an given tile on a tilemap.

Quick crash course on Binary

So our normal counting format is designed as base-10. This means that each digit in our number system represents digits 0-9 (10 digits), and the value of each place value increases in power of base 10.

So in the number '42', the 2 represents - (2 * 100) which is added to the 4 in the 'tens' place, which is (4 * 101), which equals 42.

(2 * 1) + (4 * 10) = 42
(2 * 1) + (4 * 10) = 42

T This in binary looks different, as binary is base-2, which means that each digit position has digits 0 and 1, (2 digits). This is the counting system and 'language' of computers and processors.

Quickly, let's re-assess the previous example of '42'. 42 in binary is 101010. Let's break this down in similar fashion.

Starting from the right placeholder and working our way left... The 0 in the right most digit represents 0 * 20. The next digit represents 1 * 21... and on for each digit and the exponent increases each placeholder.

0 1 0 1 0 1
_________________________________________________________________
(0 * 1) + (1 * 2) + (0 * 4) + (1 * 8) + (0 * 16) + (1 * 32) = 42
2 + 8 + 32 = 42
0 1 0 1 0 1
_________________________________________________________________
(0 * 1) + (1 * 2) + (0 * 4) + (1 * 8) + (0 * 16) + (1 * 32) = 42
2 + 8 + 32 = 42

Bits, Bytes, and Bitmasks

That is how information in computers is encoded. We can use this 'encoding' scheme to easily represent binary information, like 'on' or 'off', or in this discussion, walkable tile or not walkable. This is why in the tile-set matrix example above, we can flag non-walkable tiles as '1', and walkable tiles as '0'. This is now binary encoded.

A bit is one of these placeholders, or one digit. 8 of this bits together is a byte. Computers and processors, at a minimum, read at least a byte at a time.

We can use this binary encoding for the auto-tiling by representing the state of each of a tile's neighbors into 8 bits, one for each neighbor. This means that the condition and status of each neighbor for a tile can be encoded into one byte of data (8 bits) and CAN be represented with a decimal value, see my earlier explanation about how the number 42 is represented in binary.

So the whole point of this section is to get to this example: we are going to encode the neighbor's data for an example tile.

Quick Demonstration

bitmask example

Now the tile we are assigning the bitmask to is the green, center tile. This tile has 8 neighbors. If I start reading the 1's and 0's from the top left and reading right, then down, I can get the value: 101 (top row) - 01 (middle row) - 101 (bottom row). Remember to skip the green tile.

bitmask example

All together, this is 10101101, which can be stored as a binary value, which can be converted to a decimal value: 173. Remember to start at the rightmost bit when converting.

1 0 1 1 0 1 0 1
__________________________________________________________________________________
(1 * 1) + (0 * 2) + (1 * 4) + (1 * 8) + (0 * 16) + (1 * 32) + (0 * 64) + (1 * 128)
1 + 4 + 8 + 32 + 128 = 173
1 0 1 1 0 1 0 1
__________________________________________________________________________________
(1 * 1) + (0 * 2) + (1 * 4) + (1 * 8) + (0 * 16) + (1 * 32) + (0 * 64) + (1 * 128)
1 + 4 + 8 + 32 + 128 = 173

Now we can use that decimal value of 173 to represent the neighbor pattern for that tile. Every tile in a tilemap, can be encoded with their 'neighbors' bitmasks.

As you saw earlier, the wang tiles had bitmask values assigned to them. This is how we know which tile to substitute for each bitmask.

The Process

We have already covered the hard part. In this section we are pulling it all together in a walkthrough of the overall high level process.

Here are the steps we are covering:

Find or create a tile-set spritesheet that you would like to use Create your tilemap data, however you like. Loop through each index of tile, and evaluate the neighbor tiles, and assign bitmask Map the bitmap values to the 'appropriate' tile in your tile-set (this is the long/boring part IMO) Iterate over each tile and assign the correct image that matches the bitmask value Draw your tilemap in your game Creating a tile-set Here is an example of a tile-set that I drew for the demo project.

example tileset

These 47 tiles represent all the different 'wall' formations that would be required. I kept my floor tiles separate in a different file so that it is easier to swap out. The floor is drawn as a separate tile underneath the wall. Each tile represented in the grid is designed to match up with a specific group of neighbor patterns. Let's take the top-left tile:

topleft tile

This tile is intended to be mapped to a tile where there are walled neighbors on the right, below, and bottom right of the tile in question. There maybe a few neighbor combinations ultimately that may be mapped to this tile, in my project I found 7 combinations that this tile configuration would be mapped to.

If you look through each tile you can see how it 'matches' up with another mating tile or tiles in the map. For my implementation, I spent time testing out each configuration visually to see which tile different bitmasks needed to be mapped to.

Create your tilemap data

Now we will use either a 2d matrix or a flat array in your codebase, with each index representing a tile. I use a flat array, with a tilemap width and height parameter. It is simply preference.

You can manually set these values in your array, or you can use a procedural generation algorithm to determine what your wall and floor tiles. I can recommend my Cellular Automata aarticle that I wrote earlier if you are interested in generating the tilemap procedurally. When this is completed, you'll have a data set that will look something like this.

Tilemap

Loop through tilemap and assign bitmasks

For each index of your array, you will need to capture all the states of the neighbor tiles for each tile, and record that value on each tile. I would refer to the previous section regarding how to calculate the bitmasks.

ts
// This loops through each tile in the tilemap
private createTileMapBitmasks(map: TileMap): number[] {
// create the array of bitmasks, the indexes of this array will match up to the index
// of the tilemap
let bitmask: number[] = new Array(map.columns * map.rows).fill(0);
let tileIndex = 0;
// for each tile in the map, add the bitmask to the array
for (let tile of map.tiles) {
bitmask[tileIndex] = this._getBitmask(map, tileIndex, 1);
tileIndex++;
}
return bitmask;
}
// setting up neighbor offsets indexes /
const neighborOffsets = [
[1, 1],
[0, 1],
[-1, 1],
[1, 0],
[-1, 0],
[1, -1],
[0, -1],
[-1, -1],
];
// iterate through each neighbor tile and get the bitmask based on if the tile is solid
private _getBitmask(map: TileMap, index: number, outofbound: number): number {
let bitmask = 0;
// find the coordinates of current tile
const width = map.columns;
const height = map.rows;
let y = Math.floor(index / width);
let x = index % width;
// loop through each neighbor offset, and 'collect' their state
for (let i = 0; i < neighborOffsets.length; i++) {
const [dx, dy] = neighborOffsets[i];
const nx = x + dx;
const ny = y + dy;
//convert back to index
const altIndex = nx + ny * width;
// check if the neighbor tile is out of bounds, else if tile is a wall ('solid') shift in the bitmask
if (ny < 0 || ny >= height || nx < 0 || nx >= width) bitmask |= outofbound << i;
else if (map.tiles[altIndex].data.get("solid") === true) bitmask |= 1 << i;
}
return bitmask;
}
ts
// This loops through each tile in the tilemap
private createTileMapBitmasks(map: TileMap): number[] {
// create the array of bitmasks, the indexes of this array will match up to the index
// of the tilemap
let bitmask: number[] = new Array(map.columns * map.rows).fill(0);
let tileIndex = 0;
// for each tile in the map, add the bitmask to the array
for (let tile of map.tiles) {
bitmask[tileIndex] = this._getBitmask(map, tileIndex, 1);
tileIndex++;
}
return bitmask;
}
// setting up neighbor offsets indexes /
const neighborOffsets = [
[1, 1],
[0, 1],
[-1, 1],
[1, 0],
[-1, 0],
[1, -1],
[0, -1],
[-1, -1],
];
// iterate through each neighbor tile and get the bitmask based on if the tile is solid
private _getBitmask(map: TileMap, index: number, outofbound: number): number {
let bitmask = 0;
// find the coordinates of current tile
const width = map.columns;
const height = map.rows;
let y = Math.floor(index / width);
let x = index % width;
// loop through each neighbor offset, and 'collect' their state
for (let i = 0; i < neighborOffsets.length; i++) {
const [dx, dy] = neighborOffsets[i];
const nx = x + dx;
const ny = y + dy;
//convert back to index
const altIndex = nx + ny * width;
// check if the neighbor tile is out of bounds, else if tile is a wall ('solid') shift in the bitmask
if (ny < 0 || ny >= height || nx < 0 || nx >= width) bitmask |= outofbound << i;
else if (map.tiles[altIndex].data.get("solid") === true) bitmask |= 1 << i;
}
return bitmask;
}

Map bitmask values to each tile sprite in spritesheet

Here is the monotonous part. For a byte, or an 8-bit word, the amount of permutations of tile patterns is 256. That's a lot of mappings. Now I did mine the hard way, manually, one by one. But there may be easier ways to do this. I use Typescript, so I will share a bit of what my mappings look like. Each number key in the object is the bitmask value, and its mapped to a coordinate array [x, y] for my spritesheet that I shared earlier in the article. Now, I could have put them in order, but that does not really serve any benefit.

ts
export const tilebitmask: Record<number, Array<number>> = {
0: [3, 3],
1: [3, 3],
4: [3, 3],
128: [3, 3],
32: [3, 3],
11: [0, 0],
175: [0, 0],
15: [0, 0],
47: [0, 0],
207: [0, 5],
203: [0, 5],
124: [3, 5],
43: [0, 0],
...
ts
export const tilebitmask: Record<number, Array<number>> = {
0: [3, 3],
1: [3, 3],
4: [3, 3],
128: [3, 3],
32: [3, 3],
11: [0, 0],
175: [0, 0],
15: [0, 0],
47: [0, 0],
207: [0, 5],
203: [0, 5],
124: [3, 5],
43: [0, 0],
...

Iterate over the tiles and assign tile sprite

The last two steps we'll do together. Now we simply need to iterate over our tilemap, assign the appropriate sprite tiles. I'm using Excalibur.js for my game engine, and the code is in Typescript, but you can use whichever tool you would prefer.

ts
draw(): TileMap {
// call the method that loops through and configures all the bitmasks
let bitmask = this.createTileMapBitmasks(this.map);
let tileindex = 0;
for (const tile of this.map.tiles) {
tile.clearGraphics();
// if the tile is solid, draw the base tile first, THEN the foreground tile
if (tile.data.get("solid") === true) {
// add floor tile
tile.addGraphic(this.baseTile);
// using the tile's index grab the bitmask value
let thisTileBitmask = bitmask[tileindex];
// this is the magic... grab the coordinates of the tile sprite from tilebitmask, and provide that to Excalibur
let sprite: Sprite;
sprite = this.spriteSheet.getSprite(tilebitmask[thisTileBitmask][0], tilebitmask[thisTileBitmask][1]);
//add the wall sprite to the tile
tile.addGraphic(sprite);
} else {
// if the tile is not solid, just draw the base tile
tile.addGraphic(this.baseTile);
}
tileindex++;
}
return this.map;
}
ts
draw(): TileMap {
// call the method that loops through and configures all the bitmasks
let bitmask = this.createTileMapBitmasks(this.map);
let tileindex = 0;
for (const tile of this.map.tiles) {
tile.clearGraphics();
// if the tile is solid, draw the base tile first, THEN the foreground tile
if (tile.data.get("solid") === true) {
// add floor tile
tile.addGraphic(this.baseTile);
// using the tile's index grab the bitmask value
let thisTileBitmask = bitmask[tileindex];
// this is the magic... grab the coordinates of the tile sprite from tilebitmask, and provide that to Excalibur
let sprite: Sprite;
sprite = this.spriteSheet.getSprite(tilebitmask[thisTileBitmask][0], tilebitmask[thisTileBitmask][1]);
//add the wall sprite to the tile
tile.addGraphic(sprite);
} else {
// if the tile is not solid, just draw the base tile
tile.addGraphic(this.baseTile);
}
tileindex++;
}
return this.map;
}

Demo Application

TitleImage

Link to Demo

Link to Repo

In this demo application, I'm using Excalibur.js engine to show how auto-tiling can work, and its benefits in game development. The user can click on the tilemap to draw walkable paths onto the canvas. As the walkable paths are drawn, the auto-tiling algorithm will automatically place the correct tile in its position based on the neighbor tile's walkable status.

There are some controls at the top of this app, a button to reset the tilemap settings back to not walkable, so one can start over. Also, two drop downs that let the user swap out tile-sets for different styles. This shows the benefits of having standardized Wang tiles for your tile-sets. For example, in this demo, we have three Wang tile-sets. When you swap them out, it can automatically draw them correctly into your tilemap.

Grass

Grass Tileset

Snow

Snow Tileset

and Rock

Rock Tileset

Why Excalibur

Snow Tileset

Small Plug...

ExcaliburJS is a friendly, TypeScript 2D game engine that can produce games for the web. It is free and open source (FOSS), well documented, and has a growing, healthy community of gamedevs working with it and supporting each other. There is a great discord channel for it JOIN HERE, for questions and inquiries. Check it out!!!

Conclusions

TThat was quite a bit, no? We covered the concept of Autotiling as a tool you can use in game developement. We discussed the benefits of Wang tiles for your projects and that they allow for the auto selection of the correct tile sprites to use based off of bitmask assignments. We dug into bitmask and base-2 binary encoding a little bit just to show how we were encoding the neighbor tile information into a decimal value so we could map the tile sprites appropriately. We finished this portion by doing an example tile encoding of neighbors to demonstrate the process.

We went step by step throught he process of autotiling, looking at tilesets, looking at code snippets, and finishing at the demo application on itch. I hope you enjoyed this take on autotiling, as mentioned above, this is NOT the only way to do this, there are other ways of accomplishing the same effect. You also can tweak this to your own liking, for instance, you can introduce varying tiles so you can use different floor tiles, or adding decord on to walls to add additional variety and add a feeling of greater immersion into the worlds your building. Have fun!