diff --git a/plans/260413-1416-gameplay-improvements/phase-01-wait-action.md b/plans/260413-1416-gameplay-improvements/phase-01-wait-action.md
new file mode 100644
index 0000000..691cef3
--- /dev/null
+++ b/plans/260413-1416-gameplay-improvements/phase-01-wait-action.md
@@ -0,0 +1,54 @@
+## Phase 1: Wait Action
+
+**Priority:** P1 | **Status:** Pending | **Effort:** 15min
+
+### Overview
+
+Spacebar (and tap-on-player for mobile) skips the player's move but still advances guards.
+Creates timing puzzles — player must wait for a safe window.
+
+### Data Flow
+
+1. User presses Space / taps player cell
+2. `handleMove` is NOT called; instead new `handleWait()` fires
+3. `handleWait` calls `turnManager.nextTurn(grid, player, guards)` without moving player
+4. Same detection/completion checks run
+5. `renderVersion++` triggers re-render
+
+### Files to Modify
+
+**`src/scenes/Game.svelte`** (input handler only)
+
+### Implementation Steps
+
+- [ ] 1. In `onKeyDown`, add `' '` (Space) key check before direction map lookup
+- [ ] 2. When Space detected, call `handleWait()` and `e.preventDefault()`
+- [ ] 3. Create `handleWait()` function:
+ ```js
+ function handleWait() {
+ if (!player || !grid) return;
+ const result = turnManager.nextTurn(grid, player, guards);
+ if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; }
+ renderVersion++;
+ if (result.levelComplete) handleLevelComplete();
+ else if (result.detected) detected = true;
+ }
+ ```
+- [ ] 4. In `onCellClick`, if clicked cell === player position, call `handleWait()`
+
+### Edge Cases
+
+- Wait on level 12 still triggers princess detection expansion (covered — same `checkFinalLevel` path)
+- Wait when already at goal: `nextTurn` returns `levelComplete: true` (correct)
+- Wait counts as a turn for move counter (correct — `turnManager.turnCount++` happens in `nextTurn`)
+
+### Success Criteria
+
+- Space key advances turn without moving player
+- Guards rotate/patrol/blink as normal
+- Turn counter increments
+- Detection still works if guard light reaches player after wait
+
+### Rollback
+
+Revert single commit. No schema changes.
diff --git a/plans/260413-1416-gameplay-improvements/phase-02-star-rating.md b/plans/260413-1416-gameplay-improvements/phase-02-star-rating.md
new file mode 100644
index 0000000..fb1d757
--- /dev/null
+++ b/plans/260413-1416-gameplay-improvements/phase-02-star-rating.md
@@ -0,0 +1,89 @@
+## Phase 2: Star Rating + Par Moves
+
+**Priority:** P1 | **Status:** Pending | **Effort:** 2h
+
+### Overview
+
+Each level gets a `parMoves` value. On completion, show 1-3 star rating.
+Persist best stars + best moves in localStorage. Display stars on level select.
+
+### Data Flow
+
+```
+Level complete → turnManager.turnCount vs level.parMoves → star rating
+ → completeLevel(levelNum, totalLevels, moves) → localStorage update
+ → LevelSelect reads stars from progress
+```
+
+### Star Calculation
+
+| Condition | Stars |
+|-----------|-------|
+| moves <= parMoves | 3 |
+| moves <= parMoves + 3 | 2 |
+| completed | 1 |
+
+### Files to Modify
+
+#### 1. `src/lib/levels/levels.js`
+- [ ] Add `parMoves` field to each level definition
+- [ ] Placeholder values (will be finalized in Phase 3 after redesign):
+ - L1: 10, L2: 12, L3: 14, L4: 14, L5: 14, L6: 16
+ - L7: 16, L8: 18, L9: 18, L10: 20, L11: 22, L12: 99 (unbeatable)
+
+#### 2. `src/lib/progress.js`
+- [ ] Extend schema: add `levelStars: {}` and `levelBestMoves: {}` to DEFAULT_PROGRESS
+- [ ] `getProgress()`: merge missing keys for backwards compat
+- [ ] Update `completeLevel(levelNum, totalLevels, moves, parMoves)`:
+ - Calculate stars
+ - Store best stars (only if better than existing)
+ - Store best moves (only if fewer than existing)
+ - Return `{ ...progress, stars }` so caller can display
+
+#### 3. `src/scenes/Game.svelte`
+- [ ] Import `LEVELS` to access `parMoves` for current level
+- [ ] Pass `turnManager.turnCount` and `parMoves` to `completeLevel()`
+- [ ] After level complete, show star rating overlay before navigating
+- [ ] Add `LevelComplete` popup component (inline or separate small component)
+ - Shows 1-3 stars with animation
+ - Shows move count vs par
+ - "Next Level" button
+
+#### 4. `src/scenes/LevelSelect.svelte`
+- [ ] Read `progress.levelStars` in addition to `completedLevels`
+- [ ] Replace checkmark with star display (1-3 stars shown as filled/empty)
+- [ ] Show best move count under level number
+
+#### 5. `src/components/GameHud.svelte`
+- [ ] No changes needed — already shows turn count
+
+### localStorage Schema (backwards compatible)
+
+```js
+// Old format still works — missing keys get defaults
+{
+ maxLevel: 5,
+ completedLevels: [1, 2, 3, 4],
+ // NEW — absent in old saves, defaults to {}
+ levelStars: { "1": 3, "2": 2, "3": 1 },
+ levelBestMoves: { "1": 8, "2": 14, "3": 20 }
+}
+```
+
+### Edge Cases
+
+- Old localStorage without `levelStars`/`levelBestMoves` → defaults to `{}`
+- Level 12 unbeatable → `parMoves: 99` never matters
+- Player completes level with worse score → keep existing best
+- `completeLevel` called with no moves param (old code path) → skip star calc
+
+### Success Criteria
+
+- Star rating appears on level complete
+- Best stars persist in localStorage
+- Level select shows stars per level
+- Old saves don't break (backwards compat)
+
+### Rollback
+
+Revert commit. Old localStorage data unaffected (new keys simply disappear).
diff --git a/plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md b/plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md
new file mode 100644
index 0000000..c123cc2
--- /dev/null
+++ b/plans/260413-1416-gameplay-improvements/phase-03-level-redesign.md
@@ -0,0 +1,89 @@
+## Phase 3: Level Redesign + Chaser Guard
+
+**Priority:** P1 | **Status:** Pending | **Effort:** 3h
+
+### Overview
+
+Redesign existing 12 levels for more challenge. Add **Chaser guard** type.
+Finalize `parMoves` values after redesign. Level 12 remains unbeatable.
+
+### New Guard Type: ChaserGuard
+
+**Behavior:** Dormant until player enters Manhattan distance <= `detectionRadius` (default 3).
+Once triggered, moves 1 cell toward player's **last known position** each turn.
+Lights its own cell + adjacent cell in movement direction. Returns to start if reaches
+last known position and player not visible.
+
+**Why Chaser over Sentry:** Chaser creates dynamic panic — player must change route.
+Sentry is just a fancy static guard (cone vision = static pattern). Chaser adds genuine
+reactive gameplay with minimal code.
+
+#### Implementation in `src/lib/game/guards.js`
+
+- [ ] Add `ChaserGuard` class extending `Guard`:
+ ```
+ Properties: detectionRadius, startRow, startCol, targetRow, targetCol,
+ isChasing, lastKnownRow, lastKnownCol
+ Methods:
+ - onTurnChange(allGuards, player): check distance to player
+ - If within radius and not blocked by wall: set isChasing, record lastKnownPos
+ - If chasing: move 1 step toward lastKnownPos (prefer row then col)
+ - If reached lastKnownPos and player not in radius: return to start
+ - updateLight(): light own cell + 1 cell in facing direction
+ ```
+- [ ] Key: `onTurnChange` needs player reference — update signature
+
+#### Turn Manager Change (`src/lib/game/turn-manager.js`)
+
+- [ ] Pass `player` to `guard.onTurnChange(guards, player)` — non-breaking since
+ existing guards ignore extra params
+
+#### Level Manager Change (`src/lib/game/level-manager.js`)
+
+- [ ] Add `case 'chaser'` to guard factory switch
+
+#### Guard Sprite (`src/components/GuardSprite.svelte`)
+
+- [ ] Add chaser visual (red-tinted sprite, or different emoji/icon)
+- [ ] Show "alert" state when `isChasing` is true
+
+### Level Redesign Principles
+
+1. **Tighter corridors** — reduce open space, force specific paths
+2. **More guards per level** — current levels are sparse
+3. **Timing windows** — require waiting (Phase 1) for safe passage
+4. **Chaser placement** — put chasers near obvious paths to force detours
+5. **Level 12** — keep walls + guard placement, only verify light wave still covers all paths
+
+### Level-by-Level Notes
+
+- [ ] **L1-2** (tutorial): Keep simple but add 1 more wall each to reduce trivial paths
+- [ ] **L3-4** (rotating intro): Add extra static guard, narrow corridors
+- [ ] **L5-6** (blinking): Tighter timing — reduce safe windows from 2 turns to 1
+- [ ] **L7-8** (patrolling): Add walls to force player into patrol paths, require waiting
+- [ ] **L9** (decoy): Add chaser near the "obvious" short path — punishes rushing
+- [ ] **L10** (mirrors): Add more mirror bounces, tighter spaces
+- [ ] **L11** (throne room): Add chaser + extra patrolling guard
+- [ ] **L12** (princess): DO NOT CHANGE core mechanic. May add 1-2 more guards in upper sections
+- [ ] **Finalize parMoves** for each level after redesign (playtest manually)
+
+### Edge Cases / Risks
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| Level becomes unsolvable | Medium | High | Playtest each level; keep at least 1 valid path |
+| Chaser pathfinding hits wall | Medium | Medium | Simple Manhattan step; skip move if blocked by wall |
+| Level 12 becomes beatable | Low | High | Verify: expanding light wave from (9,9) covers all reachable cells within player's max possible steps |
+| ChaserGuard makes levels too hard | Medium | Low | Tune detectionRadius per level (2-4 range) |
+
+### Success Criteria
+
+- All levels 1-11 are solvable but require thought
+- At least 3 levels benefit from wait action
+- Chaser guard creates noticeable tension when triggered
+- Level 12 remains provably unbeatable
+- parMoves set to optimal-path length for each level
+
+### Rollback
+
+Revert commit restores old level definitions and removes ChaserGuard class.
diff --git a/plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md b/plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md
new file mode 100644
index 0000000..255b325
--- /dev/null
+++ b/plans/260413-1416-gameplay-improvements/phase-04-vision-preview.md
@@ -0,0 +1,49 @@
+## Phase 4: Guard Vision Preview (Optional)
+
+**Priority:** P3 | **Status:** Pending | **Effort:** 45min
+
+### Overview
+
+Show semi-transparent overlay of cells that will be lit NEXT turn.
+Helps player plan without trial-and-error. Toggle via button or hotkey.
+
+### Data Flow
+
+1. Before rendering, simulate next turn's guard positions/lights on a **cloned grid**
+2. Diff current lit cells vs next-turn lit cells
+3. Render "preview" cells with distinct CSS class (e.g., `cell-preview` with 20% opacity warning color)
+
+### Implementation
+
+#### `src/lib/game/turn-manager.js`
+- [ ] Add `previewNextTurn(grid, guards)` method:
+ - Deep-clone guard states (row, col, direction, isOn, currentPathIndex, isReversing)
+ - Create temporary grid clone
+ - Run `onTurnChange` on clones
+ - Return Set of `"row,col"` strings for next-turn lit cells
+
+#### `src/scenes/Game.svelte`
+- [ ] Add `showPreview` toggle state (default false)
+- [ ] Bind `v` key or HUD button to toggle
+- [ ] Compute `previewCells` derived from `turnManager.previewNextTurn()` when toggle on
+- [ ] Pass `previewCells` to `GameBoard`
+
+#### `src/components/GameBoard.svelte`
+- [ ] Accept optional `previewCells` prop
+- [ ] Add `cell-preview` CSS class to cells in preview set
+
+### Edge Cases
+
+- Chaser guard preview needs player position — pass current player pos to preview
+- Level 12 expanding wave — preview should show next ring expansion
+- Performance: cloning 5-10 guards per frame is trivial for grid sizes <= 10x10
+
+### Success Criteria
+
+- Toggle shows/hides next-turn danger zones
+- Preview visually distinct from current lit cells (different opacity/color)
+- No performance impact on turn processing
+
+### Rollback
+
+Revert commit. Preview is purely additive UI — no game logic changes.
diff --git a/plans/260413-1416-gameplay-improvements/plan.md b/plans/260413-1416-gameplay-improvements/plan.md
new file mode 100644
index 0000000..9bf2fba
--- /dev/null
+++ b/plans/260413-1416-gameplay-improvements/plan.md
@@ -0,0 +1,57 @@
+---
+title: "Gameplay Improvements: Wait Action, Star Rating, Level Redesign"
+description: "Add wait mechanic, star/par rating system, and redesign levels for challenge"
+status: done
+priority: P1
+effort: 6h
+branch: main
+tags: [gameplay, levels, ux]
+created: 2026-04-13
+---
+
+# Gameplay Improvements Plan
+
+## Phases
+
+| # | Phase | Effort | Status | Files Modified |
+|---|-------|--------|--------|----------------|
+| 1 | [Wait Action](phase-01-wait-action.md) | 15min | Done | Game.svelte |
+| 2 | [Star Rating + Par Moves](phase-02-star-rating.md) | 2h | Done | levels.js, progress.js, Game.svelte, LevelSelect.svelte, LevelCompletePopup.svelte |
+| 3 | [Level Redesign + Chaser Guard](phase-03-level-redesign.md) | 3h | Done | levels.js, guards.js, level-manager.js, GuardSprite.svelte, theme.css |
+| 4 | [Guard Vision Preview](phase-04-vision-preview.md) | 45min | Done | GameBoard.svelte, Game.svelte, turn-manager.js, GameHud.svelte |
+
+## Dependency Graph
+
+```
+Phase 1 (Wait) ── no deps, standalone
+Phase 2 (Stars) ── no deps, standalone
+Phase 3 (Levels) ── after Phase 1 + 2 (par values depend on wait mechanic existing)
+Phase 4 (Preview) ── after Phase 3 (needs final guard configs)
+```
+
+## File Ownership (no parallel conflicts)
+
+- Phase 1: Game.svelte only (input handler)
+- Phase 2: progress.js, LevelSelect.svelte, GameHud.svelte, + level complete flow in Game.svelte
+- Phase 3: levels.js, guards.js, level-manager.js, GuardSprite.svelte
+- Phase 4: GameBoard.svelte (new overlay layer)
+
+## Key Constraints
+
+- Level 12 MUST remain unbeatable (expanding light wave from goal covers all paths)
+- No new npm dependencies
+- All state stays in pure JS classes; Svelte 5 reactivity via renderVersion bump pattern
+- localStorage schema must be backwards-compatible (existing `nntv-progress` key)
+
+## Rollback
+
+Each phase is a single commit. Revert commit to rollback. No data migrations needed
+(localStorage schema only adds optional fields, old data still works).
+
+## Success Criteria
+
+1. Spacebar wait works, guards advance, player stays
+2. Stars show on level complete + level select; persist across sessions
+3. Levels require more thought; at least 3 levels need wait action to solve optimally
+4. Level 12 still unbeatable (manual verification)
+5. (Optional) Ghost overlay shows next-turn danger zones
diff --git a/src/components/GameBoard.svelte b/src/components/GameBoard.svelte
index 4541690..090c82e 100644
--- a/src/components/GameBoard.svelte
+++ b/src/components/GameBoard.svelte
@@ -1,5 +1,5 @@
{#if hasDirection}
@@ -31,6 +32,8 @@
.guard.blinking { background: var(--guard-blinking); }
.guard.blinking.off { background: var(--guard-blinking-off); }
.guard.patrolling { background: var(--guard-patrolling); }
+ .guard.chaser { background: var(--guard-chaser, #ff6622); }
+ .guard.chaser.alert { background: #ff2200; box-shadow: 0 0 8px #ff2200; }
.guard.mirror { background: var(--guard-mirror); border-radius: 4px; transform: translate(-50%, -50%) rotate(45deg); }
.direction-indicator {
diff --git a/src/components/LevelCompletePopup.svelte b/src/components/LevelCompletePopup.svelte
new file mode 100644
index 0000000..0d66d5c
--- /dev/null
+++ b/src/components/LevelCompletePopup.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
{getText('levelComplete') || 'Level Complete!'}
+
+ {#each [1, 2, 3] as i}
+ ★
+ {/each}
+
+
Moves: {moves} / Par: {parMoves}
+
+
+
+
+
diff --git a/src/lib/game/guards.js b/src/lib/game/guards.js
index b937b4f..c712fe6 100644
--- a/src/lib/game/guards.js
+++ b/src/lib/game/guards.js
@@ -141,6 +141,101 @@ export class MirrorGuard extends Guard {
}
}
+export class ChaserGuard extends Guard {
+ constructor(grid, row, col, detectionRadius) {
+ super(grid, row, col, 'chaser');
+ this.startRow = row;
+ this.startCol = col;
+ this.detectionRadius = detectionRadius || 3;
+ this.isChasing = false;
+ this.targetRow = row;
+ this.targetCol = col;
+ }
+
+ updateLight() {
+ if (this.grid.isValidPosition(this.row, this.col)) {
+ this.grid.setLight(this.row, this.col, true);
+ }
+ // Light cell in facing direction
+ const dir = this.getDirectionOffset(this.direction);
+ const fr = this.row + dir.row;
+ const fc = this.col + dir.col;
+ if (this.grid.isValidPosition(fr, fc)) {
+ this.grid.setLight(fr, fc, true);
+ }
+ }
+
+ getDirectionOffset(dir) {
+ const directions = [
+ { row: -1, col: 0 }, { row: 0, col: 1 },
+ { row: 1, col: 0 }, { row: 0, col: -1 },
+ ];
+ return directions[dir];
+ }
+
+ onTurnChange(allGuards, player) {
+ if (!player) { this.updateLight(); return; }
+
+ const dist = Math.abs(this.row - player.row) + Math.abs(this.col - player.col);
+
+ if (dist <= this.detectionRadius) {
+ this.isChasing = true;
+ this.targetRow = player.row;
+ this.targetCol = player.col;
+ }
+
+ if (this.isChasing) {
+ // Move one step toward target
+ const dr = this.targetRow - this.row;
+ const dc = this.targetCol - this.col;
+ let newRow = this.row;
+ let newCol = this.col;
+
+ // Prefer row movement, then col
+ if (dr !== 0) {
+ const step = dr > 0 ? 1 : -1;
+ if (this.grid.isValidPosition(this.row + step, this.col) &&
+ !this.grid.isWall(this.row + step, this.col)) {
+ newRow = this.row + step;
+ } else if (dc !== 0) {
+ const cstep = dc > 0 ? 1 : -1;
+ if (this.grid.isValidPosition(this.row, this.col + cstep) &&
+ !this.grid.isWall(this.row, this.col + cstep)) {
+ newCol = this.col + cstep;
+ }
+ }
+ } else if (dc !== 0) {
+ const step = dc > 0 ? 1 : -1;
+ if (this.grid.isValidPosition(this.row, this.col + step) &&
+ !this.grid.isWall(this.row, this.col + step)) {
+ newCol = this.col + step;
+ }
+ }
+
+ // Update facing direction
+ if (newRow < this.row) this.direction = 0;
+ else if (newCol > this.col) this.direction = 1;
+ else if (newRow > this.row) this.direction = 2;
+ else if (newCol < this.col) this.direction = 3;
+
+ this.row = newRow;
+ this.col = newCol;
+
+ // Reached target and player not nearby — return to start
+ if (this.row === this.targetRow && this.col === this.targetCol &&
+ dist > this.detectionRadius) {
+ this.targetRow = this.startRow;
+ this.targetCol = this.startCol;
+ if (this.row === this.startRow && this.col === this.startCol) {
+ this.isChasing = false;
+ }
+ }
+ }
+
+ this.updateLight();
+ }
+}
+
export class PatrollingGuard extends Guard {
constructor(grid, startRow, startCol, path) {
super(grid, startRow, startCol, 'patrolling');
diff --git a/src/lib/game/level-manager.js b/src/lib/game/level-manager.js
index c0f4f80..88103c6 100644
--- a/src/lib/game/level-manager.js
+++ b/src/lib/game/level-manager.js
@@ -3,7 +3,7 @@
import { LEVELS } from '../levels/levels.js';
import { GridSystem } from './grid-system.js';
import { Player } from './player.js';
-import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard, MirrorGuard } from './guards.js';
+import { StaticGuard, RotatingGuard, BlinkingGuard, PatrollingGuard, MirrorGuard, ChaserGuard } from './guards.js';
// Load a level by ID, returns complete game state
export function loadLevel(levelId) {
@@ -50,6 +50,9 @@ export function loadLevel(levelId) {
case 'mirror':
guard = new MirrorGuard(grid, g.position.row, g.position.col, g.reflectDirection);
break;
+ case 'chaser':
+ guard = new ChaserGuard(grid, g.position.row, g.position.col, g.detectionRadius);
+ break;
}
if (guard) guards.push(guard);
});
@@ -67,6 +70,7 @@ export function loadLevel(levelId) {
isFinalLevel: data.isFinalLevel || false,
goalRow: data.goal.row,
goalCol: data.goal.col,
+ parMoves: data.parMoves || 99,
};
}
diff --git a/src/lib/game/turn-manager.js b/src/lib/game/turn-manager.js
index a5c6ea0..792e59a 100644
--- a/src/lib/game/turn-manager.js
+++ b/src/lib/game/turn-manager.js
@@ -17,7 +17,7 @@ export class TurnManager {
// Clear current lights and update guards
grid.clearAllLight();
- guards.forEach(guard => guard.onTurnChange(guards));
+ guards.forEach(guard => guard.onTurnChange(guards, player));
// Check if player is now in a lit cell
if (grid.isLight(player.row, player.col)) {
@@ -27,6 +27,47 @@ export class TurnManager {
return { detected: false, levelComplete: false };
}
+ // Simulate next turn to preview future lit cells (non-destructive)
+ previewNextTurn(grid, player, guards) {
+ // Snapshot guard states
+ const snapshots = guards.map(g => ({
+ row: g.row, col: g.col, direction: g.direction,
+ isOn: g.isOn, currentPathIndex: g.currentPathIndex,
+ isReversing: g.isReversing, isChasing: g.isChasing,
+ targetRow: g.targetRow, targetCol: g.targetCol,
+ }));
+
+ // Run one turn on real guards
+ grid.clearAllLight();
+ guards.forEach(g => g.onTurnChange(guards, player));
+
+ // Collect lit cells
+ const previewSet = new Set();
+ for (let r = 0; r < grid.rows; r++) {
+ for (let c = 0; c < grid.cols; c++) {
+ if (grid.isLight(r, c)) previewSet.add(`${r},${c}`);
+ }
+ }
+
+ // Restore guard states
+ guards.forEach((g, i) => {
+ const s = snapshots[i];
+ g.row = s.row; g.col = s.col; g.direction = s.direction;
+ g.isOn = s.isOn;
+ if (s.currentPathIndex !== undefined) g.currentPathIndex = s.currentPathIndex;
+ if (s.isReversing !== undefined) g.isReversing = s.isReversing;
+ if (s.isChasing !== undefined) g.isChasing = s.isChasing;
+ if (s.targetRow !== undefined) g.targetRow = s.targetRow;
+ if (s.targetCol !== undefined) g.targetCol = s.targetCol;
+ });
+
+ // Restore current lighting
+ grid.clearAllLight();
+ guards.forEach(g => g.updateLight(guards));
+
+ return previewSet;
+ }
+
reset() {
this.turnCount = 0;
}
diff --git a/src/lib/levels/levels.js b/src/lib/levels/levels.js
index d5763c8..7312d98 100644
--- a/src/lib/levels/levels.js
+++ b/src/lib/levels/levels.js
@@ -8,10 +8,12 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 5, col: 5 },
walls: [
- { row: 1, col: 1 }, { row: 1, col: 2 },
- { row: 3, col: 3 }, { row: 3, col: 4 },
+ { row: 1, col: 1 }, { row: 1, col: 2 }, { row: 1, col: 3 },
+ { row: 3, col: 2 }, { row: 3, col: 3 }, { row: 3, col: 4 },
+ { row: 4, col: 0 }, { row: 4, col: 1 },
],
guards: [],
+ parMoves: 10,
},
{
id: 2,
@@ -21,7 +23,8 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 5, col: 5 },
walls: [
- { row: 2, col: 2 }, { row: 3, col: 3 },
+ { row: 1, col: 3 }, { row: 2, col: 1 }, { row: 2, col: 2 },
+ { row: 3, col: 3 }, { row: 3, col: 4 }, { row: 4, col: 1 },
],
guards: [
{
@@ -32,7 +35,16 @@ export const LEVELS = [
{ row: 2, col: 5 }, { row: 3, col: 4 },
],
},
+ {
+ type: "static",
+ position: { row: 4, col: 2 },
+ litCells: [
+ { row: 3, col: 2 }, { row: 4, col: 3 },
+ { row: 5, col: 2 },
+ ],
+ },
],
+ parMoves: 12,
},
// === ACT 2: THE VEGETABLE GARDEN (walls as shields + rotating guards) ===
@@ -44,28 +56,40 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 6, col: 6 },
walls: [
- { row: 1, col: 1 }, { row: 2, col: 1 },
- { row: 3, col: 3 }, { row: 3, col: 5 }, { row: 5, col: 4 },
+ { row: 1, col: 1 }, { row: 2, col: 1 }, { row: 2, col: 5 },
+ { row: 3, col: 3 }, { row: 3, col: 5 },
+ { row: 4, col: 1 }, { row: 5, col: 3 }, { row: 5, col: 4 },
],
guards: [
{
type: "static",
position: { row: 2, col: 3 },
litCells: [
- { row: 1, col: 3 }, { row: 2, col: 2 }, { row: 2, col: 4 },
+ { row: 1, col: 3 }, { row: 2, col: 2 },
+ { row: 2, col: 4 }, { row: 3, col: 3 },
],
},
{
type: "static",
- position: { row: 5, col: 5 },
+ position: { row: 4, col: 5 },
litCells: [
- { row: 4, col: 5 }, { row: 5, col: 6 },
+ { row: 3, col: 5 }, { row: 4, col: 4 },
+ { row: 4, col: 6 }, { row: 5, col: 5 },
+ ],
+ },
+ {
+ type: "static",
+ position: { row: 5, col: 1 },
+ litCells: [
+ { row: 4, col: 1 }, { row: 5, col: 0 },
+ { row: 5, col: 2 }, { row: 6, col: 1 },
],
},
],
+ parMoves: 14,
},
{
- // First rotating guard — enclosed in walls so player can observe the pattern
+ // First rotating guard — must time passage; walls create a narrow corridor
id: 4,
name: "The Searchlight",
storyKey: "level4Story",
@@ -73,17 +97,28 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 6, col: 6 },
walls: [
- { row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 },
- { row: 3, col: 2 }, { row: 3, col: 4 },
- { row: 4, col: 2 }, { row: 4, col: 3 }, { row: 4, col: 4 },
+ { row: 1, col: 2 }, { row: 1, col: 3 },
+ { row: 2, col: 2 }, { row: 2, col: 5 },
+ { row: 3, col: 4 }, { row: 3, col: 5 },
+ { row: 4, col: 1 }, { row: 4, col: 2 },
+ { row: 5, col: 4 },
],
guards: [
{
type: "rotating",
- position: { row: 3, col: 3 },
+ position: { row: 3, col: 2 },
startDirection: 0,
},
+ {
+ type: "static",
+ position: { row: 5, col: 5 },
+ litCells: [
+ { row: 4, col: 5 }, { row: 5, col: 6 },
+ { row: 6, col: 5 },
+ ],
+ },
],
+ parMoves: 15,
},
// === ACT 3: THE FORTRESS (blinking guards + timing puzzles) ===
@@ -95,23 +130,43 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 6, col: 6 },
walls: [
- { row: 2, col: 2 }, { row: 2, col: 4 },
- { row: 4, col: 2 }, { row: 4, col: 4 },
+ { row: 1, col: 3 }, { row: 2, col: 1 },
+ { row: 2, col: 5 }, { row: 3, col: 3 },
+ { row: 4, col: 1 }, { row: 4, col: 5 },
+ { row: 5, col: 3 },
],
guards: [
{
type: "blinking",
- position: { row: 3, col: 3 },
+ position: { row: 2, col: 3 },
startState: true,
litCells: [
- { row: 2, col: 3 }, { row: 3, col: 2 },
- { row: 3, col: 4 }, { row: 4, col: 3 },
+ { row: 1, col: 3 }, { row: 2, col: 2 },
+ { row: 2, col: 4 },
+ ],
+ },
+ {
+ type: "blinking",
+ position: { row: 4, col: 3 },
+ startState: false,
+ litCells: [
+ { row: 4, col: 2 }, { row: 4, col: 4 },
+ { row: 5, col: 3 },
+ ],
+ },
+ {
+ type: "static",
+ position: { row: 3, col: 6 },
+ litCells: [
+ { row: 2, col: 6 }, { row: 3, col: 5 },
+ { row: 4, col: 6 },
],
},
],
+ parMoves: 14,
},
{
- // Blinking guard + static guard — must time passage through blinking zone
+ // Blinking + static — narrow corridor requires precise timing and wait action
id: 6,
name: "The Flickering Corridor",
storyKey: "level6Story",
@@ -119,35 +174,50 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 7, col: 7 },
walls: [
- { row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }, { row: 2, col: 5 },
- { row: 3, col: 2 }, { row: 3, col: 5 },
- { row: 4, col: 2 }, { row: 4, col: 5 },
- { row: 5, col: 2 }, { row: 5, col: 3 }, { row: 5, col: 4 }, { row: 5, col: 5 },
+ { row: 1, col: 3 }, { row: 1, col: 4 },
+ { row: 2, col: 1 }, { row: 2, col: 6 },
+ { row: 3, col: 3 }, { row: 3, col: 4 },
+ { row: 4, col: 1 }, { row: 4, col: 6 },
+ { row: 5, col: 3 }, { row: 5, col: 4 },
+ { row: 6, col: 1 }, { row: 6, col: 6 },
],
guards: [
{
type: "blinking",
- position: { row: 3, col: 3 },
+ position: { row: 2, col: 3 },
startState: true,
litCells: [
- { row: 3, col: 4 }, { row: 4, col: 3 },
+ { row: 2, col: 2 }, { row: 2, col: 4 },
+ { row: 3, col: 3 },
+ ],
+ },
+ {
+ type: "blinking",
+ position: { row: 5, col: 5 },
+ startState: false,
+ litCells: [
+ { row: 4, col: 5 }, { row: 5, col: 6 },
+ { row: 6, col: 5 },
],
},
{
type: "static",
position: { row: 1, col: 6 },
litCells: [
- { row: 0, col: 6 }, { row: 1, col: 7 },
+ { row: 0, col: 6 }, { row: 1, col: 5 },
+ { row: 1, col: 7 },
],
},
{
type: "static",
- position: { row: 6, col: 1 },
+ position: { row: 6, col: 2 },
litCells: [
- { row: 5, col: 1 }, { row: 6, col: 0 }, { row: 6, col: 2 },
+ { row: 5, col: 2 }, { row: 6, col: 3 },
+ { row: 7, col: 2 },
],
},
],
+ parMoves: 16,
},
// === ACT 4: THE UNDERGROUND (patrolling guards + path prediction) ===
@@ -161,8 +231,8 @@ export const LEVELS = [
walls: [
{ row: 1, col: 2 }, { row: 1, col: 3 }, { row: 1, col: 4 }, { row: 1, col: 5 },
{ row: 2, col: 2 }, { row: 2, col: 5 },
- { row: 3, col: 2 }, { row: 3, col: 5 },
- { row: 4, col: 2 }, { row: 4, col: 5 },
+ { row: 3, col: 5 },
+ { row: 4, col: 2 },
{ row: 5, col: 2 }, { row: 5, col: 5 },
{ row: 6, col: 2 }, { row: 6, col: 3 }, { row: 6, col: 4 }, { row: 6, col: 5 },
],
@@ -175,10 +245,19 @@ export const LEVELS = [
{ row: 4, col: 4 }, { row: 4, col: 3 },
],
},
+ {
+ type: "static",
+ position: { row: 5, col: 7 },
+ litCells: [
+ { row: 4, col: 7 }, { row: 5, col: 6 },
+ { row: 6, col: 7 },
+ ],
+ },
],
+ parMoves: 17,
},
{
- // Two patrols with interlocking paths — player must weave between them
+ // Two patrols with interlocking paths + blinking gate
id: 8,
name: "The Gauntlet",
storyKey: "level8Story",
@@ -186,8 +265,10 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 7, col: 7 },
walls: [
+ { row: 1, col: 1 }, { row: 1, col: 6 },
{ row: 3, col: 3 }, { row: 3, col: 4 },
{ row: 4, col: 3 }, { row: 4, col: 4 },
+ { row: 6, col: 1 }, { row: 6, col: 6 },
],
guards: [
{
@@ -208,13 +289,21 @@ export const LEVELS = [
{ row: 5, col: 2 }, { row: 5, col: 5 },
],
},
+ {
+ type: "blinking",
+ position: { row: 3, col: 7 },
+ startState: true,
+ litCells: [
+ { row: 2, col: 7 }, { row: 4, col: 7 },
+ ],
+ },
],
+ parMoves: 18,
},
- // === ACT 5: THE ROYAL PALACE (combinations + "The Decoy") ===
+ // === ACT 5: THE ROYAL PALACE (combinations + chaser + "The Decoy") ===
{
- // The Decoy — two routes: obvious short one syncs to danger at turn 6,
- // longer route is actually safe if you count turns
+ // The Decoy — chaser guard punishes the obvious short path
id: 9,
name: "The Decoy Path",
storyKey: "level9Story",
@@ -222,9 +311,9 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 7, col: 7 },
walls: [
- // Central wall creates two corridors
{ row: 1, col: 3 }, { row: 2, col: 3 }, { row: 3, col: 3 },
{ row: 4, col: 4 }, { row: 5, col: 4 }, { row: 6, col: 4 },
+ { row: 5, col: 1 },
],
guards: [
{
@@ -249,10 +338,17 @@ export const LEVELS = [
{ row: 6, col: 7 },
],
},
+ {
+ // Chaser lurks near the short route — triggers when player rushes
+ type: "chaser",
+ position: { row: 2, col: 1 },
+ detectionRadius: 3,
+ },
],
+ parMoves: 19,
},
{
- // Hall of Mirrors — rotating guard + mirror guards redirect beams
+ // Hall of Mirrors — rotating guard + mirror guards redirect beams + chaser
id: 10,
name: "Hall of Mirrors",
storyKey: "level10Story",
@@ -260,8 +356,10 @@ export const LEVELS = [
player: { row: 0, col: 0 },
goal: { row: 7, col: 7 },
walls: [
+ { row: 2, col: 1 },
{ row: 3, col: 3 }, { row: 3, col: 4 },
{ row: 4, col: 3 }, { row: 4, col: 4 },
+ { row: 5, col: 6 },
],
guards: [
{
@@ -284,10 +382,22 @@ export const LEVELS = [
position: { row: 5, col: 5 },
startDirection: 2,
},
+ {
+ type: "mirror",
+ position: { row: 5, col: 2 },
+ reflectDirection: "cw",
+ },
+ {
+ // Chaser in bottom-right quadrant to pressure the player
+ type: "chaser",
+ position: { row: 7, col: 4 },
+ detectionRadius: 3,
+ },
],
+ parMoves: 20,
},
{
- // The Throne Room — all guard types combined
+ // The Throne Room — all guard types combined including chaser
id: 11,
name: "The Throne Room",
storyKey: "level11Story",
@@ -307,7 +417,7 @@ export const LEVELS = [
type: "static",
position: { row: 1, col: 7 },
litCells: [
- { row: 1, col: 6 }, { row: 1, col: 8 },
+ { row: 0, col: 7 }, { row: 1, col: 6 }, { row: 1, col: 8 },
],
},
{
@@ -332,12 +442,18 @@ export const LEVELS = [
{ row: 7, col: 3 }, { row: 7, col: 2 },
],
},
+ {
+ // Chaser guards the final stretch
+ type: "chaser",
+ position: { row: 7, col: 7 },
+ detectionRadius: 3,
+ },
],
+ parMoves: 24,
},
{
- // THE PRINCESS CHAMBER — escalating detection: light radiates from goal
- // when player reaches distance 4, one ring per turn lights up, creating
- // an expanding wave. The princess senses you approaching.
+ // THE PRINCESS CHAMBER — unbeatable: expanding light wave from goal
+ // When player reaches distance 4, one ring per turn lights up.
id: 12,
name: "The Princess Chamber",
storyKey: "level12Story",
@@ -395,7 +511,14 @@ export const LEVELS = [
{ row: 8, col: 6 }, { row: 8, col: 7 },
],
},
+ {
+ // Extra chaser to make it even more hopeless
+ type: "chaser",
+ position: { row: 8, col: 2 },
+ detectionRadius: 4,
+ },
],
isFinalLevel: true,
+ parMoves: 99,
},
];
diff --git a/src/lib/progress.js b/src/lib/progress.js
index 47faab9..68ce494 100644
--- a/src/lib/progress.js
+++ b/src/lib/progress.js
@@ -5,6 +5,8 @@ const STORAGE_KEY = 'nntv-progress';
const DEFAULT_PROGRESS = {
maxLevel: 1,
completedLevels: [],
+ levelStars: {},
+ levelBestMoves: {},
};
export function getProgress() {
@@ -15,6 +17,8 @@ export function getProgress() {
return {
maxLevel: parsed.maxLevel || 1,
completedLevels: parsed.completedLevels || [],
+ levelStars: parsed.levelStars || {},
+ levelBestMoves: parsed.levelBestMoves || {},
};
}
} catch (e) {
@@ -23,7 +27,14 @@ export function getProgress() {
return { ...DEFAULT_PROGRESS };
}
-export function completeLevel(levelNum, totalLevels) {
+// Calculate star rating: 3★ = ≤par, 2★ = par+1 to par+3, 1★ = completed
+export function calculateStars(moves, parMoves) {
+ if (moves <= parMoves) return 3;
+ if (moves <= parMoves + 3) return 2;
+ return 1;
+}
+
+export function completeLevel(levelNum, totalLevels, moves, parMoves) {
const progress = getProgress();
if (!progress.completedLevels.includes(levelNum)) {
progress.completedLevels.push(levelNum);
@@ -33,10 +44,20 @@ export function completeLevel(levelNum, totalLevels) {
if (nextLevel > progress.maxLevel) {
progress.maxLevel = nextLevel;
}
+ // Star rating (only update if better)
+ let stars = 1;
+ if (moves != null && parMoves != null) {
+ stars = calculateStars(moves, parMoves);
+ const key = String(levelNum);
+ const prevStars = progress.levelStars[key] || 0;
+ if (stars > prevStars) progress.levelStars[key] = stars;
+ const prevBest = progress.levelBestMoves[key];
+ if (prevBest == null || moves < prevBest) progress.levelBestMoves[key] = moves;
+ }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch (e) {
// Storage full or unavailable
}
- return progress;
+ return { ...progress, stars };
}
diff --git a/src/scenes/Game.svelte b/src/scenes/Game.svelte
index 46be84b..0260e88 100644
--- a/src/scenes/Game.svelte
+++ b/src/scenes/Game.svelte
@@ -3,12 +3,14 @@
import { getText } from '../lib/localization.js';
import { loadLevel, getTotalLevels } from '../lib/game/level-manager.js';
import { TurnManager } from '../lib/game/turn-manager.js';
- import { completeLevel } from '../lib/progress.js';
+ import { completeLevel, calculateStars } from '../lib/progress.js';
+ import { LEVELS } from '../lib/levels/levels.js';
import GameBoard from '../components/GameBoard.svelte';
import PlayerSprite from '../components/PlayerSprite.svelte';
import GuardSprite from '../components/GuardSprite.svelte';
import GameHud from '../components/GameHud.svelte';
import DetectionPopup from '../components/DetectionPopup.svelte';
+ import LevelCompletePopup from '../components/LevelCompletePopup.svelte';
import PauseMenu from '../components/PauseMenu.svelte';
let { navigate, level = 1, lives = 3 } = $props();
@@ -29,6 +31,10 @@
let detected = $state(false);
let showFlash = $state(false);
let finalMessage = $state(false);
+ let showLevelComplete = $state(false);
+ let completionStars = $state(0);
+ let completionMoves = $state(0);
+ let showPreview = $state(false);
// Render version counter — incremented after each state mutation to force
// Svelte 5 to re-derive rendering data (class instances are not proxied)
@@ -41,8 +47,11 @@
let playerRow = $derived((renderVersion, player ? player.row : 0));
let playerCol = $derived((renderVersion, player ? player.col : 0));
let guardSnapshots = $derived((renderVersion, guards.map(g => ({
- row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn
+ row: g.row, col: g.col, type: g.type, direction: g.direction, isOn: g.isOn, isChasing: g.isChasing
}))));
+ let previewCells = $derived((renderVersion, showPreview && grid && player && guards.length
+ ? turnManager.previewNextTurn(grid, player, guards)
+ : new Set()));
// Initialize level
function initLevel() {
@@ -61,23 +70,39 @@
showFlash = false;
princessAlerted = false;
alertRadius = 0;
+ showLevelComplete = false;
+ completionStars = 0;
+ completionMoves = 0;
}
onMount(() => { initLevel(); });
// Input handling
function onKeyDown(e) {
- if (isPaused || detected) return;
+ if (isPaused || detected || showLevelComplete) return;
const dirMap = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
w: 'up', s: 'down', a: 'left', d: 'right',
};
+ if (e.key === 'v') { showPreview = !showPreview; return; }
+ if (e.key === ' ') { e.preventDefault(); handleWait(); return; }
const dir = dirMap[e.key];
if (dir) { e.preventDefault(); handleMove(dir); }
}
+ function handleWait() {
+ if (!player || !grid) return;
+ const result = turnManager.nextTurn(grid, player, guards);
+ if (isFinalLevel && checkFinalLevel()) { renderVersion++; return; }
+ renderVersion++;
+ if (result.levelComplete) handleLevelComplete();
+ else if (result.detected) detected = true;
+ }
+
function onCellClick(row, col) {
- if (isPaused || detected || !player) return;
+ if (isPaused || detected || showLevelComplete || !player) return;
+ // Tap on player cell = wait
+ if (row === player.row && col === player.col) { handleWait(); return; }
const rowDiff = Math.abs(row - player.row);
const colDiff = Math.abs(col - player.col);
if ((rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1)) {
@@ -152,19 +177,28 @@
function handleLevelComplete() {
const total = getTotalLevels();
- completeLevel(currentLevel, total);
+ const levelData = LEVELS[currentLevel - 1];
+ const moves = turnManager.turnCount;
+ const par = levelData.parMoves || 99;
+ completeLevel(currentLevel, total, moves, par);
+ completionMoves = moves;
+ completionStars = calculateStars(moves, par);
showFlash = true;
- setTimeout(() => {
- showFlash = false;
- const next = currentLevel + 1;
- if (isFinalLevel) {
- navigate('GameOver', { level: currentLevel, isLastLevel: true });
- } else if (next > total) {
- navigate('GameOver', { level: currentLevel, isLastLevel: false });
- } else {
- navigate('LevelIntro', { level: next, lives: livesRemaining });
- }
- }, 600);
+ showLevelComplete = true;
+ }
+
+ function handleLevelCompleteNext() {
+ showLevelComplete = false;
+ showFlash = false;
+ const total = getTotalLevels();
+ const next = currentLevel + 1;
+ if (isFinalLevel) {
+ navigate('GameOver', { level: currentLevel, isLastLevel: true });
+ } else if (next > total) {
+ navigate('GameOver', { level: currentLevel, isLastLevel: false });
+ } else {
+ navigate('LevelIntro', { level: next, lives: livesRemaining });
+ }
}
function handleDetectionDismiss() {
@@ -185,6 +219,8 @@
lives={livesRemaining}
level={currentLevel}
{turns}
+ {showPreview}
+ ontogglepreview={() => showPreview = !showPreview}
onpause={() => isPaused = true}
onmenu={() => navigate('MainMenu')}
/>
@@ -197,6 +233,7 @@
rows={grid.rows}
cols={grid.cols}
{cellSize}
+ {previewCells}
oncellclick={onCellClick}
/>
@@ -213,6 +250,15 @@
{/if}
+ {#if showLevelComplete}
+