Moving Tiles

Moving platforms appear in most platformers — the swinging platforms in Donkey Kong Country, the cloud lifts in Mario, the crumbling bridges in Crash Bandicoot. They transform a flat level into a dynamic obstacle that rewards timing. Here’s how to build them.

Loading editor…

Before we start coding, let’s agree on the rules. Moving platforms in this tutorial work like cloud tiles - the hero can only land on them from above. Here’s what we need to handle:

  • Moving tiles travel horizontally or vertically between two boundaries
  • The hero can land on a moving tile only by falling onto it from above
  • A tile moving upward can scoop up a stationary hero
  • When riding a tile, the hero moves with it
  • Walls still block the hero even while riding a moving tile
  • When a tile pushes the hero into a wall, the hero detaches and falls

Defining Moving Tile Types

Instead of static map tiles, moving tiles need a set of behavior properties. Define them as plain objects - one entry per tile type:

const MOVING_TILE_TYPES = {
    1: {
        speed: 2,
        dirX: 0,  dirY: 1,      // Vertical mover (0 = no movement on that axis)
        rangeMinY: -1,           // Relative to starting tile: goes 1 tile up...
        rangeMaxY: 4,            // ...and 4 tiles down
        color: 0x44BB44
    },
    2: {
        speed: 2,
        dirX: 1,  dirY: 0,      // Horizontal mover
        rangeMinX: -2,           // 2 tiles left of start...
        rangeMaxX: 3,            // ...3 tiles right
        color: 0x44AACC
    }
};

dirX/dirY tell the tile which way to start moving. The range values are relative to the starting tile position, so you can place the same tile type anywhere in a map and its travel range will follow it. The tile bounces when it hits either boundary.

Placing Moving Tiles

Like enemies and items, moving tiles are stored in a data array per room:

// myMovingTiles[roomIndex] = [[type, startTileX, startTileY], ...]
const myMovingTiles = [
    [],                       // Room 0 - unused
    [[1, 4, 2]],              // Room 1: one vertical platform starting at tile (4, 2)
    [[2, 4, 4]]               // Room 2: one horizontal platform starting at tile (4, 4)
];

Call buildMovingTiles() when you build the room. It converts the relative range values into absolute pixel boundaries so you never have to recalculate them during gameplay:

const movingTiles = []; // all active moving tile objects

function buildMovingTiles() {
    movingTiles.length = 0; // Clear previous room's tiles

    for (const [type, startTileX, startTileY] of myMovingTiles[game.currentRoom]) {
        const def = MOVING_TILE_TYPES[type];

        const sprite = new PIXI.Graphics()
            .rect(0, 0, game.tileSize, game.tileSize / 2)
            .fill(def.color);

        const tile = {
            sprite,
            x: startTileX * game.tileSize,
            y: startTileY * game.tileSize,
            width:  game.tileSize,
            height: game.tileSize / 2,
            speed: def.speed,
            dirX:  def.dirX,
            dirY:  def.dirY,
            // Convert relative range → absolute pixel bounds
            minX: (startTileX + (def.rangeMinX ?? 0)) * game.tileSize,
            maxX: (startTileX + (def.rangeMaxX ?? 0)) * game.tileSize,
            minY: (startTileY + (def.rangeMinY ?? 0)) * game.tileSize,
            maxY: (startTileY + (def.rangeMaxY ?? 0)) * game.tileSize,
        };

        sprite.x = tile.x;
        sprite.y = tile.y;
        app.stage.addChild(sprite);
        movingTiles.push(tile);
    }
}

Storing absolute pixel boundaries avoids recalculating start + range on every frame.

Landing on a Moving Tile

The landing check has one critical rule: the hero must have been above the tile on the previous frame. Without this, the hero would teleport to the top of any tile they happen to overlap with from the side.

Save player.lastY at the very start of your game loop before anything moves:

function gameLoop() {
    player.lastY = player.y;  // ← must be first!

    updateMovingTiles();
    handleInput();
    // ...
}

Then when checking downward movement, test against moving tiles after static tiles:

function checkLandOnMovingTile(dy) {
    for (const tile of movingTiles) {
        // Was the player's bottom above the tile top last frame?
        if (player.lastY + player.height > tile.y) continue;

        // Will the player overlap the tile top after this movement?
        const nextBottom = player.y + player.height + dy;
        if (nextBottom >= tile.y && nextBottom <= tile.y + tile.height) {
            // Check horizontal overlap
            if (player.x + player.width > tile.x && player.x < tile.x + tile.width) {
                return tile; // Found it!
            }
        }
    }
    return null;
}

Use it inside your downward movement code, after the static floor check:

