diff --git a/README.md b/README.md
index 9528d7a..dd055f8 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# @vimazing/vim-sudoku
-Lightweight, typed **React hooks** and utilities for building interactive sudoku games with VIM-inspired controls.
+Lightweight, typed **React hooks** and utilities for building interactive sudoku games with **VIM-style modal editing**.
Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project.
@@ -10,8 +10,9 @@ Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project.
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
+- [VIM Modal Editing](#vim-modal-editing)
- [Core Hooks](#core-hooks)
-- [Utilities](#utilities)
+- [Game Mechanics](#game-mechanics)
- [Example App](#example-app)
- [Build & Release](#build--release)
- [License](#license)
@@ -19,11 +20,15 @@ Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project.
---
## Features
-- **Drop-in hooks** for sudoku game logic, player control, scoring, and key logging.
-- **Typed API** with generated declaration files for editor IntelliSense.
-- **VIM-style navigation** (`h`, `j`, `k`, `l`, counts, `i` to start, etc.).
-- **Composable architecture** – bring your own rendering and platform-specific bindings.
-- **Tree-shakeable exports** to keep bundles lean.
+- 🎮 **VIM modal editing** – Navigate in normal mode, edit cells in insert mode (R/C to enter)
+- ⌨️ **Full VIM motions** – `h`/`j`/`k`/`l`, counted moves, `^`/`$`/`0` for row navigation
+- 🎯 **Smart cell locking** – Pre-filled clues are protected with visual feedback
+- ⏱️ **Time & keystroke limits** – Configurable game-over conditions
+- 📊 **Built-in scoring** – Time-based scoring with keystroke tracking
+- 🎨 **Tokyo Night theme** – Beautiful dark theme with 3x3 box separators
+- 📦 **Typed API** – Full TypeScript support with generated declarations
+- 🪝 **Composable hooks** – Separate concerns for game state, rendering, input, and scoring
+- 🌲 **Tree-shakeable exports** – Import only what you need
---
@@ -46,65 +51,187 @@ bun add @vimazing/vim-sudoku
## Quick Start
```tsx
-import { useEffect } from "react";
import { useGame } from "@vimazing/vim-sudoku";
+import "@vimazing/vim-sudoku/sudoku.css";
export function SudokuGame() {
- const { containerRef, gameStatus, startGame } = useGame();
-
- useEffect(() => {
- startGame();
- }, [startGame]);
+ const { containerRef, initGame, gameStatus, scoreManager } = useGame();
return (
-
- VIMazing Sudoku
-
- {gameStatus === "game-won" && 🎉 You solved it!
}
-
+
+
VIMazing Sudoku
+
+
+
+
+ {gameStatus === "game-won" && (
+
🎉 Score: {scoreManager.finalScore?.toLocaleString()}
+ )}
+
);
}
```
-Default controls:
+---
-| Key | Action |
-| ------------------- | --------------- |
-| `i` | Start game |
-| `h` / `j` / `k` / `l` | Move cursor |
-| `` | Enter number |
-| `x` / `d` | Clear cell |
-| `q` | Quit game |
-| `Esc` | Cancel / pause |
+## VIM Modal Editing
+
+VIMazing Sudoku follows VIM's modal editing philosophy with strict insert/replace semantics:
+
+### Navigation Mode (default)
+
+#### Movement
+
+| Key | Action | Notes |
+| --- | ------ | ----- |
+| `h` / `j` / `k` / `l` | Move cursor left/down/up/right | Core VIM navigation |
+| `h/j/k/l` | Move with count | e.g., `3j` = down 3, `5l` = right 5 |
+| `0` / `^` | Jump to start of row | Move to column 0 |
+| `$` | Jump to end of row | Move to column 8 |
+| `.` | Repeat last motion | Repeat with optional new count |
+
+#### Editing
+
+| Key | Action | Valid On |
+| --- | ------ | -------- |
+| `I` | Enter insert mode | ✅ Empty cells only |
+| `R` | Enter replace mode | ✅ User-filled cells only |
+| `D` / `X` | Delete cell value | ✅ User-filled cells only |
+
+#### Game Control
+
+| Key | Action |
+| --- | ------ |
+| `Q` | Quit game |
+
+### Edit Mode (I/R to enter)
+
+| Key | Action |
+| --- | ------ |
+| `1-9` | Fill cell with number |
+| `x` / `Backspace` | Clear cell |
+| `Esc` / `Enter` | Exit edit mode back to navigation |
+
+---
+
+## Cell State Rules
+
+Understanding cell states is crucial for VIM-style editing:
+
+| Cell State | `I` (Insert) | `R` (Replace) | `D`/`X` (Delete) | Visual |
+| ---------- | ------------ | ------------- | ---------------- | ------ |
+| **Empty** (`.`) | ✅ Enter edit | 🔴 Flash (use I) | ➖ No effect | Normal |
+| **User-filled** | 🔴 Flash (use R) | ✅ Enter edit | ✅ Delete | Normal |
+| **Pre-filled clue** | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | Darker text |
+
+### Visual Feedback
+
+- 🔵 **Blue glow** – Navigation mode (current cell)
+- 🟡 **Yellow pulsing glow** – Edit mode (typing active)
+- 🔴 **Red flash** – Invalid action (wrong mode, locked cell, or out of bounds)
+
+### Examples
+
+```
+Empty cell: Press I → type 5 → Esc
+Replace value: Press R → type 7 → Esc
+Delete value: Press D (instant)
+Move 3 down: Press 3j
+Jump to end: Press $
+```
---
## Core Hooks
-| Hook | Description |
-| ---- | ----------- |
-| `useGame(platformHook?)` | One-stop hook that wires sudoku generation, rendering, player state, scoring, and keyboard bindings. Returns refs, status helpers, and managers you can compose with your UI. |
-| `useKeyBindings()` | Provides VIM-style key bindings, including repeat counts and movement helpers. |
-| `useScore()` | Tracks timers, moves, and final score calculation. |
+### `useGame(platformHook?)`
-Each hook is exported individually, so you can cherry-pick only what you need:
+Main hook that orchestrates the entire sudoku game. Returns:
+
+```ts
+{
+ // Core
+ containerRef: RefObject,
+ gameStatus: GameStatus, // "waiting" | "started" | "game-won" | "game-over"
+ startGame: () => void,
+ stopGame: () => void,
+ initGame: () => void, // Reset board + start
+
+ // Cursor
+ cursor: { r: number; c: number },
+ moveCursor: (dr, dc, steps?) => void,
+
+ // Board
+ board: string, // 81-character string
+ initialBoard: string, // Original clues
+ setDigit: (digit) => void,
+ erase: () => void,
+ reset: () => void,
+ solveBoard: () => void,
+ boardToString: (human?) => string, // Format board for display
+
+ // Scoring
+ scoreManager: {
+ timeValue: number, // Time in milliseconds
+ finalScore: number | null,
+ timeLimit: number, // 60000ms (60 seconds)
+ keystrokeLimit: number, // 100 keystrokes
+ },
+
+ // Key logging
+ keyLog: KeyLogEntry[],
+ editMode: "navigation" | "edit",
+}
+```
+
+### Individual Hooks
+
+Export individual hooks for granular control:
```ts
import { useScore } from "@vimazing/vim-sudoku/useScore";
+import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState";
```
---
-## Utilities
+## Game Mechanics
-Besides the hooks, the library exports:
+### Scoring
-- `SudokuGenerator` – procedural sudoku puzzle generation class.
-- `SudokuRenderer` – canvas renderer tailored for sudoku grids.
-- `types` – shared TypeScript types such as `GameStatus`, `SudokuCell`, and `KeyLogEntry`.
+Time-based scoring formula:
+```
+score = max(0, 100000 - timeInSeconds × 10)
+```
+
+### Game Over Conditions
+
+- ⏱️ **Time limit**: 60 seconds
+- ⌨️ **Keystroke limit**: 100 keystrokes
+
+Customize in `src/useScore/useScore.ts`:
+```ts
+const TIME_LIMIT_MS = 60000;
+const KEYSTROKE_LIMIT = 100;
+```
+
+### Win Detection
+
+Currently manual (solving via UI or `solveBoard()`). Automatic win detection coming soon!
+
+### Board Format
```ts
-import { SudokuGenerator, GameStatus } from "@vimazing/vim-sudoku";
+// Computer format (compact)
+boardToString() // "628.1...535928671..."
+
+// Human format (readable)
+boardToString(true)
+// 6 2 8 | 1 . . | . 5 .
+// 5 3 9 | 2 4 8 | 6 7 1
+// ...
```
---
diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts
index 4ec3a28..9290af4 100644
--- a/example/src/useKeyBindings.ts
+++ b/example/src/useKeyBindings.ts
@@ -1,21 +1,22 @@
import { useEffect } from "react";
-import type { UseGameType } from "../../src/useGame/useGame";
+import type { UseGameType } from "../../src/";
export const useKeyBindings = (gameManager: UseGameType) => {
const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
+ console.log(e.key);
// start game
- if (e.key === "i" || e.key === "I") {
+ if (e.code === "Space") {
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
clearLog();
resetCount();
if (gameStatus === "game-over" || gameStatus === "game-won") {
stopGame();
- setTimeout(() => initGame(true), 0);
+ setTimeout(() => initGame(), 0);
} else {
- initGame(true);
+ initGame();
}
return;
}
diff --git a/example/src/GameOverlay.tsx b/src/GameOverlay.tsx
similarity index 100%
rename from example/src/GameOverlay.tsx
rename to src/GameOverlay.tsx
diff --git a/example/src/MobileGameOverlay.tsx b/src/MobileGameOverlay.tsx
similarity index 100%
rename from example/src/MobileGameOverlay.tsx
rename to src/MobileGameOverlay.tsx
diff --git a/src/sudoku.css b/src/sudoku.css
index 11131c0..50469ff 100644
--- a/src/sudoku.css
+++ b/src/sudoku.css
@@ -1,20 +1,27 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
+/* :root { */
+/* --cell-size: 32px; */
+/* --cell-font-size: 16px; */
+/* --grid-gap: 2px; */
+/* --box-separator: 3px; */
+/* } */
+
:root {
- --cell-size: 32px;
- --cell-font-size: 16px;
- --grid-gap: 2px;
- --box-separator: 3px;
+ --cell-size: 48px;
+ --cell-font-size: 32px;
+ --grid-gap: 4px;
+ --box-separator: 6px;
}
-:root {
- --cell-size: 96px;
- --cell-font-size: 48px;
- --grid-gap: 6px;
- --box-separator: 9px;
-}
+/* :root { */
+/* --cell-size: 96px; */
+/* --cell-font-size: 48px; */
+/* --grid-gap: 6px; */
+/* --box-separator: 9px; */
+/* } */
/* =========================================================
Container & Grid Base
@@ -27,7 +34,7 @@
#sudoku {
position: relative;
- background-color: #1e2030; /* Tokyo Night card background */
+ background-color: var(--card, #1e2030);
border-radius: 0.5rem;
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05);
padding: 0.5rem;
@@ -64,8 +71,8 @@
border-radius: 4px;
font-family: "Fira Code", monospace;
font-size: var(--cell-font-size);
- background: #2f334d; /* Tokyo Night muted */
- color: #c8d3f5; /* Tokyo Night foreground */
+ background: var(--muted, #2f334d);
+ color: var(--foreground, #c8d3f5);
transition: all 0.15s ease;
box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
}
@@ -77,35 +84,35 @@
#sudoku .sudoku-cell[data-c="2"],
#sudoku .sudoku-cell[data-c="5"],
#sudoku .sudoku-cell[data-c="8"] {
- border-right: 2px solid rgba(130, 170, 255, 0.3);
+ border-right: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
}
/* Left borders (start of each 3x3 box horizontally) */
#sudoku .sudoku-cell[data-c="0"],
#sudoku .sudoku-cell[data-c="3"],
#sudoku .sudoku-cell[data-c="6"] {
- border-left: 2px solid rgba(130, 170, 255, 0.3);
+ border-left: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
}
/* Bottom borders (end of each 3x3 box vertically) */
#sudoku .sudoku-cell[data-r="2"],
#sudoku .sudoku-cell[data-r="5"],
#sudoku .sudoku-cell[data-r="8"] {
- border-bottom: 2px solid rgba(130, 170, 255, 0.3);
+ border-bottom: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
}
/* Top borders (start of each 3x3 box vertically) */
#sudoku .sudoku-cell[data-r="0"],
#sudoku .sudoku-cell[data-r="3"],
#sudoku .sudoku-cell[data-r="6"] {
- border-top: 2px solid rgba(130, 170, 255, 0.3);
+ border-top: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
}
/* =========================================================
User-Entered Cells
========================================================= */
#sudoku .sudoku-cell.user-entered {
- color: #82aaff; /* Tokyo Night primary blue */
+ color: var(--primary, #82aaff);
font-weight: 600;
}
@@ -113,41 +120,41 @@
Active Cell Highlight (Navigation Mode)
========================================================= */
#sudoku .sudoku-cell.active {
- outline: 2px solid #82aaff; /* Tokyo Night blue */
+ outline: 2px solid var(--primary, #82aaff);
outline-offset: -2px;
- background: #3d4269;
+ background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
box-shadow:
- 0 0 8px rgba(130, 170, 255, 0.4),
- 0 0 12px rgba(130, 170, 255, 0.2),
- inset 0 0 6px rgba(130, 170, 255, 0.15);
+ 0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent),
+ 0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent),
+ inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent);
}
/* =========================================================
Editing Cell Highlight (Edit Mode)
========================================================= */
#sudoku .sudoku-cell.editing {
- outline: 2px solid #ffc777; /* Tokyo Night yellow */
+ outline: 2px solid var(--accent, #ffc777);
outline-offset: -2px;
- background: #3d3836;
+ background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
box-shadow:
- 0 0 16px rgba(255, 199, 119, 0.7),
- 0 0 24px rgba(255, 199, 119, 0.4),
- inset 0 0 12px rgba(255, 199, 119, 0.3);
+ 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
+ 0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
animation: editing-cell-glow 1.5s ease-in-out infinite;
}
@keyframes editing-cell-glow {
0%, 100% {
box-shadow:
- 0 0 16px rgba(255, 199, 119, 0.7),
- 0 0 24px rgba(255, 199, 119, 0.4),
- inset 0 0 12px rgba(255, 199, 119, 0.3);
+ 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
+ 0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
}
50% {
box-shadow:
- 0 0 20px rgba(255, 199, 119, 0.9),
- 0 0 32px rgba(255, 199, 119, 0.6),
- inset 0 0 16px rgba(255, 199, 119, 0.4);
+ 0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent),
+ 0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent),
+ inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
}
}
@@ -158,16 +165,16 @@
#sudoku .sudoku-cell.invalid-move {
animation: sudoku-invalid-pulse 180ms ease-out;
box-shadow:
- 0 0 12px rgba(255, 117, 127, 0.85),
- 0 0 24px rgba(255, 117, 127, 0.45);
+ 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
+ 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
}
@keyframes sudoku-invalid-pulse {
0% {
transform: scale(1.05);
box-shadow:
- 0 0 16px rgba(255, 117, 127, 0.95),
- 0 0 28px rgba(255, 117, 127, 0.55);
+ 0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
+ 0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
}
100% {
transform: none;
@@ -206,11 +213,59 @@
/* Trail effect for moves */
#sudoku .sudoku-cell.trail {
- box-shadow: inset 0 0 6px rgba(130, 170, 255, 0.6), 0 0 6px rgba(130, 170, 255, 0.35);
+ box-shadow:
+ inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 60%, transparent),
+ 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent);
animation: sudoku-trail-fade 220ms ease-out;
}
@keyframes sudoku-trail-fade {
- from { background-color: rgba(130, 170, 255, 0.25); }
+ from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); }
to { background-color: transparent; }
}
+
+/* Valid move flash */
+#sudoku .sudoku-cell.valid-move {
+ animation: sudoku-valid-flash 300ms ease-out;
+}
+
+@keyframes sudoku-valid-flash {
+ 0% {
+ background: color-mix(in srgb, var(--success, #4fd6be) 40%, transparent);
+ box-shadow:
+ 0 0 12px color-mix(in srgb, var(--success, #4fd6be) 80%, transparent),
+ 0 0 20px color-mix(in srgb, var(--success, #4fd6be) 50%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--success, #4fd6be) 35%, transparent);
+ }
+ 100% {
+ background: transparent;
+ box-shadow: none;
+ }
+}
+
+/* Conflict highlighting */
+#sudoku .sudoku-cell.conflict {
+ outline: 2px solid var(--destructive, #c53b53);
+ outline-offset: -2px;
+ background: color-mix(in srgb, var(--destructive, #c53b53) 25%, var(--muted, #2f334d));
+ box-shadow:
+ 0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
+ 0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
+ animation: sudoku-conflict-pulse 1s ease-in-out infinite;
+}
+
+@keyframes sudoku-conflict-pulse {
+ 0%, 100% {
+ box-shadow:
+ 0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
+ 0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
+ inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
+ }
+ 50% {
+ box-shadow:
+ 0 0 14px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
+ 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent),
+ inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent);
+ }
+}
diff --git a/src/useGame/useBoardState.ts b/src/useGame/useBoardState.ts
index c414e0c..8bf2e02 100644
--- a/src/useGame/useBoardState.ts
+++ b/src/useGame/useBoardState.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useMemo } from "react";
import { sudoku } from "../lib/Sudoku";
import type { UseCursorType } from "./useCursor";
@@ -8,40 +8,190 @@ export function useBoardState(cursorManager: UseCursorType) {
const [initialBoard, setInitialBoard] = useState(board);
const [solution, setSolution] = useState(null);
- const setDigit = useCallback(
- (d: string) => {
- const i = cursor.r * 9 + cursor.c;
- const arr = board.split("");
- arr[i] = d;
- setBoard(arr.join(""));
- },
- [cursor, board]
- );
+ const givenCells = () => {
+ const cells = new Set();
+ for (let i = 0; i < initialBoard.length; i++) {
+ if (initialBoard[i] !== ".") {
+ cells.add(i);
+ }
+ }
+ return cells;
+ };
+
+ const canEditCell = (row: number, col: number): boolean => {
+ const index = row * 9 + col;
+ return !givenCells().has(index);
+ };
+
+ const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
+ if (digit === ".") return true;
+
+ const idx = row * 9 + col;
+
+ for (let c = 0; c < 9; c++) {
+ const checkIdx = row * 9 + c;
+ if (c !== col && testBoard[checkIdx] === digit) {
+ return false;
+ }
+ }
+
+ for (let r = 0; r < 9; r++) {
+ const checkIdx = r * 9 + col;
+ if (r !== row && testBoard[checkIdx] === digit) {
+ return false;
+ }
+ }
+
+ const boxRow = Math.floor(row / 3) * 3;
+ const boxCol = Math.floor(col / 3) * 3;
+ for (let r = boxRow; r < boxRow + 3; r++) {
+ for (let c = boxCol; c < boxCol + 3; c++) {
+ const checkIdx = r * 9 + c;
+ if ((r !== row || c !== col) && testBoard[checkIdx] === digit) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ };
+
+ const getConflicts = (testBoard: string, row: number, col: number): number[] => {
+ const conflicts: number[] = [];
+ const digit = testBoard[row * 9 + col];
+
+ if (digit === ".") return conflicts;
+
+ for (let c = 0; c < 9; c++) {
+ if (c !== col && testBoard[row * 9 + c] === digit) {
+ conflicts.push(row * 9 + c);
+ }
+ }
+
+ for (let r = 0; r < 9; r++) {
+ if (r !== row && testBoard[r * 9 + col] === digit) {
+ conflicts.push(r * 9 + col);
+ }
+ }
+
+ const boxRow = Math.floor(row / 3) * 3;
+ const boxCol = Math.floor(col / 3) * 3;
+ for (let r = boxRow; r < boxRow + 3; r++) {
+ for (let c = boxCol; c < boxCol + 3; c++) {
+ if ((r !== row || c !== col) && testBoard[r * 9 + c] === digit) {
+ const idx = r * 9 + c;
+ if (!conflicts.includes(idx)) {
+ conflicts.push(idx);
+ }
+ }
+ }
+ }
+
+ return conflicts;
+ };
+
+ const isFullyValid = (testBoard: string): boolean => {
+ for (let r = 0; r < 9; r++) {
+ let rowSum = 0;
+ for (let c = 0; c < 9; c++) {
+ const digit = testBoard[r * 9 + c];
+ if (digit === ".") return false;
+ rowSum += parseInt(digit, 10);
+ }
+ if (rowSum !== 45) return false;
+ }
+
+ for (let c = 0; c < 9; c++) {
+ let colSum = 0;
+ for (let r = 0; r < 9; r++) {
+ const digit = testBoard[r * 9 + c];
+ colSum += parseInt(digit, 10);
+ }
+ if (colSum !== 45) return false;
+ }
+
+ for (let boxRow = 0; boxRow < 3; boxRow++) {
+ for (let boxCol = 0; boxCol < 3; boxCol++) {
+ let boxSum = 0;
+ for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
+ for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
+ const digit = testBoard[r * 9 + c];
+ boxSum += parseInt(digit, 10);
+ }
+ }
+ if (boxSum !== 45) return false;
+ }
+ }
+
+ return true;
+ };
+
+ const isComplete = (testBoard: string): boolean => {
+ return !testBoard.includes(".");
+ };
+
+ const setDigit = (d: string) => {
+ const i = cursor.r * 9 + cursor.c;
+
+ if (!canEditCell(cursor.r, cursor.c)) {
+ window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
+ return false;
+ }
+
+ const arr = board.split("");
+ arr[i] = d;
+ const newBoard = arr.join("");
+
+ if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
+ const conflicts = getConflicts(newBoard, cursor.r, cursor.c);
+ window.dispatchEvent(
+ new CustomEvent("sudoku-invalid-move", {
+ detail: { row: cursor.r, col: cursor.c, digit: d, conflicts },
+ })
+ );
+ return false;
+ }
+
+ setBoard(newBoard);
+ window.dispatchEvent(
+ new CustomEvent("sudoku-valid-move", {
+ detail: { row: cursor.r, col: cursor.c, digit: d },
+ })
+ );
+ return true;
+ };
+
+ const erase = () => {
+ if (!canEditCell(cursor.r, cursor.c)) {
+ window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
+ return false;
+ }
- const erase = useCallback(() => {
const i = cursor.r * 9 + cursor.c;
const arr = board.split("");
arr[i] = ".";
setBoard(arr.join(""));
- }, [cursor, board]);
+ return true;
+ };
- const reset = useCallback(() => {
- const newBoard = sudoku.generate("medium");
+ const reset = () => {
+ const newBoard = sudoku.generate("easy");
setBoard(newBoard);
setInitialBoard(newBoard);
setSolution(null);
setCursor({ r: 0, c: 0 });
- }, [setCursor]);
+ };
- const solveBoard = useCallback(() => {
- const solved = sudoku.solve(board);
+ const solveBoard = () => {
+ const solved = sudoku.solve(initialBoard);
if (solved) {
setSolution(solved);
setBoard(solved);
+ window.dispatchEvent(new CustomEvent("sudoku-game-won"));
}
- }, [board]);
+ };
- const boardToString = useCallback((human: boolean = false) => {
+ const boardToString = (human: boolean = false) => {
// Computer-optimized: compact 81-character string
if (!human) {
return board;
@@ -60,7 +210,7 @@ export function useBoardState(cursorManager: UseCursorType) {
if (r === 2 || r === 5) result += "------+-------+------\n";
}
return result;
- }, [board]);
+ };
return {
board,
@@ -71,6 +221,12 @@ export function useBoardState(cursorManager: UseCursorType) {
reset,
solveBoard,
boardToString,
+ canEditCell,
+ isValidPlacement,
+ getConflicts,
+ isFullyValid,
+ isComplete,
+ givenCells,
};
}
diff --git a/src/useGame/useCursor.ts b/src/useGame/useCursor.ts
index fa24be5..230e508 100644
--- a/src/useGame/useCursor.ts
+++ b/src/useGame/useCursor.ts
@@ -30,6 +30,40 @@ export function useCursor(rendererManager: UseRenderer) {
setTimeout(() => el.classList.remove("locked-cell"), 300);
}, [cursor, getCellEl]);
+ const flashValid = useCallback(() => {
+ const el = getCellEl(cursor.r, cursor.c);
+ if (!el) return;
+ el.classList.add("valid-move");
+ setTimeout(() => el.classList.remove("valid-move"), 300);
+ }, [cursor, getCellEl]);
+
+ const highlightConflicts = useCallback((conflictIndices: number[]) => {
+ const container = containerRef.current;
+ if (!container) return;
+ const sudokuDiv = container.querySelector("#sudoku");
+ if (!sudokuDiv) return;
+
+ conflictIndices.forEach((idx) => {
+ const r = Math.floor(idx / 9);
+ const c = idx % 9;
+ const el = getCellEl(r, c);
+ if (el) {
+ el.classList.add("conflict");
+ }
+ });
+
+ setTimeout(() => {
+ conflictIndices.forEach((idx) => {
+ const r = Math.floor(idx / 9);
+ const c = idx % 9;
+ const el = getCellEl(r, c);
+ if (el) {
+ el.classList.remove("conflict");
+ }
+ });
+ }, 2000);
+ }, [containerRef, getCellEl]);
+
const moveCursor = useCallback(
(dr: number, dc: number, steps = 1) => {
if (animatingRef.current) return;
@@ -54,9 +88,25 @@ export function useCursor(rendererManager: UseRenderer) {
useEffect(() => {
const onLockedCell = () => flashLocked();
+ const onValidMove = () => flashValid();
+ const onInvalidMove = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (detail?.conflicts) {
+ highlightConflicts(detail.conflicts);
+ }
+ flashInvalid();
+ };
+
window.addEventListener("sudoku-locked-cell", onLockedCell);
- return () => window.removeEventListener("sudoku-locked-cell", onLockedCell);
- }, [flashLocked]);
+ window.addEventListener("sudoku-valid-move", onValidMove);
+ window.addEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
+
+ return () => {
+ window.removeEventListener("sudoku-locked-cell", onLockedCell);
+ window.removeEventListener("sudoku-valid-move", onValidMove);
+ window.removeEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
+ };
+ }, [flashLocked, flashValid, flashInvalid, highlightConflicts]);
return {
cursor,
@@ -64,6 +114,8 @@ export function useCursor(rendererManager: UseRenderer) {
moveCursor,
getCellEl,
flashInvalid,
+ flashValid,
+ highlightConflicts,
};
}
diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts
index 914be9e..6a72ef9 100644
--- a/src/useGame/useGame.ts
+++ b/src/useGame/useGame.ts
@@ -14,7 +14,17 @@ export function useGame(platformHook?: unknown) {
const gameManager = useGameStatus(rendererManager, cursorManager);
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
const boardManager = useBoardState(cursorManager);
- const { board, initialBoard, solution, setDigit, erase, reset, solveBoard } = boardManager;
+ const {
+ board,
+ initialBoard,
+ solution,
+ setDigit,
+ erase,
+ reset,
+ solveBoard,
+ isComplete,
+ isFullyValid,
+ } = boardManager;
// ---------------------------
// Keybindings
@@ -37,6 +47,10 @@ export function useGame(platformHook?: unknown) {
const scoreManager = useScore({
gameContext: { gameStatus, setGameStatus },
keystrokeCount: keyLog.length,
+ board,
+ isComplete,
+ isFullyValid,
+ initialBoard,
});
// ---------------------------
diff --git a/src/useGame/useKeyBindings.ts b/src/useGame/useKeyBindings.ts
index d1670a0..09bdba4 100644
--- a/src/useGame/useKeyBindings.ts
+++ b/src/useGame/useKeyBindings.ts
@@ -78,16 +78,22 @@ export function useKeyBindings(ctx: Ctx) {
return;
}
- // R or C to enter edit mode
- if (k === "r" || k === "R" || k === "c" || k === "C") {
+ // R to replace (only works on user-filled cells)
+ if (k === "r" || k === "R") {
recordKey(k);
- // Check if current cell is a pre-filled clue (not editable)
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
const isClue = ctx.initialBoard[cellIndex] !== ".";
+ const isEmpty = ctx.board[cellIndex] === ".";
if (isClue) {
- // Flash red - cell is locked
+ // Flash red - cell is locked (pre-filled clue)
+ window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
+ return;
+ }
+
+ if (isEmpty) {
+ // Flash red - cell is empty (use I to insert)
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
return;
}
@@ -96,6 +102,47 @@ export function useKeyBindings(ctx: Ctx) {
return;
}
+ // I to insert (only works on empty cells)
+ if (k === "i" || k === "I") {
+ recordKey(k);
+
+ const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
+ const isClue = ctx.initialBoard[cellIndex] !== ".";
+ const isEmpty = ctx.board[cellIndex] === ".";
+
+ if (isClue) {
+ // Flash red - cell is locked (pre-filled clue)
+ window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
+ return;
+ }
+
+ if (!isEmpty) {
+ // Flash red - cell already has a value (use R to replace)
+ window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
+ return;
+ }
+
+ setEditMode("edit");
+ return;
+ }
+
+ // D or X to delete cell value in navigation mode
+ if (k === "d" || k === "D" || k === "x" || k === "X") {
+ recordKey(k);
+
+ const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
+ const isClue = ctx.initialBoard[cellIndex] !== ".";
+
+ if (isClue) {
+ // Flash red - can't delete clues
+ window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
+ return;
+ }
+
+ ctx.erase();
+ return;
+ }
+
// Escape cancels count prefix
if (k === "Escape") {
recordKey(k);
diff --git a/src/useScore/useScore.ts b/src/useScore/useScore.ts
index 2365d40..a85eae0 100644
--- a/src/useScore/useScore.ts
+++ b/src/useScore/useScore.ts
@@ -3,8 +3,8 @@ import { useTimer } from "./hooks/useTimer";
import { useScoreTime } from "./useScoreTime";
import type { GameStatus } from "../types";
-const TIME_LIMIT_MS = 60000; // 60 seconds
-const KEYSTROKE_LIMIT = 100;
+const TIME_LIMIT_MS = Infinity; // No time limit by default
+const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
type GameScoreContext = {
gameStatus: GameStatus;
@@ -14,33 +14,68 @@ type GameScoreContext = {
type UseScoreParams = {
gameContext: GameScoreContext;
keystrokeCount: number;
+ board: string;
+ isComplete: (board: string) => boolean;
+ isFullyValid: (board: string) => boolean;
+ initialBoard: string;
};
-export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
+export function useScore({
+ gameContext,
+ keystrokeCount,
+ board,
+ isComplete,
+ isFullyValid,
+ initialBoard
+}: UseScoreParams) {
const timer = useTimer();
const { gameStatus, setGameStatus } = gameContext;
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
const [finalScore, setFinalScore] = useState(null);
+ const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
+ const actualMoves = keystrokeCount;
+ const efficiency = minMovesRequired > 0
+ ? Math.round((actualMoves / minMovesRequired) * 100)
+ : 0;
+
useScoreTime({ gameStatus, timer });
- // Check for game-over conditions
useEffect(() => {
if (gameStatus !== "started") return;
- // Time limit exceeded
+ if (isComplete(board) && isFullyValid(board)) {
+ setGameStatus("game-won");
+ return;
+ }
+
if (timeValue >= TIME_LIMIT_MS) {
setGameStatus("game-over");
return;
}
- // Keystroke limit exceeded
if (keystrokeCount >= KEYSTROKE_LIMIT) {
setGameStatus("game-over");
return;
}
- }, [gameStatus, timeValue, keystrokeCount, setGameStatus]);
+
+ // TODO: Enable efficiency-based game-over in future
+ // if (efficiency > 200) {
+ // setGameStatus("game-over");
+ // return;
+ // }
+ }, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
+
+ useEffect(() => {
+ const handleWin = () => {
+ if (gameStatus === "started") {
+ setGameStatus("game-won");
+ }
+ };
+ window.addEventListener("sudoku-game-won", handleWin);
+ return () => window.removeEventListener("sudoku-game-won", handleWin);
+ }, [gameStatus, setGameStatus]);
useEffect(() => {
if (gameStatus !== "game-won") return;
@@ -48,10 +83,12 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
const timeInSeconds = timeValue / 1000;
const baseScore = 100000;
const timePenalty = Math.floor(timeInSeconds * 10);
- const score = Math.max(0, baseScore - timePenalty);
+ const extraMoves = Math.max(0, actualMoves - minMovesRequired);
+ const movePenalty = extraMoves * 50;
+ const score = Math.max(0, baseScore - timePenalty - movePenalty);
setFinalScore(score);
- }, [gameStatus, timeValue]);
+ }, [gameStatus, timeValue, actualMoves, minMovesRequired]);
return {
timeValue,
@@ -61,6 +98,9 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
finalScore,
timeLimit: TIME_LIMIT_MS,
keystrokeLimit: KEYSTROKE_LIMIT,
+ minMovesRequired,
+ actualMoves,
+ efficiency,
};
}