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,
}
endErlang
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
endErlang
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
endErlang
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
endErlang
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
endErlang
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],
}
endErlang
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
endErlang
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?
- Tic-tac-toe tutorial — all the callbacks in context.
- game.* API reference — what you call from these callbacks.
- Cookbook — recipes for common patterns.