Guide

GuideRecipesCamera 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:

examples/guides/camera-follow-and-parallax/camera.ts
override init(): void {
  this._view = new View(400, 300, 800, 600);
  this.player = new Sprite(this.loader.get('image/hero.png'));
}

override update(delta: Seconds): void {
  // ... move player based on input ...

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

override draw(context: RenderingContext): void {
  context.render(this.worldLayer, { view: this._view });
}

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

examples/guides/camera-follow-and-parallax/camera.ts
override update(delta: Seconds): void {
  this._cameraX += (this.player.x - this._cameraX) * 5 * delta;
  this._cameraY += (this.player.y - this._cameraY) * 5 * delta;
  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:

examples/guides/camera-follow-and-parallax/parallax.ts
override init(): void {
  // 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 };
  });
}

override draw(context: RenderingContext): void {
  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:

examples/guides/camera-follow-and-parallax/parallax.ts
override draw(context: RenderingContext): void {
  // 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:

examples/guides/camera-follow-and-parallax/tiling-a-parallax-layer.ts
const viewW = 800;
const maxOffset = viewW * 0.3; // 30% for the slowest layer
const totalW = viewW + maxOffset * 2;
const tileWidth = 128;
const tileHeight = 600;
const g = new Graphics();

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

Examples

Parallax StarfieldPointerOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene } from '@codexo/exojs';

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 app = this.app;
    const { width, height } = app;
    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;
    });

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

  override draw(context: RenderingContext): void {
    const app = this.app;
    const { width, height } = app;
    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);
    }
  }
}

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

await app.start(ParallaxStarfieldScene);

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

Mouse ParallaxPointerOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

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 app = this.app;
    const width = app.width;
    const height = app.height;
    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.',
    });

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

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

    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);
    }
  }
}

const app = new Application({
  scenes: { MouseParallaxScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(18, 22, 34),
  loader: {
    basePath: 'assets/',
  },
});

await app.start(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 app.scenes.pause()/resume() and the scene.ui layer.