Phaser Tilemaps & Level Design — Build Game Worlds (Complete Tutorial)
Phaser Tilemaps & Level Design — Build Game Worlds (Complete Tutorial)
Build 2D game worlds with Phaser tilemaps: integrate Tiled Editor exports, work with multiple tile layers, set collision tiles, use object layers for spawn points, generate maps procedurally, and implement camera follow with world bounds.
What You’ll Learn
- What tilemaps are and why they’re more efficient than individual sprites
- How to load and render maps made in Tiled Editor (JSON format)
- Creating blank tilemaps programmatically for procedural levels
- Setting collision tiles and detecting which tile was hit
- Using Tiled object layers for player spawns, enemies, and triggers
- Implementing camera follow with world bounds for large maps
- Building an interactive level builder with tile palette and collision toggles
Phaser tilemaps are the foundation of 2D game level design — a single tileset image and a data grid can replace hundreds of individual sprites.
Why Tilemaps Matters
Imagine building a game level by placing individual sprites for every floor tile, wall, decoration, and platform. A single screen could require 200+ sprites. A tilemap does the same with one image (the tileset) and a grid of numbers (the map data). It’s faster to create, faster to load, and faster to render.
At DodaTech, security training simulators use tilemaps to build floor plans of virtual buildings for physical security exercises. Each tile type represents a different zone (secure area, public area, restricted), and collision tiles trigger alerts when trainees enter off-limits sections. Level design techniques like these power training tools built by the developers of Doda Browser and Durga Antivirus Pro.
Learning Path
flowchart LR
A[Getting Started] --> B[Sprites & Physics]
B --> C[Scenes & State]
C --> D[Input & Audio]
D --> E[Tilemaps & Level Design]
E -->|You are here| E
What Is a Tilemap? — The Grid Analogy
Think of a tilemap like a chessboard. The board is a grid (the map), and each square is either empty or has a piece (a tile). Instead of loading 64 separate chess piece images, a tilemap uses:
- One image (the tileset) — a single file containing all tiles arranged in a grid
- One data array — a 2D grid of numbers where each number points to a tile in the tileset
Tileset (one image): Map data (numbers):
[0][1][2][3] [0,0,1,1,0,0]
[4][5][6][7] → [2,2,3,3,2,2]
[8][9][A][B] [0,0,1,1,0,0]Tile index 0 might be grass, 1 might be stone, 2 might be water. The map data is just numbers — tiny, fast, and easy to generate programmatically.
Phaser vs Tiled: When to Use What
| Approach | Best For | Trade-off |
|---|---|---|
| Tiled Editor + JSON | Hand-crafted levels, game jams, polished games | Requires external tool, manual design |
| Blank dynamic tilemap | Procedural levels, roguelikes, infinite runners | No visual editor — build with code |
| Hybrid (Tiled + procedural) | Large games with hand-crafted + random areas | More complex setup |
Most games use a hybrid: designers build the main levels in Tiled, and procedural generation fills in random content (enemy placements, treasure rooms, bonus areas).
Tiled Editor Integration
Tiled is a free, open-source tile map editor. You draw levels visually, then export them as JSON for Phaser to load.
Tiled JSON Structure
When you export a Tiled map as JSON, it looks like this:
{
"width": 20,
"height": 15,
"tilewidth": 32,
"tileheight": 32,
"layers": [
{ "name": "ground", "type": "tilelayer", "data": [0, 0, 1, 1, ...] },
{ "name": "decorations", "type": "tilelayer", "data": [...] },
{ "name": "objects", "type": "objectgroup", "objects": [...] }
],
"tilesets": [
{
"firstgid": 1,
"image": "tileset.png",
"imagewidth": 256,
"imageheight": 128,
"tilewidth": 32,
"tileheight": 32,
"columns": 8,
"tilecount": 32
}
]
}Key parts to understand:
tilewidth/tileheight— size of each tile in pixels (usually 16, 32, or 64)layers— can betilelayer(grid of tiles) orobjectgroup(spawn points, triggers)tilesets— defines the image file and how tiles are arranged within itfirstgid— the tile index where this tileset starts. If you have multiple tilesets, the second one’sfirstgidis offset so tiles don’t overlap
Loading a Tiled Map in Phaser
preload() {
this.load.tilemapTiledJSON('map', 'assets/level.json');
this.load.image('tileset', 'assets/tileset.png');
}
create() {
const map = this.make.tilemap({ key: 'map' });
const tileset = map.addTilesetImage('tileset'); // Name must match Tiled's tileset name
const groundLayer = map.createLayer('ground', tileset, 0, 0);
const decorLayer = map.createLayer('decorations', tileset, 0, 0);
}Critical: The tileset name in addTilesetImage() must match the name field from Tiled’s tileset definition. If Tiled says "name": "my-tileset", use map.addTilesetImage('my-tileset'). This is the #1 cause of blank layers.
Creating Blank Dynamic Tilemaps
Don’t want to use Tiled? Create a tilemap entirely in code. This is essential for procedural levels:
create() {
const map = this.make.tilemap({
tileWidth: 32,
tileHeight: 32,
width: 25, // 25 tiles across
height: 20 // 20 tiles down
});
const tileset = map.addTilesetImage('tileset');
// Create blank layer
const layer = map.createBlankLayer('ground', tileset);
layer.fill(1); // Fill entire layer with tile index 1
// Place specific tiles
layer.putTileAt(2, 5, 10); // Tile index 2 at grid position (5, 10)
layer.putTileAt(3, 8, 12);
}Important: Tile indices start at 0 (usually empty/nothing’). Index 1 is the first visible tile. Tile index -1 means “no tile.”
Tile Layers — Foreground, Background, and Parallax
Multiple layers let you create depth. Think of them as transparent sheets stacked on top of each other:
const bgLayer = map.createLayer('background', tileset, 0, 0);
bgLayer.setScrollFactor(0.2); // Parallax — moves slower than camera
const groundLayer = map.createLayer('ground', tileset, 0, 0);
// Ground moves at normal camera speed
const fgLayer = map.createLayer('foreground', tileset, 0, 0);
// Foreground — rendered on top of everything
Parallax effect: Background layers with a lower scroll factor create an illusion of depth. Mountains in the background scroll slowly, the ground scrolls at normal speed, and foreground elements scroll slightly faster. DodaTech’s security simulators use parallax layers for outdoor training maps — fences and buildings in the foreground, terrain in the middle, sky in the back.
Collision Tiles — Making Walls Solid
Tiles don’t collide by default. You must tell Phaser which tile indices are solid:
// All tiles with index 2 or 4 are collidable
groundLayer.setCollision([2, 4]);
// Or set collision by custom property (from Tiled)
groundLayer.setCollisionByProperty({ collides: true });
// Set a callback for a specific tile position
groundLayer.setTileLocationCallback(5, 10, 1, 1, (tile) => {
console.log('Stepped on tile', tile);
});Then add a collider between the player and the layer:
this.physics.add.collider(this.player, groundLayer);Why tile index 0 should never be collidable: Index 0 typically represents empty space. If you set collision on tile 0, every empty tile becomes a wall — including the air above the ground. Only set collision on tiles that have visual content.
Detecting Which Tile Was Hit
this.physics.add.collider(this.player, groundLayer, (player, tile) => {
// 'tile' is the specific Tile object the player collided with
console.log(`Hit tile ${tile.index} at grid (${tile.x}, ${tile.y})`);
// Check tile properties for custom behavior
if (tile.properties.damage) {
player.setTint(0xff0000);
}
});This is useful for different tile behaviors: lava tiles damage the player, ice tiles reduce friction, boost tiles launch the player upward.
Object Layers from Tiled — Spawn Points and Triggers
Tiled’s object layers let you place interactive elements visually. Instead of hard-coding spawn coordinates, your level designer places them in Tiled:
const spawnPoints = map.getObjectLayer('objects');
spawnPoints.objects.forEach(obj => {
switch (obj.name) {
case 'player_spawn':
this.player = this.physics.add.sprite(obj.x, obj.y, 'player');
break;
case 'enemy_spawn':
this.enemies.push(this.physics.add.sprite(obj.x, obj.y, 'enemy'));
break;
case 'coin':
this.coins.create(obj.x, obj.y, 'coin');
break;
}
});Each object from Tiled has these properties:
obj.x, obj.y— Position (Tiled uses bottom-left origin; Phaser converts automatically for tile layers but object layers may need manual adjustment)obj.width, obj.height— Size in pixelsobj.name— Object name (set in Tiled)obj.type— Object type string (set in Tiled)obj.properties— Custom key-value pairs:{ speed: 100, color: 'red' }obj.visible— Whether the object is visible in Tiledobj.rotation— Rotation in degrees
Coordinate conversion: Tiled uses a Y-up coordinate system, while Phaser uses Y-down. For tile layers, Phaser handles the conversion automatically. For object layers, you may need: obj.y = map.heightInPixels - obj.y.
Procedural Level Generation
For roguelikes, infinite runners, or random dungeons, generate tilemaps with code:
function generateLevel(map, tileset, layer) {
const width = map.width;
const height = map.height;
// Fill with air (0 = empty)
layer.fill(0);
// Ground at bottom 2 rows
for (let x = 0; x < width; x++) {
for (let y = height - 2; y < height; y++) {
layer.putTileAt(1, x, y); // Ground tile
}
}
// Random platforms
for (let i = 0; i < 8; i++) {
const px = Phaser.Math.Between(2, width - 4);
const py = Phaser.Math.Between(4, height - 4);
const platWidth = Phaser.Math.Between(2, 5);
for (let x = 0; x < platWidth; x++) {
layer.putTileAt(2, px + x, py); // Platform tile
}
}
}When to use procedural generation: When every playthrough should be different, or when your level designers can’t hand-craft hundreds of levels. DodaTech’s security training simulators generate random building layouts so trainees can’t memorize the floor plan.
Camera Follow with World Bounds
When your tilemap is larger than the game canvas (800x600), the camera needs to follow the player while respecting the map edges:
// Set world bounds to match tilemap dimensions
const mapWidth = map.width * map.tileWidth;
const mapHeight = map.height * map.tileHeight;
this.physics.world.setBounds(0, 0, mapWidth, mapHeight);
this.cameras.main.setBounds(0, 0, mapWidth, mapHeight);
// Camera follows player smoothly
this.cameras.main.startFollow(this.player, true, 0.1, 0.1);
// Parameters: target, roundPixels, lerpX (smoothing), lerpY (smoothing)
// Dead zone — camera doesn't move until player leaves this area
this.cameras.main.setDeadzone(100, 50);
// Background color beyond map edges (visible if camera bounds are wrong)
this.cameras.main.setBackgroundColor('#1a1a2e');Why both physics and camera bounds? Physics bounds prevent the player from moving beyond the map. Camera bounds prevent the camera from showing areas outside the map. You need both — they serve different purposes.
You Might Be Wondering…
“Can I use tilemaps without Tiled?” Absolutely. Create blank dynamic tilemaps and fill them with putTileAt(). This is the standard approach for procedural content.
“How do I get the tile under the mouse cursor?” Convert pointer world coordinates to tile grid coordinates: const tile = groundLayer.getTileAtWorldXY(pointer.worldX, pointer.worldY).
“Can I have multiple tilesets in one map?” Yes. Call map.addTilesetImage() for each tileset, then pass an array to createLayer(): map.createLayer('ground', [tileset1, tileset2]).
Common Mistakes
Tileset name mismatch in
addTilesetImage()— The first argument must match thenamefield in Tiled’s tileset definition. A mismatch causes a blank layer with no errors. Always verify the name matches what Tiled exported.Not setting camera bounds for large maps — Without
this.cameras.main.setBounds(), the camera scrolls beyond the tilemap edges showing blank canvas. Always set camera bounds to matchmap.width * map.tileWidthandmap.height * map.tileHeight.Setting collision on tile index 0 — Index 0 typically means “empty/no tile.” Setting collision on tile 0 makes all empty spaces collidable, effectively filling the entire map with invisible walls. Only set collision on valid, visible tile indices.
Physics collider on a layer without collision enabled —
this.physics.add.collider(player, groundLayer)does nothing ifgroundLayer.setCollision()wasn’t called first. Verify withgroundLayer.debug = trueor checkgroundLayer.collision[x][y].Not converting Tiled object layer coordinates — Tiled uses Y-up (bottom-left origin), but Phaser uses Y-down (top-left origin). For object layers, you may need to invert the Y:
obj.y = map.heightInPixels - obj.y.Loading the entire tileset as individual textures — Some developers load each tile as a separate image. This destroys performance. Use a single tileset image (one file, many tiles) and reference tiles by index.
Practice Questions
What is the difference between a tile layer and an object layer in Tiled? A tile layer is a grid of tile indices that renders the visual map (ground, walls, decorations). An object layer contains individual placed objects used for spawn points, triggers, and zones — it doesn’t render tiles.
Why should tile index 0 never have collision enabled? Index 0 represents empty space (“no tile”). Enabling collision on it makes all empty areas solid, effectively creating invisible walls everywhere.
How do you make a background layer scroll slower than the foreground (parallax effect)? Use
bgLayer.setScrollFactor(0.2)for the background and leave the foreground layer at the defaultsetScrollFactor(1).What two bounds must be set for camera follow on a large tilemap, and why? Physics world bounds (
this.physics.world.setBounds()) to keep the player on the map, and camera bounds (this.cameras.main.setBounds()) to keep the view within the map edges.Challenge: Extend the Level Builder below to add a “Save Level” feature that exports the current tile grid as JSON using
localStorage.setItem(). Add a “Load Level” button that reads it back and restores the tile placement. How would you include collision data in the save format?
FAQ
Try It Yourself
This interactive level builder lets you place tiles from a palette, toggle collision on/off per tile, and move a player around with camera follow:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Platformer Level Builder</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #0f0f23;
font-family: monospace;
}
#game-container { position: relative; }
#toolbar {
position: absolute;
top: 8px; left: 8px;
z-index: 10;
background: rgba(0,0,0,0.85);
padding: 10px 14px;
border-radius: 8px;
color: #fff;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 150px;
}
#toolbar .row { display: flex; gap: 6px; align-items: center; }
#toolbar button {
background: #4ecdc4; border: none; color: #000;
padding: 4px 10px; border-radius: 4px; cursor: pointer;
font-family: monospace; font-size: 12px;
}
#toolbar button:hover { background: #3bb8b0; }
#toolbar button.active { background: #ffd700; }
#palette { display: grid; grid-template-columns: repeat(4, 32px); gap: 2px; margin-top: 4px; }
#palette .swatch { width: 32px; height: 32px; border: 2px solid transparent; cursor: pointer; }
#palette .swatch.selected { border-color: #ffd700; }
#tile-info {
position: absolute;
bottom: 8px; left: 8px;
z-index: 10;
background: rgba(0,0,0,0.85);
padding: 6px 10px;
border-radius: 4px;
color: #aaa;
font-size: 12px;
}
</style>
</head>
<body>
<div id="game-container">
<div id="toolbar">
<div class="row"><span style="color:#ffd700;font-weight:bold">Level Builder</span></div>
<div class="row">
<button id="collide-btn">Toggle Collision</button>
<button id="clear-btn">Clear</button>
</div>
<div style="font-size:11px;color:#888">Click map to place tiles</div>
<div id="palette"></div>
<div class="row" style="font-size:11px;color:#888"><span>Arrow keys: move player</span></div>
</div>
<div id="tile-info">Hover over a tile</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script>
class LevelBuilderScene extends Phaser.Scene {
constructor() {
super({ key: 'LevelBuilderScene' });
this.selectedTileIndex = 1;
this.collisionEnabled = {};
}
preload() { this.generateTileset(); }
generateTileset() {
const tileSize = 32; const cols = 4; const rows = 4;
const canvas = this.textures.createCanvas('tileset', cols * tileSize, rows * tileSize);
const ctx = canvas.context;
const colors = ['#2d5a27','#3a7d32','#5a4a3a','#8a7a6a','#4a90d9','#e63946','#ffd700','#888888','#6a4a3a','#4ecdc4','#ff6b35','#9b59b6','#ffffff','#2c3e50','#e67e22','#1abc9c'];
for (let i = 0; i < 16; i++) {
const col = i % cols; const row = Math.floor(i / cols);
const x = col * tileSize; const y = row * tileSize;
ctx.fillStyle = colors[i] || '#333'; ctx.fillRect(x, y, tileSize, tileSize);
ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.strokeRect(x, y, tileSize, tileSize);
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = '10px monospace'; ctx.fillText(i.toString(), x + 2, y + 12);
}
canvas.refresh();
this.paletteColors = colors;
}
create() {
this.map = this.make.tilemap({ tileWidth: 32, tileHeight: 32, width: 30, height: 20 });
this.tileset = this.map.addTilesetImage('tileset');
this.groundLayer = this.map.createBlankLayer('ground', this.tileset);
for (let x = 0; x < this.map.width; x++) {
for (let y = this.map.height - 2; y < this.map.height; y++) { this.groundLayer.putTileAt(1, x, y); }
}
const mapW = this.map.width * this.map.tileWidth;
const mapH = this.map.height * this.map.tileHeight;
this.physics.world.setBounds(0, 0, mapW, mapH);
this.cameras.main.setBounds(0, 0, mapW, mapH);
this.player = this.physics.add.sprite(64, mapH - 96, 'player');
this.player.setCollideWorldBounds(true);
this.player.body.setGravityY(600);
this.player.setBounce(0.1);
this.physics.add.collider(this.player, this.groundLayer);
this.cameras.main.startFollow(this.player, true, 0.1, 0.1);
this.cameras.main.setDeadzone(80, 50);
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({ jump: Phaser.Input.Keyboard.KeyCodes.SPACE });
this.input.on('pointerdown', (pointer) => { if (pointer.x > 200 || pointer.y < 60) { this.placeTile(pointer); } });
this.input.on('pointermove', (pointer) => { this.updateTileInfo(pointer); });
this.generatePlayerTexture();
this.buildPalette();
this.setupUI();
}
generatePlayerTexture() {
const c = this.textures.createCanvas('player', 24, 32);
const ctx = c.context;
ctx.fillStyle = '#ff6b35'; ctx.fillRect(2, 0, 20, 32);
ctx.fillStyle = '#ffd166'; ctx.fillRect(4, 4, 16, 12);
c.refresh();
}
buildPalette() {
const palette = document.getElementById('palette');
palette.innerHTML = '';
for (let i = 0; i < 16; i++) {
const swatch = document.createElement('div');
swatch.className = 'swatch' + (i === 1 ? ' selected' : '');
swatch.style.background = this.paletteColors[i];
swatch.dataset.index = i;
swatch.addEventListener('click', () => {
document.querySelectorAll('.swatch').forEach(s => s.classList.remove('selected'));
swatch.classList.add('selected');
this.selectedTileIndex = i;
});
palette.appendChild(swatch);
}
}
setupUI() {
document.getElementById('collide-btn').addEventListener('click', () => {
const tileX = Math.floor(this.player.x / 32);
const tileY = Math.floor(this.player.y / 32);
const tile = this.groundLayer.getTileAt(tileX, tileY);
if (tile && tile.index > 0) {
const key = `${tileX},${tileY}`;
this.collisionEnabled[key] = !this.collisionEnabled[key];
this.groundLayer.collision[tileX] = this.groundLayer.collision[tileX] || {};
this.groundLayer.collision[tileX][tileY] = !!this.collisionEnabled[key];
document.getElementById('collide-btn').textContent = this.collisionEnabled[key] ? 'Collision: ON' : 'Collision: OFF';
this.physics.world.colliders.destroy();
this.physics.add.collider(this.player, this.groundLayer);
}
});
document.getElementById('clear-btn').addEventListener('click', () => { this.groundLayer.fill(0); });
}
placeTile(pointer) {
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const tileX = Math.floor(worldPoint.x / 32);
const tileY = Math.floor(worldPoint.y / 32);
if (tileX >= 0 && tileX < this.map.width && tileY >= 0 && tileY < this.map.height) {
this.groundLayer.putTileAt(this.selectedTileIndex, tileX, tileY);
}
}
updateTileInfo(pointer) {
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const tileX = Math.floor(worldPoint.x / 32);
const tileY = Math.floor(worldPoint.y / 32);
const info = document.getElementById('tile-info');
if (tileX >= 0 && tileX < this.map.width && tileY >= 0 && tileY < this.map.height) {
const tile = this.groundLayer.getTileAt(tileX, tileY);
const idx = tile ? tile.index : 0;
const collides = this.groundLayer.collision[tileX]?.[tileY];
info.textContent = `Tile (${tileX}, ${tileY}) — Index: ${idx} ${collides ? '| Collision: ON' : ''}`;
} else { info.textContent = 'Out of bounds'; }
}
update() {
const speed = 250;
if (this.cursors.left.isDown) { this.player.setVelocityX(-speed); }
else if (this.cursors.right.isDown) { this.player.setVelocityX(speed); }
else { this.player.setVelocityX(0); }
if ((this.cursors.up.isDown || this.keys.jump.isDown) && this.player.body.touching.down) {
this.player.setVelocityY(-500);
}
}
}
const config = {
type: Phaser.AUTO, width: 800, height: 600,
parent: 'game-container', backgroundColor: '#1a1a2e',
physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },
scene: [LevelBuilderScene]
};
new Phaser.Game(config);
</script>
</body>
</html>This demonstrates: blank dynamic tilemap creation, tile placement via pointer clicks, a tile palette UI with 16 tile types, per-tile collision toggling, player physics with platformer movement, camera follow with dead zone and world bounds, and real-time tile information display.
What’s Next
| Topic | Description |
|---|---|
| Phaser Getting Started | Review the complete Phaser fundamentals series from the beginning |
| Phaser Sprites & Physics | Review Arcade physics — essential for tile-based platformer collision |
| Phaser Scenes & State | Multi-scene architecture for menu → gameplay → level complete flow |
| Phaser Input & Audio | Keyboard/mouse/gamepad input and audio for level builder tools |
| JSON Format Reference | Reference for Tiled export format and data manipulation |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Tilemap-based level design powers DodaTech’s security training simulators, creating realistic building layouts where trainees navigate physical security checkpoints and restricted zones.
What’s Next
Congratulations on completing this Phaser Tilemaps tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro