Lesson 16

Tile maps: stop hand-placing every wall and start designing levels.

Tile maps turn a canvas game from loose coordinates into a real world. They make collision readable, maps editable and level design much faster.

IntermediateLevel DesignGrid Systems35-55 minutes

1. Why tile maps matter

When a game has only a player and a few obstacles, raw rectangles are fine. As soon as you need rooms, paths, doors, water, grass, lava, walls, checkpoints or tower build zones, raw coordinates become hard to maintain. A tile map gives every cell a meaning. The renderer draws based on that meaning, the collision system blocks based on that meaning, and the editor can change a whole level by changing numbers.

Tile maps are also good for content quality. A level made from a data structure can have multiple variations, readable design notes and predictable QA. You can test whether a tile is walkable without guessing what rectangle happened to be drawn there.

const TILE = 32;
const tiles = {
    empty: 0,
    wall: 1,
    path: 2,
    water: 3,
    exit: 4
};

const level = [
    [1,1,1,1,1,1,1,1],
    [1,0,0,0,0,0,4,1],
    [1,0,1,1,0,0,0,1],
    [1,0,0,2,2,2,0,1],
    [1,1,1,1,1,1,1,1]
];

function tileAtPixel(x, y) {
    const col = Math.floor(x / TILE);
    const row = Math.floor(y / TILE);
    return level[row] && level[row][col];
}

Source pattern: Supagames tower defense maps use grid cells and path definitions so towers, enemies and drawing all agree on what each tile means.

2. Keep collision based on data, not art

A common mistake is drawing a wall and separately creating a collision rectangle. That doubles the work and creates bugs when the art moves but the hitbox does not. Instead, decide which tile IDs block movement. When the player wants to move, sample the corners of the player's hitbox in the grid.

const solidTiles = new Set([tiles.wall, tiles.water]);

function isSolidAt(x, y) {
    return solidTiles.has(tileAtPixel(x, y));
}

function canMoveTo(rect) {
    return !(
        isSolidAt(rect.x, rect.y) ||
        isSolidAt(rect.x + rect.w, rect.y) ||
        isSolidAt(rect.x, rect.y + rect.h) ||
        isSolidAt(rect.x + rect.w, rect.y + rect.h)
    );
}

function movePlayer(player, dx, dy) {
    const nextX = { x: player.x + dx, y: player.y, w: player.w, h: player.h };
    if (canMoveTo(nextX)) player.x += dx;

    const nextY = { x: player.x, y: player.y + dy, w: player.w, h: player.h };
    if (canMoveTo(nextY)) player.y += dy;
}

Separate X and Y movement makes sliding along walls feel natural. If you move diagonally into a corner, the player can still move along the free axis instead of stopping completely.

3. Render by tile type

Rendering a tile map is just nested loops. The art can be simple colors at first. Later you can swap colors for sprites, patterns or animated tiles without changing collision. This is the core benefit: game logic and presentation stay connected through tile IDs, but they are not tangled.

function drawLevel(ctx) {
    for (let row = 0; row < level.length; row++) {
        for (let col = 0; col < level[row].length; col++) {
            const id = level[row][col];
            const x = col * TILE;
            const y = row * TILE;

            ctx.fillStyle =
                id === tiles.wall ? "#26324a" :
                id === tiles.path ? "#8b7355" :
                id === tiles.water ? "#1f7aa8" :
                id === tiles.exit ? "#4aff8b" : "#182033";

            ctx.fillRect(x, y, TILE, TILE);
            ctx.strokeStyle = "rgba(255,255,255,0.05)";
            ctx.strokeRect(x, y, TILE, TILE);
        }
    }
}

During development, draw grid lines and tile IDs. Debug visuals are not a sign of weak code; they are a level designer's flashlight.

4. Build a tiny editor mode

You do not need a separate app to start editing maps. Add an editor flag, a selected tile and a canvas click handler that writes into the level array. Then add export to JSON so you can paste the final map into the game file.

let editorMode = true;
let selectedTile = tiles.wall;

canvas.addEventListener("pointerdown", function (event) {
    if (!editorMode) return;
    const rect = canvas.getBoundingClientRect();
    const col = Math.floor((event.clientX - rect.left) / TILE);
    const row = Math.floor((event.clientY - rect.top) / TILE);
    if (!level[row] || typeof level[row][col] === "undefined") return;
    level[row][col] = selectedTile;
});

function exportLevel() {
    return JSON.stringify(level);
}

For production, add undo, map size controls and validation. For early design, click-to-paint and JSON export already save a lot of time.

5. Validate the map before publishing

A tile map can look good and still be impossible. Add checks: player start exists, exit exists, all required keys are reachable, no enemy spawns inside a wall, no tower can be placed on the path. The validation rules depend on the genre, but the principle is universal.

function validateLevel(map) {
    let exits = 0;
    let starts = 0;
    for (const row of map) {
        for (const tile of row) {
            if (tile === tiles.exit) exits++;
            if (tile === 5) starts++;
        }
    }
    return {
        ok: exits > 0 && starts === 1,
        exits,
        starts
    };
}

Once maps are data, validation becomes realistic. That is how a small browser game grows into many levels without every new screen becoming a fresh bug hunt.

Previous: Automated testing. Next: Pathfinding and grid AI.