A phase is a stage in one session's lifecycle - lobby, then play, then results - inside a single match or world. It starts and ends with that session and is authored in the game script.

The other clock, a season, is a wall-clock window across the whole deployment - a fortnight of ranked play, a themed event. Seasons are not part of core: they ship as the asobi_seasons extension. The two do not interact.

Phases

Declare them in your game script

Phases are a list. The engine walks it in order: the first phase starts, runs for its duration, ends, and the next begins.

-- king_of_the_hill.lua
function phases(config)
  return {
    { name = "warmup",  duration = 10000 },
    { name = "combat",  duration = 120000 },
    { name = "results", duration = 8000 },
  }
end

duration is milliseconds. When the last phase ends the session's phase state is complete; a match reports phases_complete and finishes.

This is game logic. It runs identically whether you deploy to the managed cloud or self-host - nothing here touches deployment, secrets, or the database. Every phase example below is written once and is the same on both.

Start conditions

By default each phase starts when the previous one ends (prev_ended). A phase can instead wait for a condition:

function phases(config)
  return {
    { name = "lobby",  start = { players = 4 } },
    { name = "combat", duration = 120000 },
    { name = "results", duration = 8000 },
  }
end

Start conditions you can declare from Lua:

start valueMeaning
"prev_ended"when the previous phase ends (default)
{ players = N }when the Nth player has joined
{ timer = Ms }after Ms of waiting, whatever else
Ms (a bare number)shorthand for { timer = Ms }
"all_ready"when the game signals every player ready

A waiting phase has no duration clock; it holds until its condition fires.

React to transitions

Two optional callbacks fire as phases begin and end. Use them to reset scores, open a gate, freeze input. The client sends intent; the server decides the phase; the server broadcasts the result.

function on_phase_started(phase_name, state)
  if phase_name == "combat" then
    state.scores = {}
    game.broadcast("round_start", { phase = phase_name })
  end
  return state
end

function on_phase_ended(phase_name, state)
  if phase_name == "combat" then
    game.broadcast("round_over", { winner = leader(state) })
  end
  return state
end

game.broadcast is how the phase reaches your own clients with your own shape. The two calls above arrive as {"type": "match.round_start"} and {"type": "match.round_over"} (world.* from a world script); see Custom events for the naming rules. See the callback reference for the full callback list.

What the client sees on the wire

A world pushes world.phase_changed on every transition:

{
  "type": "world.phase_changed",
  "payload": {
    "status": "active",
    "phase": "combat",
    "remaining_ms": 118400,
    "config": {},
    "timers": {},
    "world_id": "..."
  }
}

It also re-sends the phase info periodically, and what a client actually receives is a burst of identical frames, not one frame every three seconds. The gate is a wall-clock check evaluated inside the tick loop, so it passes on every tick that falls in a qualifying second: at the default 20 Hz that is roughly twenty copies, once every three seconds. A slower tick_rate sends fewer, a faster one more.

Dedupe on the client. Keep the last (phase, status) you rendered and ignore a frame that repeats it; use remaining_ms for the countdown rather than counting frames.

Two other differences in the periodic frames worth handling: they carry no world_id (only the transition frame merges it in), and a world whose phases have all completed sends {"status": "complete", "phase": "undefined"} on repeat - the string, not null.

A match does not push a phase event at all. The match server runs the phase clock and your callbacks, but the client learns the phase by reading the phase block on the listing and join reply - status, phase, remaining_ms and the pending start_condition. Broadcast anything richer yourself from on_phase_started.

See WebSocket protocol for the frame envelope and Lobbies for game.broadcast.

Erlang games

An Erlang match or world module implements the same three callbacks and has the full phase feature set, including per-phase timers, an end_condition predicate, and the players_ratio and event start conditions that the Lua decoder does not expose.

phases(_Config) ->
    [
        #{name => ~"warmup", duration => 10000},
        #{name => ~"combat", duration => 120000,
          timers => [#{id => ~"suddendeath", type => countdown, duration => 100000}]},
        #{name => ~"results", duration => 8000}
    ].

on_phase_started(~"combat", GameState) ->
    {ok, GameState#{scores => #{}}};
on_phase_started(_Name, GameState) ->
    {ok, GameState}.

Limits when authoring in Lua

The Lua phases() decoder reads name, duration, start and config only. From Lua you cannot declare per-phase timers, an end_condition function, or the players_ratio and event start conditions - those need an Erlang game module. If a phase needs a timer, drive it from your own tick logic and game.broadcast, or move that game to Erlang.

Three ways a phase list fails quietly

The decoder is forgiving, and three mistakes cost you a warning you will never see:

  • A non-numeric duration becomes 0. duration = "10000" is a string, so the phase starts and ends in the same tick. Only a Lua number works.
  • An unrecognised start falls back to prev_ended. start = "all-ready" or start = "players" is not rejected; the phase simply begins when the previous one ends. The accepted values are exactly the table above.
  • A phase table with no name is dropped from the list. The rest of the list still runs, so a three-phase game silently becomes a two-phase one.

Only a non-list return from phases() logs anything. Check the phase names on the wire (world.phase_changed) or in the phase block on a listing before concluding a phase never fired.

Seasons

Seasons live in asobi_seasons, an extension. asobi still creates the seasons table - the extraction moved the code, not the migration history

  • but the schema, the query API and the background manager that flips upcoming -> active -> ended are all in that package now.

Add it to your release and read its guide.

Checkpoint

Phases, with a Lua world game running locally:

  1. Add a phases() returning warmup (5000) then active (10000) to your world script.
  2. Join the world over the WebSocket and watch the frames. Within a few seconds you see world.phase_changed with "phase": "warmup", then after five seconds another with "phase": "active". Expect repeats of the same frame in between; that is the periodic re-send, not a second transition.
  3. Call world.list; the entry carries a phase block with the live phase and remaining_ms.

If the phase frames never arrive, confirm the game is a world (matches run phases but do not push them) and that phases() returns a list. A non-list logs a warning and is ignored. If some phases arrive and others do not, check the three silent decoder failures.

Next

Voting - run a vote inside a phase to let players pick what happens in the next one.