if (player.velocityY > 0) {
    if (/* static floor check */) {
        // Hit a static floor tile - normal landing
    } else {
        const landedTile = checkLandOnMovingTile(player.velocityY);
        if (landedTile) {
            player.y = landedTile.y - player.height; // Snap to top of platform
            player.velocityY = 0;
            player.onMovingTile = landedTile;        // Remember which tile we're on
        } else {
            player.y += player.velocityY;            // Still falling
        }
    }
}

Moving All Tiles

The updateMovingTiles() function runs once per frame before player input. It handles three jobs:

  1. Move each tile and bounce it at its boundaries
  2. Scoop up a stationary hero if a tile rises up to meet them
  3. Carry the hero along if they’re already standing on a tile
function updateMovingTiles() {
    // --- Part 1: Move every tile ---
    for (const tile of movingTiles) {
        const nextX = tile.x + tile.speed * tile.dirX;
        const nextY = tile.y + tile.speed * tile.dirY;

        // Reverse direction at boundaries
        if (tile.dirX !== 0 && (nextX <= tile.minX || nextX >= tile.maxX)) tile.dirX = -tile.dirX;
        if (tile.dirY !== 0 && (nextY <= tile.minY || nextY >= tile.maxY)) tile.dirY = -tile.dirY;

        tile.x += tile.speed * tile.dirX;
        tile.y += tile.speed * tile.dirY;
        tile.sprite.x = tile.x;
        tile.sprite.y = tile.y;

        // Part 2: Can a rising tile scoop up the hero?
        if (tile.dirY < 0 && player.onMovingTile === null) {
            const tileTop = tile.y;
            if (player.y + player.height >= tileTop &&
                player.y + player.height <= tileTop + tile.height &&
                player.x + player.width > tile.x &&
                player.x < tile.x + tile.width) {
                player.onMovingTile = tile;
            }
        }
    }

    // --- Part 3: Carry the hero ---
    if (!player.onMovingTile) return;

    const tile = player.onMovingTile;

    // Move vertically with tile
    if (tile.dirY !== 0) {
        const newPlayerY = tile.y - player.height;
        const hitCeiling = isSolid(player.x + 2, newPlayerY) ||
                           isSolid(player.x + player.width - 2, newPlayerY);
        if (hitCeiling) {
            // Squashed against ceiling - detach
            player.onMovingTile = null;
            player.velocityY = 1;
        } else {
            player.y = newPlayerY;
        }
    }

    // Move horizontally with tile
    if (tile.dirX !== 0) {
        const newPlayerX = player.x + tile.speed * tile.dirX;
        const hitWall = isSolid(newPlayerX, player.y + 2) ||
                        isSolid(newPlayerX, player.y + player.height - 2) ||
                        isSolid(newPlayerX + player.width, player.y + 2) ||
                        isSolid(newPlayerX + player.width, player.y + player.height - 2);
        if (!hitWall) {
            player.x = newPlayerX;
        } else {
            // Wall blocked horizontal movement - detach and fall
            player.onMovingTile = null;
        }
    }

    // Walked off the edge? Let gravity take over
    if (player.x + player.width <= tile.x || player.x >= tile.x + tile.width) {
        player.onMovingTile = null;
    }
}

Game Loop Integration

Two small changes to your existing game loop:

Before input processing, save lastY and run tile updates:

function gameLoop() {
    player.lastY = player.y; // Always first!
    updateMovingTiles();      // Tiles move (and carry player) before input

    handleInput();

    // Skip gravity when riding a platform
    if (!player.onMovingTile) {
        player.velocityY += GRAVITY;
    }

    // ... rest of movement and collision ...
}

In your jump code, clear onMovingTile when the hero leaves the platform:

function handleJump() {
    if (keys['Space'] && (player.onGround || player.onMovingTile)) {
        player.velocityY = player.jumpPower;
        player.onGround = false;
        player.onMovingTile = null; // ← leave the platform on jump
    }
}

Note that movingTiles doesn’t need saving when changing rooms — unlike items, platforms always reset to their starting position when you re-enter a room.

Design Notes

Common patterns for moving platforms:

Pacing and rhythm: Platforms that move at the same speed as the player’s walk speed create satisfying sync. Try speed: 2 for tiles in a world where the hero also moves at 2 pixels/frame.

No wall clipping: Moving tiles don’t check the static map - it’s your job to place their boundaries so they don’t clip through walls. That’s a feature, not a bug! Want a platform that slides through a wall into a secret room? Go for it.

Multiple tiles: Add more entries to myMovingTiles to fill a room. A gauntlet of precisely timed platforms at different speeds creates the kind of challenge players remember.

Next: Enemy on Platform