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. These mirror the asobi_match Erlang behaviour — every Lua callback maps to an Erlang callback with the same name and arity.

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.

Lua

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

Erlang

init(_Config) ->
    {ok, #{
        board   => [0,0,0,0,0,0,0,0,0],
        turn    => <<"x">>,
        players => #{},
        started => false
    }}.

join(player_id, state)

A player is entering. Accept and attach them, or reject. From Lua, return the new state (or nil, error to reject). From Erlang, return {ok, NewState} or {error, Reason}.

Lua

function game.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

Erlang

join(_PlayerId, #{started := true}) ->
    {error, match_in_progress};
join(PlayerId, #{players := P} = State) ->
    Mark = case maps:size(P) of 0 -> <<"x">>; _ -> <<"o">> end,
    NewP = P#{PlayerId => Mark},
    Started = maps:size(NewP) =:= 2,
    %% Per-player send is a Lua-only helper (game.send). From Erlang,
    %% expose the mark via get_state/2 instead.
    {ok, State#{players := NewP, started := Started}}.

leave(player_id, state)

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

Lua

function game.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

Erlang

leave(PlayerId, #{players := P, started := Started} = State) ->
    NewState = State#{players := maps:remove(PlayerId, P)},
    case Started of
        true  -> {finished, #{forfeit => PlayerId}, NewState};
        false -> {ok, NewState}
    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.

Lua

function game.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

Erlang

handle_input(PlayerId, #{<<"cell">> := Cell},
             #{players := P, turn := Turn, board := Board} = State) ->
    case maps:get(PlayerId, P, undefined) of
        Turn when is_integer(Cell), Cell >= 1, Cell =< 9 ->
            case lists:nth(Cell, Board) of
                0 ->
                    NewBoard  = set_nth(Cell, Turn, Board),
                    NextTurn  = other(Turn),
                    asobi_match_server:broadcast_event(
                      self(), <<"move">>, #{cell => Cell, mark => Turn}),
                    {ok, State#{board := NewBoard, turn := NextTurn}};
                _ -> {ok, State}
            end;
        _ -> {ok, 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 — or { finished = true, result = ... } (Lua) / {finished, Result, State} (Erlang) to end the match.

Lua

function game.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

Erlang

tick(#{board := Board} = State) ->
    case winner(Board) of
        none when ?is_full(Board) -> {finished, #{draw => true}, State};
        none                      -> {ok, State};
        W                         -> {finished, #{winner => W}, 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).

Lua

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

Erlang

get_state(PlayerId, #{players := P} = State) ->
    #{
        board     => maps:get(board, State),
        turn      => maps:get(turn,  State),
        your_mark => maps:get(PlayerId, P, undefined)
    }.

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.

Lua

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

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

Erlang

vote_requested(#{offer_boons := true}) ->
    {ok, #{
        template  => <<"boon_pick">>,
        method    => plurality,
        options   => [<<"fireball">>, <<"shield">>, <<"speed">>],
        window_ms => 20000
    }};
vote_requested(_State) ->
    none.

vote_resolved(_Template, #{winner := W}, State) ->
    {ok, State#{picked_boon => W}}.

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?