526 lines
14 KiB
Markdown
526 lines
14 KiB
Markdown
# @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** for building interactive sudoku games with **VIM-style modal editing**.
|
||
|
||
Part of the [VIMazing](https://vimazing.com) project.
|
||
|
||
---
|
||
|
||
## Contents
|
||
- [Features](#features)
|
||
- [Installation](#installation)
|
||
- [Quick Start](#quick-start)
|
||
- [API Reference](#api-reference)
|
||
- [Game States](#game-states)
|
||
- [VIM Controls](#vim-controls)
|
||
- [Scoring System](#scoring-system)
|
||
- [Game Over Conditions](#game-over-conditions)
|
||
- [Hint System](#hint-system)
|
||
- [Configuration](#configuration)
|
||
- [Example App](#example-app)
|
||
- [License](#license)
|
||
|
||
---
|
||
|
||
## Features
|
||
|
||
- 🎮 **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
|
||
|
||
---
|
||
|
||
## Installation
|
||
|
||
```bash
|
||
npm install @vimazing/vim-sudoku
|
||
```
|
||
|
||
Or with bun:
|
||
|
||
```bash
|
||
bun add @vimazing/vim-sudoku
|
||
```
|
||
|
||
---
|
||
|
||
## Quick Start
|
||
|
||
```tsx
|
||
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.
|
||
|
||
---
|
||
|
||
## API Reference
|
||
|
||
### `useGame(options?, platformHook?)`
|
||
|
||
Main orchestrator hook that composes all game functionality.
|
||
|
||
#### Options
|
||
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
// 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
|
||
})
|
||
```
|
||
|
||
#### Returns: GameManager
|
||
|
||
```typescript
|
||
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[];
|
||
};
|
||
```
|
||
|
||
#### CursorManager
|
||
|
||
```typescript
|
||
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; // .
|
||
};
|
||
```
|
||
|
||
#### ScoreManager
|
||
|
||
```typescript
|
||
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!"
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Game States
|
||
|
||
The game follows a strict state machine:
|
||
|
||
```
|
||
waiting → started → game-won
|
||
↓
|
||
game-over
|
||
|
||
All states → [quit] → waiting
|
||
started ↔ paused
|
||
```
|
||
|
||
### State Descriptions
|
||
|
||
| 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 |
|
||
|
||
---
|
||
|
||
## VIM Controls
|
||
|
||
### Normal Mode (Default)
|
||
|
||
#### Movement
|
||
|
||
| 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 |
|
||
|
||
#### Edit Commands
|
||
|
||
| 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) |
|
||
|
||
#### Hints & Game Control
|
||
|
||
| 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 |
|
||
|
||
### Edit Mode (i/r/c to enter)
|
||
|
||
| Key | Action |
|
||
|-----|--------|
|
||
| `1-9` | Enter digit in current cell |
|
||
| `Backspace` | Clear current cell (stay in edit mode) |
|
||
| `Escape` | Exit to normal mode |
|
||
|
||
---
|
||
|
||
## Cell State Rules
|
||
|
||
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` |
|
||
|
||
### Visual Feedback
|
||
|
||
- 🔵 **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)
|
||
|
||
---
|
||
|
||
## Scoring System
|
||
|
||
### Formula
|
||
|
||
```
|
||
Base Score = 1000 - (time penalty) - (keystroke penalty) - (hint penalty)
|
||
Final Score = min(1000, max(0, round(Base Score × difficulty multiplier)))
|
||
```
|
||
|
||
### Penalties
|
||
|
||
**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
|
||
|
||
### Difficulty Multipliers
|
||
|
||
- 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)
|
||
|
||
### Example Scores
|
||
|
||
**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)
|
||
```
|
||
|
||
---
|
||
|
||
## Game Over Conditions
|
||
|
||
### Time Limit
|
||
|
||
- **Default:** 600 seconds (10 minutes) for all difficulties
|
||
- **Configurable:** Set via `GameOptions.timeLimit`
|
||
- **Trigger:** When `timeValue >= timeLimit × 1000`
|
||
- **Message:** "Time's up!"
|
||
|
||
### Hint Limit
|
||
|
||
- **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.
|
||
|
||
---
|
||
|
||
## Hint System
|
||
|
||
Press `Shift+H` in normal mode to request a hint.
|
||
|
||
### Hint Priority
|
||
|
||
**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)
|
||
|
||
### Hint Penalties
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
### Recommended Configurations
|
||
|
||
**Beginner Practice:**
|
||
```typescript
|
||
useGame({
|
||
difficulty: 'easy',
|
||
removedCells: 15,
|
||
timeLimit: 900 // 15 minutes
|
||
})
|
||
```
|
||
|
||
**Standard Easy:**
|
||
```typescript
|
||
useGame({ difficulty: 'easy' })
|
||
// 25 cells, 10 minutes, 1.0x multiplier
|
||
```
|
||
|
||
**Standard Medium:**
|
||
```typescript
|
||
useGame({ difficulty: 'medium' })
|
||
// 40 cells, 10 minutes, 1.5x multiplier
|
||
```
|
||
|
||
**Standard Hard:**
|
||
```typescript
|
||
useGame({ difficulty: 'hard' })
|
||
// 50 cells, 10 minutes, 2.0x multiplier
|
||
```
|
||
|
||
**Speed Challenge:**
|
||
```typescript
|
||
useGame({
|
||
difficulty: 'medium',
|
||
timeLimit: 300 // 5 minutes
|
||
})
|
||
```
|
||
|
||
**Custom Difficulty:**
|
||
```typescript
|
||
useGame({
|
||
difficulty: 'easy', // Easy scoring (1.0x, 25pt hints)
|
||
removedCells: 60, // But very hard puzzle
|
||
timeLimit: 1200 // Generous time (20 min)
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## Game Instructions Export
|
||
|
||
The package exports a structured `gameInfo` object containing complete game documentation:
|
||
|
||
```typescript
|
||
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.
|
||
|
||
---
|
||
|
||
## Example App
|
||
|
||
A demo application lives under `example/` and consumes the package directly.
|
||
|
||
```bash
|
||
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
|
||
|
||
---
|
||
|
||
## Platform Hook
|
||
|
||
Optional callback for platform integration:
|
||
|
||
```typescript
|
||
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);
|
||
```
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
MIT © [André Padez](https://github.com/andrepadez)
|