Web Demo
Live Tic-Tac-Toe Demo (Next.js + React)
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.