Scrolling

Your world is bigger than one screen. The camera follows the hero, and the whole world slides past — that’s the technique behind every side-scroller from the original Super Mario Bros to Hollow Knight.

Loading editor…

Notice that the hero stays near the center of the screen while the world slides around them. That’s the illusion of scrolling - the hero isn’t really moving on screen, the world is.

The theory

Without scrolling, the hero moves across the screen and eventually hits the edge. With scrolling, you keep the hero roughly centered and shift everything else in the opposite direction.

In PixiJS, the cleanest way to do this is a world Container. Put every tile, enemy, and sprite inside it. Then move the container itself. From the player’s point of view, nothing changes - their coordinates are still world-space pixels. The container offset is purely a visual trick.

screen position = world position - camera position

When the hero is at world x=450, and the camera is at x=300, the hero appears at screen x=150 (center of a 300px wide screen). The container shifts left by 300px and everything inside appears shifted left by that amount.

Setting up the world container

The only structural change from earlier tutorials: instead of adding tiles directly to app.stage, add them to a Container called world. The stage itself stays empty except for UI elements like a score display.

const world = new PIXI.Container();
app.stage.addChild(world);

// All game objects go into world, NOT app.stage
function buildMap() {
    for (let row = 0; row < map.length; row++) {
        for (let col = 0; col < map[row].length; col++) {
            if (map[row][col] === 1) {
                const tile = new PIXI.Graphics()
                    .rect(0, 0, game.tileSize, game.tileSize)
                    .fill(0x8B4513);
                tile.x = col * game.tileSize;
                tile.y = row * game.tileSize;
                world.addChild(tile); // ← world, not app.stage
            }
        }
    }
}

// UI elements (score, health bar) go on the stage directly
// so they don't scroll with the world
const scoreText = new Text({ text: 'Score: 0', style: scoreStyle });
app.stage.addChild(scoreText); // ← stage, so it stays fixed

Your collision detection (isSolid) doesn’t change at all - it still uses the player’s world coordinates to check the map array. The camera offset is invisible to the game logic.

The camera

The camera is just two numbers: camX and camY. You set world.x = -camX and world.y = -camY each frame to shift everything into view.

Simple version - snaps instantly to the player:

function updateCamera() {
    // Center the viewport on the player
    const camX = player.x + player.width / 2 - SCREEN_W / 2;
    const camY = player.y + player.height / 2 - SCREEN_H / 2;

    world.x = -camX;
    world.y = -camY;
}

Smooth version - eases toward the player position each frame:

let camX = 0;
let camY = 0;
const CAMERA_SMOOTH = 0.12; // 0 = never moves, 1 = instant snap

function updateCamera() {
    const targetX = player.x + player.width / 2 - SCREEN_W / 2;
    const targetY = player.y + player.height / 2 - SCREEN_H / 2;

    // Move a fraction of the remaining distance each frame
    camX += (targetX - camX) * CAMERA_SMOOTH;
    camY += (targetY - camY) * CAMERA_SMOOTH;

    // Round to whole pixels to prevent blurry sub-pixel rendering
    world.x = -Math.round(camX);
    world.y = -Math.round(camY);
}

The camera moves a fraction of the remaining distance to the target each frame, so it naturally slows as it closes in. Values between 0.08 (floaty) and 0.2 (snappy) cover most use cases.

Call updateCamera() at the end of your game loop, after all positions are updated:

function gameLoop() {
    handleInput();
    applyPhysics();
    resolveCollisions();
    updateCamera(); // ← always last
}

Large maps: tile recycling

For most games - maps up to around 100×100 tiles - just render all tiles into the world container. PixiJS automatically skips drawing anything outside the viewport, so performance is not a problem.

For truly massive maps (thousands of tiles), you can recycle tile sprites as they scroll off-screen: take the column of tiles that just left the left edge and move those same sprites to the right edge, updating their appearance to match the new map data. This keeps the sprite count constant no matter how large the map is.

The pattern — this is a description of the approach, not drop-in code; a full implementation requires tracking tileSprites (a Map keyed by column/row), firstVisibleRow, and lastVisibleRow based on the current camera position:

// Only create sprites for the visible window + 1 tile buffer on each edge
const VISIBLE_COLS = Math.ceil(SCREEN_W / TILE_SIZE) + 2; // e.g. 12
const VISIBLE_ROWS = Math.ceil(SCREEN_H / TILE_SIZE) + 2;

// When the camera moves right by one full tile:
function recycleColumn(oldCol, newCol) {
    for (let row = firstVisibleRow; row <= lastVisibleRow; row++) {
        const sprite = tileSprites.get(`${oldCol}_${row}`);

        // Move sprite to new map position
        sprite.x = newCol * TILE_SIZE;
        sprite.y = row * TILE_SIZE;

        // Update sprite appearance for new tile type
        const tileType = map[row][newCol];
        updateTileSprite(sprite, tileType);

        // Re-key in the tracking map
        tileSprites.delete(`${oldCol}_${row}`);
        tileSprites.set(`${newCol}_${row}`, sprite);
    }
}

When do you need this? If you can’t feel your game stuttering, you don’t need it. Premature optimization is the root of all evil - start with the simple container approach, and only add recycling if you hit a real performance problem.

Next up: The camera works, but walk to the edge of the map and you’ll see the problem. Next: More Scrolling