// Declarations for global dependencies provided at runtime. declare const React: any; declare const ReactDOM: any; declare const Board: any; declare const Modal: any; interface WinLine { start: { row: number; col: number }; end: { row: number; col: number }; } function readPrefs() { try { const m = document.cookie.match(/(?:^|; )prefs=([^;]+)/); if (m) { return JSON.parse(decodeURIComponent(m[1])); } } catch {} return {} as any; } function writePrefs(p: any) { document.cookie = `prefs=${encodeURIComponent(JSON.stringify(p))}; path=/; max-age=${365 * 24 * 60 * 60}`; } function calcWinLine(board: string[][], winner: string): WinLine | null { if (!winner) return null; for (let i = 0; i < 3; i++) { if ( board[i][0] === winner && board[i][1] === winner && board[i][2] === winner ) { return { start: { row: i, col: 0 }, end: { row: i, col: 2 } }; } if ( board[0][i] === winner && board[1][i] === winner && board[2][i] === winner ) { return { start: { row: 0, col: i }, end: { row: 2, col: i } }; } } if ( board[0][0] === winner && board[1][1] === winner && board[2][2] === winner ) { return { start: { row: 0, col: 0 }, end: { row: 2, col: 2 } }; } if ( board[0][2] === winner && board[1][1] === winner && board[2][0] === winner ) { return { start: { row: 0, col: 2 }, end: { row: 2, col: 0 } }; } return null; } function App() { const prefs = React.useMemo(() => readPrefs(), []); const [board, setBoard] = React.useState([ ["", "", ""], ["", "", ""], ["", "", ""], ]); const [turn, setTurn] = React.useState("X"); const [winner, setWinner] = React.useState(""); const [turns, setTurns] = React.useState({ X: 0, O: 0 }); const [hintPos, setHintPos] = React.useState(null); const [menuOpen, setMenuOpen] = React.useState(false); const [showHelp, setShowHelp] = React.useState(false); const [showAbout, setShowAbout] = React.useState(false); const [showSettings, setShowSettings] = React.useState(false); const [theme, setTheme] = React.useState(prefs.theme || "system"); const initialDark = (() => { const systemDark = window.matchMedia( "(prefers-color-scheme: dark)", ).matches; const t = prefs.theme || "system"; return t === "dark" || (t === "system" && systemDark); })(); const [isDarkMode, setIsDarkMode] = React.useState(initialDark); const [winLine, setWinLine] = React.useState(null as WinLine | null); const [user, setUser] = React.useState( prefs.username ? { username: prefs.username, name: prefs.name } : null, ); const [showRegister, setShowRegister] = React.useState(false); const [regName, setRegName] = React.useState(""); const [hasSession, setHasSession] = React.useState(false); const [sessionMsg, setSessionMsg] = React.useState("No active sessions"); const [sessionKey, setSessionKey] = React.useState(""); const [opponent, setOpponent] = React.useState(null as any); const [showConnected, setShowConnected] = React.useState(false); const [showCopied, setShowCopied] = React.useState(false); const [showTerminate, setShowTerminate] = React.useState(false); const [inGame, setInGame] = React.useState(false); const [mySymbol, setMySymbol] = React.useState("X"); const [gameID, setGameID] = React.useState(""); const [gameList, setGameList] = React.useState([] as any[]); const [showGames, setShowGames] = React.useState(false); const [mySkips, setMySkips] = React.useState(0); const [version, setVersion] = React.useState("dev"); const createCtrl = React.useRef(null as AbortController | null); const nameRef = React.useRef(null); React.useEffect(() => { fetch("/version") .then((r: any) => r.text()) .then((v: string) => setVersion(v.trim())) .catch(() => {}); }, []); const copyText = async (text: string) => { const fallback = () => { const inp = document.createElement("input"); inp.value = text; document.body.appendChild(inp); inp.select(); inp.setSelectionRange(0, inp.value.length); const ok = document.execCommand("copy"); document.body.removeChild(inp); return ok; }; if (navigator.clipboard && navigator.clipboard.writeText) { try { await navigator.clipboard.writeText(text); setShowCopied(true); return true; } catch { if (fallback()) { setShowCopied(true); return true; } return false; } } if (fallback()) { setShowCopied(true); return true; } return false; }; const copyInvite = async (key?: string) => { const k = typeof key === "string" ? key : sessionKey; if (!k) return; const url = `${window.location.origin}?key=${k}`; await copyText(url); }; React.useEffect(() => { writePrefs({ username: user?.username, name: user?.name, theme }); }, [user, theme]); React.useEffect(() => { if (!showCopied) return; const t = setTimeout(() => setShowCopied(false), 5000); return () => clearTimeout(t); }, [showCopied]); React.useEffect(() => { if (!showConnected) return; const t = setTimeout(() => setShowConnected(false), 3000); return () => clearTimeout(t); }, [showConnected]); React.useEffect(() => { const overlay = showRegister || !hasSession || (hasSession && !inGame); if (overlay) { window.scrollTo(0, 0); document.body.classList.add("overflow-hidden"); } else { document.body.classList.remove("overflow-hidden"); } return () => document.body.classList.remove("overflow-hidden"); }, [showRegister, hasSession, inGame]); React.useEffect(() => { const handler = () => { if (sessionKey) { const url = `/session/${encodeURIComponent(sessionKey)}/abandon`; if (navigator.sendBeacon) { navigator.sendBeacon(url); } } }; window.addEventListener("pagehide", handler); window.addEventListener("beforeunload", handler); return () => { window.removeEventListener("pagehide", handler); window.removeEventListener("beforeunload", handler); }; }, [sessionKey]); const refresh = () => { if (!gameID) return; fetch(`/game/${gameID}/board`) .then((r) => r.json()) .then(setBoard); fetch(`/game/${gameID}/turn`) .then((r) => r.json()) .then((d) => setTurn(d.turn)); fetch(`/game/${gameID}/turns`) .then((r) => r.json()) .then(setTurns); }; React.useEffect(() => { refresh(); }, [gameID]); React.useEffect(() => { fetch("/user/me").then(async (r) => { if (r.status === 200) { const u = await r.json(); setUser({ username: u.username || u.Username, name: u.name || u.Name, activeSession: u.activeSession || u.ActiveSession, }); if (u.activeSession) { setSessionKey(u.activeSession); setHasSession(true); } } else { setShowRegister(true); } }); }, []); React.useEffect(() => { if (!gameID) return; const es = new EventSource(`/game/${gameID}/events`); es.onmessage = (e: any) => { const d = JSON.parse(e.data); setBoard(d.board); setTurn(d.turn); setTurns(d.turns); setWinner(d.winner); setWinLine(calcWinLine(d.board, d.winner)); if (!inGame && user) { setMySymbol(d.playerX === user.username ? "X" : "O"); setInGame(true); } }; return () => es.close(); }, [gameID, inGame, user]); React.useEffect(() => { if (!user) return; const params = new URLSearchParams(window.location.search); const key = params.get("key"); if (key) { fetch(`/session/${encodeURIComponent(key)}/join`, { method: "POST", }).then(async (r) => { if (r.status === 200) { const s = await r.json(); const me = user.username; const other = s.creator.username === me ? s.joiner : s.creator; if (other) { setOpponent(other); setSessionMsg(`Connected with ${other.name}`); setHasSession(true); setShowConnected(true); } setSessionKey(s.key); const url = new URL(window.location.href); url.searchParams.delete("key"); window.history.replaceState({}, "", url.pathname + url.search); } }); } }, [user]); React.useEffect(() => { if (!user) return; fetch("/sessions").then(async (r) => { if (r.status !== 200) return; const list = await r.json(); const mine = list.filter( (s: any) => (s.creator && s.creator.username === user.username) || (s.joiner && s.joiner.username === user.username), ); const active = mine .filter((s: any) => !s.endTime) .sort( (a: any, b: any) => new Date(b.inviteTime).getTime() - new Date(a.inviteTime).getTime(), )[0]; if (active) { setHasSession(true); setSessionKey(active.key); const other = active.creator.username === user.username ? active.joiner : active.creator; if (other) { if (!opponent) { setOpponent(other); setShowConnected(true); } setSessionMsg(`Connected with ${other.name}`); } else { setSessionMsg("Waiting for opponent"); } } }); }, [user]); React.useEffect(() => { if (!hasSession || !sessionKey || !user) return; const es = new EventSource( `/session/${encodeURIComponent(sessionKey)}/events`, ); es.onmessage = (e: any) => { const info = JSON.parse(e.data); if (info.endTime) { setOpponent(null); setInGame(false); setGameID(""); setGameList([]); setShowGames(false); setMySkips(0); setSessionMsg( "Opponent disconnected. Share the invite URL to reconnect.", ); return; } const other = info.creator.username === user.username ? info.joiner : info.creator; if (other) { setOpponent(other); setSessionMsg(`Connected with ${other.name}`); setShowConnected(true); } else { setOpponent(null); setSessionMsg("Waiting for opponent"); } if (info.activeGame) { if (!inGame || gameID !== info.activeGame) { setGameID(info.activeGame); fetch(`/game/${info.activeGame}/state`) .then((r) => r.json()) .then((d) => { setBoard(d.board); setTurn(d.turn); setWinner(d.winner); setWinLine(calcWinLine(d.board, d.winner)); const mySym = d.playerX === user.username ? "X" : "O"; setMySymbol(mySym); setInGame(true); }); setShowGames(false); } } else { setInGame(false); setGameID(""); } const active = info.games.filter( (gg: any) => !gg.complete && !gg.abandoned, ); if (showGames) { setGameList(active); } else if (active.length > 1 && !inGame) { setGameList(active); setShowGames(true); } }; return () => es.close(); }, [hasSession, sessionKey, user, inGame, gameID, showGames]); React.useEffect(() => { setWinLine(calcWinLine(board, winner)); }, [board, winner]); React.useEffect(() => { const apply = () => { document.body.classList.remove("theme-dark", "theme-light"); const systemDark = window.matchMedia( "(prefers-color-scheme: dark)", ).matches; const dark = theme === "dark" || (theme === "system" && systemDark); if (dark) document.body.classList.add("theme-dark"); else document.body.classList.add("theme-light"); setIsDarkMode(dark); }; apply(); if (theme === "system") { const m = window.matchMedia("(prefers-color-scheme: dark)"); m.addEventListener("change", apply); return () => m.removeEventListener("change", apply); } }, [theme]); const menuRef = React.useRef(null); const menuButtonRef = React.useRef(null); React.useEffect(() => { if (!menuOpen) return; const handler = (e: any) => { if ( menuRef.current && !menuRef.current.contains(e.target) && !menuButtonRef.current.contains(e.target) ) setMenuOpen(false); }; document.addEventListener("click", handler); return () => document.removeEventListener("click", handler); }, [menuOpen]); const click = (r: number, c: number) => { if (winner || !inGame || turn !== mySymbol) return; if (!gameID) return; fetch(`/game/${gameID}/move`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ row: r, col: c }), }) .then((r) => r.json()) .then((d) => { setBoard(d.board); setWinner(d.winner); setWinLine(calcWinLine(d.board, d.winner)); refresh(); }); }; const newGame = () => { if (!sessionKey) { alert("No active session. Create or join a session first."); return; } fetch("/game/start?session=" + encodeURIComponent(sessionKey), { method: "POST", }).then(async (r) => { if (r.status !== 200) { r.text().then(alert); return; } const data = await r.json(); const gid = data.id; setSessionKey(sessionKey); setGameID(gid); setBoard(data.board); setTurn(data.turn); setWinner(""); setTurns({ X: 0, O: 0 }); setHintPos(null); setWinLine(null); setMySymbol(data.playerX === user.username ? "X" : "O"); setInGame(true); setMySkips(0); fetch(`/session/${encodeURIComponent(sessionKey)}/activate/${gid}`, { method: "POST", }); }); setMenuOpen(false); }; const hint = () => { if (!gameID) return; fetch(`/game/${gameID}/hint`).then(async (r) => { if (r.status === 200) { const d = await r.json(); setHintPos({ row: d.row, col: d.col }); setTimeout(() => setHintPos(null), 5000); } else { alert("No more hints"); } }); }; const skip = () => { if (!gameID) return; fetch(`/game/${gameID}/skip`, { method: "POST" }).then((r) => { if (r.status === 200) { setMySkips((s) => s + 1); refresh(); } else { r.text().then((t) => alert(t)); } }); }; const register = () => { fetch("/user/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: regName.trim(), }), }).then(async (r) => { if (r.status === 200) { const u = await r.json(); setUser({ username: u.username || u.Username, name: u.name || u.Name, }); setShowRegister(false); } else { r.text().then(alert); } }); }; const createSession = async () => { if (createCtrl.current) createCtrl.current.abort(); if (sessionKey) { fetch(`/session/${encodeURIComponent(sessionKey)}/abandon`, { method: "POST", }); } const ctrl = new AbortController(); createCtrl.current = ctrl; try { const r = await fetch("/session/new", { method: "POST", signal: ctrl.signal, }); if (r.status === 200) { const d = await r.json(); await copyInvite(d.key); setSessionKey(d.key); setSessionMsg("Waiting for opponent"); setHasSession(true); setOpponent(null); setInGame(false); } else { r.text().then(alert); } } finally { createCtrl.current = null; setMenuOpen(false); } }; const terminateSession = () => { if (!sessionKey) return; fetch(`/session/${encodeURIComponent(sessionKey)}/abandon`, { method: "POST", }).then(() => { setHasSession(false); setSessionKey(""); setOpponent(null); setGameID(""); setInGame(false); setGameList([]); setShowGames(false); setSessionMsg("No active sessions"); setShowTerminate(false); }); }; const openHelp = () => { setMenuOpen(false); setShowHelp(true); }; const openAbout = () => { setMenuOpen(false); setShowAbout(true); }; const openSettings = () => { setMenuOpen(false); setShowSettings(true); }; const disableCreate = hasSession && !!opponent; const disableNewGame = !hasSession; const showHint = turns.X >= 4 && turns.O >= 4; const boardSize = "calc(var(--cell-size) * 3 + 4vmin)"; const headerWidth = "calc((var(--cell-size) * 3 + 4vmin) * 1.2)"; return ( <>

