Docs/Live Tic-Tac-Toe Demo
Web Demo

Live Tic-Tac-Toe Demo (Next.js + React)

Try the interactive version →

This is a simplified web-only example that mirrors the live demo on the website. It is intentionally split: TurnKit Web SDK (relay connection + heartbeats) and Game Logic (Tic-Tac-Toe rules and UI).

TurnKit Web SDK (simplified for demo)

Simplified web-only SDK. For the full Unity SDK see Unity Examples.

// TurnKitRelay.ts
export async function queueSession() {
  const res = await fetch("/api/demos/tictactoe/session", { method: "POST" });
  const data = await res.json();
  if (!res.ok || !data.ok || !data.session) throw new Error(data.error || "Queue failed");
  return data.session;
}

export class TurnKitRelay {
  constructor(apiBaseUrl, relayToken, handlers = {}) {
    this.handlers = handlers;
    const url = new URL(apiBaseUrl + "/v1/client/relay/ws");
    url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
    url.searchParams.set("token", relayToken);
    this.ws = new WebSocket(url);
    this.ws.onopen = () => {
      handlers.open?.();
      this.ping();
      this.timer = setInterval(() => this.ping(), 10000);
    };
    this.ws.onmessage = (event) => handlers.message?.(JSON.parse(event.data));
    this.ws.onerror = () => handlers.error?.("WebSocket failed");
    this.ws.onclose = () => clearInterval(this.timer);
  }

  send(type, extra = {}) {
    this.ws.send(JSON.stringify({ type, ...extra }));
  }

  ping() { this.send("PING"); }
  move(json) { this.send("MOVE", { json, shouldEndMyTurn: true }); }
  vote(moveNumber, isValid) { this.send("VOTE", { moveNumber, isValid }); }
  endGame() { this.send("END_GAME"); }
}

Tic-Tac-Toe Game Logic

// TicTacToeGame.ts
const wins = [
  [0,1,2],[3,4,5],[6,7,8],
  [0,3,6],[1,4,7],[2,5,8],
  [0,4,8],[2,4,6],
];

function winner(board) {
  return wins.find(([a,b,c]) => board[a] && board[a] === board[b] && board[a] === board[c]);
}

function readCellIndex(json) {
  const index = typeof json === "object" && json !== null ? json.cellIndex : Number(json);
  return Number.isInteger(index) ? index : null;
}

function isMoveValid(board, cellIndex) {
  return cellIndex !== null && cellIndex >= 0 && cellIndex < board.length && board[cellIndex] === "" && !winner(board);
}

export async function startTicTacToe(apiBaseUrl, render) {
  const [a, b] = await Promise.all([queueSession(), queueSession()]);
  const board = Array(9).fill("");
  const seats = new Map();
  const moveValidity = new Map();

  const applyMove = (msg) => {
    const index = readCellIndex(msg.json);
    const valid = moveValidity.has(msg.moveNumber) ? moveValidity.get(msg.moveNumber) : isMoveValid(board, index);
    moveValidity.set(msg.moveNumber, valid);
    seats.forEach((seat) => seat.relay.vote(msg.moveNumber, valid));
    if (!valid) return;

    board[index] = msg.moveNumber % 2 === 0 ? "O" : "X";
    render({ board, seats });

    if (winner(board)) seats.forEach((seat) => seat.relay.endGame());
  };

  [a, b].forEach((session, i) => {
    const seat = {
      name: i === 0 ? "Player One" : "Player Two",
      yourTurn: false,
      cheatMode: false,
      relay: new TurnKitRelay(apiBaseUrl, session.relayToken, {
        message: (msg) => {
          if (msg.type === "MATCH_STARTED") seat.yourTurn = !!msg.yourTurn;
          if (msg.type === "TURN_CHANGED") seat.yourTurn = !!msg.yourTurn;
          if (msg.type === "MOVE_MADE") applyMove(msg);
          render({ board, seats });
        },
      }),
      play: (cellIndex) => seat.yourTurn && (seat.cheatMode || !board[cellIndex]) && seat.relay.move({ cellIndex }),
    };

    seats.set(session.playerId, seat);
  });

  render({ board, seats });
}

Notes

Queues via /api/demos/tictactoe/session (server keeps the token).

WebSocket uses ?token= because browsers cannot send custom headers.

See WebSocket Protocol for full message details.

Live Tic-Tac-Toe Demo (Next.js + React) - TurnKit Docs