diff --git a/.gitignore b/.gitignore
index 6051801..d02209e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,10 @@ bower_components/
# Build outputs
dist/
+# npm package tarballs
+*.tgz
+vimazing-vim-sudoku-*.tgz
+
# TypeScript
*.tsbuildinfo
diff --git a/.npmignore b/.npmignore
index a2cf60a..35e71e4 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,6 +1,12 @@
src/
+example/
node_modules/
tsconfig.json
bun.lockb
*.log
-
+.git
+.gitignore
+NPM_PUBLISHING_CHECKLIST.md
+vim_sudoku_implementation.md
+vim_sudoku_game_mechanics.md
+vimazing-vim-sudoku-*.tgz
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e15595e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,42 @@
+# Changelog
+
+All notable changes to @vimazing/vim-sudoku will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0] - 2025-01-15
+
+### Added
+- Initial release of @vimazing/vim-sudoku
+- VIM-style modal editing with navigation and edit modes
+- Full VIM motions (h/j/k/l, counted moves, ^/$/0, repeat with .)
+- Smart cell locking for pre-filled clues
+- Real-time validation system (optional, disabled by default)
+- Automatic win detection using sum-to-45 algorithm
+- Time-based scoring with keystroke penalties
+- Tokyo Night themed CSS with 3x3 box separators
+- Configurable size multiplier via CSS variables
+- Complete TypeScript support with type declarations
+- Composable hook architecture (useGame, useScore, useBoardState, etc.)
+- Tree-shakeable exports
+
+### Features
+- `useGame(platformHook?, options?)` - Main game orchestration hook
+ - `validateMoves` option for real-time move validation (default: false)
+- Automatic win detection when board is complete and valid
+- Scoring formula: `max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)`
+- VIM modal editing: navigation mode (default) and edit mode (R/C/I to enter)
+- Visual feedback for all game states (navigation glow, edit mode pulse, locked cells)
+- Support for counted motions (3j, 5l, etc.)
+- Row navigation shortcuts (^, $, 0)
+- Repeat last motion with `.`
+
+### Documentation
+- Comprehensive README with usage examples
+- VIM modal editing guide
+- Cell state rules reference
+- API documentation for all hooks
+- Build and release instructions
+
+[0.1.0]: https://github.com/vimazing/vim-sudoku/releases/tag/v0.1.0
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2b648fc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 André Padez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/NPM_PUBLISHING_CHECKLIST.md b/NPM_PUBLISHING_CHECKLIST.md
new file mode 100644
index 0000000..3b0088a
--- /dev/null
+++ b/NPM_PUBLISHING_CHECKLIST.md
@@ -0,0 +1,46 @@
+# 📋 npm Publishing Checklist for @vimazing/vim-sudoku
+
+## 🔴 Critical (Must Fix Before Publishing)
+
+- [ ] **Fix TypeScript build errors** - Package won't compile
+ - [ ] `Sudoku.ts`: operator type issues (lines 77, 93, 95)
+ - [ ] `useBoardState.ts`: remove unused `useCallback`, `useMemo` imports
+ - [ ] `types.ts`: remove non-existent `BoardString` export
+ - [ ] Remove unused variables: `idx`, `max_square`
+
+- [ ] **Add LICENSE file** - Required for MIT license in package.json
+
+- [ ] **Update package.json author** - Currently `you@example.com`
+
+## 🟡 Important (Should Fix)
+
+- [ ] **Update README.md**
+ - [ ] Remove "coming soon" for win detection (✅ implemented!)
+ - [ ] Document new `validateMoves` option in `useGame`
+ - [ ] Update scoring formula to include move penalty
+
+- [ ] **Fix tsconfig.json** - Conflicting `declaration` and `emitDeclarationOnly` flags
+
+- [ ] **Verify exports** - Check `src/index.ts` exports everything needed
+
+- [ ] **Add CHANGELOG.md** - Document version history and changes
+
+- [ ] **Add package.json keywords** - Improve npm discoverability (sudoku, vim, react, game, puzzle)
+
+- [ ] **Update .npmignore** - Verify `example/` is excluded
+
+## 🟢 Nice to Have
+
+- [ ] **Test locally** - Run `npm pack` and inspect tarball contents
+
+- [ ] **Add npm badges** - Version and downloads badges in README
+
+- [ ] **Add engines field** - Specify Node.js version requirements
+
+- [ ] **GitHub topics** - Add repository tags for discoverability
+
+---
+
+**Total: 18 tasks** (6 critical, 6 important, 6 nice-to-have)
+
+**Status:** Ready to start working on critical issues 🚀
diff --git a/README.md b/README.md
index dd055f8..d7d64b5 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,11 @@
# @vimazing/vim-sudoku
+[](https://www.npmjs.com/package/@vimazing/vim-sudoku)
+[](https://www.npmjs.com/package/@vimazing/vim-sudoku)
+[](https://github.com/vimazing/vim-sudoku/blob/main/LICENSE)
+
+
+
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.
@@ -146,9 +152,14 @@ Jump to end: Press $
## Core Hooks
-### `useGame(platformHook?)`
+### `useGame(platformHook?, options?)`
-Main hook that orchestrates the entire sudoku game. Returns:
+Main hook that orchestrates the entire sudoku game.
+
+**Options:**
+- `validateMoves?: boolean` (default: `false`) - Enable real-time move validation with conflict detection. When `false`, allows harder gameplay without immediate feedback.
+
+**Returns:**
```ts
{
@@ -201,11 +212,17 @@ import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState";
### Scoring
-Time-based scoring formula:
+Scoring formula with time and move penalties:
```
-score = max(0, 100000 - timeInSeconds × 10)
+score = max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)
```
+Where:
+- `baseScore = 100000`
+- `timePenalty = timeInSeconds × 10`
+- `extraMoves = actualKeystrokes - minMovesRequired`
+- `movePenalty = extraMoves × 50`
+
### Game Over Conditions
- ⏱️ **Time limit**: 60 seconds
@@ -219,7 +236,13 @@ const KEYSTROKE_LIMIT = 100;
### Win Detection
-Currently manual (solving via UI or `solveBoard()`). Automatic win detection coming soon!
+Automatic win detection is fully implemented using an efficient sum-to-45 algorithm:
+- Validates when board is complete (no empty cells)
+- Checks all rows sum to 45 (1+2+...+9)
+- Checks all columns sum to 45
+- Checks all 3x3 boxes sum to 45
+
+Manual win trigger available via `solveBoard()` for testing.
### Board Format
diff --git a/src/GameOverlay.tsx b/example/src/GameOverlay.tsx
similarity index 56%
rename from src/GameOverlay.tsx
rename to example/src/GameOverlay.tsx
index f03c57f..e0d6340 100644
--- a/src/GameOverlay.tsx
+++ b/example/src/GameOverlay.tsx
@@ -25,21 +25,24 @@ export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => {
export const GameWaitingContent = () => {
return (
-
-
Press New Puzzle to start
-
-
Navigation Mode (default):
+
+
Press New Puzzle to start
+
+
Navigation Mode (default):
h,j,k,l to move around
-
R or C to enter edit mode
+
R or C to enter edit mode
-
Edit Mode:
+
Edit Mode:
Type 1-9 to fill cell
-
Esc or Enter to exit edit mode
+
Esc or Enter to exit edit mode
-
+
Try vim motions: $, ^, 0, counted moves
@@ -53,13 +56,17 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType })
return (
🎉 Puzzle Solved!
Final Score:
-
{finalScore.toLocaleString()}
-
+
{finalScore.toLocaleString()}
+
max
(
0
@@ -82,8 +89,8 @@ export const GameOverContent = () => {
className="text-[#ff757f] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
-
Game Over
-
Press i to play again
+
Game Over
+
Press i to play again
);
};
diff --git a/src/MobileGameOverlay.tsx b/example/src/MobileGameOverlay.tsx
similarity index 63%
rename from src/MobileGameOverlay.tsx
rename to example/src/MobileGameOverlay.tsx
index 8d03524..10f6632 100644
--- a/src/MobileGameOverlay.tsx
+++ b/example/src/MobileGameOverlay.tsx
@@ -20,11 +20,14 @@ export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverla
export const GameWaitingContent = () => {
return (
-
-
Tap New Puzzle to start
+
+
Tap New Puzzle to start
VIM motions available
via on-screen keyboard
@@ -36,13 +39,17 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType })
return (
🎉 Solved!
Final Score:
-
{finalScore.toLocaleString()}
-
+
{finalScore.toLocaleString()}
+
max
(
0
@@ -62,8 +69,11 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType })
export const GameOverContent = () => {
return (
Game Over
diff --git a/example/src/index.css b/example/src/index.css
index f1d8c73..14599bf 100644
--- a/example/src/index.css
+++ b/example/src/index.css
@@ -1 +1,5 @@
@import "tailwindcss";
+
+:root {
+ --sudoku-size-multiplier: 3;
+}
diff --git a/example/tailwind.config.ts b/example/tailwind.config.ts
new file mode 100644
index 0000000..56cd7c4
--- /dev/null
+++ b/example/tailwind.config.ts
@@ -0,0 +1,8 @@
+import type { Config } from 'tailwindcss'
+
+export default {
+ content: [
+ './src/**/*.{js,ts,jsx,tsx}',
+ '../src/**/*.{js,ts,jsx,tsx}',
+ ],
+} satisfies Config
diff --git a/package.json b/package.json
index aa3ffa2..ea16710 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "@vimazing/vim-sudoku",
"version": "0.1.0",
"description": "VIMazing VIM Sudoku engine — a lightweight, typed React hook set for sudoku games.",
- "author": "André Padez
",
+ "author": "André Padez ",
"repository": {
"type": "git",
"url": "https://github.com/vimazing/vim-sudoku.git"
@@ -12,7 +12,21 @@
},
"homepage": "https://github.com/vimazing/vim-sudoku#readme",
"license": "MIT",
+ "keywords": [
+ "sudoku",
+ "vim",
+ "react",
+ "hooks",
+ "game",
+ "puzzle",
+ "modal-editing",
+ "typescript",
+ "vimazing"
+ ],
"type": "module",
+ "engines": {
+ "node": ">=18.0.0"
+ },
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
diff --git a/src/lib/Sudoku.ts b/src/lib/Sudoku.ts
index d6c6d00..8c1f7ee 100644
--- a/src/lib/Sudoku.ts
+++ b/src/lib/Sudoku.ts
@@ -51,11 +51,14 @@ function initialize() {
// ---------------------------------------------------------------------------
sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
+ let numericDifficulty: number;
if (typeof difficulty === "string" || typeof difficulty === "undefined") {
- difficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
+ numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
+ } else {
+ numericDifficulty = difficulty;
}
- difficulty = sudoku._force_range(difficulty, NR_SQUARES + 1, MIN_GIVENS);
+ numericDifficulty = sudoku._force_range(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
unique = unique ?? true;
const blank_board = sudoku.BLANK_BOARD;
@@ -74,7 +77,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
if (
- single_candidates.length >= difficulty &&
+ single_candidates.length >= numericDifficulty &&
sudoku._strip_dups(single_candidates).length >= 8
) {
let board = "";
@@ -90,9 +93,9 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
}
- if (givens_idxs.length > difficulty) {
+ if (givens_idxs.length > numericDifficulty) {
givens_idxs = sudoku._shuffle(givens_idxs);
- for (let i = 0; i < givens_idxs.length - difficulty; ++i) {
+ for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
const target = givens_idxs[i];
board =
board.substring(0, target) +
@@ -105,7 +108,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
}
- return sudoku.generate(difficulty);
+ return sudoku.generate(numericDifficulty);
};
// ---------------------------------------------------------------------------
@@ -187,12 +190,10 @@ sudoku._search = function (candidates: Record, reverse: boolean)
reverse = reverse || false;
let max_nr_candidates = 0;
- let max_square = "";
for (const s of SQUARES) {
const n = candidates[s].length;
if (n > max_nr_candidates) {
max_nr_candidates = n;
- max_square = s;
}
}
if (max_nr_candidates === 1) return candidates;
diff --git a/src/sudoku.css b/src/sudoku.css
index 50469ff..7aca8d4 100644
--- a/src/sudoku.css
+++ b/src/sudoku.css
@@ -1,28 +1,14 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
-/* :root { */
-/* --cell-size: 32px; */
-/* --cell-font-size: 16px; */
-/* --grid-gap: 2px; */
-/* --box-separator: 3px; */
-/* } */
-
:root {
- --cell-size: 48px;
- --cell-font-size: 32px;
- --grid-gap: 4px;
- --box-separator: 6px;
+ --sudoku-size-multiplier: 3;
+ --cell-size: calc(32px * var(--sudoku-size-multiplier));
+ --cell-font-size: calc(16px * var(--sudoku-size-multiplier));
+ --grid-gap: calc(2px * var(--sudoku-size-multiplier));
+ --box-separator: calc(3px * var(--sudoku-size-multiplier));
}
-
-/* :root { */
-/* --cell-size: 96px; */
-/* --cell-font-size: 48px; */
-/* --grid-gap: 6px; */
-/* --box-separator: 9px; */
-/* } */
-
/* =========================================================
Container & Grid Base
========================================================= */
diff --git a/src/types.ts b/src/types.ts
index 2370fc1..d09f67c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -7,7 +7,6 @@ export type UseScoreType = ReturnType;
export type {
Difficulty,
DifficultyName,
- BoardString,
} from "./lib/Sudoku";
export type GamePhase = "idle" | "playing";
diff --git a/src/useGame/useBoardState.ts b/src/useGame/useBoardState.ts
index 8bf2e02..eecce25 100644
--- a/src/useGame/useBoardState.ts
+++ b/src/useGame/useBoardState.ts
@@ -1,9 +1,14 @@
-import { useState, useCallback, useMemo } from "react";
+import { useState } from "react";
import { sudoku } from "../lib/Sudoku";
import type { UseCursorType } from "./useCursor";
-export function useBoardState(cursorManager: UseCursorType) {
+type UseBoardStateOptions = {
+ validateMoves?: boolean;
+};
+
+export function useBoardState(cursorManager: UseCursorType, options?: UseBoardStateOptions) {
const { cursor, setCursor } = cursorManager;
+ const { validateMoves = false } = options || {};
const [board, setBoard] = useState(sudoku.generate("medium"));
const [initialBoard, setInitialBoard] = useState(board);
const [solution, setSolution] = useState(null);
@@ -26,8 +31,6 @@ export function useBoardState(cursorManager: UseCursorType) {
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) {
@@ -131,7 +134,7 @@ export function useBoardState(cursorManager: UseCursorType) {
};
const setDigit = (d: string) => {
- const i = cursor.r * 9 + cursor.c;
+ const cellIndex = cursor.r * 9 + cursor.c;
if (!canEditCell(cursor.r, cursor.c)) {
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
@@ -139,10 +142,10 @@ export function useBoardState(cursorManager: UseCursorType) {
}
const arr = board.split("");
- arr[i] = d;
+ arr[cellIndex] = d;
const newBoard = arr.join("");
- if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
+ if (validateMoves && !isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
const conflicts = getConflicts(newBoard, cursor.r, cursor.c);
window.dispatchEvent(
new CustomEvent("sudoku-invalid-move", {
@@ -153,11 +156,13 @@ export function useBoardState(cursorManager: UseCursorType) {
}
setBoard(newBoard);
- window.dispatchEvent(
- new CustomEvent("sudoku-valid-move", {
- detail: { row: cursor.r, col: cursor.c, digit: d },
- })
- );
+ if (validateMoves) {
+ window.dispatchEvent(
+ new CustomEvent("sudoku-valid-move", {
+ detail: { row: cursor.r, col: cursor.c, digit: d },
+ })
+ );
+ }
return true;
};
diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts
index 6a72ef9..4150bb8 100644
--- a/src/useGame/useGame.ts
+++ b/src/useGame/useGame.ts
@@ -6,14 +6,19 @@ import { useBoardState } from "./useBoardState";
import { useKeyBindings } from "./useKeyBindings";
import { useScore } from "../useScore";
-export function useGame(platformHook?: unknown) {
+export type UseGameOptions = {
+ validateMoves?: boolean;
+};
+
+export function useGame(platformHook?: unknown, options?: UseGameOptions) {
+ const { validateMoves = false } = options || {};
const rendererManager = useRenderer();
const { containerRef, renderEmptyBoard, renderBoard, highlightCursor } = rendererManager;
const cursorManager = useCursor(rendererManager);
const { cursor, moveCursor } = cursorManager;
const gameManager = useGameStatus(rendererManager, cursorManager);
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
- const boardManager = useBoardState(cursorManager);
+ const boardManager = useBoardState(cursorManager, { validateMoves });
const {
board,
initialBoard,
diff --git a/tsconfig.json b/tsconfig.json
index 14cd137..c98748a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,6 +10,8 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
diff --git a/vim-sudoku.gif b/vim-sudoku.gif
new file mode 100644
index 0000000..bc787d7
Binary files /dev/null and b/vim-sudoku.gif differ