Tic-Tac-Twist

{menuOpen && (
{hasSession && ( )}
)}
{inGame && (
{winner ? ( ) : ( <> You are {mySymbol}. It is{" "} {turn === mySymbol ? "your" : `${opponent?.name}'s`} turn. )}
)}
{inGame && (
)} {!hasSession && (
)} {hasSession && !inGame && (
{opponent ? ( ) : (
)}
)}
{winner && ( <>
{`${winner === mySymbol ? user.name : opponent?.name} wins`}
)} {inGame && !winner && (
{showHint && ( <> )}
)}
{showHelp && ( setShowHelp(false)} theme={theme}>

How to Play

Create a session and share the invite URL with a friend. Wait for the friend to register and join before starting or resuming a game.

Only one player can move at a time.

Your oldest mark disappears after your fifth move to ensure that the game ends in a Win. Before such a move, you may use Hints (up to 3 times per game) to view the oldest mark that will disappear. You may also choose to Skip your turn, which will yield the turn to your opponent.

)} {showAbout && ( setShowAbout(false)} theme={theme}>

About

Tic-Tac-Twist icon

Tic-Tac-Twist v{version}

Created by Deepak Amin

)} {showSettings && ( setShowSettings(false)} theme={theme}>

Settings

)} {showConnected && (
Connected with {opponent?.name}
)} {showCopied && ( setShowCopied(false)} theme={theme}>

Session created, and Invite URL copied to clipboard

)} {showGames && ( setShowGames(false)} theme={theme}>

Resume Game

)} {showTerminate && ( setShowTerminate(false)} theme={theme}>

Terminate session with {opponent?.name}?

)} {showRegister && ( {}} theme={theme}>

Register

setRegName((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter" && regName) { register(); } }} />
)}
{user ? `Hello, ${user.name}!` : ""} {sessionMsg} {sessionKey && !opponent && ( )}
); } ReactDOM.render(, document.getElementById("root"));