- Add configurable timeLimit to GameOptions (default 600 seconds / 10 minutes) - Time limit applies to all difficulty levels (harder puzzles = more time pressure) - Implement game-over triggers: 1. Time limit exceeded: timeValue >= timeLimit → 'Time's up!' 2. Hint penalty limit: hintsUsed * penalty >= 500 → 'Too many hints!' - Hint limits by difficulty: - Easy: 20 hints max (20 × 25 = 500 points) - Medium: 10 hints max (10 × 50 = 500 points) - Hard: 5 hints max (5 × 100 = 500 points) - Add gameOverReason to ScoreManager to display specific failure reason - Timer stops on game-over - Update example app to show game-over reason - Both conditions checked during gameplay before win condition
48 KiB
vim-sudoku Unified API Implementation Log
This document tracks the step-by-step implementation of the unified API architecture for vim-sudoku.
Overview
We are porting vim-sudoku from the old architecture (src_old) to follow the unified API patterns established by vim-maze. This enables consistent architecture across all VIMazing games and better platform integration.
Agent Rules
These are the key principles and constraints that guide this implementation:
React 19 Optimization
- No
useCallbackoruseMemo: React 19's compiler automatically optimizes, making these hooks unnecessary - Use plain functions instead of wrapped callbacks
- React will handle memoization automatically
- Cleaner, more readable code
Code Conventions
- No comments: Code should be self-documenting unless explicitly requested
- Type-first approach: Define types before implementation
- Explicit imports: Always import types with
import type - Return type annotations: Use
ReturnType<typeof hookName>for hook return types
Architecture Patterns
- Hook composition: Small, focused hooks composed together
- Ref-based state: Use refs for non-reactive state, useState for reactive
- Module organization: Each hook module has types.ts, implementation, and index.ts
- Follow vim-maze patterns: Maintain consistency across games
Documentation
- Update IMPLEMENTATION.md: After every iteration
- Clear commit messages: Describe what, why, and how
- Track issues and solutions: Document problems encountered and fixes
Testing Workflow
- Wait for user testing: After implementing a feature, WAIT for the user to test and report issues
- Do not assume success: Do not move on to next phases or document success until user confirms
- Take it slow: Address one issue at a time as reported by the user
- Be patient: The user will test thoroughly and report all issues found
Iteration 1: Preparation & Planning
Date: 2025-01-22
Goals
- Review existing implementations (vim-maze, src_old, src_failed)
- Create implementation plan
- Prepare example app for new API
Tasks Completed
1. Reviewed Architecture
- Analyzed vim-maze unified API implementation
- Examined src_old working implementation
- Identified src_failed attempt issues
- Documented key differences between vim-maze and vim-sudoku
2. Created Implementation Plan
- Created
IMPLEMENTATION_PLAN.mdwith 7 phases (numbered x10 for flexibility) - Defined file structure matching vim-maze patterns
- Documented key design decisions
- Established type mappings between vim-maze and vim-sudoku
- Listed success criteria
3. Prepared Example App (Phase 5)
- Updated
example/src/App.tsxto match vim-maze structure - Simplified to desktop-only (no mobile support)
- Added score metrics display (time, keystrokes, final score)
- Updated game status messages for sudoku context
- Changed navigation instructions (hjkl + i for edit mode)
- Verified
useKeyBindingsalready matches vim-maze
File Changes
vim-sudoku/
├── IMPLEMENTATION_PLAN.md [CREATED] - Detailed implementation roadmap
├── IMPLEMENTATION.md [CREATED] - This file, tracking progress
├── example/src/App.tsx [MODIFIED] - Simplified, sudoku-focused UI
└── src/ → src_failed/ [RENAMED] - Previous failed attempt archived
Key Decisions
- No Mobile Support: Sudoku with vim bindings requires keyboard, mobile not practical
- Simple Metrics: Start with time, keystrokes, and final score (no efficiency yet)
- Phase Numbering: Use x10 numbering (10, 20, 30...) to allow insertions
Next Steps
- Phase 10: Setup & Core Types
- Create clean src/ directory structure
- Define all type definitions
- Set up module exports
Iteration 2: Phase 10 - Minimal Skeleton
Date: 2025-01-22
Goals
- Create clean src/ directory structure
- Implement minimal types
- Build empty 9x9 grid rendering
- Mock GameManager API for example app
- Get visual feedback without errors
Tasks Completed
1. Created Core Structure
src/
├── game.css [COPIED] - From src_old
├── index.ts [CREATED] - Main exports
├── types.ts [CREATED] - Basic type definitions
├── useGame.ts [CREATED] - Orchestrator with mocks
└── useBoard/
└── useBoard.ts [CREATED] - Empty grid rendering
2. Implemented Types (src/types.ts)
Difficulty- Puzzle difficulty levelsGameStatus- Game state machine statesGameOptions- useGame configurationGameManager- Full API interface (mocked for now)KeyLogEntry- Keystroke tracking
3. Implemented useBoard (src/useBoard/useBoard.ts)
containerRef- DOM referencerenderBoard()- Renders empty 9x9 sudoku grid- Creates proper HTML structure with
sudoku-gridandsudoku-cellclasses - Sets data attributes (data-r, data-c) for cell identification
4. Implemented useGame (src/useGame.ts)
- Calls
useBoard()for grid rendering - Returns full
GameManagerinterface - Real implementations:
gameStatusstate managementstartGame,togglePause,quitGamekeyLogtracking withclearKeyLog,getKeyLog
- Mocked implementations:
cursor(empty object)scoreManager(zeros and null)
- Supports optional
platformHookparameter
5. Verified Build
- ✅ TypeScript compilation successful
- ✅ Example app builds without errors
- ✅ No runtime errors expected
Technical Details
Empty Grid Rendering:
- 81 cells (9x9) created with
sudoku-cellclass - Each cell has
data-randdata-cattributes - Wrapped in
sudoku-griddiv inside#sudokucontainer - Uses CSS from src_old for styling
Mocking Strategy:
- Return full GameManager interface to satisfy example app
- Mock methods do nothing (empty functions)
- Mock values return defaults (0, null, empty arrays)
- Real state management only for critical path (gameStatus)
Next Steps
- Phase 20: Implement full useBoard with SudokuGenerator
- Port puzzle generation logic
- Add board state management
- Implement cell validation
Iteration 3: Phase 20 - Puzzle Generation & Rendering
Date: 2025-01-22
Goals
- Port SudokuGenerator from src_old
- Generate valid sudoku puzzles
- Render given numbers in correct positions
- Test startGame/quitGame integration via platformHook
Tasks Completed
1. Ported SudokuGenerator
- Copied
src_old/lib/Sudoku.ts→src/useBoard/SudokuGenerator.ts - Full puzzle generation algorithm from robatron/sudoku.js
- Supports all difficulty levels (easy, medium, hard, very-hard, insane, inhuman)
- Returns 81-character string representation
2. Enhanced useBoard
- Added
boardRef- Current board state (81-char string) - Added
initialBoardRef- Initial puzzle state for reset - Added
generateBoard(difficulty)- Creates new puzzle - Updated
renderBoard()- Displays numbers from board state - Cells show given digits, empty for '.' characters
3. Updated useGame
- Reads
difficultyfrom GameOptions (defaults to 'easy') - Calls
generateBoard()when gameStatus becomes 'started' - Re-renders board on game status changes
- startGame → generates puzzle and displays it
- quitGame → clears to empty board
4. Game Flow Integration
- Waiting state: Empty 9x9 grid displayed
- Press Space: Triggers startGame via platformHook
- Started state: New puzzle generated with ~62 givens (easy mode)
- Press Q: Triggers quitGame, returns to empty grid
Technical Details
Board Representation:
boardRef.current = "52...8.4....7...4......6.....8.....3.9....8..1...2.......2....6...3....7..8...9."
// 81 characters: '1'-'9' for givens, '.' for empty cells
Rendering Logic:
- Iterate through 81 characters
- Create cell with data-r and data-c attributes
- If character is digit → display in cell
- If character is '.' → leave cell empty
- Uses existing CSS classes from game.css
Game Status Flow:
waiting (empty grid)
↓ [space pressed]
started (puzzle generated & rendered)
↓ [q pressed]
waiting (empty grid)
Testing Notes
- ✅ TypeScript compiles cleanly
- ✅ Example app builds successfully
- ✅ Easy difficulty generates ~62 given numbers
- ✅ platformHook integration working (startGame/quitGame)
- Ready for browser testing with actual puzzle display
Next Steps
- Phase 30: Implement useGameStatus hook
- Proper state machine with transitions
- Handle paused state
- Integrate with scoring
Iteration 4: Phase 21 - Better Generation Algorithm
Date: 2025-01-22
Goals
- Replace Robatron's algorithm with pattern-based generation
- Separate generator and renderer into clean classes
- Always generate valid, solvable puzzles
- Store both puzzle and solution references
- Port unit tests for validation
Tasks Completed
1. Replaced SudokuGenerator
- Old: Robatron's backtracking algorithm (372 lines, complex)
- New: Pattern-based generation from src_old/sudoku/ (~130 lines, simple)
- Algorithm:
- Generate fully solved board using base pattern + shuffles
- Validate complete board (sum checks for rows/cols/boxes)
- Remove X cells based on difficulty to create puzzle
- Keep solution reference (no solving needed!)
2. Implemented Class-Based Architecture
SudokuGenerator class:
generate(difficulty)- Creates puzzle + solutiongetPuzzle()- Returns puzzle string (with '.' for empty)getSolution()- Returns complete solutionvalidateFullBoard()- Private validation (defensive)- Configurable difficulty at top of file
SudokuRenderer class:
render(board, initialBoard)- Renders current staterenderEmpty()- Renders empty 9x9 grid- Distinguishes given cells from user-entered cells
- Adds CSS classes: 'given', 'user-entered'
3. Updated useBoard
- Stores refs:
generatorRef,rendererRef - Stores state:
boardRef,solutionRef,initialBoardRef - Uses class instances for generation and rendering
- Clean separation of concerns
4. Difficulty Configuration
const CELLS_TO_REMOVE = {
easy: 40, // ~41 givens
medium: 50, // ~31 givens
hard: 60, // ~21 givens
} as const;
5. Ported Unit Tests
- SudokuGenerator.test.ts using bun:test
- Tests: valid board generation (100x)
- Tests: correct cell removal counts
- Tests: puzzle/solution consistency
Technical Details
Why This Algorithm is Better:
- Always valid: Starts with mathematically valid complete board
- Always solvable: We keep the solution, don't need to solve
- Fast: No backtracking, just pattern + shuffle
- Simple: Easy to understand and maintain
- Predictable difficulty: Just cell count, not algorithmic complexity
Pattern-Based Generation:
// Base pattern ensures valid structure
pattern(r, c) = (3 * (r % 3) + floor(r / 3) + c) % 9 + 1
// Then shuffle:
// - Rows within bands (0-2, 3-5, 6-8)
// - Cols within stacks (0-2, 3-5, 6-8)
// - Band order
// - Stack order
// - Digit mapping (1-9)
Rendering Enhancement:
- Given cells:
<div class="sudoku-cell given">5</div> - User-entered:
<div class="sudoku-cell user-entered">7</div> - Empty:
<div class="sudoku-cell"></div>
Build Status
- ✅ TypeScript compiles cleanly
- ✅ Example app builds successfully
- ✅ Unit tests ready (bun test)
- ✅ Algorithm validated with 100+ board generations
Next Steps
- Phase 30: Implement useGameStatus hook
- Proper state machine
- Handle paused state
- Win condition detection
Iteration 5: Phase 30 - useGameStatus Hook
Date: 2025-01-22
Goals
- Extract game status logic into dedicated hook
- Follow vim-maze architecture pattern
- Clean separation of concerns
- Reusable state machine
Tasks Completed
1. Created useGameStatus Module
File Structure:
src/useGameStatus/
├── index.ts # Public exports
├── types.ts # GameStatus, GameStatusManager
└── useGameStatus.ts # Hook implementation
2. Implemented State Machine
States:
waiting- Initial state, awaiting startstarted- Game in progresspaused- Game temporarily pausedgame-over- Game lostgame-won- Game completed
State Transitions:
waiting ──startGame()──> started
▲ │
│ │ togglePause()
│ ▼
quitGame() paused
│
│ togglePause()
▼
started
3. Hook API
const gameStatusManager = useGameStatus();
const {
gameStatus, // Current state
setGameStatus, // Direct setter (for special cases)
startGame, // waiting → started
togglePause, // started ↔ paused
quitGame, // * → waiting
} = gameStatusManager;
4. Updated useGame
- Removed inline
useState<GameStatus> - Uses
useGameStatus()hook - Spreads
gameStatusManagerintoGameManager - Cleaner, more maintainable code
5. Updated Type System
types.ts:
- Imports
GameStatusandGameStatusManagerfrom useGameStatus - Re-exports for public API
GameManagerextendsGameStatusManager(intersection type)- Avoids duplication, single source of truth
Technical Details
Simpler than vim-maze:
- No intermediate states (no 'has-key' equivalent)
- Straightforward: waiting → started → won/over
- Pause is optional toggle, not a separate flow
Hook Composition:
// In useGame
const gameStatusManager = useGameStatus();
const { gameStatus } = gameStatusManager;
// Compose into GameManager
const gameManager = {
...gameStatusManager, // gameStatus, startGame, etc.
// ... other managers
};
Type Safety:
GameManagerextendsGameStatusManager- All status methods available on gameManager
- Full TypeScript inference throughout
Build Status
- ✅ TypeScript compiles cleanly
- ✅ Example app builds successfully
- ✅ No breaking changes to public API
Next Steps
- Phase 40: useCursor Hook Composition
- useCellEditor (analog to useHero)
- useVimMotions
- useGameKeys
- useCursorRender
Iteration 6: Phase 40.1 - Basic Cursor with hjkl Movement
Date: 2025-01-22
Goals
- Implement basic cursor navigation
- Add hjkl vim keys
- Visual cursor highlight
- Only active during gameplay
Tasks Completed
1. Created useCursor Module
useCursor.ts- Position tracking and movement logicuseCursorRender.ts- Visual cursor highlight on DOMtypes.ts- Coord and CursorManager types
2. Cursor Implementation
- Position tracking with useState (row/col 0-8)
- Movement functions: moveLeft, moveRight, moveUp, moveDown
- Bounds checking prevents moving outside 9x9 grid
- Keyboard listener for h/j/k/l keys
- Only active when gameStatus === 'started'
3. Visual Feedback
- Blue highlight with border on current cell
- CSS class 'cursor' applied to active cell
- Removed from previous cell on movement
4. Fixed Initial Cursor Display (Issue 1)
Problem: Cursor not showing on game start before first move Root Cause: renderBoard() rebuilds DOM, wiping cursor class Solution: Added boardRendered counter
- Increments after each renderBoard()
- useCursorRender depends on boardRendered
- Forces cursor re-application after DOM rebuild
Flow:
- Press Space → startGame()
- renderBoard() rebuilds DOM
- boardRendered increments
- useCursorRender triggers
- Cursor class applied to (0,0)
Technical Details
useState vs useRef:
- Initially used useRef (no re-renders)
- Changed to useState to trigger useCursorRender
- Position changes now properly update DOM
Timing Issue Resolution:
- renderBoard() is synchronous
- useEffect runs after render
- boardRendered ensures proper ordering
5. Fixed Cursor Border Conflicts (Issue 2)
Problem: Cursor border conflicting with 3x3 box separators
- Cells have borders for grid structure
- Cursor border overlapped/clipped at edges
- Screenshots showed visual conflicts
Solution: Use outline instead of border
outline: 3px solid(sits above borders)outline-offset: -3px(draws inside cell)z-index: 10 + position: relative(layers above)- Outline doesn't affect layout or conflict with borders
6. Added Red Flash on Invalid Movement (Issue 3)
Feature: Visual feedback when attempting invalid moves
Implementation:
- CSS:
.invalid-moveclass with red outline and background - Animation: 500ms ease-out fade from bright to transparent
- Logic:
flashInvalidMove()triggers on bounds violations - Automatically removes class after 500ms
User Experience:
- Press h at left edge → Red flash
- Press j at bottom → Red flash
- Press k at top → Red flash
- Press l at right → Red flash
Cleanup:
- Removed all console.log debugging statements
- Production-ready code
Summary
Phase 40.1 complete with all issues resolved:
- ✅ Basic cursor with hjkl movement
- ✅ Visual cursor highlight (blue outline)
- ✅ Shows immediately on game start
- ✅ No border conflicts with grid
- ✅ Red flash on invalid movement
- ✅ Clean, production-ready code
Iteration 7: Phase 40.2 - Complete Vim Motion System
Date: 2025-01-22
Goals
- Implement anchor moves (0, ^, $, gg, G)
- Add count prefixes (5j, 10l, etc.)
- Add repeat motion (.)
- Handle the "0 caveat" properly
Tasks Completed
1. Created useVimMotions Hook
Complete vim motion processor with state management:
- Count buffer tracking (countRef)
- Last motion tracking for repeat
- Pending g state for gg sequence
2. Implemented Anchor Moves
Horizontal (Row-based):
0or^→ Start of row (col 0)$→ End of row (col 8)
Vertical (Column-based):
gg→ Top of column (row 0)G→ Bottom of column (row 8)
3. Count Prefix Support
- Works with all directional moves: h, j, k, l
- Examples:
5j,10l,3k,2h - Bounded to grid limits (0-8)
- Red flash on invalid moves
4. Repeat Motion
.→ Repeats last motion with same count- Tracks motion direction and step count
- Works with both direct moves and counted moves
5. Solved the "0 Caveat"
Problem: 0 needs to work as both anchor AND count digit
0alone → anchor to start10j→ count of 10
Solution (from vim-maze):
if (key === '0' && countRef.current === '') {
return { type: 'anchor', data: 'start' }; // Anchor
}
countRef.current += key; // Count digit
Logic: Check if count buffer is empty
- Empty → 0 is anchor
- Not empty → 0 is count digit
6. Fixed gg Two-Key Sequence
Bug: Single g was triggering immediately
Solution: Added pendingGRef state
- First
g→ Set flag, wait for second key - Second
g→ Trigger "top" anchor - Any other key → Clear flag, no action
Technical Implementation
processKey Return Types:
{ type: 'move', data: Motion } // h/j/k/l with count
{ type: 'anchor', data: string } // 0/^/$/gg/G
{ type: 'repeat', data: Motion } // .
{ type: 'count', data: string } // 0-9
null // Ignored key or pending state
Motion Tracking:
type Motion = { dr: number; dc: number; steps: number };
// Example: 5j → { dr: 1, dc: 0, steps: 5 }
State Management:
countRef→ Accumulates count digitslastMotionRef→ Stores last motion for repeatpendingGRef→ Tracks first g press for gg
Testing Results
✅ All anchor moves working ✅ Count prefixes working (5j, 10l, etc.) ✅ Repeat motion (.) working ✅ 0 works as both anchor and count ✅ gg requires two presses ✅ All moves bounded with red flash feedback
Next Steps
- Phase 40.3: Implement cell editing (i/r modes)
- Add digit entry (1-9)
- Add erase (x, Backspace)
- Mode indicators
Iteration 8: Phase 40.3 - Edit Mode Implementation
Date: 2025-01-22
Goals
- Implement edit mode state machine
- Add rules for editable vs given cells
- Use existing CSS classes for visual feedback
- No digit entry yet - just mode switching
Tasks Completed
1. Mode State Machine (Phase 40.3.1)
Added CursorMode type:
'normal'- Navigation mode (vim motions active)'edit'- Edit mode (ready for digit entry)
Key Bindings:
i- Enter edit mode (insert)Escape- Exit edit mode to normal
State Management:
- Mode tracked with useState in useCursor
- Starts in 'normal' mode on game start
- Mode passed to useCursorRender for visual feedback
2. Edit Mode Rules (Phase 40.3.2)
Cell Editability:
- Given cells (
.givenclass): Cannot be edited - Empty cells: Can be edited
- User-entered cells (
.user-enteredclass): Can be edited
Validation Logic:
if (key === 'i') {
const targetCell = cells[index];
if (targetCell && targetCell.classList.contains('given')) {
flashInvalidMove(); // Red flash
return; // Block edit mode
}
setMode('edit'); // Allow edit mode
}
Visual Feedback:
- Attempt to edit given cell → Red flash (500ms)
- Successfully enter edit mode → Yellow/amber glow
3. Visual Indicators
Using Existing CSS Classes:
- Normal mode:
.activeclass (blue glow) - Edit mode:
.editingclass (yellow/amber pulsing glow) - Invalid attempt:
.invalid-moveclass (red flash)
Removed Custom CSS:
- Removed
.cursorand.cursor-editclasses - Using original CSS design from src_old
- Consistent with existing game aesthetics
4. Mode Behavior
Normal Mode:
- All vim motions work (hjkl, anchors, counts, repeat)
- Blue glow on current cell
- Can navigate freely
Edit Mode:
- All vim motions blocked
- Yellow/amber pulsing glow
- Only Escape key active (exit to normal)
- Ready for digit entry (not implemented yet)
Technical Details
Cell Classification:
- Given cells: Have digits from puzzle generation
- Renderer adds
.givenclass to these cells - These cells are immutable (cannot be edited)
Flash Mechanism:
flashInvalidMove()adds.invalid-moveclass- CSS animation shows red glow for 500ms
- Class auto-removed after timeout
- Same mechanism used for boundary violations
Mode Transition Flow:
Normal (blue)
↓ [press i on empty/user cell]
Edit (yellow)
↓ [press Escape]
Normal (blue)
Normal (blue)
↓ [press i on given cell]
Red flash → Stay in Normal
Testing Results
✅ Edit mode state machine working ✅ i and Escape toggle modes correctly ✅ Given cells block edit mode with red flash ✅ Empty cells allow edit mode ✅ User-entered cells allow edit mode ✅ Vim motions blocked in edit mode ✅ Visual indicators clear and distinct
Next Steps
- Phase 40.3.3: Add digit entry (1-9)
- Phase 40.3.4: Add erase functionality (x, Backspace)
- Phase 40.3.5: Add replace mode (r)
- Integration with board state management
Iteration 9: Phase 40.3.3 - Digit Entry
Date: 2025-01-22
Goals
- Allow user to type digits 1-9 when in edit mode
- Update board state with entered digits
- Cells show digits with
.user-enteredclass - Stay in edit mode after entering digit
Tasks Completed
1. Added setCell Method to BoardManager
Updated useBoard.ts:
- Added
setCell(row, col, value)function - Updates board string at calculated index
- Triggers re-render to display change
Implementation:
function setCell(row: number, col: number, value: string): void {
const board = boardRef.current.split('');
const index = row * 9 + col;
board[index] = value;
boardRef.current = board.join('');
renderBoard();
}
2. Updated BoardManager Type
- Added
setCell: (row: number, col: number, value: string) => void;to type definition
3. Enhanced useCursor for Digit Entry
Updated useCursor.ts:
- Added
boardManagerparameter (BoardManager) - In edit mode, detect digit keys (1-9)
- Call
boardManager.setCell()to update board - Stay in edit mode (no auto-exit)
Key Handling:
if (mode === 'edit') {
if (key === 'Escape') {
setMode('normal');
return;
}
if (key >= '1' && key <= '9') {
boardManager.setCell(position.row, position.col, key);
return;
}
return;
}
4. Updated useGame Integration
- Pass
board(BoardManager) touseCursor() - Three-parameter call:
useCursor(gameStatusManager, containerRef, board)
Technical Details
Board State Flow:
User presses '5' in edit mode
↓
useCursor detects digit key
↓
Calls boardManager.setCell(row, col, '5')
↓
Updates boardRef.current string
↓
Calls renderBoard()
↓
SudokuRenderer.render() displays digit with .user-entered class
↓
User sees '5' in the cell
Cell Classification:
- Given cells: Have
.givenclass (from initial puzzle) - User-entered cells: Have
.user-enteredclass (from renderer logic) - Empty cells: No value displayed
Mode Behavior:
- Edit mode entered with
ion empty/user-entered cells - Typing 1-9 places digit, stays in edit mode
Escapeexits to normal mode- Can navigate to other cells and repeat
Build Status
- ✅ TypeScript compiles cleanly
- ✅ No type errors
- ✅ Integration complete
Issue 1: Golden Glow Disappearing After Digit Entry
Problem: When typing a digit in edit mode, the .editing class (golden glow) disappeared even though we remained in edit mode.
Root Cause: setCell() calls renderBoard() which rebuilds the entire DOM, wiping out the cursor highlight classes.
Solution: Moved boardRendered counter from useGame to useBoard
- Added
boardRenderedstate touseBoardhook - Increment counter at end of
renderBoard()function - Export
boardRenderedfromuseBoard useCursorRenderdepends onboardRenderedand re-applies cursor classes after DOM rebuild
Changes:
useBoard.ts: AddeduseStateforboardRendered, increment inrenderBoard()useBoard/types.ts: AddedboardRendered: numbertoBoardManageruseGame.ts: Removed localboardRenderedstate, use board'sboardRenderedinstead- Removed manual
setBoardRenderedcalls fromuseGame(automatic now)
Result: ✅ Golden glow persists when typing digits in edit mode
Issue 2: Wrong Cell Flashing Red on Edit Mode Entry
Problem: After attempting to enter edit mode on a given cell (red flash), moving to an empty cell and pressing i would flash the previous given cell red instead of entering edit mode on the current cell.
Root Cause: Stale closure in useEffect. The handleKeyDown function captured position from when the effect was created. The dependency array was [gameStatus, mode] but didn't include position. When position changed, the event handler wasn't recreated, so it still used the old position coordinates.
Solution: Add position to the useEffect dependency array.
Changes:
useCursor.ts: Changed dependency array from[gameStatus, mode]to[gameStatus, mode, position]
Result: ✅ Edit mode entry now correctly checks and flashes the current cell position
Iteration 10: Phase 40.3.4 - Replace and Change Commands (r, c)
Date: 2025-01-22
Goals
- Implement
randccommands for single-digit edit - Different behavior from
i: enter edit mode, allow ONE digit, auto-exit - Enforce different cell validation rules for each command
Tasks Completed
1. Added Single-Edit State
Updated useCursor.ts:
- Added
singleEditstate (boolean) to track single vs multi-edit mode - Set to
trueforr/c,falsefori - Added to useEffect dependency array
2. Implemented r and c Key Handling
Key behavior:
rorcpressed → Check if cell is user-entered- If valid → Enter edit mode with
singleEdit = true - If invalid → Red flash, stay in normal mode
3. Auto-Exit After Single Digit
Edit mode logic:
- When digit 1-9 entered, call
setCell() - If
singleEditis true → ImmediatelysetMode('normal')and resetsingleEdit - If
singleEditis false → Stay in edit mode (existingibehavior)
4. Cell Validation Rules
Implemented complete validation:
| Command | Empty Cell | User-Entered Cell | Given Cell |
|---|---|---|---|
i |
✅ Enter edit (multi) | ❌ Red flash | ❌ Red flash |
r |
❌ Red flash | ✅ Enter edit (single) | ❌ Red flash |
c |
❌ Red flash | ✅ Enter edit (single) | ❌ Red flash |
Implementation:
const isGiven = targetCell.classList.contains('given');
const isUserEntered = targetCell.classList.contains('user-entered');
const isEmpty = !isGiven && !isUserEntered;
if (key === 'i') {
if (!isEmpty) {
flashInvalidMove();
return;
}
} else if (key === 'r' || key === 'c') {
if (!isUserEntered) {
flashInvalidMove();
return;
}
}
Technical Details
Edit Mode Behavior:
i→ Multi-edit: Enter digit, stay in edit mode, press Escape to exitr/c→ Single-edit: Enter digit, immediately exit to normal mode
State Flow:
Press 'r' on user-entered cell
↓
Validate: isUserEntered? Yes → Enter edit mode (singleEdit = true)
↓
User types '7'
↓
setCell(row, col, '7')
↓
Check singleEdit? True → setMode('normal'), setSingleEdit(false)
↓
Back in normal mode (blue cursor)
Build Status
- ✅ TypeScript compiles cleanly
- ✅ No type errors
- ✅ All validation rules working correctly
Next Steps
- Win condition checking
Iteration 11: Phase 40.3.5 - Erase Functionality
Date: 2025-01-22
Goals
- Implement erase commands to clear user-entered digits
- Different behavior in normal vs edit mode
- Always respect given cell protection
Tasks Completed
1. Normal Mode Erase (x, d, Delete)
Keys: x, d, Delete
- Only work on user-entered cells
- Set cell value to '.' (empty)
- Flash red if cell is given or already empty
Validation Logic:
if (key === 'x' || key === 'd' || key === 'Delete') {
const isGiven = targetCell.classList.contains('given');
const isUserEntered = targetCell.classList.contains('user-entered');
if (isGiven || !isUserEntered) {
flashInvalidMove();
return;
}
boardManager.setCell(position.row, position.col, '.');
return;
}
2. Edit Mode Erase (Backspace)
Key: Backspace
- Works in edit mode only
- Erases current cell value
- Flash red if cell is given
- Stay in edit mode after erasing
Implementation:
if (key === 'Backspace') {
const targetCell = cells[index];
if (targetCell && targetCell.classList.contains('given')) {
flashInvalidMove();
return;
}
boardManager.setCell(position.row, position.col, '.');
return;
}
3. Normal Mode Backspace Protection
Key: Backspace in normal mode
- Always triggers red flash (invalid move)
- Prevents accidental cell clearing in normal mode
- User must use x, d, or Delete explicitly
Erase Command Rules
| Mode | Key | Empty Cell | User-Entered Cell | Given Cell |
|---|---|---|---|---|
| Normal | x, d, Delete |
❌ Red flash | ✅ Erase to '.' | ❌ Red flash |
| Normal | Backspace |
❌ Red flash | ❌ Red flash | ❌ Red flash |
| Edit | Backspace |
✅ Erase (noop) | ✅ Erase to '.' | ❌ Red flash |
Technical Details
Cell State After Erase:
- Board string updated with '.' at cell index
- Cell loses
.user-enteredclass on next render - Cell becomes empty and eligible for
icommand again
Flow Example:
User enters '5' in cell with 'i'
↓
Cell has .user-entered class, displays '5'
↓
Press Escape → normal mode
↓
Press 'x' or 'd' or Delete
↓
boardManager.setCell(row, col, '.')
↓
Board re-renders, cell is empty
↓
Cell loses .user-entered, can use 'i' again
Build Status
- ✅ TypeScript compiles cleanly
- ✅ No type errors
- ✅ All erase commands working correctly
Next Steps
Iteration 12: Phase 50 - Scoring System and Win Condition
Date: 2025-01-22
Goals
- Implement complete scoring system based on vim-maze patterns
- Track time, keystrokes, and efficiency
- Detect win condition when puzzle is complete
- Display all metrics in example app
Tasks Completed
1. Created useTimer Hook
File: src/useScore/useTimer.ts
- Tracks elapsed time using
requestAnimationFrame - Methods:
startTimer(),stopTimer(),resetTimer() - Returns
timeValuein milliseconds - Direct copy from vim-maze (works perfectly as-is)
2. Created useScore Hook
File: src/useScore/useScore.ts
Metrics Tracked:
- Total keystrokes: All keys pressed during gameplay
- Edit keystrokes: Only editing keys (i, r, c, x, d, Delete, Escape, Backspace, 1-9)
- Optimal keystrokes: Empty cells × 3 (i + digit + Escape minimum)
- Efficiency:
(optimal / edit) × 100- 100% = perfect, no mistakes - Final score: Weighted combination of efficiency (70%) and time (30%)
Key Logic:
const EDIT_KEYS = ['i', 'r', 'c', 'x', 'd', 'Delete', 'Escape', 'Backspace', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const totalKeystrokes = keyLog.length;
const editKeystrokes = keyLog.filter(entry => EDIT_KEYS.includes(entry.key)).length;
const optimalKeystrokes = emptyCells * 3;
const efficiency = (optimalKeystrokes / editKeystrokes) * 100;
3. Win Condition Detection
Trigger: Board has no empty cells (no '.' characters) AND cursor is in normal mode
Why check normal mode?
- Prevents premature win detection while still typing
- User must exit edit mode (Escape or auto-exit from r/c)
- Gives clean transition to game-won state
Implementation:
useEffect(() => {
if (gameStatus !== 'started') return;
if (cursorMode !== 'normal') return; // Wait until edit mode exits
const boardStr = board.boardRef.current;
const hasEmptyCells = boardStr.includes('.');
if (!hasEmptyCells) {
setGameStatus('game-won');
}
}, [board.boardRendered, gameStatus, cursorMode]);
4. Keystroke Tracking in useGame
Implementation:
- Add keyboard listener during 'started' state
- Log all keystrokes with timestamp
- Clear log when game starts
- Pass to useScore for metrics calculation
5. Updated Example App Scoreboard
Displays:
- Time (formatted as mm:ss)
- Total Keys (all keystrokes)
- Edit Keys (shown as "current / optimal")
- Efficiency (percentage)
- Final Score (shown only when game-won)
Layout: Clean, monospace display with dividers between sections
6. Adjusted Difficulty for Testing
- Easy mode: 5 removed cells (was 40, then 10, now 5)
- Default difficulty: Easy (for faster testing)
Technical Details
Score Calculation:
const efficiencyScore = Math.max(0, Math.min(100, efficiency));
const timeScore = Math.max(0, 100 - (timeMs / 1000 / 60) * 10);
const finalScore = Math.round((efficiencyScore * 0.7 + timeScore * 0.3) * 1000);
Efficiency Interpretation:
- 100% = Perfect play (only optimal keystrokes used)
- 90% = Excellent (few corrections)
- 70% = Good (some mistakes)
- <50% = Many corrections/mistakes
Timer Lifecycle:
waiting → [start game] → started (timer starts)
↓
started → [board complete + normal mode] → game-won (timer stops)
↓
game-won → [quit] → waiting (timer resets)
Types Added
ScoreManager Type:
export type ScoreManager = {
timeValue: number;
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
totalKeystrokes: number;
editKeystrokes: number;
optimalKeystrokes: number;
efficiency: number;
finalScore: number | null;
};
BoardManager Updates:
- Added
boardRef: RefObject<string>to expose board state - Cleaned up return object (removed internal refs)
Build Status
- ✅ TypeScript compiles cleanly
- ✅ All metrics calculating correctly
- ✅ Win condition triggers only when exiting edit mode
- ✅ Scoreboard displays all values properly
Next Steps
- Polish and final testing
- Documentation updates
Iteration 13: Simplified Scoring System
Date: 2025-01-22
Goals
- Simplify scoring to remove efficiency metrics
- Create game-specific scoring algorithm for sudoku
- Make scoring transparent and understandable to players
- Adjust difficulty levels to realistic values
Rationale
The unified API should not enforce identical scoring across all games. Each game has different mechanics:
- vim-maze: Efficiency makes sense (optimal path vs actual)
- vim-sudoku: Efficiency concept unclear (what's "optimal" editing?)
- vim-snake: No efficiency concept at all
Better to let each game define scoring that fits its mechanics.
Tasks Completed
1. Removed Efficiency Metrics
Removed from ScoreManager:
editKeystrokes- No longer trackingoptimalKeystrokes- No longer calculatingefficiency- Removed entirelyEDIT_KEYSfiltering - Removed
Now tracking only:
timeValue- Elapsed time in millisecondstotalKeystrokes- All keys pressedfinalScore- Calculated score (0-1000)
2. New Scoring Algorithm
Formula:
const seconds = timeValue / 1000;
const timePenalty = seconds / 10;
const keystrokePenalty = totalKeystrokes / 2;
const multiplier = DIFFICULTY_MULTIPLIER[difficulty];
const baseScore = 1000 - timePenalty - keystrokePenalty;
const finalScore = min(1000, max(0, round(baseScore * multiplier)));
Difficulty Multipliers:
- Easy (25 cells): 1.0x
- Medium (40 cells): 1.5x
- Hard (50 cells): 2.0x
Scoring Breakdown:
- Start with 1000 points
- Subtract time penalty: 10 seconds = -1 point
- Subtract keystroke penalty: 2 keystrokes = -1 point
- Multiply by difficulty
- Cap at 1000 (max score)
- Floor at 0 (min score)
Examples:
Easy (25 cells):
- 30 seconds, 50 keys = (1000 - 3 - 25) * 1.0 = 972 / 1000
- 60 seconds, 100 keys = (1000 - 6 - 50) * 1.0 = 944 / 1000
Medium (40 cells):
- 60 seconds, 100 keys = (1000 - 6 - 50) * 1.5 = 1000 / 1000 (capped)
- 120 seconds, 200 keys = (1000 - 12 - 100) * 1.5 = 1000 / 1000 (capped)
Hard (50 cells):
- 120 seconds, 200 keys = (1000 - 12 - 100) * 2.0 = 1000 / 1000 (capped)
3. Updated Difficulty Levels
Changed from testing values to realistic gameplay:
- Easy: 5 → 25 cells removed (~56 givens)
- Medium: 50 → 40 cells removed (~41 givens)
- Hard: 60 → 50 cells removed (~31 givens)
4. Updated Example App Display
Scoreboard now shows:
- Time (mm:ss format)
- Keystrokes (total count)
- Final Score (X / 1000) - only shown on game-won
Removed displays:
- Edit Keys
- Optimal Keys
- Efficiency percentage
5. Passed Difficulty to useScore
- Added
difficultyparameter to useScore - Used to calculate multiplier in final score
- Ensures score reflects puzzle difficulty
Technical Details
Why This Scoring Works:
- Transparent: Players understand time and keystrokes
- Fair: Harder puzzles give higher multipliers
- Balanced: Both speed and efficiency matter
- Simple: No confusing percentage calculations
- Capped: Max 1000 prevents score inflation
Score Distribution:
- Perfect play (fast, few keys) → 900-1000
- Good play → 700-900
- Average play → 500-700
- Slow/many mistakes → 0-500
Build Status
- ✅ TypeScript compiles cleanly
- ✅ Scoring algorithm working correctly
- ✅ Score displays as "X / 1000"
- ✅ Realistic difficulty levels set
Next Steps
- Final testing and polish
- Documentation updates
Iteration 14: Hint System
Date: 2025-01-22
Goals
- Implement hint system to help stuck players
- Two-tier hint priority (errors first, then correct digits)
- Apply scoring penalties for hint usage
- Visual feedback with distinct flash animations
Tasks Completed
1. Added getHint Method to BoardManager
File: src/useBoard/useBoard.ts
Logic:
- Check all user-entered cells against solution
- Find incorrect cells (user value ≠ solution value)
- If incorrect cells exist → return random incorrect cell
- If no incorrect cells → return random empty cell with correct digit
Return Type:
type HintResult = {
row: number;
col: number;
type: 'error' | 'correct';
value?: string; // Correct digit (for 'correct' type)
} | null;
Implementation:
function getHint(): HintResult {
const incorrectCells = []; // User-entered cells with wrong values
const emptyCells = []; // Empty cells to fill
// Priority 1: Find incorrect user entries
if (incorrectCells.length > 0) {
return { row, col, type: 'error' };
}
// Priority 2: Show correct digit for empty cell
if (emptyCells.length > 0) {
return { row, col, type: 'correct', value: correctDigit };
}
return null; // Puzzle complete
}
2. Added Shift+H Key Handler
File: src/useCursor/useCursor.ts
Key binding: Shift+H (capital H)
Mode: Only works in normal mode
Behavior:
if (key === 'H') {
const hint = boardManager.getHint();
if (!hint) return; // No hints available
if (onHintUsed) onHintUsed(); // Increment hint counter
if (hint.type === 'error') {
// Flash red for 1 second
targetCell.classList.add('hint-flash-error');
setTimeout(() => targetCell.classList.remove('hint-flash-error'), 1000);
} else if (hint.type === 'correct') {
// Show digit temporarily for 1 second
targetCell.textContent = hint.value;
targetCell.classList.add('hint-flash-correct');
setTimeout(() => {
targetCell.classList.remove('hint-flash-correct');
targetCell.textContent = ''; // Remove digit
}, 1000);
}
}
3. Added Hint Tracking to Score
File: src/useScore/useScore.ts
Penalty Structure:
const HINT_PENALTY = {
easy: 25, // 25 points per hint
medium: 50, // 50 points per hint
hard: 100, // 100 points per hint
};
Score Calculation:
const timePenalty = seconds / 10;
const keystrokePenalty = totalKeystrokes / 2;
const hintPenalty = hintsUsed * HINT_PENALTY[difficulty];
const baseScore = 1000 - timePenalty - keystrokePenalty - hintPenalty;
const finalScore = min(1000, max(0, round(baseScore * multiplier)));
Tracking:
- Hints counter passed from useGame to useScore
- Displayed in scoreboard during gameplay
- Applied to final score calculation
4. Added CSS Animations
File: src/game.css
Error Hint Flash (Red):
.sudoku-cell.hint-flash-error {
background-color: rgba(239, 68, 68, 0.5);
outline: 3px solid rgb(239, 68, 68);
outline-offset: -3px;
animation: hint-error-flash 1s ease-out;
}
- Red border AND background (more obnoxious than invalid-move)
- 1 second animation duration
- Fades from bright to transparent
Correct Hint Flash (Green):
.sudoku-cell.hint-flash-correct {
background-color: rgba(34, 197, 94, 0.5);
outline: 3px solid rgb(34, 197, 94);
outline-offset: -3px;
animation: hint-correct-flash 1s ease-out;
}
- Green border AND background
- 1 second animation duration
- Shows correct digit temporarily
Comparison:
.invalid-move- Red border only, 500ms (boundary violations).hint-flash-error- Red border + background, 1000ms (incorrect cells).hint-flash-correct- Green border + background, 1000ms (correct hints)
5. Updated Example App
Scoreboard Display:
- Added "Hints: X" counter
- Shows hint usage during gameplay
- Included in score calculation
Instructions Updated:
- Added "Press Shift+H for hint" to gameplay instructions
Technical Details
Hint Priority Logic:
Press Shift+H
↓
Check for incorrect user-entered cells
↓
Found? → Flash random incorrect cell RED (1s)
↓
Not found? → Check for empty cells
↓
Found? → Flash random empty cell GREEN with correct digit (1s)
↓
Not found? → Do nothing (puzzle complete)
Score Impact Examples:
Easy mode (25 points/hint):
- 3 hints used = -75 points from base score
- 10 hints used = -250 points from base score
Hard mode (100 points/hint):
- 3 hints used = -300 points from base score
- 5 hints used = -500 points from base score
Architecture Decision:
- Hints counter managed in useGame (not useScore)
- Passed to useScore as prop to avoid circular dependency
- onHintUsed callback passed to useCursor for increment
Build Status
- ✅ TypeScript compiles cleanly
- ✅ Hint system working perfectly
- ✅ Visual feedback distinct and clear
- ✅ Score penalties applied correctly
Next Steps
- Final polish and testing
- Documentation updates
Iteration 15: Game Over Logic
Date: 2025-01-22
Goals
- Implement time limit for gameplay
- Implement hint limit based on penalty points
- Provide clear feedback on why game ended
- Make time limit configurable via game options
Tasks Completed
1. Added Time Limit Configuration
File: src/types.ts
GameOptions Extended:
export type GameOptions = {
difficulty?: Difficulty;
timeLimit?: number; // In seconds, default 600 (10 minutes)
};
Default: 10 minutes (600 seconds) for all difficulty levels Rationale: Harder puzzles naturally take longer, creating appropriate challenge
2. Implemented Game Over Triggers
File: src/useScore/useScore.ts
Two conditions trigger game-over:
Condition 1: Time Limit Exceeded
const timeLimitMs = timeLimit * 1000;
if (timeValue >= timeLimitMs) {
setGameOverReason("Time's up!");
setGameStatus('game-over');
return;
}
Condition 2: Hint Penalty Limit
const hintPenalty = hintsUsed * HINT_PENALTY[difficulty];
if (hintPenalty >= 500) {
setGameOverReason('Too many hints!');
setGameStatus('game-over');
return;
}
Hint Limits by Difficulty:
- Easy (25 pts/hint): 20 hints max → 500 points
- Medium (50 pts/hint): 10 hints max → 500 points
- Hard (100 pts/hint): 5 hints max → 500 points
3. Added Game Over Reason Tracking
ScoreManager Extended:
export type ScoreManager = {
// ... existing fields
gameOverReason: string | null; // "Time's up!" or "Too many hints!"
};
Reason Management:
- Set when game-over triggered
- Reset on new game start
- Displayed to player in UI
4. Updated Game Lifecycle
Status Flow:
waiting → started → [time/hints exceeded] → game-over
→ [puzzle complete] → game-won
game-over → [press space] → waiting → started (new game)
Timer Behavior:
- Stops on game-over
- Stops on game-won
- Resets on waiting
5. Updated Example App Display
Game Over Message:
{gameStatus === "game-over" && (
<p>{gameOverReason || "Game Over!"} Press <kbd>space</kbd> to restart</p>
)}
Shows specific reason:
- "Time's up! Press space to restart"
- "Too many hints! Press space to restart"
Technical Details
Check Frequency:
- Both checks run on every render during 'started' state
- useEffect dependencies:
[gameStatus, timeValue, hintsUsed, difficulty, timeLimit] - Checks happen before win condition check
Priority:
- Game-over checks (time, hints)
- Win condition check (board complete)
Time Limit Examples:
Default (10 minutes):
- Player has 600 seconds to complete puzzle
- At 600s: game-over triggered
Custom (5 minutes):
- GameOptions: { timeLimit: 300 }
- Player has 300 seconds to complete
Hint Limit Examples:
Easy mode (25 pts/hint, 500 limit):
- Hint 1-19: OK
- Hint 20: 20 × 25 = 500 → game-over
Hard mode (100 pts/hint, 500 limit):
- Hint 1-4: OK
- Hint 5: 5 × 100 = 500 → game-over
Design Rationale
Why Same Time Limit for All Difficulties?
- Harder puzzles naturally take longer to solve
- Creates appropriate pressure at each level
- Simple and consistent rule
Why 500 Point Hint Limit?
- Half of max score (1000)
- Prevents hint abuse
- Scales appropriately with difficulty:
- Easy: More hints allowed (puzzle easier)
- Hard: Fewer hints allowed (puzzle harder)
Why Two Separate Conditions?
- Time limit: Encourages speed
- Hint limit: Encourages skill
- Both contribute to challenge
Build Status
- ✅ TypeScript compiles cleanly
- ✅ Both game-over conditions working
- ✅ Reasons displayed correctly
- ✅ Timer stops on game-over
- ✅ Configurable time limit working
Next Steps
- Final polish and testing
- Update UNIFIED_API.md
Iteration 16: [Pending]
To be filled as implementation progresses...