Guide

GuideRecipesCinematics

Cinematics

Coordinate timing, camera, and audio for scripted scene beats.

Intermediate~3 min read

Cinematics

A cinematic (or cutscene) is a choreographed sequence where the camera moves, dialogue appears, characters animate, music swells, and the player’s input is temporarily suspended — all driven by timing, not by gameplay logic. ExoJS does not ship a timeline or cutscene system, but you can build one from the primitives that already exist: tweens (for timed animation), View (for camera movement), AudioStream (for music), and app.scenes.change(...) for transitioning between game states.

Approach

A cinematic is a scene. It owns the sequence. Tweens drive every timed element — camera pans, title reveals, character entrances, music fades. Navigate to the cinematic scene via app.scenes.change(CinematicScene) to fully replace the game scene while the cutscene plays, and call app.scenes.change(...) again when the sequence ends to return to gameplay.

The key technique: tween chains and delays. Each tween starts at a specific time offset. Together they form a timeline:

examples/guides/cinematics/cinematic-scene.ts
const TITLE = 'VOID EMPEROR';

class CinematicScene extends Scene {
  private bossTexture!: Texture;
  private trackStream!: AudioStream;
  private view!: View;
  private boss!: Sprite;
  private musicVoice!: Voice;
  private barSize!: { v: number };
  private bars!: Graphics;
  private titleState!: { count: number };
  private titleText!: Text;

  override async load(): Promise<void> {
    // AudioStream has no bare-path form, so use `Asset.type(...)` - and since
    // `get(Asset.type('music', ...))` isn't supported, keep the loaded
    // instances as direct references instead of looking them up later.
    [this.bossTexture, this.trackStream] = await Promise.all([this.loader.load('image/boss.png'), this.loader.load(Asset.type('music', 'audio/track.ogg'))]);
  }

  override init(): void {
    // Background - the engine clears to this before every `draw`.
    this.app.clearColor.set(16, 16, 24);

    // Camera - pans from title position to boss reveal
    this.view = new View(220, 300, 800, 600);

    // Boss - starts small, scales up during the pan
    this.boss = new Sprite(this.bossTexture)
      .setAnchor(0.5)
      .setScale(0.4)
      .setPosition(560, 320)
      .setTint(new Color(255, 130, 130));

    // Music - start quiet, fade in over the sequence. Keep the Voice to tween.
    this.musicVoice = this.app.audio.play(this.trackStream, { loop: true, volume: 0.2 });

    // Shutter bars - open at the very start
    this.barSize = { v: 0 };
    this.bars = new Graphics();
    this.app.tweens.create(this.barSize).to({ v: 70 }, 0.6).start();

    // Camera pan - 2 seconds, starts immediately
    this.app.tweens.create(this.view.center).to({ x: 520, y: 300 }, 2.0).start();

    // Boss scale-in - 1.8 seconds, starts after 1.1s delay
    this.app.tweens.create(this.boss.scale).to({ x: 2.1, y: 2.1 }, 1.8).delay(1.1).start();

    // Title reveal - 1 second, starts after 1.6s delay
    this.titleState = { count: 0 };
    this.titleText = new Text('', { fillColor: Color.white, fontSize: 56 });
    this.titleText.setPosition(150, 120);

    this.app.tweens
      .create(this.titleState)
      .to({ count: TITLE.length }, 1.0)
      .delay(1.6)
      .onUpdate(() => {
        this.titleText.text = TITLE.slice(0, this.titleState.count | 0);
      })
      .start();

    // Music fade - the Voice's volume is a plain get/set, so tween it directly.
    this.app.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2.0).start();
  }

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

    context.render(this.titleText);

    // Screen-space shutter bars
    this.bars.clear();
    this.bars.fillColor = Color.black;
    this.bars.drawRectangle(0, 0, 800, this.barSize.v);
    this.bars.drawRectangle(0, 600 - this.barSize.v, 800, this.barSize.v);
    context.render(this.bars);
  }
}

The timeline

The sequence above runs as follows:

Time Event
0.0s Shutter bars start opening. Camera begins panning. Music starts fading in.
1.1s Boss begins scaling up from 0.4x to 2.1x.
1.6s Title text begins revealing character-by-character.
2.0s Camera pan completes. Music reaches full volume.

Each tween operates independently — they don’t need to know about each other. The delay() method offsets the start time. The .onUpdate() callback on the title tween drives the character reveal, mirroring the typewriter pattern from UI patterns.

Gating input

Navigate to the cinematic scene from the game to fully replace the active scene while the cutscene plays:

examples/guides/cinematics/cutscene-flow.ts
// From the game scene - switch to the cinematic:
void this.app.scenes.change(CinematicScene);

