Guide

Guide Recipes Camera follow & parallax

Camera follow & parallax

Create depth and motion parallax from camera and pointer movement.

Intermediate ~2 min read

Camera follow & parallax

Parallax means layers at different depths move at different rates relative to the camera. In 2D, you create it by moving background layers slower than foreground layers when the camera or pointer shifts. No depth buffer, no z-axis — just scaled position offsets applied per layer each frame.

Camera follow

A View controls what portion of the world is visible. To make the camera follow a player, update view.setCenter(player.x, player.y) each frame in update before draw:

init(loader) {
    this._view = new View(400, 300, 800, 600);
    this.player = new Sprite(loader.get(Texture, 'hero'));
}

update(delta) {
    // ... move player based on input ...

    // Camera follows player
    this._view.setCenter(this.player.position.x, this.player.position.y);
}

draw(context) {
    context.backend.clear();
    context.render(this.worldLayer, { view: this._view });
}

For smooth following, lerp the camera center instead of snapping:

this._cameraX += (this.player.x - this._cameraX) * 5 * delta.seconds;
this._cameraY += (this.player.y - this._cameraY) * 5 * delta.seconds;
this._view.setCenter(this._cameraX, this._cameraY);

The multiplier controls how quickly the camera catches up. Higher values = snappier. This is a simple exponential ease — no tween needed.

Pointer-driven parallax

For menu screens, background effects, or mouse-driven depth, offset layers proportionally to the pointer position relative to the screen center:

init(loader) {
    // Three layers at different depths
    this._layers = [0.15, 0.35, 0.6].map(speed => {
        const g = new Graphics();
        // ... draw layer content ...
        return { graphics: g, speed };
    });

    this._pointer = { x: 400, y: 300 };
    this.app.input.onPointerMove.add(p => {
        this._pointer = { x: p.x, y: p.y };
    });
}

draw(context) {
    context.backend.clear();
    for (const layer of this._layers) {
        const offsetX = (400 - this._pointer.x) * layer.speed;
        const offsetY = (300 - this._pointer.y) * layer.speed;
        layer.graphics.setPosition(offsetX, offsetY);
        context.render(layer.graphics);
    }
}

The speed multiplier per layer creates the illusion of depth. A background layer with speed = 0.15 moves slowly — it feels far away. A foreground layer with speed = 0.6 moves quickly — it feels close. The pointer offset from center ((400 - x)) gives direction: moving the mouse right shifts layers left, as if the “camera” moved right.

Scrolling parallax

For side-scrolling games, offset background layers relative to the camera position rather than the pointer:

draw(context) {
    context.backend.clear();

    // Background — moves at 30% of camera speed
    this._skyLayer.setPosition(
        this._view.center.x * 0.3,
        0
    );
    context.render(this._skyLayer, { view: this._view });

    // Midground — moves at 60%
    this._hillsLayer.setPosition(
        this._view.center.x * 0.6,
        0
    );
    context.render(this._hillsLayer, { view: this._view });

    // Foreground — moves 1:1 with camera (no parallax)
    context.render(this._worldLayer, { view: this._view });
}

The layer position is set relative to the camera center before rendering. Since the camera view is already active, the layer position offsets add to the camera transform, producing the parallax effect. The foreground layer at 1:1 has no parallax — it moves exactly with the camera.

Wide layers

Parallax layers need to be wider than the viewport, or you see edges when the camera shifts. For tiled backgrounds, draw the tile pattern across a range that extends beyond the viewport boundaries by at least the maximum parallax offset:

const viewW = 800;
const maxOffset = viewW * 0.3; // 30% for the slowest layer
const totalW = viewW + maxOffset * 2;

// Tile the pattern across [(-maxOffset), (viewW + maxOffset)]
for (let x = -maxOffset; x < viewW + maxOffset; x += tileWidth) {
    g.drawRectangle(x, 0, tileWidth, tileHeight);
}

Examples

Parallax Starfield Pointer Open in Playground View source
import { Application, Color, Graphics, Scene } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

const speeds = [0.15, 0.35, 0.6];
const counts = [120, 80, 48];
const colors = [new Color(120, 140, 200), new Color(170, 190, 255), new Color(255, 255, 255)];

class ParallaxStarfieldScene extends Scene {
    private layers!: Graphics[];
    private pointer = { x: 0, y: 0 };

    override init(): void {
        const { width, height } = this.app.canvas;
        const margin = 80;

        this.pointer = { x: width / 2, y: height / 2 };

        this.layers = counts.map((count, index) => {
            const g = new Graphics();
            g.fillColor = colors[index];
            for (let i = 0; i < count; i++) {
                const x = Math.random() * (width + margin * 2) - margin;
                const y = Math.random() * (height + margin * 2) - margin;
                const r = 1 + index;
                g.drawCircle(x, y, r);
            }
            return g;
        });

        this.app.input.onPointerMove.add(pointer => {
            this.pointer = { x: pointer.x, y: pointer.y };
        });
    }

    override draw(context): void {
        const { width, height } = this.app.canvas;
        context.backend.clear();
        for (let i = 0; i < this.layers.length; i++) {
            const layer = this.layers[i];
            const factor = speeds[i];
            layer.setPosition((width / 2 - this.pointer.x) * factor, (height / 2 - this.pointer.y) * factor);
            context.render(layer);
        }
    }
}

app.start(new ParallaxStarfieldScene());

Three layers of procedurally drawn stars, each moving at a different speed relative to the pointer — depth from motion alone.

Mouse Parallax Pointer Open in Playground View source
import { Application, Color, Container, Graphics, Scene } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

const scales = [0.03, 0.06, 0.1];
const colors = [new Color(70, 90, 140), new Color(100, 140, 220), new Color(180, 220, 255)];

class MouseParallaxScene extends Scene {
    private layers!: Container[];
    private pointer = { x: 0, y: 0 };
    private hud!: ReturnType<typeof mountControls>;

    override init(): void {
        const { width, height } = this.app.canvas;
        this.pointer = { x: width / 2, y: height / 2 };

        // Spread the circle field across the full 16:9 canvas: 16 columns wide so
        // the layers fill the extra horizontal space instead of bunching on the left.
        const columns = 16;
        const stepX = width / columns;

        this.layers = scales.map((_, i) => {
            const layer = new Container();
            const shape = new Graphics();
            shape.fillColor = colors[i];
            for (let n = 0; n < columns; n++) {
                shape.drawCircle(stepX * 0.5 + n * stepX, height * 0.28 + (n % 3) * (height * 0.22), 28 + i * 8);
            }
            layer.addChild(shape);
            return layer;
        });

        this.hud = mountControls({
            title: 'Mouse Parallax',
            controls: [{ keys: 'Mouse', action: 'shift the layers' }],
            hint: 'Move the mouse — far layers drift slowly, near layers race ahead.',
        });

        this.app.input.onPointerMove.add(p => {
            this.pointer = { x: p.x, y: p.y };
        });
    }

    override draw(context): void {
        const { width, height } = this.app.canvas;

        context.backend.clear(new Color(18, 22, 34));
        for (let i = 0; i < this.layers.length; i++) {
            const layer = this.layers[i];
            layer.setPosition((width / 2 - this.pointer.x) * scales[i], (height / 2 - this.pointer.y) * scales[i]);
            context.render(layer);
        }
    }
}

app.start(new MouseParallaxScene());

Three layers of colored circles with pointer-driven parallax offsets, demonstrating the speed-multiplier technique.

Where to go next

The next recipe, Pause menu, covers how to freeze gameplay and overlay a menu using the scene stack.