diff --git a/src/numbermatch.html b/src/numbermatch.html
new file mode 100644
index 0000000..1f57868
--- /dev/null
+++ b/src/numbermatch.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+ Number Match
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Matches cleared: 0
+
+
+ Tiles remaining: 0
+
+
+ Add Numbers used: 0/3
+
+
+ Hints used: 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/numbermatch.js b/src/numbermatch.js
new file mode 100644
index 0000000..564566a
--- /dev/null
+++ b/src/numbermatch.js
@@ -0,0 +1,891 @@
+/**
+ * Shuffle an array in place using the Fisher-Yates algorithm.
+ * @template T
+ * @param {T[]} array Array to shuffle.
+ * @returns {T[]} The shuffled array.
+ */
+function shuffle(array) {
+ for (let i = array.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const temp = array[i];
+ array[i] = array[j];
+ array[j] = temp;
+ }
+ return array;
+}
+
+/**
+ * Core game state manager for Number Match logic.
+ */
+class NumberMatchGame {
+ /**
+ * @param {{ width?: number, rows?: number }} [options] Configuration object.
+ */
+ constructor(options = {}) {
+ const { width = 9, rows = 6 } = options;
+ this.width = width;
+ this.initialRows = rows;
+ const initialState = NumberMatchGame.generateInitialState(width, rows);
+ this.tiles = initialState.boardTiles;
+ this.reserveTiles = initialState.reserveTiles;
+ }
+
+ /**
+ * Regenerate the board with a fresh puzzle.
+ * @returns {void}
+ */
+ reset() {
+ const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows);
+ this.tiles = initialState.boardTiles;
+ this.reserveTiles = initialState.reserveTiles;
+ }
+
+ /**
+ * Get a shallow copy of the current tiles.
+ * @returns {(number|null)[]} Current tile values.
+ */
+ getTiles() {
+ return [...this.tiles];
+ }
+
+ /**
+ * Count the remaining non-null tiles.
+ * @returns {number} Number of tiles left on the board.
+ */
+ getRemainingCount() {
+ const boardCount = this.tiles.filter((tile) => tile !== null).length;
+ return boardCount;
+ }
+
+ /**
+ * Determine whether the board has been cleared.
+ * @returns {boolean} True when all tiles have been removed.
+ */
+ isComplete() {
+ return this.tiles.every((tile) => tile === null);
+ }
+
+ /**
+ * Attempt to match and remove two indices.
+ * @param {number} firstIndex Index of the first tile.
+ * @param {number} secondIndex Index of the second tile.
+ * @returns {boolean} True if the match succeeded.
+ */
+ selectPair(firstIndex, secondIndex) {
+ if (!this.canPair(firstIndex, secondIndex)) {
+ return false;
+ }
+
+ this.tiles[firstIndex] = null;
+ this.tiles[secondIndex] = null;
+ this.compactEmptyRows();
+ return true;
+ }
+
+ /**
+ * Check whether the two indices can form a valid pair.
+ * @param {number} firstIndex Index of the first tile.
+ * @param {number} secondIndex Index of the second tile.
+ * @returns {boolean} True if the tiles satisfy match rules and path constraints.
+ */
+ canPair(firstIndex, secondIndex) {
+ if (firstIndex === secondIndex) {
+ return false;
+ }
+
+ if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) {
+ return false;
+ }
+
+ const valueA = this.tiles[firstIndex];
+ const valueB = this.tiles[secondIndex];
+
+ if (valueA === null || valueB === null) {
+ return false;
+ }
+
+ if (!(valueA === valueB || valueA + valueB === 10)) {
+ return false;
+ }
+
+ return this.hasClearPath(firstIndex, secondIndex);
+ }
+
+ /**
+ * Append remaining numbers to the end of the grid, respecting Add Numbers behavior.
+ * @returns {boolean} True when new numbers were appended.
+ */
+ addNumbers(usesRemaining = 1) {
+ this.compactEmptyRows();
+ return this.appendRemainingTiles();
+ }
+
+ /**
+ * Collect active tiles in reading order.
+ * @returns {number[]} Non-null tile values.
+ */
+ getActiveTiles() {
+ return this.tiles.filter((tile) => tile !== null);
+ }
+
+ /**
+ * Append remaining tiles in reading order after exhausting reserve uses.
+ * @returns {boolean} True when tiles were appended.
+ */
+ appendRemainingTiles() {
+ const remaining = this.getActiveTiles();
+ if (remaining.length === 0) {
+ return false;
+ }
+
+ this.trimTrailingEmptySlots();
+ this.tiles.push(...remaining);
+ return true;
+ }
+
+ /**
+ * Remove trailing null placeholders from the board.
+ * @returns {void}
+ */
+ trimTrailingEmptySlots() {
+ while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) {
+ this.tiles.pop();
+ }
+ }
+
+ /**
+ * Verify that a straight path between two indices is unobstructed.
+ * @param {number} firstIndex One tile index.
+ * @param {number} secondIndex The other tile index.
+ * @returns {boolean} True if any allowed path between the indices is clear.
+ */
+ hasClearPath(firstIndex, secondIndex) {
+ const start = Math.min(firstIndex, secondIndex);
+ const end = Math.max(firstIndex, secondIndex);
+
+ const rowStart = Math.floor(start / this.width);
+ const rowEnd = Math.floor(end / this.width);
+ const colStart = start % this.width;
+ const colEnd = end % this.width;
+
+ if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) {
+ return true;
+ }
+
+ const diff = end - start;
+ if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) {
+ return true;
+ }
+
+ const rowDelta = rowEnd - rowStart;
+ const colDelta = colEnd - colStart;
+
+ if (rowDelta === colDelta && rowDelta > 0) {
+ if (colStart + rowDelta < this.width && this.isSegmentClear(start, end, this.width + 1)) {
+ return true;
+ }
+ }
+
+ if (rowDelta === -colDelta && rowDelta > 0) {
+ if (colStart - rowDelta >= 0 && this.isSegmentClear(start, end, this.width - 1)) {
+ return true;
+ }
+ }
+
+ return this.isSegmentClear(start, end, 1);
+ }
+
+ /**
+ * Remove any fully empty rows from the board to keep the grid compact.
+ * @returns {void}
+ */
+ compactEmptyRows() {
+ let index = 0;
+ while (index < this.tiles.length) {
+ const remaining = this.tiles.length - index;
+ const span = Math.min(this.width, remaining);
+ const slice = this.tiles.slice(index, index + span);
+ const isEmptyRow = slice.every((value) => value === null);
+ if (isEmptyRow) {
+ this.tiles.splice(index, span);
+ continue;
+ }
+ index += this.width;
+ }
+ }
+
+ /**
+ * Verify that the indices along a stepped path are empty.
+ * @param {number} start Starting index (inclusive).
+ * @param {number} end Ending index (inclusive).
+ * @param {number} step Increment applied each iteration.
+ * @returns {boolean} True if no non-null tiles exist between start and end.
+ */
+ isSegmentClear(start, end, step) {
+ for (let current = start + step; current < end; current += step) {
+ if (this.tiles[current] !== null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Determine whether the provided index is valid for the current tile array.
+ * @param {number} index Index to evaluate.
+ * @param {(number|null)[]} tiles Tile array.
+ * @returns {boolean} True when the index is within bounds.
+ */
+ static isValidIndex(index, tiles) {
+ return index >= 0 && index < tiles.length;
+ }
+
+ /**
+ * Create a randomized board configuration split between visible tiles and a reserve pile.
+ * @param {number} width Number of columns.
+ * @param {number} rows Number of initial rows.
+ * @returns {{ boardTiles: number[], reserveTiles: number[] }} Generated tile sets.
+ */
+ static generateInitialState(width, rows) {
+ const boardCapacity = width * rows;
+ const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2));
+ const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches);
+ const forcedAssignments = new Map();
+
+ const horizontalStarts = [];
+ for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
+ for (let colIndex = 0; colIndex < width - 1; colIndex += 1) {
+ horizontalStarts.push(rowIndex * width + colIndex);
+ }
+ }
+
+ const shuffledStarts = shuffle(horizontalStarts);
+ const reserved = new Set();
+ let startCursor = 0;
+
+ forcedPairs.forEach((pair) => {
+ let startIndex = null;
+ while (startCursor < shuffledStarts.length) {
+ const candidate = shuffledStarts[startCursor];
+ startCursor += 1;
+ if (reserved.has(candidate) || reserved.has(candidate + 1)) {
+ continue;
+ }
+ startIndex = candidate;
+ break;
+ }
+
+ if (startIndex === null) {
+ return;
+ }
+
+ forcedAssignments.set(startIndex, pair[0]);
+ forcedAssignments.set(startIndex + 1, pair[1]);
+ reserved.add(startIndex);
+ reserved.add(startIndex + 1);
+ });
+
+ const boardTiles = new Array(boardCapacity).fill(null);
+
+ for (let index = 0; index < boardCapacity; index += 1) {
+ const forcedValue = forcedAssignments.get(index);
+ if (typeof forcedValue === "number") {
+ boardTiles[index] = forcedValue;
+ continue;
+ }
+
+ const rawNextForced = forcedAssignments.get(index + 1);
+ const nextForcedValue = typeof rawNextForced === "number" ? rawNextForced : null;
+
+ boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue);
+ }
+
+ const reserveTiles = NumberMatchGame.buildReserveTiles(boardTiles, boardCapacity);
+
+ return {
+ boardTiles,
+ reserveTiles
+ };
+ }
+
+ /**
+ * Produce a random pair of values that form a legal match.
+ * @returns {[number, number]} Two values that either match or sum to ten.
+ */
+ static generatePair() {
+ const complementPairs = [
+ [1, 9],
+ [2, 8],
+ [3, 7],
+ [4, 6],
+ [5, 5]
+ ];
+ const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+
+ if (Math.random() < 0.5) {
+ const value = identicalValues[Math.floor(Math.random() * identicalValues.length)];
+ return [value, value];
+ }
+
+ const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)];
+ return [...pair];
+ }
+
+ /**
+ * Create the predetermined matches that appear on the initial board.
+ * @param {number} target Total matches to prepare.
+ * @returns {Array<[number, number]>} Ordered list of forced pairs.
+ */
+ static getForcedPairs(target) {
+ const essentialPairs = [[5, 5]];
+ const complementPool = shuffle([
+ [1, 9],
+ [2, 8],
+ [3, 7],
+ [4, 6],
+ [9, 1],
+ [8, 2],
+ [7, 3],
+ [6, 4]
+ ]);
+ const pairs = [];
+
+ for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) {
+ pairs.push(essentialPairs[i]);
+ }
+
+ let complementIndex = 0;
+ while (pairs.length < target && complementIndex < complementPool.length) {
+ pairs.push(complementPool[complementIndex]);
+ complementIndex += 1;
+ }
+
+ while (pairs.length < target) {
+ pairs.push(NumberMatchGame.generatePair());
+ }
+
+ return shuffle(pairs);
+ }
+
+ /**
+ * Pick a filler value that will not immediately create an accessible match.
+ * @param {Array} tiles Current partially filled board.
+ * @param {number} width Board width.
+ * @param {number} index Index to populate.
+ * @param {number|null} nextForcedValue Upcoming forced value, if one exists.
+ * @returns {number} Safe filler digit.
+ */
+ static pickSafeFillerValue(tiles, width, index, nextForcedValue) {
+ const attempt = (respectNext) => {
+ const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null);
+ const candidates = [];
+ for (let value = 1; value <= 9; value += 1) {
+ if (!forbidden.has(value)) {
+ candidates.push(value);
+ }
+ }
+ return candidates;
+ };
+
+ let candidates = attempt(true);
+ if (candidates.length === 0) {
+ candidates = attempt(false);
+ }
+
+ if (candidates.length === 0) {
+ return Math.floor(Math.random() * 9) + 1;
+ }
+
+ return candidates[Math.floor(Math.random() * candidates.length)];
+ }
+
+ /**
+ * Collect forbidden values based on already placed neighbors and optional future constraints.
+ * @param {Array} tiles Current board state.
+ * @param {number} width Board width.
+ * @param {number} index Target index.
+ * @param {number|null} nextForcedValue Forced value to appear next in sequence, if any.
+ * @returns {Set} Values that should not be used at the index.
+ */
+ static collectForbiddenValues(tiles, width, index, nextForcedValue) {
+ const forbidden = new Set();
+ const register = NumberMatchGame.registerForbiddenValue;
+
+ if (index > 0) {
+ register(forbidden, tiles[index - 1]);
+ }
+
+ const row = Math.floor(index / width);
+ const col = index % width;
+
+ if (row > 0) {
+ register(forbidden, tiles[index - width]);
+ if (col > 0) {
+ register(forbidden, tiles[index - width - 1]);
+ }
+ if (col < width - 1) {
+ register(forbidden, tiles[index - width + 1]);
+ }
+ }
+
+ if (typeof nextForcedValue === "number") {
+ register(forbidden, nextForcedValue);
+ }
+
+ return forbidden;
+ }
+
+ /**
+ * Register a value and its complement as forbidden for immediate placement.
+ * @param {Set} forbidden Collection of disallowed values.
+ * @param {number|null|undefined} value Value to mark as forbidden.
+ * @returns {void}
+ */
+ static registerForbiddenValue(forbidden, value) {
+ if (typeof value !== "number") {
+ return;
+ }
+
+ forbidden.add(value);
+ const complement = NumberMatchGame.getComplement(value);
+ forbidden.add(complement);
+ }
+
+ /**
+ * Compute the complement digit that forms a sum-to-ten relationship.
+ * @param {number} value Value between 1 and 9.
+ * @returns {number} Complement digit.
+ */
+ static getComplement(value) {
+ if (value === 5) {
+ return 5;
+ }
+ const complement = 10 - value;
+ return Math.min(9, Math.max(1, complement));
+ }
+
+ /**
+ * Build the reserve pile so that complements exist for non-matching board values.
+ * @param {Array} boardTiles Generated board.
+ * @param {number} boardCapacity Total board slots.
+ * @returns {number[]} Reserve tiles array.
+ */
+ static buildReserveTiles(boardTiles, boardCapacity) {
+ const reserve = [];
+
+ boardTiles.forEach((value) => {
+ if (typeof value !== "number") {
+ return;
+ }
+ reserve.push(NumberMatchGame.getComplement(value));
+ });
+
+ while (reserve.length < Math.ceil(boardCapacity * 1.1)) {
+ const [first, second] = NumberMatchGame.generatePair();
+ reserve.push(first, second);
+ }
+
+ return shuffle(reserve);
+ }
+}
+
+/**
+ * UI controller responsible for rendering and interactions.
+ */
+class NumberMatchUI {
+ /**
+ * @param {NumberMatchGame} game Game state instance.
+ */
+ constructor(game) {
+ this.game = game;
+ this.boardElement = /** @type {HTMLElement} */ (document.getElementById("board"));
+ this.messageElement = /** @type {HTMLElement} */ (document.getElementById("message"));
+ this.matchesElement = /** @type {HTMLElement} */ (document.getElementById("matchesCount"));
+ this.tilesRemainingElement = /** @type {HTMLElement} */ (document.getElementById("tilesRemaining"));
+ this.addNumbersElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersUsed"));
+ this.addNumbersBadgeElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersCounter"));
+ this.hintsElement = /** @type {HTMLElement} */ (document.getElementById("hintsUsed"));
+ this.addNumbersButton = /** @type {HTMLButtonElement} */ (document.getElementById("addNumbersButton"));
+ this.hintButton = /** @type {HTMLButtonElement} */ (document.getElementById("hintButton"));
+ this.newGameButton = /** @type {HTMLButtonElement} */ (document.getElementById("newGameButton"));
+
+ this.selectedIndices = [];
+ this.matchesCleared = 0;
+ this.addNumbersUsed = 0;
+ this.hintsUsed = 0;
+ this.highlightedIndices = [];
+ this.dragStartIndex = null;
+ this.isDragging = false;
+ }
+
+ /**
+ * Initialize event listeners and render the starting board.
+ * @returns {void}
+ */
+ init() {
+ this.bindControlEvents();
+ this.renderBoard();
+ this.updateMetrics();
+ this.showMessage("Select two tiles that match or sum to ten.");
+ }
+
+ /**
+ * Attach event listeners to control buttons.
+ * @returns {void}
+ */
+ bindControlEvents() {
+ this.addNumbersButton.addEventListener("click", () => {
+ const remainingUses = NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed;
+ const appended = this.game.addNumbers(remainingUses);
+ if (appended) {
+ if (remainingUses > 0) {
+ this.addNumbersUsed += 1;
+ }
+ this.showMessage("Numbers duplicated to the end of the grid.", "success");
+ this.selectedIndices = [];
+ this.clearHighlights();
+ this.renderBoard();
+ this.updateMetrics();
+ } else {
+ this.showMessage("No numbers remain to duplicate.", "error");
+ this.updateAddNumbersPrompt();
+ }
+ });
+
+ this.hintButton.addEventListener("click", () => {
+ const hint = this.findHintPair();
+ if (!hint) {
+ this.showMessage("No matches available. Try adding numbers.", "error");
+ this.updateAddNumbersPrompt();
+ return;
+ }
+ this.selectedIndices = [];
+ this.highlightedIndices = hint;
+ this.hintsUsed += 1;
+ this.renderBoard();
+ this.updateMetrics();
+ this.showMessage("Try matching the highlighted tiles.");
+ });
+
+ this.newGameButton.addEventListener("click", () => {
+ this.startNewGame();
+ });
+ }
+
+ /**
+ * Handle tile clicks and manage selection/matching lifecycle.
+ * @param {number} index Tile index that was clicked.
+ * @returns {void}
+ */
+ handleTileClick(index) {
+ if (this.isDragging) {
+ return;
+ }
+ const tiles = this.game.getTiles();
+ this.clearHighlights();
+ if (tiles[index] === null) {
+ return;
+ }
+
+ if (this.selectedIndices.includes(index)) {
+ this.selectedIndices = this.selectedIndices.filter((item) => item !== index);
+ if (this.selectedIndices.length === 1) {
+ this.highlightCompatibleTiles(this.selectedIndices[0]);
+ }
+ this.renderBoard();
+ return;
+ }
+
+ this.selectedIndices.push(index);
+
+ if (this.selectedIndices.length < 2) {
+ this.highlightCompatibleTiles(index);
+ this.renderBoard();
+ return;
+ }
+
+ this.renderBoard();
+ this.clearHighlights();
+ const [first, second] = this.selectedIndices;
+ const didMatch = this.game.selectPair(first, second);
+ if (didMatch) {
+ this.matchesCleared += 1;
+ this.selectedIndices = [];
+ this.renderBoard();
+ this.updateMetrics();
+ this.showMessage("Great match!", "success");
+ if (this.game.isComplete()) {
+ this.showMessage("Board cleared! Start a new game when you are ready.", "success");
+ this.addNumbersButton.disabled = true;
+ this.hintButton.disabled = true;
+ this.clearHighlights();
+ }
+ return;
+ }
+
+ this.selectedIndices = [second];
+ this.highlightCompatibleTiles(second);
+ this.renderBoard();
+ }
+
+ /**
+ * Handle pointer down events for drag-to-match functionality.
+ * @param {number} index Tile index where pointer down occurred.
+ * @returns {void}
+ */
+ handleTilePointerDown(index, event) {
+ const tiles = this.game.getTiles();
+ if (tiles[index] === null) {
+ return;
+ }
+ this.dragStartIndex = index;
+ this.isDragging = false;
+ event.target.setPointerCapture(event.pointerId);
+ }
+
+ /**
+ * Handle pointer enter events during drag.
+ * @param {number} index Tile index being entered.
+ * @returns {void}
+ */
+ handleTilePointerEnter(index, event) {
+ if (this.dragStartIndex === null || this.dragStartIndex === undefined) {
+ return;
+ }
+ const tiles = this.game.getTiles();
+ if (tiles[index] === null) {
+ return;
+ }
+ if (index === this.dragStartIndex) {
+ return;
+ }
+
+ if (event.pressure > 0 || event.buttons > 0) {
+ this.isDragging = true;
+ const didMatch = this.game.selectPair(this.dragStartIndex, index);
+ if (didMatch) {
+ this.matchesCleared += 1;
+ this.selectedIndices = [];
+ this.clearHighlights();
+ this.dragStartIndex = null;
+ this.renderBoard();
+ this.updateMetrics();
+ this.showMessage("Great match!", "success");
+ if (this.game.isComplete()) {
+ this.showMessage("Board cleared! Start a new game when you are ready.", "success");
+ this.addNumbersButton.disabled = true;
+ this.hintButton.disabled = true;
+ this.clearHighlights();
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle pointer up events to reset drag state.
+ * @returns {void}
+ */
+ handleTilePointerUp() {
+ this.dragStartIndex = null;
+ setTimeout(() => {
+ this.isDragging = false;
+ }, 10);
+ }
+
+ /**
+ * Render the board based on the current game state.
+ * @returns {void}
+ */
+ renderBoard() {
+ const tiles = this.game.getTiles();
+ this.boardElement.innerHTML = "";
+ this.boardElement.style.gridTemplateColumns = `repeat(${this.game.width}, minmax(0, 1fr))`;
+ tiles.forEach((value, index) => {
+ const tile = document.createElement("button");
+ tile.type = "button";
+ tile.className = "tile";
+ tile.dataset.index = index.toString();
+ if (value === null) {
+ tile.classList.add("tile--empty");
+ tile.disabled = true;
+ tile.setAttribute("aria-hidden", "true");
+ } else {
+ tile.textContent = String(value);
+ tile.addEventListener("click", () => this.handleTileClick(index));
+ tile.addEventListener("pointerdown", (e) => this.handleTilePointerDown(index, e));
+ tile.addEventListener("pointermove", (e) => this.handleTilePointerEnter(index, e));
+ tile.addEventListener("pointerup", () => this.handleTilePointerUp());
+ tile.addEventListener("pointercancel", () => this.handleTilePointerUp());
+ tile.style.touchAction = "none";
+ }
+ if (this.selectedIndices.includes(index)) {
+ tile.classList.add("tile--selected");
+ }
+ if (this.highlightedIndices.includes(index)) {
+ tile.classList.add("tile--hinted");
+ }
+ this.boardElement.appendChild(tile);
+ });
+ this.updateAddNumbersPrompt();
+ }
+
+ /**
+ * Update score-related UI metrics.
+ * @returns {void}
+ */
+ updateMetrics() {
+ this.matchesElement.textContent = String(this.matchesCleared);
+ this.tilesRemainingElement.textContent = String(this.game.getRemainingCount());
+ this.addNumbersElement.textContent = `${this.addNumbersUsed}/${NumberMatchUI.ADD_NUMBERS_LIMIT}`;
+ this.hintsElement.textContent = String(this.hintsUsed);
+ this.updateAddNumbersBadge();
+ }
+
+ /**
+ * Reflect remaining Add Numbers uses in the badge.
+ * @returns {void}
+ */
+ updateAddNumbersBadge() {
+ const remaining = Math.max(0, NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed);
+ if (this.addNumbersBadgeElement) {
+ this.addNumbersBadgeElement.textContent = String(remaining);
+ this.addNumbersBadgeElement.classList.toggle("board-action__badge--empty", remaining === 0);
+ }
+ }
+
+ /**
+ * Highlight tiles compatible with the currently selected tile.
+ * @param {number} baseIndex Index of the selected tile.
+ * @returns {void}
+ */
+ highlightCompatibleTiles(baseIndex) {
+ const tiles = this.game.getTiles();
+ const baseValue = tiles[baseIndex];
+ if (baseValue === null) {
+ this.highlightedIndices = [];
+ return;
+ }
+
+ const matches = [];
+ for (let i = 0; i < tiles.length; i += 1) {
+ if (i === baseIndex) {
+ continue;
+ }
+ const value = tiles[i];
+ if (value === null) {
+ continue;
+ }
+ if (value === baseValue || value + baseValue === 10) {
+ if (this.game.canPair(baseIndex, i)) {
+ matches.push(i);
+ }
+ }
+ }
+ this.highlightedIndices = matches;
+ }
+
+ /**
+ * Clear any highlighted tile indices.
+ * @returns {void}
+ */
+ clearHighlights() {
+ this.highlightedIndices = [];
+ }
+
+ /**
+ * Display a status message with optional styling.
+ * @param {string} message Message to display.
+ * @param {"success"|"error"|"info"} [type="info"] Message type for styling.
+ * @returns {void}
+ */
+ showMessage(message, type = "info") {
+ this.messageElement.textContent = message;
+ this.messageElement.classList.remove("status__message--error", "status__message--success");
+ if (type === "success") {
+ this.messageElement.classList.add("status__message--success");
+ } else if (type === "error") {
+ this.messageElement.classList.add("status__message--error");
+ }
+ }
+
+ /**
+ * Reset state and begin a new puzzle.
+ * @returns {void}
+ */
+ startNewGame() {
+ this.game.reset();
+ this.matchesCleared = 0;
+ this.addNumbersUsed = 0;
+ this.hintsUsed = 0;
+ this.selectedIndices = [];
+ this.dragStartIndex = null;
+ this.addNumbersButton.disabled = false;
+ this.hintButton.disabled = false;
+ this.clearHighlights();
+ this.renderBoard();
+ this.updateMetrics();
+ this.showMessage("New puzzle loaded. Good luck!", "success");
+ }
+
+ /**
+ * Determine whether any valid matches remain on the board.
+ * @returns {boolean} True if at least one valid pair exists.
+ */
+ hasAvailableMoves() {
+ return NumberMatchUI.findFirstValidPair(this.game) !== null;
+ }
+
+ /**
+ * Update Add Numbers control state, including glow cues and disabling logic.
+ * @returns {void}
+ */
+ updateAddNumbersPrompt() {
+ const hasNumbersRemaining = this.game.getRemainingCount() > 0;
+ const activeAction = hasNumbersRemaining && !this.game.isComplete();
+ this.addNumbersButton.disabled = !activeAction;
+
+ const noMoves = !this.hasAvailableMoves();
+ const shouldGlow = noMoves && hasNumbersRemaining && activeAction;
+ this.addNumbersButton.classList.toggle("board-action--attention", shouldGlow);
+ this.updateAddNumbersBadge();
+ }
+
+ /**
+ * Find a valid pair of tiles for hint functionality.
+ * @returns {[number, number] | null} Tuple of indices or null when no match exists.
+ */
+ findHintPair() {
+ return NumberMatchUI.findFirstValidPair(this.game);
+ }
+
+ /**
+ * Compute the first valid pair of tiles available.
+ * @param {NumberMatchGame} game Game state instance to inspect.
+ * @returns {[number, number] | null} Indices representing a valid pair or null.
+ */
+ static findFirstValidPair(game) {
+ const tiles = game.getTiles();
+ for (let i = 0; i < tiles.length; i += 1) {
+ if (tiles[i] === null) {
+ continue;
+ }
+ for (let j = i + 1; j < tiles.length; j += 1) {
+ if (tiles[j] === null) {
+ continue;
+ }
+ if (game.canPair(i, j)) {
+ return [i, j];
+ }
+ }
+ }
+ return null;
+ }
+}
+
+NumberMatchUI.ADD_NUMBERS_LIMIT = 3;
+
+window.addEventListener("DOMContentLoaded", () => {
+ const game = new NumberMatchGame({ width: 9, rows: 6 });
+ const ui = new NumberMatchUI(game);
+ ui.init();
+});
diff --git a/src/numbermatchstyle.css b/src/numbermatchstyle.css
new file mode 100644
index 0000000..c698b2a
--- /dev/null
+++ b/src/numbermatchstyle.css
@@ -0,0 +1,313 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+ background-color: #0f172a;
+ color: #e2e8f0;
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: stretch;
+ padding: 1.5rem;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+.app {
+ display: grid;
+ gap: 2rem;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
+ width: min(1100px, 100%);
+}
+
+.app__game,
+.app__info {
+ background-color: #1e293b;
+ border-radius: 1rem;
+ padding: 1.5rem;
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
+}
+
+.app__title {
+ margin: 0;
+ font-size: 2rem;
+ font-weight: 700;
+}
+
+.app__subtitle {
+ margin: 0.25rem 0 0;
+ color: #94a3b8;
+}
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin: 1.5rem 0;
+}
+
+.control-button {
+ background: linear-gradient(135deg, #22d3ee, #2563eb);
+ border: none;
+ border-radius: 999px;
+ color: #0f172a;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 600;
+ padding: 0.75rem 1.5rem;
+ transition: transform 120ms ease, box-shadow 120ms ease;
+}
+
+.control-button:hover,
+.control-button:focus {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 25px rgba(37, 99, 235, 0.45);
+}
+
+.control-button:active {
+ transform: translateY(0);
+ box-shadow: none;
+}
+
+.control-button--secondary {
+ background: #334155;
+ color: #e2e8f0;
+}
+
+.status {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.status__message {
+ min-height: 1.5rem;
+ margin: 0;
+ color: #f8fafc;
+ font-weight: 600;
+}
+
+.status__message.status__message--error {
+ color: #f87171;
+}
+
+.status__message.status__message--success {
+ color: #4ade80;
+}
+
+.status__metrics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ color: #cbd5f5;
+}
+
+.status__metric {
+ font-size: 0.95rem;
+}
+
+.board {
+ margin-top: 1.5rem;
+ display: grid;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.board-actions {
+ margin-top: 1.25rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.board-action {
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: 50%;
+ border: none;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 2rem;
+ font-weight: 700;
+ cursor: pointer;
+ color: #0f172a;
+ transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
+ position: relative;
+}
+
+.board-action:hover,
+.board-action:focus {
+ transform: translateY(-2px);
+ box-shadow: 0 16px 30px rgba(15, 23, 42, 0.35);
+}
+
+.board-action:active {
+ transform: translateY(0);
+ box-shadow: none;
+}
+
+.board-action--primary {
+ background: linear-gradient(135deg, #22d3ee, #2563eb);
+}
+
+.board-action--secondary {
+ background: #e2e8f0;
+ color: #0f172a;
+}
+
+.board-action__icon {
+ line-height: 1;
+}
+
+.board-action__badge {
+ position: absolute;
+ top: -0.35rem;
+ right: -0.35rem;
+ min-width: 1.5rem;
+ padding: 0.15rem 0.4rem;
+ border-radius: 999px;
+ background: #0ea5e9;
+ color: #0f172a;
+ font-size: 0.85rem;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 6px 14px rgba(14, 165, 233, 0.45);
+}
+
+.board-action__badge.board-action__badge--empty {
+ background: #cbd5f5;
+ color: #475569;
+ box-shadow: none;
+}
+
+.board-action--attention {
+ box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.4), 0 18px 36px rgba(8, 145, 178, 0.45);
+ animation: board-action-pulse 0.9s ease-in-out infinite alternate;
+}
+
+@keyframes board-action-pulse {
+ from {
+ transform: translateY(-2px) scale(1);
+ }
+ to {
+ transform: translateY(-3px) scale(1.05);
+ }
+}
+
+.tile {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ border-radius: 0.75rem;
+ border: none;
+ font-size: clamp(1.125rem, 4vw, 1.5rem);
+ font-weight: 700;
+ color: #0f172a;
+ background: linear-gradient(145deg, #f1f5f9, #cbd5f5);
+ cursor: pointer;
+ transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.tile:hover,
+.tile:focus {
+ transform: translateY(-2px);
+ box-shadow: 0 14px 25px rgba(15, 23, 42, 0.35);
+}
+
+.tile--selected {
+ background: linear-gradient(145deg, #fde68a, #f97316);
+ box-shadow: 0 16px 35px rgba(251, 191, 36, 0.4);
+}
+
+.tile--hinted:not(.tile--selected) {
+ background: linear-gradient(145deg, #bfdbfe, #93c5fd);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5), 0 12px 24px rgba(37, 99, 235, 0.35);
+}
+
+.tile--empty {
+ background: rgba(148, 163, 184, 0.25);
+ border: 1px dashed rgba(148, 163, 184, 0.45);
+ cursor: default;
+ box-shadow: none;
+}
+
+.tile--empty:hover,
+.tile--empty:focus {
+ transform: none;
+}
+
+.app__info h2,
+.app__info h3 {
+ margin-top: 0;
+ color: #f8fafc;
+}
+
+.app__info ul {
+ padding-left: 1.25rem;
+ margin: 0.75rem 0 1rem;
+}
+
+.app__info li {
+ margin-bottom: 0.5rem;
+}
+
+.app__info p {
+ color: #cbd5f5;
+ line-height: 1.5;
+}
+
+@media (max-width: 960px) {
+ body {
+ padding: 0;
+ }
+
+ .app {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 1.25rem;
+ }
+
+ .app__info {
+ order: 1;
+ margin-top: 0;
+ }
+
+ .board {
+ gap: 0.35rem;
+ }
+
+ .board-actions {
+ margin-top: 1rem;
+ gap: 0.5rem;
+ }
+
+ .board-action {
+ width: 3.1rem;
+ height: 3.1rem;
+ font-size: 1.75rem;
+ }
+
+ .board-action__badge {
+ top: -0.25rem;
+ right: -0.25rem;
+ min-width: 1.35rem;
+ font-size: 0.75rem;
+ }
+}