Skip to main content

Scenes 🎬

Every game has phases: menu → playing → paused → gameover → menu. gameplate ships a compile-time-checked finite state machine for exactly that.

The basics

import { createMachine } from 'gameplate';

type Scene = 'menu' | 'playing' | 'paused' | 'gameover';
type Event = 'start' | 'pause' | 'resume' | 'die' | 'restart';

const scene = createMachine<Scene, Event>({
initial: 'menu',
on: {
menu: { start: 'playing' },
playing: { pause: 'paused', die: 'gameover' },
paused: { resume: 'playing' },
gameover: { restart: 'menu' },
},
});

scene.current(); // → 'menu'
scene.send('start'); // → 'playing'
scene.send('jump'); // ❌ TS error: 'jump' is not in Event
scene.send('start'); // ignored — there's no transition from 'playing' on 'start'
scene.send('pause'); // → 'paused'
scene.matches('paused'); // → true (and narrows the type)

Hooks: onEnter / onExit

Run side effects when a state is entered or exited:

const scene = createMachine<Scene, Event>({
initial: 'menu',
on: {
/* ... */
},
onEnter: {
menu: () => music.play('menu'),
playing: () => music.play('battle'),
gameover: () => audio.playGameOver(),
},
onExit: {
menu: () => music.stop(),
},
});

The initial state's onEnter fires synchronously when the machine is constructed.

Subscribing to transitions

scene.subscribe((current, previous, event) => {
console.log(`${previous} --(${event})--> ${current}`);
analytics.track('scene_transition', { from: previous, to: current, via: event });
});

Only fires on actual transitions (not on ignored sends).

Wiring scenes into the game loop

A common pattern: branch your update logic per scene.

const scene = createMachine<Scene, Event>({
/* ... */
});

const game = createGame({
state,
actions,
update: (state, dt, actions) => {
switch (scene.current()) {
case 'menu':
return updateMenu(state, dt);
case 'playing':
return updatePlay(state, dt, actions);
case 'paused':
return; // freeze
case 'gameover':
return updateGameOver(state, dt);
}
},
});

game.keyboard.onDown('Escape', () => {
if (scene.matches('playing')) scene.send('pause');
else if (scene.matches('paused')) scene.send('resume');
});

Exhaustiveness, for real

The TypeScript types are tight enough that the compiler will catch:

  • ✅ Sending an event that isn't in Event.
  • ✅ A transition table value pointing at a state that isn't in Scene.
  • ✅ An onEnter / onExit hook for a state that isn't in Scene.

You cannot typo a scene name or event without TS yelling at you. This is the whole point.

Why not just an enum + switch?

You could. Many games do. The downsides:

  • No central place to read the transition table — it's scattered across switch cases.
  • No onEnter / onExit discipline — side effects pile up inside transition handlers.
  • No subscribe for cross-cutting concerns (analytics, debug overlay, etc.).
  • No exhaustiveness checking unless you set it up by hand.

createMachine is the smallest abstraction that gives you all of those.

Compose with state

The FSM and the state store are separate concerns. Keep "is the player jumping" in state; keep "is the game in the menu" in the FSM. The split tends to be:

Lives in…Things like…
State storePlayer position, HP, score, enemy list, world snapshot
FSM / scenemenu vs playing vs paused, level-loading vs level-done, dialog open/closed

A rule of thumb: if the value is discrete and triggers mode changes, it's a scene. If it's continuous data the renderer reads every frame, it's state.

When to reach for XState instead

gameplate's machine has no hierarchical states, no parallel regions, no history, no guards. If you need those, XState composes cleanly — just hand its interpret value to gameplate's update loop.