
Phaser Gamedev
- 44 installs
- 83 repo stars
- Updated January 23, 2026
- chongdashu/phaserjs-oakwoods
Build 2D browser games with Phaser 3 covering scenes, sprites, physics, tilemaps, animations, and spritesheet loading.
About
Covers Phaser 3 game development including scene lifecycle, Arcade/Matter physics, tilemaps, and careful spritesheet loading. Used when a developer builds a 2D browser game with Phaser.
- Scene, sprite, and physics architecture
- Mandatory spritesheet inspection protocol
Phaser Gamedev by the numbers
- 44 all-time installs (skills.sh)
- Ranked #174 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/chongdashu/phaserjs-oakwoods --skill phaser-gamedevAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 83 |
| Last updated | January 23, 2026 |
| Repository | chongdashu/phaserjs-oakwoods ↗ |
What it does
Build 2D browser games with Phaser 3 covering scenes, sprites, physics, tilemaps, animations, and spritesheet loading.
Files
Phaser Game Development
Build 2D browser games using Phaser 3's scene-based architecture and physics systems.
---
STOP: Before Loading Any Spritesheet
Read [spritesheets-nineslice.md](references/spritesheets-nineslice.md) FIRST.
Spritesheet loading is fragile—a few pixels off causes silent corruption that compounds into broken visuals. The reference file contains the mandatory inspection protocol.
Quick rules (details in reference):
1. Measure the asset before writing loader code—never guess frame dimensions 2. Character sprites use SQUARE frames: If you calculate frameWidth=56, try 56 for height first 3. Different animations have different frame sizes: A run cycle needs wider frames than idle; an attack needs extra width for weapon swing. Measure EACH spritesheet independently 4. Check for spacing: Gaps between frames require spacing: N in loader config 5. Verify the math: imageWidth = (frameWidth × cols) + (spacing × (cols - 1))
---
Reference Files
Read these BEFORE working on the relevant feature:
| When working on... | Read first |
|---|---|
| Loading ANY spritesheet | spritesheets-nineslice.md |
| Nine-slice UI panels | spritesheets-nineslice.md |
| Tiled tilemaps, collision layers | tilemaps.md |
| Physics tuning, groups, pooling | arcade-physics.md |
| Performance issues, object pooling | performance.md |
---
Architecture Decisions (Make Early)
Physics System Choice
| System | Use When |
|---|---|
| Arcade | Platformers, shooters, most 2D games. Fast AABB collisions |
| Matter | Physics puzzles, ragdolls, realistic collisions. Slower, more accurate |
| None | Menu scenes, visual novels, card games |
Scene Structure
scenes/
├── BootScene.ts # Asset loading, progress bar
├── MenuScene.ts # Title screen, options
├── GameScene.ts # Main gameplay
├── UIScene.ts # HUD overlay (launched parallel)
└── GameOverScene.ts # End screen, restartScene Transitions
this.scene.start('GameScene', { level: 1 }); // Stop current, start new
this.scene.launch('UIScene'); // Run in parallel
this.scene.pause('GameScene'); // Pause
this.scene.stop('UIScene'); // Stop---
Core Patterns
Game Configuration
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 800,
height: 600,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
physics: {
default: 'arcade',
arcade: { gravity: { y: 300 }, debug: false }
},
scene: [BootScene, MenuScene, GameScene]
};Scene Lifecycle
class GameScene extends Phaser.Scene {
init(data) { } // Receive data from previous scene
preload() { } // Load assets (runs before create)
create() { } // Set up game objects, physics, input
update(time, delta) { } // Game loop, use delta for frame-rate independence
}Frame-Rate Independent Movement
// CORRECT: scales with frame rate
this.player.x += this.speed * (delta / 1000);
// WRONG: varies with frame rate
this.player.x += this.speed;---
Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
Global state on window | Scene transitions break state | Use scene data, registries |
Loading in create() | Assets not ready when referenced | Load in preload(), use Boot scene |
| Frame counting | Game speed varies with FPS | Use delta / 1000 |
| Matter for simple collisions | Unnecessary complexity | Arcade handles most 2D games |
| One giant scene | Hard to extend | Separate gameplay/UI/menus |
| Magic numbers | Impossible to balance | Config objects, constants |
| No object pooling | GC stutters | Groups with setActive(false) |
---
Remember
Phaser provides powerful primitives—scenes, sprites, physics, input—but architecture is your responsibility.
Before coding: What scenes? What entities? How do they interact? What physics model?
Claude can build complete, polished Phaser games. These guidelines illuminate the path—they don't fence it.
Arcade Physics Deep Dive
Comprehensive reference for Phaser 3 Arcade Physics system.
World Configuration
// In game config
physics: {
default: 'arcade',
arcade: {
gravity: { x: 0, y: 300 },
debug: true, // Show collision bodies
debugShowBody: true,
debugShowStaticBody: true,
debugShowVelocity: true,
debugBodyColor: 0xff00ff,
debugStaticBodyColor: 0x0000ff,
debugVelocityColor: 0x00ff00,
fps: 60, // Physics update rate
timeScale: 1, // 0.5 = half speed, 2 = double
checkCollision: {
up: true,
down: true,
left: true,
right: true
},
overlapBias: 4, // Overlap tolerance
tileBias: 16, // Tile collision bias
forceX: false, // Prioritize X-axis separation
maxEntries: 16, // Quadtree max per node
useTree: true // Spatial hash (false for >5000 bodies)
}
}Body Types
Dynamic Bodies
Move, respond to velocity, gravity, and collisions.
// Create with physics enabled
const player = this.physics.add.sprite(100, 100, 'player');
// Or add to existing sprite
const sprite = this.add.sprite(100, 100, 'player');
this.physics.add.existing(sprite);
// Body properties
player.body.setVelocity(100, -200);
player.body.setVelocityX(100);
player.body.setVelocityY(-200);
player.body.setBounce(0.5, 0.5);
player.body.setDrag(100, 100);
player.body.setFriction(0.5, 0.5);
player.body.setMaxVelocity(300, 400);
player.body.setGravityY(500); // Additional gravity
player.body.setAcceleration(100, 0);
player.body.setCollideWorldBounds(true);
player.body.onWorldBounds = true; // Enable worldbounds eventStatic Bodies
Immovable, no velocity or gravity response. Efficient for platforms.
// Create static group
const platforms = this.physics.add.staticGroup();
platforms.create(400, 568, 'ground');
// Or make existing body static
sprite.body.setImmovable(true);
sprite.body.moves = false;
// After scaling/moving static bodies
sprite.refreshBody();Body Sizing and Shape
// Custom body size
sprite.body.setSize(width, height, center);
sprite.body.setSize(32, 48, true); // Centered
// Offset body from sprite
sprite.body.setOffset(x, y);
// Circular body (for rolling objects)
sprite.body.setCircle(radius, offsetX, offsetY);
sprite.body.setCircle(16, 0, 0);Collision Detection
Collider vs Overlap
// Collider: Physical collision, objects separate
this.physics.add.collider(player, platforms);
this.physics.add.collider(player, enemies, hitEnemy, null, this);
// Overlap: Detect overlap without physical response
this.physics.add.overlap(player, coins, collectCoin, null, this);
function collectCoin(player, coin) {
coin.disableBody(true, true); // (disableGameObject, hideGameObject)
}Collision Callbacks
// Process callback (return false to skip collision)
function shouldCollide(player, enemy) {
return !player.isInvincible;
}
this.physics.add.collider(player, enemies, hitEnemy, shouldCollide, this);Collision Events
// World bounds event
this.physics.world.on('worldbounds', (body, up, down, left, right) => {
if (down) {
// Hit bottom of world
}
});
// Enable on body first
player.body.onWorldBounds = true;
player.body.setCollideWorldBounds(true);Groups
Static Groups
const platforms = this.physics.add.staticGroup();
// Add children
platforms.create(400, 568, 'ground');
platforms.createMultiple({
key: 'brick',
repeat: 10,
setXY: { x: 50, y: 300, stepX: 70 }
});
// After modifying
platforms.refresh();Dynamic Groups
const enemies = this.physics.add.group({
key: 'enemy',
repeat: 5,
setXY: { x: 100, y: 0, stepX: 100 }
});
// Group defaults
const bullets = this.physics.add.group({
defaultKey: 'bullet',
maxSize: 50,
runChildUpdate: true, // Call update() on children
collideWorldBounds: true,
velocityY: -300
});
// Iterate children
enemies.children.iterate(enemy => {
enemy.setBounce(0.5);
});
// Get first inactive (for pooling)
const bullet = bullets.get(x, y);
if (bullet) {
bullet.setActive(true).setVisible(true);
bullet.body.enable = true;
}Object Pooling
Reuse objects instead of create/destroy for performance.
// In create()
this.bulletPool = this.physics.add.group({
defaultKey: 'bullet',
maxSize: 100,
createCallback: (bullet) => {
bullet.setName('bullet' + this.bulletPool.getLength());
}
});
// Fire bullet
fire(x, y, velocityX, velocityY) {
const bullet = this.bulletPool.get(x, y);
if (bullet) {
bullet.setActive(true);
bullet.setVisible(true);
bullet.body.enable = true;
bullet.body.setVelocity(velocityX, velocityY);
}
}
// Return to pool
killBullet(bullet) {
bullet.setActive(false);
bullet.setVisible(false);
bullet.body.enable = false;
bullet.body.stop();
}Movement Patterns
Basic Movement
update(time, delta) {
const speed = 160;
player.setVelocity(0);
if (cursors.left.isDown) {
player.setVelocityX(-speed);
} else if (cursors.right.isDown) {
player.setVelocityX(speed);
}
if (cursors.up.isDown) {
player.setVelocityY(-speed);
} else if (cursors.down.isDown) {
player.setVelocityY(speed);
}
}Platformer Movement
update(time, delta) {
// Horizontal movement
if (cursors.left.isDown) {
player.setVelocityX(-160);
player.anims.play('walk', true);
player.flipX = true;
} else if (cursors.right.isDown) {
player.setVelocityX(160);
player.anims.play('walk', true);
player.flipX = false;
} else {
player.setVelocityX(0);
player.anims.play('idle', true);
}
// Jump (only when grounded)
if (cursors.up.isDown && player.body.blocked.down) {
player.setVelocityY(-330);
}
}Acceleration-Based Movement
const accel = 600;
const maxSpeed = 200;
const drag = 400;
player.body.setMaxVelocity(maxSpeed);
player.body.setDrag(drag, 0);
update() {
if (cursors.left.isDown) {
player.body.setAccelerationX(-accel);
} else if (cursors.right.isDown) {
player.body.setAccelerationX(accel);
} else {
player.body.setAccelerationX(0);
}
}Velocity Helpers
// Move to point
this.physics.moveTo(sprite, targetX, targetY, speed);
// Move to object
this.physics.moveToObject(sprite, target, speed);
// Accelerate to point
this.physics.accelerateTo(sprite, targetX, targetY, accel, maxSpeedX, maxSpeedY);
// Velocity from angle
this.physics.velocityFromAngle(angle, speed, outVelocity);
this.physics.velocityFromRotation(rotation, speed, outVelocity);Checking Collisions
// Touch flags (in update)
if (player.body.blocked.down) {
// On ground
}
if (player.body.blocked.left || player.body.blocked.right) {
// Hitting wall
}
// Touching another body
if (player.body.touching.down) {
// Standing on something
}
// Was touching last frame
if (player.body.wasTouching.down && !player.body.touching.down) {
// Just left ground
}
// Overlapping check
const overlapping = this.physics.overlap(player, enemy);
// Distance check
const distance = Phaser.Math.Distance.Between(
player.x, player.y, enemy.x, enemy.y
);World Bounds
// Set world bounds
this.physics.world.setBounds(0, 0, 3000, 600);
// Different bounds per side
this.physics.world.setBoundsCollision(true, true, true, false); // No bottom
// Camera follows player in larger world
this.cameras.main.setBounds(0, 0, 3000, 600);
this.cameras.main.startFollow(player);Debug Visualization
// Toggle debug at runtime
this.physics.world.drawDebug = true;
this.physics.world.debugGraphic.clear(); // Clear previous
// Custom debug rendering
const graphics = this.add.graphics();
this.physics.world.on('worldstep', () => {
graphics.clear();
enemies.children.iterate(enemy => {
graphics.strokeCircle(
enemy.body.center.x,
enemy.body.center.y,
enemy.body.halfWidth
);
});
});Common Patterns
One-Way Platforms
// In process callback
function oneWayPlatform(player, platform) {
// Only collide if player is falling and above platform
if (player.body.velocity.y > 0 &&
player.body.bottom <= platform.body.top + 10) {
return true;
}
return false;
}
this.physics.add.collider(player, platforms, null, oneWayPlatform, this);Knockback
function hitEnemy(player, enemy) {
const knockbackForce = 200;
const direction = player.x < enemy.x ? -1 : 1;
player.setVelocity(direction * knockbackForce, -knockbackForce);
player.isInvincible = true;
this.time.delayedCall(1000, () => {
player.isInvincible = false;
});
}Moving Platforms
// In create()
this.movingPlatform = this.physics.add.image(400, 400, 'platform');
this.movingPlatform.body.setImmovable(true);
this.movingPlatform.body.setAllowGravity(false);
// Tween for movement
this.tweens.add({
targets: this.movingPlatform,
x: 600,
duration: 2000,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1
});
// In update() - move player with platform
if (player.body.touching.down &&
player.body.blocked.down) {
// Player is on platform
}Performance Optimization
Strategies for maintaining smooth 60fps in Phaser 3 games.
Object Pooling
The most impactful optimization for games with many spawning/despawning objects.
Why Pool?
Creating/destroying objects causes:
- Memory allocation overhead
- Garbage collection pauses (stutters)
- Texture rebinding costs
Implementation Pattern
class BulletPool {
constructor(scene) {
this.scene = scene;
this.pool = scene.physics.add.group({
defaultKey: 'bullet',
maxSize: 100,
runChildUpdate: true
});
}
spawn(x, y, velocityX, velocityY) {
const bullet = this.pool.get(x, y);
if (!bullet) return null; // Pool exhausted
bullet.setActive(true);
bullet.setVisible(true);
bullet.body.enable = true;
bullet.body.reset(x, y);
bullet.setVelocity(velocityX, velocityY);
return bullet;
}
kill(bullet) {
bullet.setActive(false);
bullet.setVisible(false);
bullet.body.enable = false;
bullet.body.stop();
}
}
// Usage
this.bulletPool = new BulletPool(this);
// Spawn
const bullet = this.bulletPool.spawn(player.x, player.y, 500, 0);
// Kill (in collision callback or update)
this.bulletPool.kill(bullet);Built-in Group Pooling
// Configure group for pooling
const enemies = this.physics.add.group({
maxSize: 50,
classType: Enemy,
createCallback: (enemy) => {
enemy.setName('enemy' + enemies.getLength());
},
removeCallback: (enemy) => {
enemy.setName('');
}
});
// Get inactive member (or create if under maxSize)
const enemy = enemies.get(x, y);
// Return to pool (don't destroy)
enemy.setActive(false);
enemy.setVisible(false);Texture Atlases
Combine sprites into atlases to reduce draw calls.
Why Atlases?
- Single texture bind per atlas
- Reduced HTTP requests
- Better GPU memory usage
Using TexturePacker
Export as Phaser 3 JSON Hash format.
// Load atlas
this.load.atlas('sprites', 'atlas/sprites.png', 'atlas/sprites.json');
// Use frames
this.add.sprite(x, y, 'sprites', 'player-idle-1');
// Animation from atlas
this.anims.create({
key: 'walk',
frames: this.anims.generateFrameNames('sprites', {
prefix: 'player-walk-',
start: 1,
end: 8,
zeroPad: 2
}),
frameRate: 10,
repeat: -1
});Camera Culling
Only render what's visible.
Automatic Culling
Phaser culls off-camera objects by default for:
- Sprites
- Images
- TileSprites
Disable if needed:
sprite.setScrollFactor(0); // Fixed to camera (no culling)Manual Culling for Custom Objects
update() {
const cam = this.cameras.main;
const bounds = cam.worldView;
this.enemies.children.iterate(enemy => {
if (Phaser.Geom.Rectangle.Contains(bounds, enemy.x, enemy.y)) {
enemy.setActive(true);
enemy.setVisible(true);
} else {
enemy.setActive(false);
enemy.setVisible(false);
}
});
}Physics Optimization
Reduce Collision Checks
// Only check relevant collisions
this.physics.add.collider(player, groundLayer);
this.physics.add.collider(enemies, groundLayer);
this.physics.add.overlap(player, enemies, hitEnemy);
// DON'T: enemies vs enemies if not needed
// this.physics.add.collider(enemies, enemies);Disable Physics When Not Needed
// Disable body temporarily
sprite.body.enable = false;
// Re-enable
sprite.body.enable = true;
// For off-screen objects
if (!cam.worldView.contains(enemy.x, enemy.y)) {
enemy.body.enable = false;
} else {
enemy.body.enable = true;
}Use Spatial Hash for Many Objects
physics: {
arcade: {
useTree: true, // Enable quadtree (default)
maxEntries: 16 // Tune for your object count
}
}
// For >5000 dynamic bodies, disable tree
// useTree: falseSimplify Collision Shapes
// Use circles for round objects (faster than rectangles)
sprite.body.setCircle(16);
// Reduce body size for tighter collisions
sprite.body.setSize(24, 32); // Smaller than spriteRendering Optimization
Batch Similar Sprites
Group sprites using same texture for batching:
// Good: All use same atlas
const coins = this.add.group({
key: 'atlas',
frame: 'coin',
repeat: 100
});
// Bad: Mixed textures break batchingReduce Blend Modes
// Normal blend mode is fastest
sprite.setBlendMode(Phaser.BlendModes.NORMAL);
// Avoid if possible:
// - ADD, MULTIPLY, SCREEN cause extra draw callsUse Static Images for Backgrounds
// TileSprite for repeating backgrounds (efficient)
this.add.tileSprite(0, 0, 800, 600, 'background').setOrigin(0);
// Don't animate large backgrounds in update()Limit Particle Count
const emitter = this.add.particles(x, y, 'particle', {
speed: 100,
lifespan: 500,
quantity: 2, // Particles per emit
maxParticles: 100, // Hard limit
frequency: 50 // ms between emits
});Memory Management
Destroy Unused Objects
// Properly destroy sprites
sprite.destroy();
// Clear groups
group.clear(true, true); // Remove from scene, destroy
// Scene cleanup
shutdown() {
this.enemies.destroy(true);
this.bulletPool.destroy(true);
}Unload Unused Assets
// Remove texture
this.textures.remove('unused-texture');
// Remove audio
this.sound.remove('unused-sound');
// In scene shutdown
shutdown() {
this.cache.tilemap.remove('level1');
}Monitor Memory
// Check texture memory (approximate)
console.log(this.textures.list);
// Chrome DevTools:
// - Memory tab for heap snapshots
// - Performance tab for frame timingUpdate Loop Optimization
Throttle Expensive Operations
create() {
this.lastAIUpdate = 0;
this.aiUpdateInterval = 100; // ms
}
update(time, delta) {
// Every frame (required for smooth movement)
this.updatePlayerMovement();
// Throttled (AI, pathfinding)
if (time - this.lastAIUpdate > this.aiUpdateInterval) {
this.updateEnemyAI();
this.lastAIUpdate = time;
}
}Avoid Creating Objects in Update
// BAD: Creates new object every frame
update() {
const velocity = { x: 100, y: 0 }; // GC pressure
sprite.setVelocity(velocity.x, velocity.y);
}
// GOOD: Reuse or use primitives
update() {
sprite.setVelocity(100, 0);
}
// Or pre-create
create() {
this.tempVec = new Phaser.Math.Vector2();
}
update() {
this.tempVec.set(100, 0);
sprite.body.velocity.copy(this.tempVec);
}Use Delta Time
// Framerate-independent movement
update(time, delta) {
const speed = 200; // pixels per second
sprite.x += speed * (delta / 1000);
}Profiling
Built-in Stats
const config = {
// ...
fps: {
target: 60,
forceSetTimeOut: false,
smoothStep: true
}
};
// Add FPS display
this.add.text(10, 10, '', { fontSize: '16px' }).setScrollFactor(0);
this.fpsText = this.add.text(10, 10, '');
update() {
this.fpsText.setText('FPS: ' + Math.round(this.game.loop.actualFps));
}Chrome DevTools
1. Performance Tab: Record gameplay, identify frame drops 2. Memory Tab: Track heap size, find leaks 3. Console: Phaser.GAMES[0].loop.actualFps
Common Bottlenecks
| Symptom | Likely Cause | Solution |
|---|---|---|
| Gradual slowdown | Memory leak | Check destroy() calls |
| Periodic stutters | GC pauses | Object pooling |
| Low FPS always | Too many objects | Culling, pooling |
| Spikes on spawn | Object creation | Pre-pool objects |
| Slow collisions | Too many checks | Spatial partitioning |
Quick Wins Checklist
- [ ] Use texture atlases (not individual images)
- [ ] Pool frequently spawned objects
- [ ] Disable physics for off-screen objects
- [ ] Use appropriate physics shapes (circles are faster)
- [ ] Throttle AI/pathfinding updates
- [ ] Avoid object creation in update loop
- [ ] Use delta time for movement
- [ ] Destroy objects when done
- [ ] Limit particle counts
- [ ] Profile before optimizing
Spritesheets and Nine-Slice UI Panels
Philosophy: Measure Before You Code
Spritesheet loading seems simple until it breaks. A few pixels off in frame size or a missing parameter causes silent corruption that manifests as broken visuals. Always inspect the source asset before writing loader code.
---
Spritesheet Loading
Basic Loading
this.load.spritesheet('player', 'assets/player.png', {
frameWidth: 32,
frameHeight: 48
});Spritesheets with Gaps (Spacing)
Many asset packs include spacing between frames for visual clarity in the source file:
// Asset is 448x448 with 3x3 grid of 144px frames and 8px gaps
this.load.spritesheet('ui-wood-table', 'assets/wood-table.png', {
frameWidth: 144,
frameHeight: 144,
spacing: 8 // Gap between frames
});Spritesheets with Margins
Some spritesheets have padding around the entire image:
this.load.spritesheet('icons', 'assets/icons.png', {
frameWidth: 32,
frameHeight: 32,
margin: 4, // Padding around entire sheet
spacing: 2 // Gap between frames
});Calculating Frame Dimensions
Formula:
imageWidth = (frameWidth × cols) + (spacing × (cols - 1)) + (margin × 2)
imageHeight = (frameHeight × rows) + (spacing × (rows - 1)) + (margin × 2)CRITICAL: Check for Square Frames First
Character spritesheets commonly use square frames (frameWidth = frameHeight). Before assuming different width/height:
Image: 448×392 pixels
WRONG approach (assuming 8x8 grid):
- 448/8 = 56 (width) ✓
- 392/8 = 49 (height) ← Produces bleeding artifacts!
CORRECT approach (try square frames first):
- Try 56×56: 448/56 = 8 cols ✓, 392/56 = 7 rows ✓
- This is an 8×7 grid with square 56×56 framesVerification Steps: 1. Calculate frameWidth from image width and visible column count 2. Try using that same value for frameHeight (square frames) 3. Check if height divides evenly: imageHeight / frameWidth = integer? 4. Only use different height if square doesn't work
Example Calculation:
448px image with 3 columns:
- If spacing=0: 448/3 = 149.33 (not clean - wrong assumption)
- If spacing=8: (448 - 16) / 3 = 144 (clean - correct!)
Verify: 144*3 + 8*2 = 432 + 16 = 448 ✓---
Asset Inspection Protocol
Before writing spritesheet loader code:
1. Open the asset file in an image viewer or editor 2. Note total dimensions (e.g., 448×448 pixels) 3. Count rows and columns (e.g., 3×3 grid) 4. Measure one frame's actual size (zoom in, use pixel ruler) 5. Check for gaps between frames (that's your spacing value) 6. Check for padding around edges (that's your margin value) 7. Calculate and verify the dimensions add up
Red Flags
- Image dimension doesn't divide evenly by expected frame count
- Calculated frame size has decimals
- Visual inspection shows gaps between frame content
- frameWidth ≠ frameHeight for character sprites - most character animations use square frames; non-square dimensions are a warning sign to re-verify
- Assuming row count matches column count - an 8-column sheet is NOT necessarily 8 rows; count rows visually or test if
imageHeight / frameWidthgives an integer
---
Nine-Slice UI Panels
Nine-slice (or 9-patch) panels allow UI elements to scale while preserving corner/edge details.
Frame Layout (3x3 Grid)
[0] [1] [2] ← Top row (corners 0,2 don't scale)
[3] [4] [5] ← Middle row (3,5 stretch vertically)
[6] [7] [8] ← Bottom row (corners 6,8 don't scale)
↑
1,4,7 stretch horizontallyManual Nine-Slice Assembly
When Phaser's built-in NineSlice doesn't work (e.g., frames with transparent edges):
showNineSlicePanel(framesKey, frameSize, panelWidth, panelHeight, bgColor) {
const centerX = this.cameras.main.width / 2;
const centerY = this.cameras.main.height / 2;
// Panel bounds
const left = centerX - panelWidth / 2;
const top = centerY - panelHeight / 2;
const right = left + panelWidth;
const bottom = top + panelHeight;
// Position corners inward from panel edges
const cornerInset = frameSize * 0.3;
const overlap = frameSize * 0.5; // Extra size to fill gaps
const container = this.add.container(0, 0);
// 1. Solid background (fills transparent gaps)
container.add(this.add.rectangle(centerX, centerY, panelWidth, panelHeight, bgColor));
// 2. Center fill (scaled to panel size)
const center = this.add.image(centerX, centerY, framesKey, 4);
center.setDisplaySize(panelWidth + overlap, panelHeight + overlap);
container.add(center);
// 3. Edges (stretched)
const topEdge = this.add.image(centerX, top + cornerInset, framesKey, 1);
topEdge.setDisplaySize(panelWidth + overlap, frameSize);
container.add(topEdge);
const bottomEdge = this.add.image(centerX, bottom - cornerInset, framesKey, 7);
bottomEdge.setDisplaySize(panelWidth + overlap, frameSize);
container.add(bottomEdge);
const leftEdge = this.add.image(left + cornerInset, centerY, framesKey, 3);
leftEdge.setDisplaySize(frameSize, panelHeight + overlap);
container.add(leftEdge);
const rightEdge = this.add.image(right - cornerInset, centerY, framesKey, 5);
rightEdge.setDisplaySize(frameSize, panelHeight + overlap);
container.add(rightEdge);
// 4. Corners (not scaled, positioned last/on top)
container.add(this.add.image(left + cornerInset, top + cornerInset, framesKey, 0));
container.add(this.add.image(right - cornerInset, top + cornerInset, framesKey, 2));
container.add(this.add.image(left + cornerInset, bottom - cornerInset, framesKey, 6));
container.add(this.add.image(right - cornerInset, bottom - cornerInset, framesKey, 8));
return container;
}Asset-Specific Parameters
Different assets need different values. Don't hardcode - configure per asset:
const UI_PANEL_CONFIG = {
'paper-regular': {
frameSize: 106,
spacing: 0,
cornerInset: 0.28,
overlap: 55,
bgColor: 0xF5E6C8 // Beige
},
'paper-special': {
frameSize: 106,
spacing: 0,
cornerInset: 0.28,
overlap: 55,
bgColor: 0x4A5568 // Dark blue-gray
},
'wood-table': {
frameSize: 144,
spacing: 8,
cornerInset: 0.35,
overlap: 80,
bgColor: 0x8B5A2B // Brown
}
};---
Common Pitfalls
1. Wrong Frame Dimensions
Symptom: Frames display corrupted, shifted, or partial content
Cause: Frame width/height doesn't match actual asset layout
Fix: Open asset, measure frames, calculate with spacing/margin
2. Missing Spacing Parameter
Symptom: First frame looks correct, subsequent frames are offset
Cause: Asset has gaps between frames that weren't specified
Fix: Add spacing: N to spritesheet config
3. Wrong Background Color for Nine-Slice
Symptom: Different color showing through transparent edges
Cause: Background fill color doesn't match asset's interior color
Fix: Sample color from center frame (frame 4), use per-asset config
4. Assuming Similar Assets Are Identical
Symptom: One variant works, another doesn't
Cause: Different assets in same pack may have different layouts
Example:
- Paper assets: 320×320, 106px frames, no spacing
- Wood Table: 448×448, 144px frames, 8px spacing
Fix: Inspect and configure each asset type individually
5. Internal Padding Inside Frames (“Side Bars”)
Symptom: Paper-like UI panels show opaque bands just inside the edges.
Cause: The art in each 3×3 cell is centered with transparent padding, so the edge tiles contribute interior fill when stretched.
Fix: Trim each tile to the actual painted bounds (alpha bbox), composite/cache a texture at the target size, and add a small overlap (≈1px, smoothing off) to avoid seams.
6. Scaling Discontinuous UI Art (Ribbons / Banners)
Symptom: A ribbon looks broken or the fill disappears when scaled.
Cause: The source row is actually three separate slices (left cap, center bar, right cap) with transparent gutters. Scaling the entire crop includes the gutters, making the UI look segmented or blank.
Fix: Treat it as a multi-slice (usually 3-slice). Draw left cap, stretched center, right cap into a canvas at the desired width; disable smoothing and include small seam overlaps so the stitched texture feels seamless.
function createRibbonSlice(scene, srcKey, width, height, row, slices) {
const key = `${srcKey}_3slice_${row}_${width}x${height}`;
if (scene.textures.exists(key)) return key;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const sy = row * slices.frameH;
const leftW = Math.round(slices.left.w * (height / slices.frameH));
const rightW = Math.round(slices.right.w * (height / slices.frameH));
const centerW = Math.max(1, width - leftW - rightW);
const seam = 1;
const src = scene.textures.get(srcKey).getSourceImage();
ctx.drawImage(src, slices.left.x, sy, slices.left.w, slices.frameH, 0, 0, leftW + seam, height);
ctx.drawImage(src, slices.center.x, sy, slices.center.w, slices.frameH, leftW - seam, 0, centerW + seam * 2, height);
ctx.drawImage(src, slices.right.x, sy, slices.right.w, slices.frameH, leftW + centerW - seam, 0, rightW + seam, height);
scene.textures.addCanvas(key, canvas);
return key;
}7. Assuming Uniform Frame Sizes Across Animations
Symptom: First animation (e.g., idle) looks correct, other animations (run, attack) are corrupted or shifted
Cause: Different animations of the same character often have different frame sizes to accommodate varying poses. A run cycle needs wider frames for the stride; an attack needs extra width for the weapon swing.
Example:
Boss Character Spritesheets:
- Idle: 64x80 pixels per frame (compact standing pose)
- Run: 80x80 pixels per frame (wider stride)
- Attack: 96x80 pixels per frame (extended sword swing)Fix: Measure EACH animation spritesheet independently. Never assume frame width transfers between animations of the same character.
How to verify:
For each spritesheet:
1. Open image, note total width
2. Count frames visually
3. Calculate: frameWidth = totalWidth / frameCount
4. Verify result is a clean integer---
Debugging Spritesheets
Raw Frame Visualization
Always include a test mode that displays extracted frames:
showRawFrames(key, frameCount) {
for (let i = 0; i < frameCount; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
const x = 100 + col * 120;
const y = 100 + row * 120;
this.add.image(x, y, key, i);
this.add.text(x, y + 50, `${i}`, { fontSize: '12px' }).setOrigin(0.5);
}
}If frames look wrong here, the loader config is wrong. Fix it before proceeding.
Animation Test Scene Pattern
For characters with multiple animation spritesheets, create a dedicated test scene to verify each animation in isolation before integrating into gameplay:
// Access via URL parameter: ?scene=AnimTest
class AnimTestScene extends Phaser.Scene {
private currentIndex = 0;
private sprite: Phaser.GameObjects.Sprite;
private label: Phaser.GameObjects.Text;
// Define each animation's spritesheet config
private spritesheets = [
{ key: "boss-idle", path: "assets/boss/idle.png", fw: 64, fh: 80, frames: 4 },
{ key: "boss-run", path: "assets/boss/run.png", fw: 80, fh: 80, frames: 8 },
{ key: "boss-attack", path: "assets/boss/attack.png", fw: 96, fh: 80, frames: 8 },
];
preload() {
this.spritesheets.forEach(s => {
this.load.spritesheet(s.key, s.path, { frameWidth: s.fw, frameHeight: s.fh });
});
}
create() {
// Create animations
this.spritesheets.forEach(s => {
this.anims.create({
key: `${s.key}-anim`,
frames: this.anims.generateFrameNumbers(s.key, { start: 0, end: s.frames - 1 }),
frameRate: 10,
repeat: -1
});
});
// Display sprite and info
this.sprite = this.add.sprite(400, 300, this.spritesheets[0].key);
this.sprite.play(`${this.spritesheets[0].key}-anim`);
this.label = this.add.text(400, 450, '', { fontSize: '16px' }).setOrigin(0.5);
this.updateLabel();
// Arrow keys to cycle animations
this.input.keyboard.on('keydown-LEFT', () => this.cycleAnim(-1));
this.input.keyboard.on('keydown-RIGHT', () => this.cycleAnim(1));
}
cycleAnim(dir: number) {
this.currentIndex = (this.currentIndex + dir + this.spritesheets.length) % this.spritesheets.length;
const s = this.spritesheets[this.currentIndex];
this.sprite.play(`${s.key}-anim`);
this.updateLabel();
}
updateLabel() {
const s = this.spritesheets[this.currentIndex];
this.label.setText(`${s.key} | ${s.fw}x${s.fh} | ${s.frames} frames\n← → to cycle`);
}
}Why this helps: Isolating each animation reveals frame dimension errors immediately. A corrupted run animation is obvious when viewed alone, but may be missed in a busy game scene.
Checklist When Frames Look Wrong
1. Open source asset in image editor 2. Measure actual frame dimensions 3. Check for gaps between frames 4. Check for padding around edges 5. Recalculate: does math add up to image dimensions? 6. Update loader with correct frameWidth, frameHeight, spacing, margin
---
Asset Documentation Template
Document each spritesheet's structure:
/**
* UI Panel Assets - Tiny Swords Pack
*
* Regular Paper: 320×320, 3×3 grid, 106px frames, no spacing
* Special Paper: 320×320, 3×3 grid, 106px frames, no spacing
* Wood Table: 448×448, 3×3 grid, 144px frames, 8px spacing
*
* All use nine-slice layout:
* [0][1][2] corners=0,2,6,8 (don't scale)
* [3][4][5] edges=1,3,5,7 (stretch one axis)
* [6][7][8] center=4 (scales both axes)
*/---
Remember
- Measure the asset before writing loader code
- Different assets need different configs even in the same pack
- Test raw frames first before building complex UI
- Document asset parameters for future reference
- A few pixels off cascades into completely broken rendering
Tilemaps Reference
Comprehensive expert guide for Phaser 3 tilemap integration with Tiled Map Editor.
---
Tiled Map Editor Fundamentals
Tileset Types
| Type | Description | Use Case |
|---|---|---|
| Image-based | Single image with fixed tile size, margin, spacing | Standard tilesets, consistent tile sizes |
| Collection-based | Each tile is separate image file | Variable-size tiles, sprites as tiles |
Layer Types
| Layer | Purpose | Phaser Access |
|---|---|---|
| Tile Layer | Grid-based tile storage with flip flags | map.createLayer() |
| Object Layer | Free-positioned shapes, points, polygons, tile objects | map.getObjectLayer() |
| Image Layer | Background/foreground images with repeat | map.images array |
| Group Layer | Hierarchical organization | Flattened on export |
Recommended Layer Structure (Top to Bottom in Tiled)
Foreground (renders above player, depth: 10+)
├── Trees-Top
├── Roof-Tops
Objects (spawn points, triggers, collision zones)
├── Enemies
├── Collectibles
├── Triggers
Player-Ref (reference layer, not exported)
Ground (main collision layer)
├── Platforms
├── Walls
Background (decoration, no collision)
├── Decorations
├── Parallax-Far
├── Parallax-Near---
Tiled JSON Format Structure
Understanding the JSON format is critical for debugging and advanced manipulation.
Map Root Object
{
"width": 100, // Map width in tiles
"height": 50, // Map height in tiles
"tilewidth": 16, // Tile width in pixels
"tileheight": 16, // Tile height in pixels
"orientation": "orthogonal", // "orthogonal", "isometric", "staggered", "hexagonal"
"renderorder": "right-down",
"infinite": false,
"layers": [...], // Array of layer objects
"tilesets": [...], // Array of tileset references
"properties": [...] // Custom map properties
}Layer Object
{
"type": "tilelayer", // "tilelayer", "objectgroup", "imagelayer", "group"
"name": "Ground",
"id": 1,
"data": [1, 2, 0, 5, ...], // Global Tile IDs (GIDs) - tilelayer only
"width": 100,
"height": 50,
"x": 0, "y": 0,
"offsetx": 0, "offsety": 0,
"opacity": 1,
"visible": true,
"parallaxx": 1, // Parallax factor X (1 = normal scroll)
"parallaxy": 1, // Parallax factor Y
"tintcolor": "#ffffff", // Layer tint color
"properties": [...]
}Tileset Reference
{
"firstgid": 1, // First Global ID for this tileset
"source": "terrain.tsx", // External tileset file (or inline)
"name": "terrain",
"tilewidth": 16,
"tileheight": 16,
"tilecount": 256,
"columns": 16,
"margin": 0, // Border around tileset image
"spacing": 0, // Gap between tiles
"image": "terrain.png",
"imagewidth": 256,
"imageheight": 256
}---
Global Tile IDs (GIDs)
Critical concept for understanding tilemap data.
How GIDs Work
- GID 0 = Empty tile
- GID 1+ = References tiles across all tilesets
- Each tileset has a
firstgid- the GID of its first tile (local ID 0) - Local ID = GID - tileset.firstgid
Flip Flags (Stored in High Bits)
const FLIPPED_HORIZONTALLY = 0x80000000; // Bit 32
const FLIPPED_VERTICALLY = 0x40000000; // Bit 31
const FLIPPED_DIAGONALLY = 0x20000000; // Bit 30 (rotation)
const ROTATED_HEX_120 = 0x10000000; // Bit 29 (hexagonal only)
// Extract flags then clear them
function parseGID(rawGid) {
const flipH = (rawGid & FLIPPED_HORIZONTALLY) !== 0;
const flipV = (rawGid & FLIPPED_VERTICALLY) !== 0;
const flipD = (rawGid & FLIPPED_DIAGONALLY) !== 0;
const gid = rawGid & ~(0xF0000000); // Clear all flags
return { gid, flipH, flipV, flipD };
}Mapping GID to Tileset
// Example tilesets in map:
// TilesetA: firstgid=1, tilecount=64 → GIDs 1-64
// TilesetB: firstgid=65, tilecount=50 → GIDs 65-114
// TilesetC: firstgid=115, tilecount=100 → GIDs 115-214
function findTileset(gid, tilesets) {
// Find tileset with largest firstgid <= gid
for (let i = tilesets.length - 1; i >= 0; i--) {
if (tilesets[i].firstgid <= gid) {
return {
tileset: tilesets[i],
localId: gid - tilesets[i].firstgid
};
}
}
return null;
}---
Loading Tilemaps in Phaser
JSON Format (Recommended)
preload() {
// Load tilemap JSON (exported from Tiled)
this.load.tilemapTiledJSON('level1', 'assets/tilemaps/level1.json');
// Load tileset image(s)
this.load.image('tiles', 'assets/tilesets/tileset.png');
// For multiple tilesets
this.load.image('terrain', 'assets/tilesets/terrain.png');
this.load.image('props', 'assets/tilesets/props.png');
}CSV Format
preload() {
this.load.tilemapCSV('map', 'assets/map.csv');
this.load.image('tiles', 'assets/tileset.png');
}
create() {
// Must specify tile dimensions for CSV
const map = this.make.tilemap({
key: 'map',
tileWidth: 16,
tileHeight: 16
});
}Extruded Tilesets (Prevent Bleeding)
When tiles show thin lines between them, use extruded tilesets:
// Tileset was extruded by 1px (use tile-extruder tool)
// margin: 1 (border around entire image)
// spacing: 2 (gap between tiles = 2 * extrusion)
const tileset = map.addTilesetImage('tileset-name', 'tiles', 16, 16, 1, 2);---
Creating the Map
Basic Setup
create() {
// Create tilemap from loaded JSON
const map = this.make.tilemap({ key: 'level1' });
// Add tileset image (name MUST match Tiled tileset name exactly!)
const tileset = map.addTilesetImage('tileset-name-in-tiled', 'tiles');
// Create layers (names MUST match Tiled layer names)
const bgLayer = map.createLayer('Background', tileset, 0, 0);
const groundLayer = map.createLayer('Ground', tileset, 0, 0);
const fgLayer = map.createLayer('Foreground', tileset, 0, 0);
// Set depth for render order
bgLayer.setDepth(0);
groundLayer.setDepth(5);
fgLayer.setDepth(10); // Renders above player
}Multiple Tilesets Per Layer
const terrainTileset = map.addTilesetImage('terrain', 'terrain-img');
const propsTileset = map.addTilesetImage('props', 'props-img');
const decorTileset = map.addTilesetImage('decorations', 'decor-img');
// Layer can use array of tilesets
const groundLayer = map.createLayer('Ground', [
terrainTileset,
propsTileset,
decorTileset
]);Layer Properties
// Scale
layer.setScale(2); // 2x zoom
layer.setScale(1, 0.5); // Different X/Y scale
// Position offset
layer.setPosition(100, 50);
// Alpha/visibility
layer.setAlpha(0.8);
layer.setVisible(false);
// Tint (multiply color)
layer.setTint(0xff8888); // Reddish tint
// Scroll factor (parallax)
layer.setScrollFactor(0.5); // Scrolls at 50% camera speed---
Collision Setup
By Custom Property (Recommended)
Set properties in Tiled: Tileset Editor → Select tile → Add Property
// Collide tiles with "collides: true" property
groundLayer.setCollisionByProperty({ collides: true });
// Multiple property conditions (AND logic)
groundLayer.setCollisionByProperty({
solid: true,
type: 'wall'
});
// Array of possible values (OR logic for that property)
groundLayer.setCollisionByProperty({
collides: true,
type: ['wall', 'platform', 'ground']
});By Tile Index
// Single tile index
groundLayer.setCollision(1);
// Multiple specific tiles
groundLayer.setCollision([1, 2, 3, 4, 5]);
// Range of tiles (inclusive)
groundLayer.setCollisionBetween(1, 100);
// All tiles EXCEPT these (good for empty tiles)
groundLayer.setCollisionByExclusion([-1, 0]);One-Way Platforms
// Only collide from above
groundLayer.forEachTile(tile => {
if (tile.properties.oneWay) {
tile.collideDown = false;
tile.collideLeft = false;
tile.collideRight = false;
}
});Physics Collider
// Basic collision
this.physics.add.collider(player, groundLayer);
// With callback
this.physics.add.collider(player, hazardLayer, this.onHazardHit, null, this);
onHazardHit(player, tile) {
console.log('Hit hazard at tile', tile.x, tile.y);
console.log('Tile properties:', tile.properties);
player.damage(tile.properties.damage || 10);
}
// Tile index callback (triggers when stepping on specific tiles)
groundLayer.setTileIndexCallback([77, 78], (sprite, tile) => {
console.log('Stepped on special tile!');
return true; // Return true to allow collision processing
}, this);
// Location callback (specific tile coordinates)
groundLayer.setTileLocationCallback(10, 5, 3, 3, (sprite, tile) => {
console.log('Entered trigger zone!');
}, this);Matter.js Collision
// Convert tilemap layer to Matter bodies
this.matter.world.convertTilemapLayer(groundLayer);
// With custom collision shapes from Tiled
// (Set collision shapes in Tileset → Tile Collision Editor)
this.matter.world.convertTilemapLayer(groundLayer, {
// Options
});---
Object Layers
Reading Objects
// Get entire object layer
const objectLayer = map.getObjectLayer('Objects');
const objects = objectLayer.objects;
objects.forEach(obj => {
console.log(obj.name, obj.type); // Name and class/type
console.log(obj.x, obj.y); // Position (pixels)
console.log(obj.width, obj.height); // Size
console.log(obj.rotation); // Rotation in degrees
console.log(obj.properties); // Custom properties array
});
// Find specific object by name
const spawnPoint = map.findObject('Objects', obj => obj.name === 'PlayerSpawn');
if (spawnPoint) {
player.setPosition(spawnPoint.x, spawnPoint.y);
}
// Filter objects by type/class
const enemies = map.filterObjects('Enemies', obj => obj.type === 'goblin');
const triggers = map.filterObjects('Objects', obj => obj.type === 'trigger');Accessing Custom Properties
Properties in Tiled are stored as array of {name, type, value}:
const door = map.findObject('Objects', o => o.name === 'door');
// Helper function to get property value
function getProperty(obj, propName) {
if (!obj.properties) return undefined;
const prop = obj.properties.find(p => p.name === propName);
return prop ? prop.value : undefined;
}
const isLocked = getProperty(door, 'locked'); // boolean
const requiredKey = getProperty(door, 'keyType'); // string
const damage = getProperty(door, 'damage'); // int/floatCreating Sprites from Objects
// Create sprites from object layer
const coins = map.createFromObjects('Collectibles', {
name: 'coin', // Object name in Tiled
key: 'coin', // Texture key in Phaser
classType: Phaser.Physics.Arcade.Sprite // Optional custom class
});
// Enable physics on created sprites
coins.forEach(coin => {
this.physics.add.existing(coin);
coin.body.setAllowGravity(false);
coin.body.setImmovable(true);
});
// With custom class
class Goblin extends Phaser.Physics.Arcade.Sprite {
constructor(scene, x, y, texture) {
super(scene, x, y, texture);
scene.add.existing(this);
scene.physics.add.existing(this);
this.health = 100;
}
}
const enemies = map.createFromObjects('Enemies', {
name: 'goblin',
key: 'goblin',
classType: Goblin
});Object Shapes
map.getObjectLayer('Collision').objects.forEach(obj => {
if (obj.rectangle) {
// Rectangle: use obj.x, obj.y, obj.width, obj.height
this.physics.add.existing(
this.add.zone(obj.x + obj.width/2, obj.y + obj.height/2, obj.width, obj.height)
);
}
if (obj.ellipse) {
// Ellipse: use same properties, but it's circular/elliptical
}
if (obj.polygon) {
// Polygon: obj.polygon is array of {x, y} points
const points = obj.polygon.map(p => `${p.x} ${p.y}`).join(' ');
}
if (obj.polyline) {
// Polyline: obj.polyline is array of {x, y} points (open path)
}
if (obj.point) {
// Point: just obj.x, obj.y (no size)
}
if (obj.gid) {
// Tile object: references a tile from tileset
}
});---
Tile Manipulation
Get/Set Tiles
// Get tile at world coordinates
const tile = groundLayer.getTileAtWorldXY(pointer.worldX, pointer.worldY);
// Get tile at tile coordinates
const tile = groundLayer.getTileAt(tileX, tileY);
const tile = map.getTileAt(tileX, tileY, true, 'Ground'); // true = include empty
// Check if tile exists
if (tile && tile.index !== -1) {
console.log('Tile exists:', tile.index);
}
// Place tile
groundLayer.putTileAt(tileIndex, tileX, tileY);
groundLayer.putTileAtWorldXY(tileIndex, worldX, worldY);
// Remove tile
groundLayer.removeTileAt(tileX, tileY);
groundLayer.removeTileAtWorldXY(worldX, worldY);
// Replace all instances of a tile
groundLayer.replaceByIndex(oldIndex, newIndex);
// Swap two tile types
groundLayer.swapByIndex(indexA, indexB);Fill and Randomize
// Fill rectangular area
groundLayer.fill(tileIndex, startX, startY, width, height);
// Randomize area with equal probability
groundLayer.randomize(x, y, width, height, [1, 2, 3, 4]);
// Weighted randomize
groundLayer.weightedRandomize(x, y, width, height, [
{ index: 1, weight: 10 }, // Grass (common)
{ index: 2, weight: 3 }, // Flower (uncommon)
{ index: 3, weight: 1 } // Mushroom (rare)
]);
// Copy region
groundLayer.copy(srcX, srcY, width, height, destX, destY);
// Shuffle tiles in region
groundLayer.shuffle(x, y, width, height);Tile Properties
if (tile) {
// Position
console.log(tile.x, tile.y); // Tile coordinates
console.log(tile.pixelX, tile.pixelY); // World position (top-left)
console.log(tile.getCenterX(), tile.getCenterY()); // Center position
// Dimensions
console.log(tile.width, tile.height); // Tile size
console.log(tile.baseWidth, tile.baseHeight); // Map's base tile size
// Identity
console.log(tile.index); // Tile index (-1 = empty)
console.log(tile.tileset); // Tileset reference
console.log(tile.properties); // Custom properties from Tiled
// Collision
console.log(tile.canCollide); // Has any collision
console.log(tile.collideLeft, tile.collideRight);
console.log(tile.collideUp, tile.collideDown);
console.log(tile.faceLeft, tile.faceRight); // Interesting faces
// Rendering
tile.alpha = 0.5;
tile.tint = 0xff0000;
tile.flipX = true;
tile.flipY = true;
tile.rotation = Math.PI / 4;
tile.visible = false;
}Iterate Tiles
// Process all tiles in layer
groundLayer.forEachTile(tile => {
if (tile.properties.spawnEnemy) {
spawnEnemy(tile.getCenterX(), tile.getCenterY());
groundLayer.removeTileAt(tile.x, tile.y);
}
});
// With filter options
groundLayer.forEachTile(callback, context,
startX, startY, width, height,
{ isNotEmpty: true } // Only non-empty tiles
);
// Get tiles in area
const tiles = groundLayer.getTilesWithin(x, y, width, height);
const tiles = groundLayer.getTilesWithinWorldXY(worldX, worldY, width, height);
// Get tiles in shape
const rect = new Phaser.Geom.Rectangle(100, 100, 200, 150);
const tiles = groundLayer.getTilesWithinShape(rect);
// Filter tiles
const waterTiles = groundLayer.filterTiles(tile =>
tile.properties.type === 'water'
);---
Camera and World Bounds
// Map dimensions
console.log(map.widthInPixels, map.heightInPixels);
console.log(map.width, map.height); // In tiles
// Set physics world bounds to map size
this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
// Camera bounds
this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
// Follow player with lerp (smoothing)
this.cameras.main.startFollow(player, true, 0.1, 0.1);
// Deadzone (player can move in center without camera moving)
this.cameras.main.setDeadzone(200, 100);
// Round pixels (prevents tile seams at fractional positions)
this.cameras.main.roundPixels = true;---
Parallax Scrolling
In Tiled
Set parallaxx and parallaxy properties on layers:
- 1.0 = Normal scroll speed (default)
- 0.5 = Half speed (appears farther away)
- 0.0 = Fixed (doesn't scroll)
- 1.5 = Faster than camera (foreground effect)
In Phaser
// Set scroll factor on tilemap layers
skyLayer.setScrollFactor(0); // Fixed background
cloudsLayer.setScrollFactor(0.2); // Very slow
mountainsLayer.setScrollFactor(0.5); // Half speed
groundLayer.setScrollFactor(1); // Normal (default)
foregroundLayer.setScrollFactor(1.2); // Slightly faster
// For non-tilemap backgrounds (tileSprite for infinite repeat)
const bg = this.add.tileSprite(0, 0,
this.cameras.main.width,
this.cameras.main.height,
'background'
);
bg.setOrigin(0, 0);
bg.setScrollFactor(0); // Fixed position
// Update in update() for parallax effect
update() {
bg.tilePositionX = this.cameras.main.scrollX * 0.3;
bg.tilePositionY = this.cameras.main.scrollY * 0.3;
}Parallax Reference Point
Tiled uses parallax origin (default 0,0) and view center distance:
- When parallax origin == view center: no parallax effect
- Distance × parallax factor = layer offset
---
Animated Tiles
Phaser doesn't natively support Tiled tile animations. Solutions:
Manual Animation
// Define water animation frames
const WATER_FRAMES = [10, 11, 12, 13];
let waterFrameIndex = 0;
// Timer to cycle frames
this.time.addEvent({
delay: 200, // ms per frame
callback: () => {
waterFrameIndex = (waterFrameIndex + 1) % WATER_FRAMES.length;
const newIndex = WATER_FRAMES[waterFrameIndex];
waterLayer.forEachTile(tile => {
if (WATER_FRAMES.includes(tile.index)) {
tile.index = newIndex;
}
});
},
loop: true
});Replace with Sprites
// Replace animated tiles with actual sprites
groundLayer.forEachTile(tile => {
if (tile.properties.animated) {
const sprite = this.add.sprite(
tile.getCenterX(),
tile.getCenterY(),
'animatedTiles'
);
sprite.anims.play(tile.properties.animKey);
groundLayer.removeTileAt(tile.x, tile.y);
}
});Plugin: phaser-animated-tiles
// Use community plugin for automatic Tiled animation support
// https://github.com/nkholski/phaser-animated-tiles
this.load.scenePlugin('AnimatedTiles', AnimatedTiles, 'animatedTiles', 'animatedTiles');
create() {
const map = this.make.tilemap({ key: 'map' });
// ... create layers
this.animatedTiles.init(map);
}---
Procedural Tilemaps
Create Blank Map
// Create empty tilemap
const map = this.make.tilemap({
tileWidth: 32,
tileHeight: 32,
width: 100,
height: 50
});
// Add tileset
const tileset = map.addTilesetImage('tiles');
// Create blank layer
const layer = map.createBlankLayer('Ground', tileset);
// Fill programmatically
for (let x = 0; x < map.width; x++) {
for (let y = 0; y < map.height; y++) {
if (y === map.height - 1) {
layer.putTileAt(1, x, y); // Ground
} else if (y === map.height - 2 && Math.random() < 0.3) {
layer.putTileAt(2, x, y); // Grass decoration
}
}
}
// Set collision
layer.setCollision([1]);Cellular Automata Cave
function generateCave(width, height, fillPercent, iterations) {
// Initialize with random
let grid = Array(height).fill(null).map(() =>
Array(width).fill(null).map(() =>
Math.random() < fillPercent ? 1 : 0
)
);
// Run cellular automata
for (let i = 0; i < iterations; i++) {
grid = iterate(grid);
}
return grid;
}
function iterate(grid) {
const newGrid = grid.map(row => [...row]);
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[0].length; x++) {
const neighbors = countNeighbors(grid, x, y);
if (neighbors > 4) newGrid[y][x] = 1;
else if (neighbors < 4) newGrid[y][x] = 0;
}
}
return newGrid;
}
// Apply to tilemap
const caveData = generateCave(100, 50, 0.45, 5);
caveData.forEach((row, y) => {
row.forEach((cell, x) => {
layer.putTileAt(cell === 1 ? WALL_TILE : FLOOR_TILE, x, y);
});
});---
Debugging Tilemaps
Visual Debug
// Debug collision tiles
const debugGraphics = this.add.graphics();
groundLayer.renderDebug(debugGraphics, {
tileColor: null, // Non-colliding tiles (null = don't render)
collidingTileColor: new Phaser.Display.Color(243, 134, 48, 200),
faceColor: new Phaser.Display.Color(40, 39, 37, 255) // Collision edges
});
// Toggle with key
this.input.keyboard.on('keydown-D', () => {
debugGraphics.visible = !debugGraphics.visible;
});Tile Coordinates Overlay
groundLayer.forEachTile(tile => {
if (tile.index !== -1) {
this.add.text(tile.pixelX + 2, tile.pixelY + 2,
`${tile.x},${tile.y}`,
{ fontSize: '8px', color: '#00ff00' }
).setDepth(1000);
}
});Console Logging
// Log tile on click
this.input.on('pointerdown', (pointer) => {
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
const tile = groundLayer.getTileAtWorldXY(worldPoint.x, worldPoint.y);
if (tile) {
console.log('Clicked tile:', {
index: tile.index,
position: { x: tile.x, y: tile.y },
worldPos: { x: tile.pixelX, y: tile.pixelY },
properties: tile.properties,
collides: tile.canCollide,
tileset: tile.tileset?.name
});
}
});---
Terrain Auto-Tiling (Tiled Feature)
Tiled's Terrain Sets enable automatic tile selection for natural-looking maps.
Terrain Set Types
| Type | Description | Complete Set Size |
|---|---|---|
| Corner | Matches at corners | 16 tiles (2 terrains) |
| Edge | Matches at sides (roads, fences) | 16 tiles (2 terrains) |
| Mixed | Both corners and edges | 256 tiles (2 terrains) |
Workflow
1. In Tiled Tileset Editor, enable Terrain Sets mode 2. Create terrain set, add terrain types 3. Paint terrain labels on tile corners/edges 4. Use Terrain Brush on map to auto-select correct tiles
Probability
- Set
probabilityon tiles for weighted random selection - Lower probability = less frequent (decorations, variations)
- Set probability to 0 to exclude from auto-selection but still recognize
---
Performance Tips
1. Use Static Layers: If tiles don't change, layer is faster 2. Cull Off-Screen: Phaser automatically culls, but verify with large maps 3. Limit Layer Count: Merge purely visual layers when possible 4. Use Texture Atlas: Combine tilesets into single atlas 5. Object Pooling: Reuse sprites created from object layers 6. Chunk Large Maps: Split very large maps into loadable chunks
// Check if layer is being culled efficiently
console.log('Visible tiles:', layer.culledTiles.length);
// Force specific culling bounds
layer.setCullPadding(2, 2); // Extra tiles around viewport---
Quick Reference
Map Properties
map.width / map.height // Size in tiles
map.widthInPixels / map.heightInPixels // Size in pixels
map.tileWidth / map.tileHeight // Tile size
map.layers // All layers
map.tilesets // All tilesets
map.properties // Custom propertiesEssential Layer Methods
// Creation
map.createLayer(name, tileset, x, y)
map.createBlankLayer(name, tileset, x, y, width, height)
// Collision
layer.setCollision(indexes)
layer.setCollisionBetween(start, end)
layer.setCollisionByProperty({ prop: value })
layer.setCollisionByExclusion([-1])
// Tiles
layer.getTileAt(x, y)
layer.getTileAtWorldXY(worldX, worldY)
layer.putTileAt(index, x, y)
layer.removeTileAt(x, y)
layer.fill(index, x, y, w, h)
layer.forEachTile(callback)
// Rendering
layer.setDepth(n)
layer.setScrollFactor(x, y)
layer.setAlpha(a)
layer.setTint(color)
layer.setScale(x, y)Object Layer Methods
map.getObjectLayer(name)
map.findObject(layerName, callback)
map.filterObjects(layerName, callback)
map.createFromObjects(layerName, config)