commit 2833d9610ae5bf52b54809e9f96a7804361ac8b7 Author: Spencer Date: Tue May 5 10:52:28 2026 -0400 Initial Tictactics proof of concept diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20d6b3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +build/ +out/ + +# Local env and editor state +.env +.env.* +!.env.example +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS files +.DS_Store +Thumbs.db + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3b6f43 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Tictactics + +A notepad-playable browser game that combines Tic Tac Toe's line-making goal with a small capture mechanic. + +## Rules + +- Two players alternate placing `X` and `O` on empty cells. +- The board is `5 x 5`. +- After placing a mark, enemy marks trapped in a straight line between the new mark and another friendly mark are captured and removed. +- Captures can happen in any of the eight directions. +- First player to make `4 in a row` wins. +- If the board fills with no winner, the game is a draw. + +## Run + +Open `index.html` in a browser. + +No build step is required. + diff --git a/game.js b/game.js new file mode 100644 index 0000000..5b286f1 --- /dev/null +++ b/game.js @@ -0,0 +1,228 @@ +const size = 5; +const target = 4; +const empty = ""; +const directions = [ + [-1, -1], + [-1, 0], + [-1, 1], + [0, -1], + [0, 1], + [1, -1], + [1, 0], + [1, 1], +]; + +const boardElement = document.querySelector("#board"); +const messageElement = document.querySelector("#message"); +const turnBadge = document.querySelector("#turnBadge"); +const resetButton = document.querySelector("#resetButton"); +const xCapturesElement = document.querySelector("#xCaptures"); +const oCapturesElement = document.querySelector("#oCaptures"); +const moveList = document.querySelector("#moveList"); + +let board; +let currentPlayer; +let captures; +let gameOver; +let moveNumber; +let winningCells; + +function createBoard() { + return Array.from({ length: size }, () => Array(size).fill(empty)); +} + +function setupGame() { + board = createBoard(); + currentPlayer = "X"; + captures = { X: 0, O: 0 }; + gameOver = false; + moveNumber = 0; + winningCells = []; + moveList.innerHTML = ""; + render(); + setMessage("Trap enemy marks between your marks. First to four wins."); +} + +function render() { + boardElement.innerHTML = ""; + boardElement.style.setProperty("--size", size); + + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + const cell = document.createElement("button"); + cell.className = "cell"; + cell.type = "button"; + cell.role = "gridcell"; + cell.dataset.row = row; + cell.dataset.col = col; + cell.setAttribute("aria-label", cellLabel(row, col)); + cell.disabled = gameOver || board[row][col] !== empty; + + if (winningCells.some(([winRow, winCol]) => winRow === row && winCol === col)) { + cell.classList.add("winning"); + } + + if (board[row][col]) { + const mark = document.createElement("span"); + mark.className = `mark mark-${board[row][col].toLowerCase()}`; + mark.textContent = board[row][col]; + cell.append(mark); + } + + cell.addEventListener("click", () => playMove(row, col)); + boardElement.append(cell); + } + } + + turnBadge.textContent = gameOver ? "Game over" : `${currentPlayer} to play`; + turnBadge.className = `turn-badge player-${currentPlayer.toLowerCase()}`; + xCapturesElement.textContent = captures.X; + oCapturesElement.textContent = captures.O; +} + +function cellLabel(row, col) { + const mark = board[row][col]; + const position = `row ${row + 1}, column ${col + 1}`; + return mark ? `${mark} at ${position}` : `Empty cell at ${position}`; +} + +function playMove(row, col) { + if (gameOver || board[row][col] !== empty) { + return; + } + + moveNumber += 1; + board[row][col] = currentPlayer; + const captured = captureFrom(row, col, currentPlayer); + captures[currentPlayer] += captured.length; + + const win = findWin(currentPlayer); + addMove(row, col, captured.length); + + if (win.length > 0) { + winningCells = win; + gameOver = true; + setMessage(`${currentPlayer} wins with four in a row.`); + render(); + return; + } + + if (isDraw()) { + gameOver = true; + setMessage("Draw. The board is full."); + render(); + return; + } + + currentPlayer = opponentOf(currentPlayer); + setMessage(captured.length > 0 + ? `${opponentOf(currentPlayer)} captured ${captured.length}. ${currentPlayer} to play.` + : `${currentPlayer} to play.` + ); + render(); + flashCapturedCells(captured); +} + +function captureFrom(row, col, player) { + const opponent = opponentOf(player); + const captured = []; + + for (const [rowStep, colStep] of directions) { + const candidates = []; + let nextRow = row + rowStep; + let nextCol = col + colStep; + + while (isInside(nextRow, nextCol) && board[nextRow][nextCol] === opponent) { + candidates.push([nextRow, nextCol]); + nextRow += rowStep; + nextCol += colStep; + } + + if (candidates.length > 0 && isInside(nextRow, nextCol) && board[nextRow][nextCol] === player) { + captured.push(...candidates); + } + } + + for (const [captureRow, captureCol] of captured) { + board[captureRow][captureCol] = empty; + } + + return captured; +} + +function findWin(player) { + const axes = [ + [0, 1], + [1, 0], + [1, 1], + [1, -1], + ]; + + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + if (board[row][col] !== player) { + continue; + } + + for (const [rowStep, colStep] of axes) { + const cells = [[row, col]]; + + for (let index = 1; index < target; index += 1) { + const nextRow = row + rowStep * index; + const nextCol = col + colStep * index; + + if (!isInside(nextRow, nextCol) || board[nextRow][nextCol] !== player) { + break; + } + + cells.push([nextRow, nextCol]); + } + + if (cells.length === target) { + return cells; + } + } + } + } + + return []; +} + +function isDraw() { + return board.every((row) => row.every((cell) => cell !== empty)); +} + +function opponentOf(player) { + return player === "X" ? "O" : "X"; +} + +function isInside(row, col) { + return row >= 0 && row < size && col >= 0 && col < size; +} + +function addMove(row, col, captureCount) { + const item = document.createElement("li"); + const captureText = captureCount === 0 ? "" : `, captured ${captureCount}`; + item.textContent = `${moveNumber}. ${currentPlayer} to ${row + 1},${col + 1}${captureText}`; + moveList.prepend(item); +} + +function setMessage(text) { + messageElement.textContent = text; +} + +function flashCapturedCells(captured) { + for (const [row, col] of captured) { + const cell = boardElement.querySelector(`[data-row="${row}"][data-col="${col}"]`); + if (!cell) { + continue; + } + + cell.classList.add("captured"); + window.setTimeout(() => cell.classList.remove("captured"), 380); + } +} + +resetButton.addEventListener("click", setupGame); +setupGame(); + diff --git a/index.html b/index.html new file mode 100644 index 0000000..f31248f --- /dev/null +++ b/index.html @@ -0,0 +1,54 @@ + + + + + + Tictactics + + + +
+
+
+
+

