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