Skip to content

States ​

Menus, pause, game over and level transitions are a state: a resource that holds one value from a fixed list, with run conditions for gating systems on it.

ts
// src/data.ts
/** Which screen the game is on. */
export const Screen = state(['menu', 'playing', 'paused', 'gameover'], 'menu')

// src/main.ts
  .init(Screen)
  .add(Fixed, runIf(inState(Screen, 'playing'), chain(PlayerMove, Movement, Collide)))
  .add(Update, runIf(onEnter(Screen, 'menu'), ShowMenu), runIf(onExit(Screen, 'menu'), HideMenu))

The initial value defaults to the first in the list.

Reading and changing it ​

ts
res.get(Screen).current          // 'menu'
res.get(Screen).previous         // the value before the last transition, or null
res.get(Screen).is('paused')
res.mut(Screen).set('playing')   // applies at the start of the next frame; the last set() wins

Setting a value that isn't in the list throws with the list. Setting the current value cancels a pending transition.

Conditions ​

ConditionTrue
inState(S, v)While the state is v
onEnter(S, v)On the first frame after v is entered
onExit(S, v)On the first frame after v is left
stateChanged(S)On the first frame after any transition

"First frame" means one fixed step when the condition is checked in Fixed, with the same edge rule as Input.pressed, so an onEnter system in Fixed runs exactly once even on frames with several steps. The initial value counts as entered on frame 1. Use these conditions in Update, Fixed or Late; Startup runs before frame 1.

Combine them with any other condition: runIf(inState(Screen, 'playing'), runIf(not(onEnter(Screen, 'playing')), Spawner)).

Despawn on exit ​

Entities that belong to one state are spawned with despawnOnExit, and are despawned when that value is left:

ts
/** Builds the level whenever play starts. */
export const SpawnLevel = system(({ commands }) => {
  const player = commands.spawn(Player, { at: origin })
  commands.add(player, despawnOnExit(Screen, 'playing'))
  for (const at of coinSpots) commands.spawn(Transform({ ...at }), IsCoin, despawnOnExit(Screen, 'playing'))
})

// src/main.ts
  .add(Update, runIf(onEnter(Screen, 'playing'), SpawnLevel))

With level entities spawned this way, a restart is just gameover → playing: leaving playing cleared the old level and entering it again builds a new one.

A menu, end to end ​

ts
export const Menu = system(({ res, events }) => {
  const ui = res.mut(Ui)
  ui.text('menu.title', 'Coin Rush', { anchor: 'center', size: 56 })
  ui.button('menu.start', 'Start', { anchor: 'center', action: 'jump' })
  for (const c of events.read(UiClick)) if (c.id === 'menu.start') res.mut(Screen).set('playing')
})

export const HideMenu = system(({ res }) => res.mut(Ui).clear('menu.'))

// src/main.ts
  .add(Update, runIf(inState(Screen, 'menu'), Menu), runIf(onExit(Screen, 'menu'), HideMenu))

See HUD and UI for buttons and UiClick.