capture tic tac toe

+

Tictactics

+
+ +
+ +
+
X to play
+
Trap enemy marks between your marks. First to four wins.
+
+ +
+
+ + +
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..a4fc8ad --- /dev/null +++ b/styles.css @@ -0,0 +1,310 @@ +:root { + color-scheme: light; + --ink: #161616; + --muted: #62635f; + --paper: #f9f6ee; + --panel: #fffdfa; + --line: #252525; + --grid: #d9d1bf; + --x: #cb3d3d; + --o: #216e8d; + --x-soft: #ffe1df; + --o-soft: #d9f1f8; + --focus: #6750a4; + --shadow: 0 20px 50px rgba(30, 25, 15, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + linear-gradient(rgba(22, 22, 22, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(22, 22, 22, 0.035) 1px, transparent 1px), + var(--paper); + background-size: 32px 32px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button { + font: inherit; +} + +.app { + width: min(1120px, calc(100vw - 32px)); + min-height: 100vh; + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 28px; + align-items: center; + padding: 32px 0; +} + +.play-area, +.side-panel { + background: rgba(255, 253, 250, 0.86); + border: 1px solid rgba(37, 37, 37, 0.12); + box-shadow: var(--shadow); +} + +.play-area { + padding: 24px; +} + +.side-panel { + padding: 18px; +} + +.topbar, +.status-row, +.score-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: clamp(2.2rem, 4.8rem, 5rem); + line-height: 0.92; +} + +.icon-button { + width: 44px; + height: 44px; + display: inline-grid; + place-items: center; + border: 2px solid var(--line); + border-radius: 8px; + color: var(--ink); + background: var(--panel); + cursor: pointer; + transition: transform 150ms ease, background 150ms ease; +} + +.icon-button:hover { + background: #eee6d6; + transform: translateY(-1px); +} + +.icon-button:focus-visible, +.cell:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +.status-row { + margin: 24px 0 18px; +} + +.turn-badge { + min-width: 104px; + padding: 9px 12px; + border: 2px solid currentColor; + border-radius: 8px; + font-weight: 900; + text-align: center; +} + +.player-x { + color: var(--x); + background: var(--x-soft); +} + +.player-o { + color: var(--o); + background: var(--o-soft); +} + +.message { + color: var(--muted); + font-size: 0.95rem; + text-align: right; +} + +.board { + aspect-ratio: 1; + width: min(100%, 620px); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: repeat(5, 1fr); + border: 3px solid var(--line); + background: var(--line); + gap: 3px; +} + +.cell { + min-width: 0; + border: 0; + display: grid; + place-items: center; + background: var(--panel); + color: var(--ink); + cursor: pointer; + position: relative; + isolation: isolate; +} + +.cell::before { + content: ""; + position: absolute; + inset: 13%; + border: 2px dashed rgba(37, 37, 37, 0.14); + border-radius: 50%; + opacity: 0; + transition: opacity 120ms ease; +} + +.cell:hover::before { + opacity: 1; +} + +.cell[disabled] { + cursor: default; +} + +.mark { + display: grid; + place-items: center; + width: 72%; + height: 72%; + border-radius: 50%; + font-size: clamp(2rem, 8vw, 4.4rem); + font-weight: 950; + line-height: 1; + transform: scale(0.96); + animation: pop 180ms ease-out; +} + +.mark-x { + color: var(--x); +} + +.mark-o { + color: var(--o); +} + +.cell.captured { + animation: capture 360ms ease-out; +} + +.cell.winning { + background: #fff0aa; +} + +.score-row { + margin-bottom: 16px; +} + +.score-box { + flex: 1; + padding: 14px; + border: 1px solid rgba(37, 37, 37, 0.14); + border-radius: 8px; + background: var(--panel); +} + +.score-label { + display: block; + color: var(--muted); + font-size: 0.76rem; + font-weight: 800; + text-transform: uppercase; +} + +.score-box strong { + display: block; + margin-top: 4px; + font-size: 2rem; +} + +.rule-card { + padding: 14px; + border: 1px solid rgba(37, 37, 37, 0.14); + border-radius: 8px; + background: #f5efe2; +} + +.rule-card h2 { + margin: 0 0 8px; + font-size: 1rem; +} + +.rule-card p { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.move-list { + max-height: 320px; + margin: 16px 0 0; + padding: 0 0 0 24px; + overflow: auto; + color: var(--muted); +} + +.move-list li { + margin-bottom: 8px; + padding-left: 4px; +} + +@keyframes pop { + from { + opacity: 0; + transform: scale(0.66); + } +} + +@keyframes capture { + 35% { + background: #202020; + } +} + +@media (max-width: 860px) { + .app { + min-height: auto; + grid-template-columns: 1fr; + align-items: start; + padding: 18px 0; + } + + .play-area, + .side-panel { + padding: 16px; + } + + .status-row { + align-items: flex-start; + flex-direction: column; + } + + .message { + text-align: left; + } + + h1 { + font-size: 3rem; + } + + .move-list { + max-height: 180px; + } +} +