Cinematics
Coordinate timing, camera, and audio for scripted scene beats.
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.scene.setScene(...) 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.scene.setScene(new CinematicScene()) to fully replace the game scene while the cutscene plays, and call app.scene.setScene(...) 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:
class CinematicScene extends Scene {
async load(loader) {
await loader.load(Texture, { boss: 'image/boss.png' });
await loader.load(AudioStream, { 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 — start quiet, fade in over the sequence. Keep the Voice to tween.
this.musicVoice = this.app.audio.play(loader.get(AudioStream, 'track'), { 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();
}
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:
| 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:
// From the game scene — switch to the cinematic:
this.app.scene.setScene(new CinematicScene());
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:
// 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(() => {
this.app.scene.setScene(new GameScene(), { transition: { type: 'fade' } });
})
.start();
The closing shutter bars animate over the scene, then setScene(...) 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:
init(loader) {
// ... 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:
init(loader) {
// ... cinematic setup ...
this.inputs.onTrigger(Keyboard.Space, () => {
this.app.scene.setScene(new GameScene());
});
const pad = this.app.input.getGamepad(0);
pad.onTrigger(GamepadButton.Start, () => {
this.app.scene.setScene(new GameScene());
});
}
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.musicVoice.volume = 0.85;
this.boss.setScale(2.1, 2.1);
// ... snap other properties ...
this.app.scene.setScene(new 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 TweenManager.create().
Examples
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.