Lightweight, typed React hooks for building interactive maze games with VIM-style navigation.
Part of the VIMazing project.
- Features
- Installation
- Quick Start
- API Reference
- Game States
- VIM Controls
- Scoring System
- Game Over Conditions
- Configuration
- Example App
- License
- 🎮 VIM navigation – Full hjkl movement with counts, anchors, and repeat
- 🗺️ Procedural mazes – Algorithm-generated mazes with guaranteed solvability
- ⌨️ Complete VIM motions – hjkl, counts (5j), anchors (^/$), gg/G, repeat (.)
- 🎯 Maze navigation – Find key, reach exit, with wall collision detection
- ⏱️ Time-based gameplay – Configurable time limits with game-over on timeout
- 📊 Simple scoring – Time + keystrokes with maze size multiplier
- 🎨 Tokyo Night theme – Beautiful dark theme with clear visual feedback
- 📦 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-maze
Or with bun:
bun add @vimazing/vim-maze
import { useGame } from "@vimazing/vim-maze";
import "@vimazing/vim-maze/game.css";
export function MazeGame() {
const gameManager = useGame({
rows: 24,
cols: 32,
timeLimit: 600 // 10 minutes
});
const { containerRef, gameStatus, scoreManager, startGame } = gameManager;
return (
<div>
<h1>VIMazing Maze</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 = {
rows: number; // Maze height in cells
cols: number; // Maze width in cells
timeLimit?: number; // In seconds, default: 600 (10 min)
};
Examples:
// Small maze
useGame({ rows: 16, cols: 24 })
// Large maze with time pressure
useGame({
rows: 32,
cols: 48,
timeLimit: 300 // 5 minutes
})
// Custom configuration
useGame({
rows: 24,
cols: 32,
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 (Hero)
cursor: CursorManager;
hero: HeroManager;
renderer: HeroRenderManager;
// Scoring
scoreManager: ScoreManager;
// Input Tracking
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
};
type CursorManager = {
position: () => Coord; // Current { row, col }
mode: () => CursorMode; // 'normal' | 'insert'
move: (dCols: number, dRows: number, count: number) => void;
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; // .
resetCount: () => void;
getCount: () => string;
hasCount: () => boolean;
hero?: HeroManager;
};
type HeroManager = {
heroPos: Coord | null; // Hero location
canMoveTo: (coord: Coord) => boolean;
moveTo: (coord: Coord) => void;
pickupKey: () => void;
reachExit: () => void;
reset: () => void;
};
type ScoreManager = {
timeValue: number; // Milliseconds elapsed
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
distToKey: number; // Distance to key
distToExit: number; // Distance to exit
keystrokes: number; // Total keys pressed
finalScore: number | null; // 0-1000, null until game-won
};
The game follows a strict state machine:
waiting → started → has-key → game-won
↓
game-over
All states → [quit] → waiting
started/has-key ↔ paused
State | Description | Triggers |
---|---|---|
waiting |
Initial state, awaiting start | Default on load |
started |
Game in progress, searching for key | Press Space or call startGame() |
has-key |
Key obtained, heading to exit | Hero reaches key cell |
paused |
Game temporarily paused | Press P or call togglePause() |
game-over |
Failed to complete in time | Time limit exceeded |
game-won |
Successfully reached exit with key | Hero reaches exit after getting key |
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, 10l moves 10 right |
0 or ^ |
Jump to row start | Move to leftmost walkable cell |
$ |
Jump to row end | Move to rightmost walkable cell |
gg |
Jump to maze top | Move to topmost walkable row |
G |
Jump to maze bottom | Move to bottommost walkable row |
. |
Repeat last motion | Repeats with same count |
Key | Action | Notes |
---|---|---|
q |
Quit game | Return to waiting state |
p |
Pause/unpause | Toggle pause state |
Space |
Start new game | Only in waiting/game-over state |
- Wall collision: Movement stops at walls, no wrapping
- Counted moves: Multi-step movements (e.g.,
5j
) stop at first wall - Key pickup: Automatic when hero reaches key cell
- Exit: Can only enter after obtaining key
Base Score = 1000 - (time penalty) - (keystroke penalty)
Size Multiplier = max(1.0, (rows × cols) / 500)
Final Score = min(1000, max(0, round(Base Score × Size Multiplier)))
Time Penalty: seconds / 10
- 10 seconds = -1 point
- 60 seconds = -6 points
- 300 seconds = -30 points
Keystroke Penalty: keystrokes / 2
- 2 keystrokes = -1 point
- 50 keystrokes = -25 points
- 200 keystrokes = -100 points
Rewards larger, more complex mazes:
- Small maze (16×24 = 384 cells): 1.0x multiplier
- Medium maze (24×32 = 768 cells): 1.54x multiplier
- Large maze (32×48 = 1536 cells): 3.07x multiplier
Small Maze (16×24):
60 seconds, 80 keys:
= 1000 - 6 - 40 = 954 × 1.0 = 954 / 1000
Medium Maze (24×32):
120 seconds, 150 keys:
= 1000 - 12 - 75 = 913 × 1.54 = 1000 / 1000 (capped)
Large Maze (32×48):
180 seconds, 200 keys:
= 1000 - 18 - 100 = 882 × 3.07 = 1000 / 1000 (capped)
- Default: 600 seconds (10 minutes)
- Configurable: Set via
GameOptions.timeLimit
- Trigger: When
timeValue >= timeLimit × 1000
- States: Checked during
started
andhas-key
states
Unlike vim-sudoku, vim-maze has no hint system or additional penalties. The only way to lose is running out of time.
Beginner:
useGame({
rows: 12,
cols: 16,
timeLimit: 900 // 15 minutes
})
Standard Small:
useGame({
rows: 16,
cols: 24,
timeLimit: 600 // 10 minutes
})
Standard Medium:
useGame({
rows: 24,
cols: 32,
timeLimit: 600 // 10 minutes
})
Standard Large:
useGame({
rows: 32,
cols: 48,
timeLimit: 600 // 10 minutes
})
Speed Challenge:
useGame({
rows: 24,
cols: 32,
timeLimit: 300 // 5 minutes
})
Marathon:
useGame({
rows: 48,
cols: 64,
timeLimit: 1200 // 20 minutes
})
The package exports a structured gameInfo
object containing complete game documentation:
import { gameInfo } from '@vimazing/vim-maze';
// Access structured instructions
console.log(gameInfo.name); // "VIM Maze"
console.log(gameInfo.controls); // Navigation, game controls
console.log(gameInfo.rules); // Movement, game flow, maze elements
console.log(gameInfo.scoring); // Formula, penalties, size multiplier, examples
console.log(gameInfo.gameOver); // Time limit condition
console.log(gameInfo.mazeGeneration); // Algorithm, guarantees, placement
console.log(gameInfo.metrics); // Tracked metrics during gameplay
console.log(gameInfo.objective); // Win condition
Use cases:
- Render in-game help screens
- Generate tutorials
- Display control reference
- Show scoring breakdown
- Explain maze 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:
- Maze size configuration
- Live scoreboard (Time, Keystrokes, Distances)
- 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('Maze initialized:', gameManager.hero.heroPos);
// 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 === 'has-key') {
console.log('Key obtained!');
}
if (gameManager.gameStatus === 'game-won') {
console.log('Victory!', gameManager.scoreManager.finalScore);
clearInterval(interval);
}
}, 100);
}
const gameManager = useGame(
{ rows: 24, cols: 32 },
myPlatformHook
);
Mazes are procedurally generated using a depth-first search algorithm with guaranteed solvability:
- Entrance: Always top-left area
- Key: Placed in maze requiring navigation
- Exit: Always bottom-right area
- Paths: Guaranteed path from entrance → key → exit
- Walls: Procedurally generated with no isolated areas
MIT © André Padez
Inspired by The Art of Web – Random Maze Generator and extended for the VIMazing platform.