Docs / Learn / End a world

End a world

Goal: end your persistent arena cleanly, on the server's terms, and watch every client receive world.finished.

An arena round was a short session that finished when the round was over. A persistent arena is always on, so it needs a rule for when it is done. There are exactly two ways a persistent arena ends, and both are decided by the server.

  1. The game module signals a finish. Your post_tick sets _finished on the state.
  2. The world empties out. The last player leaves and the empty-grace timer expires.

When either happens the server broadcasts one world.finished push to whoever is still connected, then tears the world down. The client never ends a world; it only sends intent and reacts to the broadcast.

This is the last step of the arc. By the end of it your persistent arena starts, runs, and stops on its own.

Finish from post_tick

Your persistent arena already advances in post_tick(tick, state) (step 12). To end it, set _finished = true and attach a _result table. The result is arbitrary and is delivered verbatim to clients as JSON.

post_tick is handed the tick number every tick, so the simplest end condition is a fixed-length round: finish once the count is reached. It needs no entity state - just the counter the platform already gives you. World ticks default to 20 Hz, so 3600 ticks is three minutes.

function post_tick(tick, state)
    if tick >= 3600 then
        state._finished = true
        state._result = { reason = "time_up", ticks = tick }
    end
    return state
end

Once _finished is set the world stops ticking and world.finished goes out with your _result as its result. Swap the condition for whatever ends your world - a score cap, an objective, one team left standing - keeping the same _finished/_result shape.

The Erlang behaviour signals the same thing by returning {finished, Result, State} from post_tick/2.

Erlang tab
post_tick(Tick, State) when Tick >= 3600 ->
    {finished, #{reason => time_up, ticks => Tick}, State};
post_tick(_Tick, State) ->
    {ok, State}.

Finish when the world empties

You do not have to write any code for the other path. When the last player leaves, the world waits empty_grace_ms and then finishes on its own. This is a property of the mode, set as a global in world.lua alongside game_type = "world".

game_type = "world"
empty_grace_ms = 5000

The default is 0, which means finish the instant the world is empty. A few seconds of grace lets a player rejoin after a flaky connection without the world dying underneath them. Set it once in the mode script; nobody joining or leaving needs to know it exists.

This grace value is game logic, so it lives in the same Lua global on cloud and self-hosted alike - there is nothing to configure per environment. The full mode option table (including empty_grace_ms, zone_idle_timeout, and snapshot_interval) is in the world-server reference.

The client just listens

Ending is entirely server-side, so there is no new client call to send. The one thing each client must do is register a world.finished handler so it can react - show a result screen, return to a lobby, disconnect. Register it before joining, the same rule as every other world push. The world.finished handler is the same SDK pattern you used for world.tick in Run a world.

The payload is:

{"type": "world.finished", "payload": {"world_id": "...", "result": {"reason": "time_up", "ticks": 3600}}}

result is exactly the _result table you set in post_tick. When the world ended by empty-grace instead, result is whatever the server sends for that path - do not rely on your own fields being present. Handle a world.finished with an empty result too.

Registering the handler is identical across every SDK apart from the base server URL, so it is written once here rather than in per-SDK tabs. The event name and payload shape are the same everywhere; see the websocket-protocol reference for the wire format.

Checkpoint

Prove both endings.

The post_tick finish:

  1. Boot the server (asobi dev locally, or your deployed environment).
  2. Join the arena from a client with a world.finished handler registered.
  3. Let the world run to the tick limit (lower the constant to end it sooner).
  4. The client logs world.finished with result.reason = "time_up".

The empty-grace finish:

  1. Set empty_grace_ms = 3000 in world.lua and rejoin.
  2. Leave the world (world.leave, or just disconnect).
  3. After three seconds the world finishes; a second client still connected receives world.finished.
  4. Re-run world.list - the world is gone.

If both endings fire and the world disappears from the list, the persistent arena ends cleanly. That is the whole loop: create, join, run, end. You have a working arena backend.

Next: Where next

You have built a backend end to end - identity, storage, matches, and worlds. Everything left off the linear path (chat, voting, matchmaking, IAP, presence, notifications) hands you to the reference guides.