Build a Multiplayer Game: The Tic Tac Toe Demo Explained
demo.php page that ships with AbraTabia Game Server is a complete two-player tic tac toe in one page of plain JavaScript, built on nothing but the five API calls. That makes it the best teacher in the project: every pattern a real client needs, joining, polling, rendering from the action log, optimistic moves, and reconnect thinking, appears in a form short enough to read in one sitting. This guide walks through it piece by piece so you can lift the structure straight into your own game.
The Shape of Every Client
Strip away the tic tac toe and the demo is a state machine with three screens: a setup card where the player picks how to join, a waiting card while matchmaking runs, and a game card once the match starts. Every client of this server ends up with the same three states, whatever the game looks like, so the demo's structure is the reusable part. The client keeps five pieces of state: its playerid, the matchid, its own player number, the last action ID it has processed, and the game state itself, here a nine cell board array.
Joining: One Function, Three Buttons
The demo has one join function, and its three buttons just call it with different extras:
var body = { playerid: playerid, gameMode: "TICTACTOE", numPlayers: 2, username: username() };
for (var k in extra) body[k] = extra[k];
var data = await api("join", body);
Find Public Match sends the body as is. Create Private Match adds private: "1", and the response's joinCode goes on screen for a friend. Join With Code adds the typed joinCode. Notice the mode is TICTACTOE, its own string, so demo players only ever match with other demo players even on a server running other games. That is modes doing their job.
If the join response comes back already matched, the game starts immediately. Otherwise the demo shows the waiting card and polls status every 2 seconds. A none status means the queue spot expired, so the demo returns to setup with a friendly message, and a Cancel button calls leave to exit the queue on purpose.
Starting the Match
When a matched object arrives, from join or from a status poll, one function reads everything the client needs:
matchid = data.matchid;
myNum = data.yourPlayerNum;
lastActionID = 0;
board = ["", "", "", "", "", "", "", "", ""];
The opponent's name comes from the players array, the entry whose playerNum is not mine. Player 0 is X and player 1 is O, straight from the slot numbers the server assigned, and the demo starts polling actions every 1.5 seconds. That is the whole handshake: no lobby negotiation code, the matched object carries everything.
Whose Turn Is It?
The server delivers moves in order and leaves the rules to the clients, so the demo derives the turn from the board itself:
function myTurn() { return movesMade() % 2 === myNum; }
X moves on even counts, O on odd counts, and since every client computes this from the same shared log, everyone always agrees. This is the pattern to remember for your own game: turn order, legality, and win conditions are functions of the action log, computed identically everywhere, with no referee needed.
Sending a Move Optimistically
When the player clicks an empty cell on their turn, the demo applies the move locally first, then sends it:
board[idx] = symbolOf(myNum);
renderBoard();
await api("action", { playerid: playerid, matchid: matchid, actionType: "move", data1: "" + idx });
The move is one action with the cell index in data1, and the local-first update makes the game feel instant. A sending flag blocks double clicks while the request is in flight. Your own game's moves are the same idea with richer data: pack a chess move, a card play, or a word into the three data fields, they are yours to define.
Receiving Moves From the Log
The actions poll asks for everything newer than lastActionID and applies what comes back:
var data = await api("actions", { matchid: matchid, since: lastActionID });
lastActionID = data.lastActionID || lastActionID;
for (var i = 0; i < (data.actions || []).length; i++) {
var a = data.actions[i];
if (a.actionType !== "move") continue;
var idx = parseInt(a.data1, 10);
if (board[idx] !== "") continue;
board[idx] = symbolOf(a.player);
}
Three small details carry a lot of weight. The log includes your own moves, and the board[idx] !== "" check skips ones already applied optimistically, which is the simplest possible dedup. Unknown action types are skipped, which is how a client stays compatible when the game grows new actions like chat or rematch offers. And each action's player field says who moved, so the symbol comes from the server's slot assignment, never from guesswork.
Ending the Game
After every board change the demo checks the eight winning lines and the draw case, all client-side, and stops the actions timer when the game ends. The win check runs on both clients over the same log, so both reach the same verdict at the same move. A New Game button then resets to the setup card, ready to queue again with the same player ID.
Reusing the Pattern
To turn this into your own game, keep the skeleton and swap the game layer: your mode string in join, your action types and data fields in send, your reducer from actions to game state, and your rules derived from that state. The API reference has every field the five calls accept, and for a game that wants an AI game master narrating on top of the player sync, the storyteller's gamehost guide shows the companion piece.
The demo proves the whole client pattern in one page: join with your own mode string, poll status until matched, apply your move locally and send it as an action, then rebuild state from the shared log where your own moves come back deduped. Copy the skeleton, swap the rules, and your game is multiplayer.