Lightweight, typed React hooks for building interactive sudoku games with VIM-style modal editing.
Part of the VIMazing project.
- Features
- Installation
- Quick Start
- API Reference
- Game States
- VIM Controls
- Scoring System
- Game Over Conditions
- Hint System
- Configuration
- Example App
- License
- 🎮 VIM modal editing – Navigate in normal mode, edit cells with i/r/c commands
- ⌨️ Full VIM motions – hjkl, counts (5j), anchors (^/$), gg/G, repeat (.)
- 🎯 Smart cell protection – Pre-filled clues locked with visual feedback
- 💡 Hint system – Get hints for incorrect cells or empty cells (Shift+H)
- ⏱️ Configurable limits – Time limits and hint penalties trigger game-over
- 📊 Comprehensive scoring – Time + keystrokes + hints with difficulty multipliers
- 🎨 Tokyo Night theme – Beautiful dark theme with proper 3x3 box separators
- 📦 Full TypeScript – Complete type safety with generated declarations
- 🪝 Composable architecture – Clean separation: board, cursor, score, game status
- 🌐 Platform hooks – Optional integration for analytics and custom bindings
npm install @vimazing/vim-sudoku
Or with bun:
bun add @vimazing/vim-sudoku
import { useGame } from "@vimazing/vim-sudoku";
import "@vimazing/vim-sudoku/game.css";
export function SudokuGame() {
const gameManager = useGame({ difficulty: 'easy' });
const { containerRef, gameStatus, scoreManager, startGame } = gameManager;
return (
<div>
<h1>VIMazing Sudoku</h1>
{gameStatus === 'waiting' && (
<button onClick={startGame}>Start Game</button>
)}
<div ref={containerRef} />
{gameStatus === 'game-won' && (
<div>
<h2>You Won!</h2>
<p>Score: {scoreManager.finalScore} / 1000</p>
<p>Time: {Math.floor(scoreManager.timeValue / 1000)}s</p>
</div>
)}
</div>
);
}
Note: You must manually import
game.css
for styling.
Main orchestrator hook that composes all game functionality.
type GameOptions = {
difficulty?: 'easy' | 'medium' | 'hard'; // Default: 'easy'
timeLimit?: number; // In seconds, default: 600 (10 min)
removedCells?: number; // Override difficulty default
};
Difficulty Defaults:
easy
: 25 cells removed (~56 givens)medium
: 40 cells removed (~41 givens)hard
: 50 cells removed (~31 givens)
Examples:
// Default configuration
useGame()
// Hard difficulty with defaults
useGame({ difficulty: 'hard' })
// Custom puzzle size
useGame({ removedCells: 30 })
// Custom everything
useGame({
difficulty: 'hard',
removedCells: 45,
timeLimit: 480 // 8 minutes
})
type GameManager = {
// DOM Reference
containerRef: RefObject<HTMLDivElement | null>;
// Rendering
renderBoard: () => void;
// Game Status
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
togglePause: (pause?: boolean) => void;
quitGame: () => void;
// Cursor
cursor: CursorManager;
// Scoring
scoreManager: ScoreManager;
// Input Tracking
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
};
type CursorManager = {
position: () => Coord; // Current { row, col }
mode: () => CursorMode; // 'normal' | 'edit'
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
moveDown: (count?: number) => void;
moveToStart: () => void; // ^ or 0
moveToEnd: () => void; // $
moveToTop: () => void; // gg
moveToBottom: () => void; // G
repeatLastMotion: () => void; // .
};
type ScoreManager = {
timeValue: number; // Milliseconds elapsed
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
totalKeystrokes: number; // All keys pressed
hintsUsed: number; // Number of hints requested
finalScore: number | null; // 0-1000, null until game-won
gameOverReason: string | null; // "Time's up!" or "Too many hints!"
};
The game follows a strict state machine:
waiting → started → game-won
↓
game-over
All states → [quit] → waiting
started ↔ paused
State | Description | Triggers |
---|---|---|
waiting |
Initial state, awaiting start | Default on load |
started |
Game in progress | Press Space or call startGame() |
paused |
Game temporarily paused | Press P or call togglePause() |
game-won |
Puzzle completed successfully | All cells filled correctly |
game-over |
Failed to complete in time/hints | Time limit or hint limit exceeded |
Key | Action | Example |
---|---|---|
h |
Move left | h moves 1 left |
j |
Move down | j moves 1 down |
k |
Move up | k moves 1 up |
l |
Move right | l moves 1 right |
<count><motion> |
Move with count | 5j moves 5 down, 3l moves 3 right |
0 or ^ |
Jump to row start | Move to column 0 |
$ |
Jump to row end | Move to column 8 |
gg |
Jump to board top | Move to row 0 |
G |
Jump to board bottom | Move to row 8 |
. |
Repeat last motion | Repeats with same count |
Key | Action | Valid On | Mode |
---|---|---|---|
i |
Insert digit | ✅ Empty cells only | Multi-edit: type digits until Esc |
r |
Replace digit | ✅ User-entered cells only | Single-edit: auto-exit after 1 digit |
c |
Change digit | ✅ User-entered cells only | Single-edit: auto-exit after 1 digit |
x |
Delete digit | ✅ User-entered cells only | Instant |
d |
Delete digit | ✅ User-entered cells only | Instant |
Delete |
Delete digit | ✅ User-entered cells only | Instant |
Backspace |
Invalid move | ❌ All cells | Red flash (use in edit mode) |
Key | Action | Notes |
---|---|---|
Shift+H |
Request hint | Penalty: 25/50/100 pts based on difficulty |
q |
Quit game | Return to waiting state |
p |
Pause/unpause | Toggle pause state |
Space |
Start new game | Only in waiting/game-over state |
Key | Action |
---|---|
1-9 |
Enter digit in current cell |
Backspace |
Clear current cell (stay in edit mode) |
Escape |
Exit to normal mode |
Understanding cell states is crucial for VIM-style editing:
Cell State | i Insert |
r /c Replace |
x /d /Delete |
Visual Class |
---|---|---|---|---|
Empty | ✅ Enter multi-edit | 🔴 Flash (use i) | 🔴 Flash (nothing to delete) | None |
User-entered | 🔴 Flash (use r/c) | ✅ Enter single-edit | ✅ Delete instantly | .user-entered |
Given | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | .given |
- 🔵 Blue outline – Normal mode cursor (
.active
) - 🟡 Yellow pulsing glow – Edit mode cursor (
.editing
) - 🔴 Red flash – Invalid move (
.invalid-move
- 500ms) - 🔴 Red flash + background – Hint: incorrect cell (
.hint-flash-error
- 1000ms) - 🟢 Green flash + background – Hint: correct digit shown (
.hint-flash-correct
- 1000ms)
Base Score = 1000 - (time penalty) - (keystroke penalty) - (hint penalty)
Final Score = min(1000, max(0, round(Base Score × difficulty multiplier)))
Time Penalty: seconds / 10
- 10 seconds = -1 point
- 60 seconds = -6 points
- 300 seconds = -30 points
Keystroke Penalty: totalKeystrokes / 2
- 2 keystrokes = -1 point
- 50 keystrokes = -25 points
- 200 keystrokes = -100 points
Hint Penalty: hintsUsed × penalty
- Easy: 25 points per hint
- Medium: 50 points per hint
- Hard: 100 points per hint
- Easy: 1.0x (no bonus)
- Medium: 1.5x (can exceed 1000 base, capped at 1000)
- Hard: 2.0x (can exceed 1000 base, capped at 1000)
Easy Mode (25 cells, 1.0x):
30 seconds, 50 keys, 0 hints:
= 1000 - 3 - 25 - 0 = 972 × 1.0 = 972 / 1000
Hard Mode (50 cells, 2.0x):
120 seconds, 150 keys, 2 hints:
= 1000 - 12 - 75 - 200 = 713 × 2.0 = 1000 / 1000 (capped)
- Default: 600 seconds (10 minutes) for all difficulties
- Configurable: Set via
GameOptions.timeLimit
- Trigger: When
timeValue >= timeLimit × 1000
- Message: "Time's up!"
- Threshold: 500 points total hint penalty
- Easy: 20 hints max (20 × 25 = 500)
- Medium: 10 hints max (10 × 50 = 500)
- Hard: 5 hints max (5 × 100 = 500)
- Trigger: When
hintsUsed × HINT_PENALTY[difficulty] >= 500
- Message: "Too many hints!"
Both conditions checked continuously during gameplay. Timer stops on game-over.
Press Shift+H
in normal mode to request a hint.
Priority 1: Show Incorrect Cell (Red Flash)
- Finds all user-entered cells with wrong values
- Selects one randomly
- Flashes red with background for 1 second
- Does NOT reveal correct digit (player must figure it out)
Priority 2: Show Correct Digit (Green Flash)
- If no incorrect cells exist
- Finds all empty cells
- Selects one randomly
- Shows correct digit with green flash for 1 second
- Digit disappears after flash (player must remember and enter it)
Hints subtract directly from base score before multiplier:
- Easy: -25 points per hint
- Medium: -50 points per hint
- Hard: -100 points per hint
After 500 points of penalties, game-over triggers.
Beginner Practice:
useGame({
difficulty: 'easy',
removedCells: 15,
timeLimit: 900 // 15 minutes
})
Standard Easy:
useGame({ difficulty: 'easy' })
// 25 cells, 10 minutes, 1.0x multiplier
Standard Medium:
useGame({ difficulty: 'medium' })
// 40 cells, 10 minutes, 1.5x multiplier
Standard Hard:
useGame({ difficulty: 'hard' })
// 50 cells, 10 minutes, 2.0x multiplier
Speed Challenge:
useGame({
difficulty: 'medium',
timeLimit: 300 // 5 minutes
})
Custom Difficulty:
useGame({
difficulty: 'easy', // Easy scoring (1.0x, 25pt hints)
removedCells: 60, // But very hard puzzle
timeLimit: 1200 // Generous time (20 min)
})
The package exports a structured gameInfo
object containing complete game documentation:
import { gameInfo } from '@vimazing/vim-sudoku';
// Access structured instructions
console.log(gameInfo.name); // "VIM Sudoku"
console.log(gameInfo.controls); // Navigation, editing, deletion, hints, game
console.log(gameInfo.rules); // Cell types, modes, visual feedback
console.log(gameInfo.scoring); // Formula, penalties, multipliers, examples
console.log(gameInfo.gameOver); // Time and hint limit conditions
console.log(gameInfo.hints); // How the hint system works
console.log(gameInfo.objective); // Win condition
Use cases:
- Render in-game help screens
- Generate tutorials
- Display control reference
- Show scoring breakdown
- Explain game mechanics
All data is fully typed with the GameInfo
type for type safety.
A demo application lives under example/
and consumes the package directly.
cd example
npm install
npm run dev
The example shows:
- Difficulty selection (Easy/Medium/Hard)
- Live scoreboard (Time, Keystrokes, Hints)
- Game status messages
- Final score display on win
- All vim controls working
Optional callback for platform integration:
function myPlatformHook(gameManager: GameManager) {
// Track analytics
console.log('Game initialized');
// Add custom key handlers
window.addEventListener('keydown', (e) => {
if (e.key === 'F1') {
console.log('Help requested');
}
});
// Monitor game events
const interval = setInterval(() => {
if (gameManager.gameStatus === 'game-won') {
console.log('Victory!', gameManager.scoreManager.finalScore);
clearInterval(interval);
}
}, 100);
}
const gameManager = useGame({ difficulty: 'easy' }, myPlatformHook);
MIT © André Padez