scenes.change is asynchronous — the incoming scene’s load() has to run before it can activate — and the void says the caller is not waiting for it. Switching from an input handler or a tween callback, as here, is exactly that case: there is nothing to hand the promise to. Await it instead when the code around the switch depends on the new scene being live, and catch it if a failed switch needs to reach the player.

Because the cinematic is now the only active scene, the game scene neither renders nor updates for the duration. When the cinematic ends, switch back:

examples/guides/cinematics/cutscene-flow.ts
// At the end of the sequence - chain a tween to transition back to the game:
this.app.tweens
  .create(this.barSize)
  .to({ v: 70 }, 0.6)
  .delay(3.5) // start closing bars after the sequence plays
  .onComplete(() => {
    void this.app.scenes.change(GameScene, { transition: new FadeSceneTransition() });
  })
  .start();

The closing shutter bars animate over the scene, then change(...) transitions back to the game with a fade.

Letterbox overlays on scene.ui

If you want the shutter bars or subtitle text to stay in screen space and be immune to camera transforms, put them on scene.ui instead of rendering them in draw:

examples/guides/cinematics/letterbox.ts
override init(): void {
  // ... main cinematic setup ...

  // Letterbox bars as UI nodes - always screen-aligned.
  this.topBar = new Panel({ width: 1280, height: 0, color: Color.black });
  this.topBar.anchorIn(this.ui, 'top-left');
  this.ui.addChild(this.topBar);

  this.bottomBar = new Panel({ width: 1280, height: 0, color: Color.black });
  this.bottomBar.anchorIn(this.ui, 'bottom-left');
  this.ui.addChild(this.bottomBar);

  this.app.tweens.create(this.topBar).to({ height: 70 }, 0.6).start();
  this.app.tweens.create(this.bottomBar).to({ height: 70 }, 0.6).start();
}

Use scene.ui for any overlay that must remain fixed to the screen — subtitles, dialogue boxes, skip prompts.

Skip support

Add a skip mechanism — press any confirm key (Space, Enter, gamepad Start) to jump to the end and switch scenes:

examples/guides/cinematics/cutscene-flow.ts
override init(): void {
  // ... cinematic setup ...

  this.inputs.onTrigger(Keyboard.Space, () => {
    void this.app.scenes.change(GameScene);
  });

  const pad = this.app.input.getGamepad(0);
  pad.onTrigger(GamepadButton.Start, () => {
    void this.app.scenes.change(GameScene);
  });
}

For a smoother skip, fast-forward remaining tweens to completion rather than cutting hard:

examples/guides/cinematics/cutscene-flow.ts
this.inputs.onTrigger(Keyboard.Space, () => {
  // Jump all tweens to their end state
  this.app.tweens.clear(); // stops all active tweens
  this.musicVoice.volume = 0.85;
  this.boss.setScale(2.1, 2.1);
  // ... snap other properties ...
  void this.app.scenes.change(GameScene);
});

When not to build a cinematic system

For a single boss intro, the flat approach above — one scene, many tweens with delays — is sufficient. For a game with many cutscenes, you might wrap the pattern in a Sequence class that accepts an array of { time, tween } entries and starts them at the right moments. ExoJS does not ship this abstraction — it’s a few dozen lines of your own code on top of Tween.delay() and TweenSystem.create().

Examples

Boss Intro CinematicPointerKeyboardAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, AudioStream, Color, FixedResolutionCanvasSizing, Graphics, Keyboard, type Pausable, type RenderingContext, Scene, type Seconds, type Seekable, Sprite, Text, Time, View, type Voice } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const titleText = 'VOID EMPEROR';

class BossIntroCinematicScene extends Scene {
  private view!: View;
  private bg!: Graphics;
  private bars!: Graphics;
  private barSize!: { v: number };
  private title!: Text;
  private titleState!: { count: number };
  private boss!: Sprite;
  private music!: AudioStream;
  private musicVoice!: Voice & Seekable & Pausable;
  private hud!: ReturnType<typeof mountControls>;
  private tapPrompt!: Text;
  private width = 0;
  private height = 0;

