On this page
  1. State stack
  2. Transition order
  3. Reading the stack
  4. Lifecycle policies
  5. State-aware work
  6. Snapshot setup

State stack

Every world owns a stack of named states. Use it for play, pause, menus, cutscenes, and game-over overlays.

Each state owns a tag component. A spawn automatically receives the tag of the state currently on top.

local GameState <const> = world:createState(
    "game",
    {
        onBlur = "pause",
        onFocus = "resume",
    }
)

local PauseState <const> = world:createState("pause")

world:pushState("game")
world:spawn(Player()) -- receives GameState

world:pushState("pause")
world:spawn(PauseMenu()) -- receives PauseState

world:popState()

The default exit action despawns the entities tagged with the popped state. Spawn permanent cameras, services, and HUD entities before the first pushState so they receive no state tag.

Transition order

Pushing a state performs these steps:

  1. Apply the outgoing state's blur policy.
  2. Emit StateBlur.
  3. Push the new state and select its auto-tag.
  4. Apply its enter policy.
  5. Emit StateEnter.

Popping performs these steps:

  1. Apply the top state's exit policy.
  2. Emit StateExit.
  3. Remove it from the stack.
  4. Select the revealed state's auto-tag.
  5. Apply its focus policy and emit StateFocus.

Popping the last state clears the auto-tag. Popping an empty stack or pushing an unregistered name raises.

Each transition stages its entity mutations as one transaction and publishes them together before returning.

Reading the stack

world:peekState() answers the top name, and world:listStates() answers the whole stack bottom-first:

world:pushState("game")
world:pushState("pause")

world:listStates() -- {"game", "pause"}
world:peekState() -- "pause"

Read the whole stack to tell a pause pushed over play from a pause that is all there is, which is what a back button, a save prompt and a debugger each need. The returned list is a fresh copy and holds only the states that were pushed, so a state createState registered and nothing pushed does not appear. The debug server reports the same stack as the states command.

Lifecycle policies

Hook Moment
onEnter The state reaches the top through a push.
onBlur Another state covers it.
onFocus A pop reveals it.
onExit The state leaves through a pop. Defaults to despawn.

Blur, focus, and exit hooks accept these built-in actions:

Action Effect on entities carrying the state tag
pause Add Paused.
resume Remove Paused.
disable Add Disabled.
despawn Despawn the entities.

A hook may instead call a function. A table with apply and call runs the built-in action first, then the function. Enter accepts a function.

pause only affects queries declared with type = "logic" or an explicit Paused exclusion. Render queries continue to match paused entities. Use disable when the entities should leave all ordinary queries and stop drawing.

State-aware work

The tag returned by createState works in any query:

local enemies <const> = world:newQuery({
    include = {GameState, Enemy},
    type = "logic",
})

Gate a whole system on the current top state:

world:addSystem({
    name = "game.Update",
    phase = tecs.ecs.phases.Update,
    runIf = tecs.ecs.runif.inState("game"),
    run = updateGame,
})

Observe transition events at address zero when runtime code needs notification:

world:observe(
    0,
    tecs.ecs.StateEnter,
    function(event: tecs.ecs.StateEnter)
        print("entered", event.state)
    end
)

Snapshot setup

Snapshots carry the stack, state tags, Paused, and Disabled. Policies are functions and do not enter the save.

Create every state with its policy during plugin setup before loading a snapshot. Load raises when the saved stack names a state this world has not registered.

After load, use state-tag queries to rebuild group indexes and EntityKey for the few individual entities that code must rediscover. See Save games.