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
}
end2. 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
end3. 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
end4. 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
end5. 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
end6. Deploy and play
Your game is the Lua in the server's /app/game. Self-hosted, restart to load it:
docker compose restart asobiOn 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 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.
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.