  override async load(): Promise<void> {
    const app = this.app;
    const { width, height } = app;
    this.width = width;
    this.height = height;

    // Start the camera left of the boss so the push-in sweeps across to it.
    this.view = new View(width * 0.42, height / 2, width, height);
    this.bg = new Graphics();
    this.bars = new Graphics();
    this.barSize = { v: 0 };
    this.title = new Text('', { fillColor: Color.white, fontSize: 56, fontWeight: 'bold' });
    this.title.setPosition(width * 0.12, height * 0.2);
    this.titleState = { count: 0 };
    this.boss = new Sprite(this.loader.get(assets.demo.textures.shipA))
      .setAnchor(0.5)
      .setScale(0.4)
      .setPosition(width * 0.62, height / 2)
      .setTint(new Color(255, 130, 130));
    // AudioStream is a non-leaf resource kind (no seamless placeholder), so it
    // is loaded directly through `Asset.type('music', ...)` and awaited rather
    // than fetched synchronously via `get()`.
    this.music = await this.loader.load(Asset.type('music', assets.demo.music.loopMain));

    this.hud = mountControls({
      title: 'Boss Intro Cinematic',
      controls: [{ keys: ['R', 'Click'], action: 'replay sequence' }],
      status: 'Playing…',
      hint: 'Push-in, letterbox bars, a typewriter title reveal, and a screen shake punched on the reveal beat.',
    });

    // Shown while the browser still blocks audio (`app.audio.locked`); the
    // first click or keypress unlocks it and the sting + cinematic start.
    this.tapPrompt = new Text('Click or press any key to start the cinematic', { fillColor: Color.white, fontSize: 22, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(width / 2, height - 64);

    // Core defers playback until the AudioContext unlocks on the first
    // gesture; start the cinematic in lockstep with the sting on unlock.
    this.musicVoice = app.audio.play(this.music, { loop: true, volume: 0.2 }) as Voice & Seekable & Pausable;
    app.audio.onUnlock.add(() => this.playSequence());

    this.inputs.onTrigger(Keyboard.R, () => this.replay());
    app.input.onPointerDown.add(() => this.replay());
  }

  private replay(): void {
    const app = this.app;
    if (app.audio.locked) {
      return;
    }

    // Restart the sting from the top so the reveal beat lines up again.
    this.musicVoice.seek(0);
    this.musicVoice.volume = 0.2;
    if (this.musicVoice.paused) {
      this.musicVoice.resume();
    }
    this.playSequence();
    this.hud.setStatus('Replaying…');
  }

  private playSequence(): void {
    const app = this.app;
    const { width, height } = this;

    // Wipe any in-flight tweens and reset the visible state to frame zero.
    app.tweens.clear();
    this.view.reset(width * 0.42, height / 2, width, height);
    this.view.clearShake();
    this.barSize.v = 0;
    this.titleState.count = 0;
    this.title.text = '';
    this.boss.setScale(0.4);

    // Letterbox bars slam in.
    app.tweens.create(this.barSize).to({ v: 84 }, 0.6).start();
    // Slow camera push-in toward the boss.
    app.tweens
      .create(this.view.center)
      .to({ x: width * 0.55, y: height / 2 }, 2.0)
      .start();
    // The boss looms larger as the camera arrives.
    app.tweens.create(this.boss.scale).to({ x: 2.1, y: 2.1 }, 1.8).delay(1.1).start();
    // Typewriter title reveal - its onStart IS the reveal beat: punch a shake.
    app.tweens
      .create(this.titleState)
      .to({ count: titleText.length }, 1.0)
      .delay(1.6)
      .onStart(() => {
        this.view.shake(18, Time.seconds(0.52), { frequency: 24, decay: true });
      })
      .onUpdate(() => {
        this.title.text = titleText.slice(0, this.titleState.count | 0);
      })
      .start();
    // Music swells up under the reveal.
    app.tweens.create(this.musicVoice).to({ volume: 0.85 }, 2.0).start();
  }

  override update(delta: Seconds): void {
    // Advance the camera shake (and follow/bounds) animation each frame.
    this.view.update(delta * 1000);
  }

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

    this.bg.clear();
    this.bg.fillColor = new Color(36, 42, 70);
    // Span well past the view edges so the push-in never reveals a seam.
    this.bg.drawRectangle(-width * 0.25, 0, width * 1.5, height);
    context.backend.setView(this.view);
    context.render(this.bg);
    context.render(this.boss);
    context.backend.setView(null);
    context.render(this.title);
    this.bars.clear();
    this.bars.fillColor = Color.black;
    this.bars.drawRectangle(0, 0, width, this.barSize.v);
    this.bars.drawRectangle(0, height - this.barSize.v, width, this.barSize.v);
    context.render(this.bars);

    if (app.audio.locked) {
      context.render(this.tapPrompt);
    }
  }
}

const app = new Application({
  scenes: { BossIntroCinematicScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(16, 16, 24),
});

await app.start(BossIntroCinematicScene);

A boss-intro cutscene: shutter bars open, camera pans, boss scales up, title reveals character-by-character, music swells. All driven by parallel tweens with staggered delays.

Where to go next

The next recipe, Gameplay collision, covers the shape primitives, SAT-based overlap response, and quadtree broad-phase that gameplay is built on — the last building block before the capstone game. To experiment with any pattern from the Guide, open the Playground and edit the examples directly.