Guide

Guide Recipes Cinematics

Cinematics

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

Intermediate ~2 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), Music (for audio), and scene-stack management (for input gating).

Approach

A cinematic is a scene. It owns the sequence. Tweens drive every timed element — camera pans, title reveals, character entrances, music fades. Push the cinematic scene with mode: 'opaque' so it fully covers the game, and pop it when the sequence ends.

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

class CinematicScene extends Scene {
    async load(loader) {
        await loader.load(Texture, { boss: 'image/boss.png' });
        await loader.load(Music, { track: 'audio/track.ogg' });
    }

    init(loader) {
        // 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(loader.get(Texture, 'boss'))
            .setAnchor(0.5)
            .setScale(0.4)
            .setPosition(560, 320)
            .setTint(new Color(255, 130, 130));

        // Music — fades in over the sequence
        this.music = loader.get(Music, 'track').setLoop(true).setVolume(0.2).play();

        // 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('', { fill: '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 — reaches full volume over 2 seconds
        this.app.tweens.create(this.music)
            .to({ volume: 0.85 }, 2.0)
            .start();
    }

    draw(context) {
        context.backend.clear(new Color(16, 16, 24));

        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:

TimeEvent
0.0sShutter bars start opening. Camera begins panning. Music starts fading in.
1.1sBoss begins scaling up from 0.4x to 2.1x.
1.6sTitle text begins revealing character-by-character.
2.0sCamera 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

Push the cinematic scene with mode: 'opaque' to hide and freeze the game while the cutscene plays:

// From the game scene:
this.app.scene.pushScene(new CinematicScene(), {
    mode: 'opaque', // game scene neither renders nor updates
});

mode: 'opaque' gives clean separation — the underlying game scene is invisible and frozen for the duration. When the cinematic ends, pop the scene:

// At the end of the sequence — chain a tween to pop the scene:
this.app.tweens.create(this.barSize)
    .to({ v: 70 }, 0.6)
    .delay(3.5) // start closing bars after the sequence plays
    .onComplete(() => {
        this.app.scene.popScene();
    })
    .start();

The closing shutter bars animate over the scene, then popScene() returns to the game.

Skip support

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

init(loader) {
    // ... cinematic setup ...

    this.inputs.onTrigger(Keyboard.Space, () => {
        this.app.scene.popScene();
    });

    const pad = this.app.input.getGamepad(0);
    pad.onTrigger(GamepadButton.Start, () => {
        this.app.scene.popScene();
    });
}

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

this.inputs.onTrigger(Keyboard.Space, () => {
    // Jump all tweens to their end state
    this.app.tweens.clear(); // stops all active tweens
    this.music.setVolume(0.85);
    this.boss.setScale(2.1, 2.1);
    // ... snap other properties ...
    this.app.scene.popScene();
});

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 TweenManager.create().

Examples

Boss Intro Cinematic Pointer Keyboard Audio Open in Playground View source
import { Application, Color, Graphics, Keyboard, Music, Scene, Sprite, Text, Texture, View } 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,
});

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!: Music;
    private hud!: ReturnType<typeof mountControls>;
    private tapPrompt!: Text;
    private width = 0;
    private height = 0;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { boss: assets.demo.textures.shipA });
        await loader.load(Music, { track: assets.demo.music.loopMain });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        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(loader.get(Texture, 'boss'))
            .setAnchor(0.5)
            .setScale(0.4)
            .setPosition(width * 0.62, height / 2)
            .setTint(new Color(255, 130, 130));
        this.music = loader.get(Music, 'track');

        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.music.setLoop(true).setVolume(0.2).play();
        this.app.audio.onUnlock.add(() => this.playSequence());

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

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

        // Restart the sting from the top so the reveal beat lines up again.
        this.music.currentTime = 0;
        this.music.setVolume(0.2);
        if (this.music.paused) {
            this.music.play();
        }
        this.playSequence();
        this.hud.setStatus('Replaying…');
    }

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

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

        // Letterbox bars slam in.
        this.app.tweens.create(this.barSize).to({ v: 84 }, 0.6).start();
        // Slow camera push-in toward the boss.
        this.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.
        this.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.
        this.app.tweens
            .create(this.titleState)
            .to({ count: titleText.length }, 1.0)
            .delay(1.6)
            .onStart(() => {
                this.view.shake(18, 520, { frequency: 24, decay: true });
            })
            .onUpdate(() => {
                this.title.text = titleText.slice(0, this.titleState.count | 0);
            })
            .start();
        // Music swells up under the reveal.
        this.app.tweens.create(this.music).to({ volume: 0.85 }, 2.0).start();
    }

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

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

        context.backend.clear(new Color(16, 16, 24));
        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 (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new 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.