From 07554cafa10c3a1c16561a1454851d88bf9d838d Mon Sep 17 00:00:00 2001 From: Hyde <108911977+Hyaxon@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:59:44 -0500 Subject: [PATCH 1/3] feat: added tic-tac-toe game --- src/components/ui/games/TicTacToe.astro | 373 ++++++++++++++++++++++++ src/lib/client/tic-tac-toe.ts | 211 ++++++++++++++ src/lib/games/tic-tac-toe/cpu.ts | 131 +++++++++ src/lib/games/tic-tac-toe/engine.ts | 76 +++++ src/pages/index.astro | 4 + 5 files changed, 795 insertions(+) create mode 100644 src/components/ui/games/TicTacToe.astro create mode 100644 src/lib/client/tic-tac-toe.ts create mode 100644 src/lib/games/tic-tac-toe/cpu.ts create mode 100644 src/lib/games/tic-tac-toe/engine.ts diff --git a/src/components/ui/games/TicTacToe.astro b/src/components/ui/games/TicTacToe.astro new file mode 100644 index 0000000..080b5bb --- /dev/null +++ b/src/components/ui/games/TicTacToe.astro @@ -0,0 +1,373 @@ +--- +import ItemBox from "../ItemBox.astro"; +import type { Mark } from "../../../lib/games/tic-tac-toe/engine"; +import type { Difficulty } from "../../../lib/games/tic-tac-toe/cpu"; + +interface Props { + title?: string; + difficulty?: Difficulty; + humanMark?: Mark; +} + +const { title, difficulty = "easy", humanMark = "X" } = Astro.props; +const difficulties = ["easy", "medium", "hard"] as const; +// Initial markup only; the browser controller will render game state later. +const board = ["", "", "", "", "", "", "", "", ""]; +--- + + + + + + {board.map((mark, index) => ( + + {mark} + + ))} + + + + + + W: 0 + + + L: 0 + + + + + Difficulty + + + {difficulties.map((level) => ( + + + {level[0].toUpperCase() + level.slice(1)} + + + {level === "hard" && ( + + Hard mode uses{" "} + + minimax + {" "} + to evaluate possible moves and choose the optimal play. + + )} + + ))} + + + + + Your Shape: {humanMark} + + + Your Turn + {/* Reserve the longest message's wrapped height before a round ends. */} + + You won! Click the board to play again. + + + + + + + + + + diff --git a/src/lib/client/tic-tac-toe.ts b/src/lib/client/tic-tac-toe.ts new file mode 100644 index 0000000..7c73409 --- /dev/null +++ b/src/lib/client/tic-tac-toe.ts @@ -0,0 +1,211 @@ +import { + applyMove, + createInitialState, + type GameState, + type Mark, +} from "../games/tic-tac-toe/engine"; +import { chooseMove, type Difficulty } from "../games/tic-tac-toe/cpu"; + +interface Session { + state: GameState; + difficulty: Difficulty; + humanMark: Mark; + thinking: boolean; + scores: Record; +} + +export function initTicTacToe(root: HTMLElement): () => void { + // Each widget owns its own session and event listeners. + const session: Session = { + state: createInitialState(), + difficulty: + root.dataset.difficulty === "medium" || root.dataset.difficulty === "hard" + ? root.dataset.difficulty + : "easy", + humanMark: root.dataset.humanMark === "O" ? "O" : "X", + thinking: false, + scores: { + easy: { wins: 0, losses: 0 }, + medium: { wins: 0, losses: 0 }, + hard: { wins: 0, losses: 0 }, + }, + }; + const events = new AbortController(); + let cpuTimer: number | undefined; + + const squares = root.querySelectorAll("[data-square]"); + const difficultyButtons = root.querySelectorAll( + "[data-difficulty-option]", + ); + + function commitMove(square: number): void { + const previousOutcome = session.state.outcome; + session.state = applyMove(session.state, square); + + // Count a result once, when a move finishes a round. Draws and restarts + // leave the running totals unchanged. + if (previousOutcome === "playing") { + const outcome = session.state.outcome; + const score = session.scores[session.difficulty]; + if (outcome === session.humanMark) { + score.wins++; + } else if (outcome === "X" || outcome === "O") { + score.losses++; + } + } + + render(); + } + + function handleSquarePress(square: number): void { + if ( + session.thinking || + (session.state.outcome === "playing" && + session.state.turn !== session.humanMark) + ) + return; + console.log("[TicTacToe] Square pressed:", square); + commitMove(square); + scheduleCpuTurn(); + } + + function cancelCpuTurn(): void { + window.clearTimeout(cpuTimer); + cpuTimer = undefined; + session.thinking = false; + } + + function scheduleCpuTurn(): void { + if ( + events.signal.aborted || + session.thinking || + session.state.outcome !== "playing" || + session.state.turn === session.humanMark + ) { + return; + } + + session.thinking = true; + render(); + + const delay = 250 + Math.floor(Math.random() * 501); + cpuTimer = window.setTimeout(() => { + cpuTimer = undefined; + try { + const move = chooseMove(session.state, session.difficulty); + if (move !== null) commitMove(move); + } finally { + session.thinking = false; + render(); + } + }, delay); + } + + function handleDifficultyPress(difficulty: Difficulty): void { + console.log("[TicTacToe] Difficulty pressed:", difficulty); + if (session.difficulty === difficulty) return; + + cancelCpuTurn(); + session.state = createInitialState(); + session.difficulty = difficulty; + session.thinking = false; + render(); + scheduleCpuTurn(); + } + + function render(): void { + const { state, humanMark, thinking } = session; + const score = session.scores[session.difficulty]; + const finished = state.outcome !== "playing"; + const cpuTurn = state.turn !== humanMark; + + squares.forEach((button) => { + const square = Number(button.dataset.square); + const mark = state.board[square]; + const symbol = button.querySelector("span"); + + if (symbol) symbol.textContent = mark ?? ""; + + const position = `Row ${Math.floor(square / 3) + 1}, column ${(square % 3) + 1}`; + button.setAttribute( + "aria-label", + `${position}: ${mark ?? "empty"}${finished ? ". Play again" : ""}`, + ); + // Finished boards stay clickable so the next click can restart the round. + button.disabled = !finished && (thinking || cpuTurn || mark != null); + }); + + difficultyButtons.forEach((button) => { + button.setAttribute( + "aria-pressed", + String(button.dataset.difficultyOption === session.difficulty), + ); + }); + + let status: string; + if (finished) { + const result = + state.outcome === "draw" + ? "Draw!" + : state.outcome === humanMark + ? "You won!" + : "CPU won!"; + status = `${result} Click the board to play again.`; + } else if (thinking) { + status = "CPU is thinking…"; + } else { + status = cpuTurn ? "CPU's Turn" : "Your Turn"; + } + + const textUpdates: [string, string][] = [ + ["[data-status]", status], + ["[data-wins]", String(score.wins)], + ["[data-losses]", String(score.losses)], + ["[data-human-mark-label]", humanMark], + ]; + + textUpdates.forEach(([selector, text]) => { + const element = root.querySelector(selector); + // Avoid repeating an unchanged live-region announcement. + if (element && element.textContent !== text) element.textContent = text; + }); + + root.dataset.difficulty = session.difficulty; + root.dataset.humanMark = humanMark; + } + + squares.forEach((button) => { + button.addEventListener( + "click", + () => handleSquarePress(Number(button.dataset.square)), + { + signal: events.signal, + }, + ); + }); + + difficultyButtons.forEach((button) => { + button.addEventListener( + "click", + () => { + const difficulty = button.dataset.difficultyOption; + if ( + difficulty === "easy" || + difficulty === "medium" || + difficulty === "hard" + ) { + handleDifficultyPress(difficulty); + } + }, + { signal: events.signal }, + ); + }); + + render(); + scheduleCpuTurn(); + + return () => { + events.abort(); + cancelCpuTurn(); + }; +} diff --git a/src/lib/games/tic-tac-toe/cpu.ts b/src/lib/games/tic-tac-toe/cpu.ts new file mode 100644 index 0000000..914aa2f --- /dev/null +++ b/src/lib/games/tic-tac-toe/cpu.ts @@ -0,0 +1,131 @@ +import { applyMove, type GameState, type Mark } from "./engine"; + +export type Difficulty = "easy" | "medium" | "hard"; + +function getAvailableMoves(state: GameState): number[] | null { + const availableMoves = state.board + .map((cell, index) => (cell === null ? index : null)) + .filter((index): index is number => index !== null); + + if (availableMoves.length === 0) { + return null; + } + return availableMoves; +} + +function minimax(cpuMark: Mark, state: GameState, depth: number): number { + if (state.outcome !== "playing") { + if (state.outcome === cpuMark) { + return 10 - depth; + } else if (state.outcome == "draw") { + return 0; + } + return depth - 10; + } + + const availableMoves = getAvailableMoves(state); + + if (availableMoves === null) { + return 0; + } + + const scores = Array(availableMoves.length).fill(0); + + for (const [index, move] of availableMoves.entries()) { + const nextState = applyMove(state, move); + scores[index] = minimax(cpuMark, nextState, depth + 1); + } + if (state.turn === cpuMark) { + return Math.max(...scores); + } + return Math.min(...scores); +} + +export function chooseMove( + state: GameState, + difficulty: Difficulty, +): number | null { + if (state.outcome !== "playing") return null; + + const availableMoves = getAvailableMoves(state); + if (availableMoves === null) { + return null; + } + + if (difficulty === "easy") { + const randomIndex = Math.floor(Math.random() * availableMoves.length); + return availableMoves[randomIndex]; + } + + if (difficulty === "medium") { + // 1. Take a winning move + for (const move of availableMoves) { + const nextState = applyMove(state, move); + + if (nextState.outcome === state.turn) { + return move; + } + } + + // 2. Block opponent's winning move + const opponentMark: Mark = state.turn === "X" ? "O" : "X"; + + for (const move of availableMoves) { + const opponentState: GameState = { + ...state, + turn: opponentMark, + }; + + const nextState = applyMove(opponentState, move); + + if (nextState.outcome === opponentMark) { + return move; + } + } + + // 3. Otherwise use weighted selection + const weightedMoves: number[] = []; + + for (const move of availableMoves) { + let weight = 1; + + if (move === 4) { + weight = 5; + } else if ([0, 2, 6, 8].includes(move)) { + weight = 3; + } + + for (let i = 0; i < weight; i++) { + weightedMoves.push(move); + } + } + + const randomIndex = Math.floor(Math.random() * weightedMoves.length); + return weightedMoves[randomIndex]; + } + + if (difficulty === "hard") { + const scores = Array(availableMoves.length).fill(0); + + for (const [index, move] of availableMoves.entries()) { + const nextState = applyMove(state, move); + scores[index] = minimax(state.turn, nextState, 0); + } + + const bestScore = Math.max(...scores); + + const bestMoves = availableMoves.filter( + (_, index) => scores[index] === bestScore, + ); + + if (bestMoves.includes(4)) { + return 4; + } + + return bestMoves[0]; + } + // TODO: Choose a square for state.turn; return null if no move is available. + // Only select a move here. The controller applies it through the engine. + // Keep DOM updates and any artificial thinking delay out of this module. + throw new Error("TODO: implement chooseMove"); +} diff --git a/src/lib/games/tic-tac-toe/engine.ts b/src/lib/games/tic-tac-toe/engine.ts new file mode 100644 index 0000000..0a3697e --- /dev/null +++ b/src/lib/games/tic-tac-toe/engine.ts @@ -0,0 +1,76 @@ +// Pure game rules: no DOM, timers, or knowledge of human/CPU players. +export type Mark = "X" | "O"; +export type Cell = Mark | null; +export type Outcome = "playing" | "draw" | Mark; + +export interface GameState { + board: Cell[]; + turn: Mark; + outcome: Outcome; +} + +export function createInitialState(): GameState { + return { + board: Array(9).fill(null), + turn: "X", + outcome: "playing", + }; +} + +export function applyMove(state: GameState, square: number): GameState { + if (!Number.isInteger(square)) { + return state; + } + if (square < 0 || square > 8) { + return state; + } + + const nextState: GameState = { + ...state, + board: [...state.board], + }; + + if (state.outcome != "playing") { + return createInitialState(); + } + + if (state.board[square] != null) { + return state; + } + + nextState.board[square] = state.turn; + nextState.outcome = getOutcome(nextState.board); + + if (nextState.outcome === "playing") { + nextState.turn = state.turn === "X" ? "O" : "X"; + } + + return nextState; +} + +export function getOutcome(board: readonly Cell[]): Outcome { + for (let i = 0; i <= 6; i += 3) { + const mark = board[i]; + + if (mark != null && mark == board[i + 1] && mark === board[i + 2]) { + return mark; + } + } + + for (let i = 0; i <= 2; i++) { + const mark = board[i]; + + if (mark != null && mark == board[i + 3] && mark === board[i + 6]) { + return mark; + } + } + + if ( + (board[0] != null && board[0] == board[4] && board[0] == board[8]) || + (board[2] != null && board[2] == board[4] && board[2] == board[6]) + ) { + return board[4]; + } + + return board.every((cell) => cell !== null) ? "draw" : "playing"; +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 648dd59..a89add3 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -11,6 +11,7 @@ import ProjectCard from "../components/ui/ProjectCard.astro"; import { getHomepageProjects } from "../lib/projects/selectors"; import HeroAscii from "../components/ui/HeroAscii.astro"; import EmailWidget from "../components/ui/widgets/EmailWidget.astro"; +import TicTacToe from "../components/ui/games/TicTacToe.astro"; const homepagePosts = (await getPosts()).slice(0, 5); @@ -155,6 +156,9 @@ const homepageProjects = await getHomepageProjects(6); + + + {/* From a34a93e2851d46c5e8338644a8de58a226ba435d Mon Sep 17 00:00:00 2001 From: Hyde <108911977+Hyaxon@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:10:42 -0500 Subject: [PATCH 2/3] fix: tic-tac-toe widget order --- src/pages/index.astro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/index.astro b/src/pages/index.astro index a89add3..0babcb7 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -298,6 +298,10 @@ const homepageProjects = await getHomepageProjects(6); order: 10; } + .tic-tac-toe { + order: 10; + } + .fun { order: 11; } From 70ed7c7871cc840894e1aa208f295ab6b9ddfd46 Mon Sep 17 00:00:00 2001 From: Hyde <108911977+Hyaxon@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:17:09 -0500 Subject: [PATCH 3/3] fix: remove tooltip on mobile --- src/components/ui/games/TicTacToe.astro | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/components/ui/games/TicTacToe.astro b/src/components/ui/games/TicTacToe.astro index 080b5bb..5c6870a 100644 --- a/src/components/ui/games/TicTacToe.astro +++ b/src/components/ui/games/TicTacToe.astro @@ -350,18 +350,12 @@ const board = ["", "", "", "", "", "", "", "", ""]; padding-inline: var(--space-3); } - .game-difficulty__option:has(.game-difficulty__tooltip)::after { - top: 100%; - left: 0; - width: 100%; - height: var(--space-2); + .game-difficulty__tooltip { + display: none; } - .game-difficulty__tooltip { - top: calc(100% + var(--space-2)); - left: 0; - transform: none; - width: min(15rem, calc(100vw - 3rem)); + .game-difficulty__option:has(.game-difficulty__tooltip)::after { + display: none; } }
+ Your Shape: {humanMark} +