Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 397 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,397 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tic-Tac-Toe</title>
<style>
/* ── Reset & base ─────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
/* Centre everything on the page, both axes */
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;

/* System font stack — readable and accessible */
font-family: system-ui, -apple-system, sans-serif;
font-size: 1rem;
background: #f5f5f5;
color: #1a1a1a;
}

/* ── App wrapper ──────────────────────────────────────────── */
#app {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem 1rem;
width: 100%;
max-width: 400px;
}

/* ── Title ────────────────────────────────────────────────── */
h1 {
font-size: 2rem; /* 32px — prominent but not overwhelming */
font-weight: 700;
letter-spacing: 0.03em;
color: #1a1a1a;
}

/* ── Status bar ───────────────────────────────────────────── */
/* Communicates current turn and game result to the player.
min-height prevents layout shift when text changes. */
#status {
font-size: 1.25rem; /* 20px — large enough to read at a glance */
font-weight: 500;
min-height: 2rem;
text-align: center;
color: #333;
}

/* ── Board (3×3 CSS Grid) ─────────────────────────────────── */
/* Using CSS Grid for a clean, equal-cell layout.
gap creates the visible grid lines without extra elements. */
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
background: #444; /* gap colour acts as grid lines */
border: 4px solid #444;
border-radius: 4px;
width: 100%;
max-width: 360px;
}

/* ── Cell ─────────────────────────────────────────────────── */
/* Each cell must be at least 100×100px per spec.
aspect-ratio keeps cells square as the container resizes. */
.cell {
background: #fff;
min-width: 100px;
min-height: 100px;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem; /* Large mark — readable at a glance */
font-weight: 700;
cursor: pointer;
user-select: none; /* Prevent accidental text selection on rapid clicks */
transition: background 0.1s ease;
}

/* ── Cell states & polish ────────────────────────────────── */

/* Hover: only on empty, interactive cells.
.taken cells have pointer-events:none so :hover never fires on them. */
.cell:not(.taken):hover {
background: #e8f4fd; /* Light blue tint — clearly interactive, not aggressive */
}

/* Taken cells: disable pointer so no hover flicker after a move */
.cell.taken {
cursor: default;
pointer-events: none;
}

/* X mark — strong blue (contrast ratio > 4.5:1 on white) */
.cell.x {
color: #1565c0;
}

/* O mark — strong red (contrast ratio > 4.5:1 on white) */
.cell.o {
color: #c62828;
}

/* Win highlight — warm gold background; dark text still readable on it */
.cell.winner {
background: #fff176; /* AA-compliant: dark marks on light yellow */
}

/* ── Restart button ───────────────────────────────────────── */
/* Styled consistently with the board colour scheme.
Using a neutral dark colour that contrasts well (4.5:1+) on the button. */
#restart {
font-family: inherit; /* Match body font — form elements don't inherit by default */
font-size: 1rem;
font-weight: 600;
padding: 0.6rem 2rem;
border: 2px solid #444;
border-radius: 4px;
background: #fff;
color: #1a1a1a;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}

#restart:hover,
#restart:focus-visible {
background: #444;
color: #fff;
outline: none;
}

#restart:focus-visible {
outline: 3px solid #0078d4; /* Keyboard-focus ring for accessibility */
outline-offset: 2px;
}
</style>
</head>
<body>

<!-- ── Application root ────────────────────────────────────── -->
<main id="app" aria-label="Tic-Tac-Toe game">
<h1>Tic-Tac-Toe</h1>

<!-- Status bar: shows whose turn it is, or the result -->
<p id="status" role="status" aria-live="polite">Player X's turn</p>

<!-- Game board: 9 cells generated in JS and injected here -->
<div class="board" id="board" role="grid" aria-label="Game board"></div>

<!-- Restart: always visible, resets to initial state -->
<button id="restart" type="button">Restart</button>
</main>

<script>
// ═══════════════════════════════════════════════════════════
// GAME ENGINE — pure functions, zero DOM access
// All functions take state as input and return new values;
// they never mutate their arguments (immutable pattern).
// ═══════════════════════════════════════════════════════════

// All 8 winning combinations expressed as board-index triples.
// Defined once at module scope so they're computed only once.
const WIN_COMBINATIONS = [
[0, 1, 2], // top row
[3, 4, 5], // middle row
[6, 7, 8], // bottom row
[0, 3, 6], // left column
[1, 4, 7], // middle column
[2, 5, 8], // right column
[0, 4, 8], // diagonal ↘
[2, 4, 6], // diagonal ↙
];

/**
* initState — returns a brand-new game state object.
* Centralising initial state here means a single source of
* truth: restart just calls this rather than resetting fields
* one by one, avoiding partial-reset bugs.
*/
function initState() {
return {
board: Array(9).fill(null), // null | 'X' | 'O' for each cell (index 0–8)
currentPlayer: 'X', // X always goes first per spec
winner: null, // null | 'X' | 'O' | 'draw'
winningCells: [], // indices of the three winning cells (empty if no win)
gameOver: false,
};
}

/**
* checkWinner — evaluates all 8 win combinations against board.
* Returns { winner: 'X'|'O', winningCells: [i,j,k] } if found,
* or null if no winner yet.
* Pure: reads board array, returns a value, no side effects.
*/
function checkWinner(board) {
for (const [a, b, c] of WIN_COMBINATIONS) {
// All three cells must be the same non-null mark
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return { winner: board[a], winningCells: [a, b, c] };
}
}
return null; // no winner found
}

