Docs / Lua / Callbacks

Game module callbacks

The functions you write in a game module. Asobi calls them at the right moments in the match lifecycle. If you write in Erlang instead, these map 1:1 to the asobi_match behaviour - see the Erlang API.

End-of-match from Lua: to finish a match from tick or leave, set state._finished = true and state._result = {...}, then return the state. Returning { finished = true, ... } does nothing.

Required: init, join, leave, handle_input, get_state. Optional: tick, phases, on_phase_started, on_phase_ended, vote_requested, vote_resolved.

init(config)

Called once when the match is created. Receives the config map the match was started with (mode-specific). Return the initial state.

function init(config)
    return {
        board    = { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
        turn     = "x",
        players  = {},
        started  = false,
    }
end

join(player_id, state)

A player is entering. Accept and attach them, or reject. Return the new state, or nil, error to reject.

function join(player_id, state)
    if state.started then
        return nil, "match_in_progress"
    end
    state.players[player_id] = (next(state.players) == nil) and "x" or "o"
    if #state.players == 2 then state.started = true end
    game.send(player_id, { kind = "welcome", mark = state.players[player_id] })
    return state
end

leave(player_id, state)

A player disconnected or was removed. Cannot fail. Use this to stop timers, release reservations, or mark the slot empty.

function leave(player_id, state)
    state.players[player_id] = nil
    if state.started then
        state._finished = true
        state._result   = { forfeit = player_id }
    end
    return state
end

handle_input(player_id, input, state)

A player action arrived over WebSocket. Validate and apply. Inputs are serialised onto the match process - you can mutate state here without worrying about races.

function handle_input(player_id, input, state)
    local mark = state.players[player_id]
    if not mark or mark ~= state.turn then return state end
    local cell = tonumber(input.cell)
    if not cell or state.board[cell] ~= 0 then return state end
    state.board[cell] = mark
    state.turn = (mark == "x") and "o" or "x"
    game.broadcast("move", { cell = cell, mark = mark })
    return state
end

tick(state)

Called on a fixed interval (default 10 Hz, configurable per mode). Advance time, resolve AI, check win conditions. Return the new state; to end the match, set state._finished = true and state._result first (see the callout above).

function tick(state)
    local w = winner(state.board)
    if w then
        state._finished = true
        state._result   = { winner = w }
    elseif is_full(state.board) then
        state._finished = true
        state._result   = { draw = true }
    end
    return state
end

get_state(player_id, state)

Project the full match state into what this player should see. Hide opponent cards, enemy positions out of sight, hidden rolls. Called whenever a client asks for the current state (on reconnect, on view refresh).

function get_state(player_id, state)
    return {
        board    = state.board,
        turn     = state.turn,
        your_mark = state.players[player_id],
    }
end

Optional: phases (Erlang match mode, Lua world mode)

Declare named phases (lobby, active, results...) and hook into their transitions. Phases are supported for Erlang match games and for Lua world games. Lua match games cannot use phases/1 yet - model them inside tick using explicit state fields.

phases(_Config) ->
    [
        #{name => <<"lobby">>,   duration => 30000},
        #{name => <<"active">>,  duration => 180000},
        #{name => <<"results">>, duration => 15000}
    ].

on_phase_started(Name, State) ->
    asobi_match_server:broadcast_event(self(), <<"phase">>, #{name => Name}),
    {ok, State}.

on_phase_ended(_Name, State) -> {ok, State}.

Optional: voting

Hook into in-match voting - provide vote config on request, react to results.

function vote_requested(state)
    if state.offer_boons then
        return {
            template  = "boon_pick",
            options   = { "fireball", "shield", "speed" },
            window_ms = 20000,
        }
    end
    return nil
end

function vote_resolved(_template, result, state)
    state.picked_boon = result.winner
    return state
end

World-mode callbacks

A game with game_type = "world" runs on the world server and implements a different callback set. init, join, leave, get_state, phases and the on_phase_* hooks carry over; the rest are world-specific. Worked examples live on the world server page.

  • generate_world(seed, config) - return the initial per-zone state map, keyed by "x,y".
  • spawn_templates(config) - declare the entity templates game.zone.spawn draws from.
  • spawn_position(player_id, state) - where a joining player enters the world.
  • zone_tick(entities, zone_state) - per-zone simulation step; return entities, zone_state. Replaces tick.
  • handle_input(player_id, input, entities) - apply a player action; return the entities table.
  • post_tick(tick_n, state) - world-level step after all zones tick (boss phases, votes, finish).
  • on_world_recovered(snapshots, state), on_zone_loaded(cx, cy, state), on_zone_unloaded(cx, cy, state) - lazy-zone and snapshot lifecycle hooks.

Pattern: minimum viable game

The smallest correct game implements init, join, leave, handle_input, get_state. tick is optional - if you don't need a fixed time step, skip it.

Where next?