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. Written in Lua; each step has an Erlang tab too, if you prefer the native behaviour.

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.

-- game/ttt.lua
function init(_config)
    return {
        board   = { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
        turn    = "x",
        players = {},
        started = false,
        result  = nil,  -- set when finished
    }
end

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.

function 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

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.

function 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

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.

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 tick(state)
    if state._finished or not state.started then return state end
    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
end

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.

function 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 leave(player_id, state)
    state.players[player_id] = nil
    if state.started and not state._finished then
        state._finished = true
        state._result   = { forfeit = player_id }
    end
    return state
end

6. Deploy and play

Your game is the Lua in the server's /app/game. Self-hosted, restart to load it:

docker compose restart asobi

On managed cloud, ship it to an environment instead:

asobi deploy prod lua

(Writing this in Erlang? Rebuild and restart the release, or rebar3 shell hot-loads it in development.)

Then start two WebSocket clients and matchmake into a game:

# terminal 1
wscat -c ws://localhost:8084/ws
> {"type":"session.connect","payload":{"token":"alice-token"}}
> {"type":"matchmaker.add","payload":{"mode":"ttt"}}
# server replies with match.matched { match_id: "<id>" }
> {"type":"match.join","payload":{"match_id":"<id>"}}

# terminal 2
wscat -c ws://localhost:8084/ws
> {"type":"session.connect","payload":{"token":"bob-token"}}
> {"type":"matchmaker.add","payload":{"mode":"ttt"}}
> {"type":"match.join","payload":{"match_id":"<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.

Where next?