Tic-tac-toe tutorial
Build a complete two-player tic-tac-toe game end to end: state, inputs, win detection, broadcasting, and reconnect. Every step is shown in both Lua and Erlang so you can pick either path.
Prerequisites: finish the quick start first (engine running, CLI installed).
What we're building
- Two players, authoritative server.
- 3×3 board, alternating turns, input validation.
- Server detects a win or a draw and ends the match.
- State view is projected per-player so each side sees their mark.
1. Shape the state
We need: a board (9 cells), whose turn it is, the two players' ids and their marks, and a started flag so late-joiners get rejected.
Lua
-- game/ttt.lua
local game = {}
function game.init(_config)
return {
board = { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
turn = "x",
players = {},
started = false,
result = nil, -- set when finished
}
endErlang
%% src/ttt_game.erl
-module(ttt_game).
-behaviour(asobi_match).
-export([init/1, join/2, leave/2, handle_input/3, tick/1, get_state/2]).
init(_Config) ->
{ok, #{
board => [0,0,0,0,0,0,0,0,0],
turn => <<"x">>,
players => #{},
started => false,
result => undefined
}}.2. Accept players
First player gets x, second gets o, third is rejected. Once we have two, the match is started and we broadcast go.
Lua
function game.join(player_id, state)
if state.started then
return nil, "match_full"
end
local count = 0
for _ in pairs(state.players) do count = count + 1 end
local mark = (count == 0) and "x" or "o"
state.players[player_id] = mark
game.send(player_id, { kind = "welcome", mark = mark })
if count + 1 == 2 then
state.started = true
game.broadcast("go", { turn = state.turn })
end
return state
endErlang
join(_PlayerId, #{started := true}) ->
{error, match_full};
join(PlayerId, #{players := P} = State) ->
Mark = case maps:size(P) of 0 -> <<"x">>; _ -> <<"o">> end,
NewP = P#{PlayerId => Mark},
asobi_match:send(PlayerId, #{kind => <<"welcome">>, mark => Mark}),
State1 = State#{players := NewP},
case maps:size(NewP) of
2 ->
asobi_match:broadcast(<<"go">>, #{turn => maps:get(turn, State)}),
{ok, State1#{started := true}};
_ ->
{ok, State1}
end.3. Validate and apply moves
Three rules: the player must be in the match, it must be their turn, and the target cell must be empty. Anything else is silently ignored — never trust the client.
Lua
function game.handle_input(player_id, input, state)
if not state.started or state.result then return state end
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 cell < 1 or cell > 9 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, _Input, #{started := false} = State) ->
{ok, State};
handle_input(_PlayerId, _Input, #{result := R} = State) when R =/= undefined ->
{ok, State};
handle_input(PlayerId, #{<<"cell">> := Cell},
#{players := P, turn := Turn, board := Board} = State) ->
case {maps:get(PlayerId, P, undefined), is_integer(Cell), Cell} of
{Turn, true, C} when C >= 1, C =< 9 ->
case lists:nth(C, Board) of
0 ->
NewBoard = set_nth(C, Turn, Board),
asobi_match:broadcast(<<"move">>, #{cell => C, mark => Turn}),
{ok, State#{board := NewBoard, turn := other(Turn)}};
_ ->
{ok, State}
end;
_ ->
{ok, State}
end.
set_nth(1, V, [_|T]) -> [V|T];
set_nth(I, V, [H|T]) -> [H | set_nth(I - 1, V, T)].
other(<<"x">>) -> <<"o">>;
other(<<"o">>) -> <<"x">>.4. Detect a winner
Eight winning lines, same for both players. Run them every tick: cheap enough that we don't need to optimise, clear enough to audit.
Lua
local LINES = {
{1,2,3},{4,5,6},{7,8,9}, -- rows
{1,4,7},{2,5,8},{3,6,9}, -- cols
{1,5,9},{3,5,7}, -- diagonals
}
local function winner(board)
for _, l in ipairs(LINES) do
local a, b, c = board[l[1]], board[l[2]], board[l[3]]
if a ~= 0 and a == b and b == c then return a end
end
return nil
end
local function is_full(board)
for i = 1, 9 do if board[i] == 0 then return false end end
return true
end
function game.tick(state)
if state.result or not state.started then return state end
local w = winner(state.board)
if w then
state.result = { winner = w }
return { finished = true, result = state.result }
elseif is_full(state.board) then
state.result = { draw = true }
return { finished = true, result = state.result }
end
return state
endErlang
-define(LINES, [
{1,2,3},{4,5,6},{7,8,9},
{1,4,7},{2,5,8},{3,6,9},
{1,5,9},{3,5,7}
]).
tick(#{result := R} = State) when R =/= undefined ->
{ok, State};
tick(#{started := false} = State) ->
{ok, State};
tick(#{board := Board} = State) ->
case winner(Board) of
none ->
case is_full(Board) of
true -> finish(#{draw => true}, State);
false -> {ok, State}
end;
W -> finish(#{winner => W}, State)
end.
winner(Board) ->
Lines = [ {lists:nth(A, Board),
lists:nth(B, Board),
lists:nth(C, Board)} || {A,B,C} <- ?LINES ],
case [ X || {X, X, X} = {X,_,_} <- Lines, X =/= 0 ] of
[W|_] -> W;
[] -> none
end.
is_full(B) -> not lists:member(0, B).
finish(Result, State) ->
{finished, Result, State#{result := Result}}.5. Hide opponent info (there is none, but...)
Tic-tac-toe is fully observable — both players see the whole board. We still implement get_state so reconnects work: the client can ask the server for the current view at any time.
Lua
function game.get_state(player_id, state)
return {
board = state.board,
turn = state.turn,
started = state.started,
your_mark = state.players[player_id],
result = state.result, -- nil if still playing
}
end
function game.leave(player_id, state)
state.players[player_id] = nil
if state.started and not state.result then
state.result = { forfeit = player_id }
return { finished = true, result = state.result }
end
return state
endErlang
get_state(PlayerId, State) ->
#{
board => maps:get(board, State),
turn => maps:get(turn, State),
started => maps:get(started, State),
your_mark => maps:get(PlayerId, maps:get(players, State), undefined),
result => maps:get(result, State)
}.
leave(PlayerId, #{players := P, started := true, result := undefined} = State) ->
Res = #{forfeit => PlayerId},
{finished, Res, State#{
players := maps:remove(PlayerId, P),
result := Res
}};
leave(PlayerId, #{players := P} = State) ->
{ok, State#{players := maps:remove(PlayerId, P)}}.6. Deploy and play
Lua path — drop the file in your bundle and deploy:
asobi deploy ./gameErlang path — compile and hot-reload into the running node:
rebar3 compile
rebar3 nova reloadThen start two WebSocket clients and exchange moves:
# terminal 1
wscat -c ws://localhost:8080/ws
> {"type":"session.connect","payload":{"token":"alice-token"}}
> {"type":"match.create","payload":{"mode":"ttt"}}
# terminal 2 (after match id returned)
wscat -c ws://localhost:8080/ws
> {"type":"session.connect","payload":{"token":"bob-token"}}
> {"type":"match.join","payload":{"match_id":""}}
# either terminal
> {"type":"match.input","payload":{"cell":5}} Done. You have an authoritative, two-player, reconnect-safe tic-tac-toe on Asobi in roughly 70 lines.
Exercises
- Add a rematch flow: on
input.action == "rematch", reset state and broadcastgo. - Add a per-player move timer — forfeit if a player takes longer than 30 seconds.
- Add a spectator role that can see the board but cannot submit inputs.
- Submit the winner to a leaderboard via
game.leaderboard.submit(Lua) orasobi_leaderboard:submit/3(Erlang).
Where next?
- Game module callbacks — the full shape of what you can hook into.
- Lua API reference / Erlang API reference — everything the runtime exposes.
- Cookbook — recipes for more ambitious games.