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.

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,
    asobi_match:send(PlayerId, #{kind => <<"welcome">>, mark => Mark}),
    {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
        return { finished = true, 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:broadcast(<<"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
        return { finished = true, result = { winner = w } }
    elseif is_full(state.board) then
        return { finished = true, 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

Declare named phases (lobby, active, results...) and hook into their transitions.

Lua

function game.phases(_config)
    return {
        { name = "lobby",   duration_ms = 30000 },
        { name = "active",  duration_ms = 180000 },
        { name = "results", duration_ms = 15000 },
    }
end

function game.on_phase_started(name, state)
    game.broadcast("phase", { name = name })
    return state
end

function game.on_phase_ended(_name, state) return state end

Erlang

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

on_phase_started(Name, State) ->
    asobi_match:broadcast(<<"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" },
            duration_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">>],
        duration_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?