Phases
View SourceA 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 },
}
endduration 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 },
}
endStart conditions you can declare from Lua:
start value | Meaning |
|---|---|
"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
endgame.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
durationbecomes 0.duration = "10000"is a string, so the phase starts and ends in the same tick. Only a Lua number works. - An unrecognised
startfalls back toprev_ended.start = "all-ready"orstart = "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
nameis 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 -> endedare all in that package now.
Add it to your release and read its guide.
Checkpoint
Phases, with a Lua world game running locally:
- Add a
phases()returningwarmup(5000) thenactive(10000) to your world script. - Join the world over the WebSocket and watch the frames. Within a few
seconds you see
world.phase_changedwith"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. - Call
world.list; the entry carries aphaseblock with the livephaseandremaining_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.