/**
* checkDraw — returns true when the board is full and there is
* no winner. Caller is responsible for checking winner first so
* we don't need to re-evaluate win conditions here.
*/
function checkDraw(board) {
// every() short-circuits on the first null, keeping this fast
return board.every(cell => cell !== null);
}

/**
* makeMove — applies a player's move at `index` and returns the
* resulting game state. Does NOT mutate the passed-in state.
*
* Guard conditions (returns state unchanged):
* - cell already occupied
* - game is already over
*/
function makeMove(state, index) {
// Guard: ignore invalid moves
if (state.gameOver || state.board[index] !== null) {
return state;
}

// Build a new board with the mark placed (slice creates a copy)
const board = state.board.slice();
board[index] = state.currentPlayer;

// Check outcomes — order matters: win check before draw check
const winResult = checkWinner(board);
if (winResult) {
return {
...state,
board,
winner: winResult.winner,
winningCells: winResult.winningCells,
gameOver: true,
};
}

if (checkDraw(board)) {
return {
...state,
board,
winner: 'draw',
winningCells: [],
gameOver: true,
};
}

// Game continues — toggle the current player
return {
...state,
board,
currentPlayer: state.currentPlayer === 'X' ? 'O' : 'X',
};
}

// ═══════════════════════════════════════════════════════════
// QUICK SELF-TEST (runs once on page load, logs to console)
// Validates the game engine before the UI wires up.
// Removed in production build; kept here as living docs.
// ═══════════════════════════════════════════════════════════
(function selfTest() {
// Test: X wins top row
let s = initState();
s = makeMove(s, 0); // X
s = makeMove(s, 3); // O
s = makeMove(s, 1); // X
s = makeMove(s, 4); // O
s = makeMove(s, 2); // X — wins top row
console.assert(s.winner === 'X', 'FAIL: X should win top row');
console.assert(s.winningCells.join() === '0,1,2', 'FAIL: winning cells 0,1,2');
console.assert(s.gameOver === true, 'FAIL: gameOver should be true');

// Test: draw
// Target board: X O X / X O O / O X X — no three-in-a-row for either player
// Move sequence verified to produce no win before all 9 cells are filled:
// X:0, O:1, X:3, O:4, X:8, O:6, X:2, O:5, X:7
let d = initState();
[0, 1, 3, 4, 8, 6, 2, 5, 7].forEach((i) => { d = makeMove(d, i); });
console.assert(d.winner === 'draw', 'FAIL: should be a draw');

// Test: occupied cell is ignored
let t = initState();
t = makeMove(t, 0); // X plays 0
t = makeMove(t, 0); // O tries to play 0 — should be ignored
console.assert(t.board[0] === 'X', 'FAIL: occupied cell should not be overwritten');
console.assert(t.currentPlayer === 'O', 'FAIL: still O\'s turn after invalid move');

console.log('Game engine self-test: all assertions passed');
})();

// ═══════════════════════════════════════════════════════════
// UI CONTROLLER — all DOM access lives here
// Reads game state produced by the engine and reflects it
// in the DOM. Also wires up all user interaction.
// ═══════════════════════════════════════════════════════════

// Cache DOM references once — querying the DOM on every render
// is unnecessary overhead when the elements never change.
const boardEl = document.getElementById('board');
const statusEl = document.getElementById('status');
const restartEl = document.getElementById('restart');

// Application state — single mutable reference, replaced (not mutated)
// on every move so the engine's immutability contract is respected.
let state = initState();

/**
* render — synchronises the DOM with the current game state.
* Called after every state change (move or restart).
* Rebuilds cell elements each time for simplicity; at 9 cells
* this is negligible overhead.
*/
function render(s) {
// ── Board cells ────────────────────────────────────────
boardEl.innerHTML = ''; // clear previous cells

s.board.forEach((mark, index) => {
const cell = document.createElement('div');
cell.className = 'cell';
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', `Cell ${index + 1}${mark ? ', ' + mark : ''}`);

if (mark) {
cell.textContent = mark;
cell.classList.add('taken', mark.toLowerCase()); // .x or .o for colour coding
}

// Highlight winning cells
if (s.winningCells.includes(index)) {
cell.classList.add('winner');
}

// Disable interaction: occupied cells OR game over
// Using pointer-events + aria-disabled rather than a real <button>
// because the cell grid is better represented as a grid of divs.
if (mark || s.gameOver) {
cell.classList.add('taken');
cell.setAttribute('aria-disabled', 'true');
} else {
// Only attach listener to playable cells — avoids wasted calls
cell.addEventListener('click', () => handleCellClick(index));
}

boardEl.appendChild(cell);
});

// ── Status message ─────────────────────────────────────
if (s.winner === 'draw') {
statusEl.textContent = "It's a draw!";
} else if (s.winner) {
statusEl.textContent = `Player ${s.winner} wins!`;
} else {
statusEl.textContent = `Player ${s.currentPlayer}'s turn`;
}
}

/**
* handleCellClick — called when a playable cell is clicked.
* Delegates move logic entirely to the pure engine function,
* then re-renders with the new state.
*/
function handleCellClick(index) {
state = makeMove(state, index);
render(state);
}

// Restart: replace state with a fresh game and re-render
restartEl.addEventListener('click', () => {
state = initState();
render(state);
});

// ── Initial render on page load ─────────────────────────
render(state);
</script>
</body>
</html>