Docs / Tutorials / Tic-tac-toe

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
    }
end

Erlang

%% 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
end

Erlang

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
end

Erlang

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
end

Erlang

-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
end

Erlang

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

Erlang path — compile and hot-reload into the running node:

rebar3 compile
rebar3 nova reload

Then 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 broadcast go.
  • 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) or asobi_leaderboard:submit/3 (Erlang).

Where next?