On this page
  1. State stack
  2. Transition order
  3. Lifecycle policies
  4. State-aware work
  5. 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 runs inside a deferred scope, so its entity mutations drain together.

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.