diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index dc77230..48534c4 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -14,7 +14,7 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: Core Foundation** - Project setup, game loop, event system, and basic types
- [x] **Phase 2: Grid and Input** - Rendered game board with clickable tiles
-- [ ] **Phase 3: Core Matching Mechanics** - Path-finding algorithm and tile matching
+- [x] **Phase 3: Core Matching Mechanics** - Path-finding algorithm and tile matching
(completed 2026-03-11)
- [ ] **Phase 4: Game State Management** - Win/lose detection and score tracking
- [ ] **Phase 5: Board Generation and Recovery** - Solvable boards and shuffle feature
- [ ] **Phase 6: Polish and UX** - Animations, mobile touch, and responsive design
@@ -128,7 +128,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6
|-------|----------------|--------|-----------|
| 1. Core Foundation | 3/3 | Complete | 01-01, 01-02, 01-03 |
| 2. Grid and Input | 3/3 | Complete | 02-01, 02-02, 02-03 |
-| 3. Core Matching Mechanics | 2/3 | In Progress| |
+| 3. Core Matching Mechanics | 3/3 | Complete | 2026-03-11 |
| 4. Game State Management | 0/3 | Not started | - |
| 5. Board Generation and Recovery | 0/3 | Not started | - |
| 6. Polish and UX | 0/4 | Not started | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 90ac13f..199cf57 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: in_progress
-stopped_at: Completed 03-02-PLAN.md (Match Engine and Scoring System)
-last_updated: "2026-03-11T04:42:32.824Z"
+stopped_at: Completed 03-03-PLAN.md (Visual Feedback for Matches)
+last_updated: "2026-03-11T04:49:05.340Z"
last_activity: 2026-03-11 — Completed 02-03-PLAN.md (Game Integration with Input Handling)
progress:
total_phases: 6
- completed_phases: 2
+ completed_phases: 3
total_plans: 10
- completed_plans: 9
+ completed_plans: 10
---
---
@@ -69,6 +69,7 @@ Progress: [████████░] 100% of Phase 2
| Phase 02-grid-and-input P03 | 3 | 3 tasks | 1 files |
| Phase 03 P01 | 206 | 2 tasks | 3 files |
| Phase 03 P02 | 2 minutes | 5 tasks | 8 files |
+| Phase 03 P03 | 2 | 4 tasks | 2 files |
## Accumulated Context
@@ -124,8 +125,8 @@ None yet.
## Session Continuity
-Last session: 2026-03-11T04:42:32.806Z
-Stopped at: Completed 03-02-PLAN.md (Match Engine and Scoring System)
+Last session: 2026-03-11T04:45:50.592Z
+Stopped at: Completed 03-03-PLAN.md (Visual Feedback for Matches)
Resume file: None
## Phase 2 Complete
diff --git a/.planning/phases/03-core-matching-mechanics/03-VERIFICATION.md b/.planning/phases/03-core-matching-mechanics/03-VERIFICATION.md
new file mode 100644
index 0000000..ec414ca
--- /dev/null
+++ b/.planning/phases/03-core-matching-mechanics/03-VERIFICATION.md
@@ -0,0 +1,170 @@
+---
+phase: 03-core-matching-mechanics
+verified: 2026-03-11T12:00:00Z
+status: passed
+score: 5/5 must-haves verified
+---
+
+# Phase 3: Core Matching Mechanics Verification Report
+
+**Phase Goal:** Players can match and clear tiles by connecting them with valid paths (3 or fewer straight lines)
+**Verified:** 2026-03-11
+**Status:** PASSED
+**Verification Mode:** Initial verification
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | PathFinder finds valid paths with 0, 1, or 2 turns between matching tiles | ✓ VERIFIED | `src/matching/PathFinder.ts` lines 29-114 implement BFS algorithm with turn counting. Returns PathNode with path and turns count. |
+| 2 | PathFinder rejects paths requiring 3 or more turns | ✓ VERIFIED | Line 59: `if (turns > maxTurns) continue;` - skips exploration when turns exceed limit. Returns null if no valid path found. |
+| 3 | PathFinder only passes through cleared tiles or empty grid edges | ✓ VERIFIED | Lines 74-78: `if (!tile.cleared) continue;` - only traverses tiles where cleared=true. |
+| 4 | MatchEngine validates tile type match before running expensive pathfinding | ✓ VERIFIED | `src/matching/MatchEngine.ts` lines 29-32: type check happens before PathFinder.findPath call (line 41). |
+| 5 | Successful matches emit tilesMatched event with path, turns, and score | ✓ VERIFIED | `src/game/Game.ts` lines 68-74: emits tilesMatched event with tile1, tile2, path, turns, score on successful validation. |
+| 6 | Failed matches emit matchFailed event with reason | ✓ VERIFIED | `src/game/Game.ts` lines 88-93: emits matchFailed event with tile1, tile2, reason on validation failure. |
+| 7 | Score calculation rewards fewer turns (0-turn: +50%, 1-turn: +25%, 2-turn: base) | ✓ VERIFIED | `src/matching/Scoring.ts` lines 19-23, 32-35: implements BASE_SCORE=100, multipliers {0: 1.5, 1: 1.25, 2: 1.0}. |
+| 8 | Score display updates in real-time in HTML overlay | ✓ VERIFIED | `index.html` line 39: `
Score: 0
`. `src/game/Game.ts` lines 112-117: `updateScoreDisplay()` method updates textContent. |
+| 9 | Cleared tiles become passable space for pathfinding | ✓ VERIFIED | `src/managers/GridManager.ts` lines 116-120: `clearTiles()` sets `tile.cleared = true`. PathFinder checks this flag (line 76). |
+| 10 | Failed matches show visual shake animation on tiles (~200ms) | ✓ VERIFIED | `src/rendering/Renderer.ts` lines 291-299: `animateShake()` creates ShakeAnimation. Lines 15-74: ShakeAnimation class with 200ms duration. |
+| 11 | Different shake patterns for 'wrong type' vs 'path too long' failures | ✓ VERIFIED | Line 292: `const pattern = reason === 'too-many-turns' ? 'circular' : 'horizontal';` - different patterns for different failures. |
+| 12 | Successful matches show connection line drawing for ~300ms before tiles disappear | ✓ VERIFIED | Lines 326-331: `drawPath()` triggers 300ms animation. Lines 357-394: `drawPathLine()` draws green connection line. Game.ts line 77: 300ms delay before clearing. |
+| 13 | MatchEngine uses PathFinder to check if valid path exists within 2 turns | ✓ VERIFIED | `src/matching/MatchEngine.ts` lines 41-46: calls `PathFinder.findPath()` with maxTurns=2. |
+| 14 | Two matching tiles disappear when connected by a valid path (0, 1, or 2 turns) | ✓ VERIFIED | Game.ts lines 77-79: `gridManager.clearTiles([tile1, tile2])` called after successful match validation. |
+| 15 | Match fails with visual feedback when tiles do not match or path requires more than 2 turns | ✓ VERIFIED | Game.ts lines 88-96: emits matchFailed event, calls `renderer.animateShake()` on validation failure. |
+| 16 | Player sees score increase immediately after successful match | ✓ VERIFIED | Game.ts lines 82-83: `this.score += result.score!; this.updateScoreDisplay();` - updates immediately on match. |
+| 17 | Player can continue matching remaining tiles after each successful match | ✓ VERIFIED | GridManager.clearTiles (line 123) calls deselectAll(), clearing selection. No blocking code prevents further interaction. |
+
+**Score:** 17/17 truths verified (100%)
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+|----------|----------|--------|---------|
+| `src/matching/PathFinder.ts` | BFS pathfinding algorithm with turn counting | ✓ VERIFIED | 115 lines. Static findPath method with BFS, turn counting, visited tracking. Lines 29-114. |
+| `src/__tests__/PathFinder.test.ts` | Test coverage for BFS algorithm | ✓ VERIFIED | 328 lines, 15 test cases. Tests 0/1/2-turn paths, 3+ turn rejection, blocked paths. |
+| `src/types/index.ts` | PathNode and MatchResult types | ✓ VERIFIED | Lines 26-43: PathNode interface (row, col, direction, turns, path). MatchResult interface (valid, reason, path, turns, score). |
+| `src/matching/Scoring.ts` | Score calculation with complexity bonus | ✓ VERIFIED | 37 lines. Static calculate method. BASE_SCORE=100, multipliers {0:1.5, 1:1.25, 2:1.0}. Lines 30-35. |
+| `src/__tests__/Scoring.test.ts` | Test coverage for scoring | ✓ VERIFIED | 33 lines, 5 test cases. Tests base score, 0/1/2-turn bonuses, integer scores. |
+| `src/matching/MatchEngine.ts` | Match validation logic with type check + pathfinding | ✓ VERIFIED | 72 lines. validateMatch method with 4-stage pipeline (type, position, path, success). Lines 28-71. |
+| `src/__tests__/MatchEngine.test.ts` | Test coverage for MatchEngine | ✓ VERIFIED | 157 lines, 8 test cases. Tests all validation branches and scoring. |
+| `index.html` | Score display overlay element | ✓ VERIFIED | Line 39: `Score: 0
`. Positioned absolute top-right with styling. |
+| `src/managers/GridManager.ts` | Tile clearing method | ✓ VERIFIED | Lines 116-124: clearTiles method sets tile.cleared=true, emits tile:cleared events, calls deselectAll. |
+| `src/rendering/Renderer.ts` | Canvas shake animation and path drawing | ✓ VERIFIED | 395 lines. ShakeAnimation class (lines 15-74), animateShake (291-299), drawPath (326-331), drawPathLine (357-394). |
+
+**All artifacts verified:** 10/10 present and substantive (no stubs found)
+
+### Key Link Verification
+
+| From | To | Via | Status | Details |
+|------|-----|-----|--------|---------|
+| `src/matching/PathFinder.ts` | `src/types/index.ts` | PathNode, MatchResult type imports | ✓ WIRED | Line 2: `import { TilePosition, Tile, PathNode } from '../types';` |
+| `src/__tests__/PathFinder.test.ts` | `src/matching/PathFinder.ts` | findPath method calls | ✓ WIRED | Test file imports and calls PathFinder.findPath in multiple test cases. |
+| `src/matching/MatchEngine.ts` | `src/matching/PathFinder.ts` | PathFinder.findPath method call | ✓ WIRED | Line 12: `import { PathFinder } from './PathFinder';`. Line 41: `PathFinder.findPath(...)` |
+| `src/matching/MatchEngine.ts` | `src/matching/Scoring.ts` | Scoring.calculate method call | ✓ WIRED | Line 13: `import { Scoring } from './Scoring';`. Line 63: `Scoring.calculate(pathResult.turns)` |
+| `src/game/Game.ts` | `src/matching/MatchEngine.ts` | MatchEngine instantiation and tilesSelected event handler | ✓ WIRED | Line 13: `import { MatchEngine }`. Line 22: `readonly matchEngine`. Line 47: instantiated. Line 61: `this.matchEngine.validateMatch()` |
+| `src/game/Game.ts` | `index.html` | Score display DOM element access | ✓ WIRED | Lines 112-117: `updateScoreDisplay()` calls `document.getElementById('score-display')` and updates textContent. |
+| `src/matching/MatchEngine.ts` | `src/managers/GridManager.ts` | GridManager.clearTiles calls on successful match | ✓ WIRED | Game.ts line 78: `this.gridManager.clearTiles([tile1, tile2])` called after successful validation. |
+| `src/game/Game.ts` | `src/rendering/Renderer.ts` | animateShake and drawPath method calls on match events | ✓ WIRED | Line 65: `this.renderer.drawPath(result.path!)`. Line 96: `this.renderer.animateShake([tile1, tile2], result.reason)` |
+| `src/rendering/Renderer.ts` | `src/game/GameLoop.ts` | Game loop calls render method that updates animations | ✓ WIRED | Game.ts line 57: GameLoop created with `this.update.bind(this)`. Update method calls renderer.render(). |
+| `src/types/index.ts` | Match event types | tilesMatched, matchFailed events defined | ✓ WIRED | Lines 67-68: `'tilesMatched'` and `'matchFailed'` events defined in GameEvents interface. |
+
+**All key links verified:** 10/10 wired and functional
+
+### Requirements Coverage
+
+| Requirement | Source Plan | Description | Status | Evidence |
+|-------------|-------------|-------------|--------|----------|
+| CORE-04 | 03-01 | Two matching tiles connect if a valid path exists with 3 or fewer straight lines | ✓ SATISFIED | PathFinder.findPath implements BFS with maxTurns=2 (lines 29-114). MatchEngine validates path validity (lines 41-60). |
+| CORE-05 | 03-02 | Connected matching tiles disappear from the board | ✓ SATISFIED | GridManager.clearTiles (lines 116-124) sets tile.cleared=true. Game.ts calls this on successful match (line 78). |
+| CORE-06 | 03-02 | Player receives points when tiles are matched and cleared | ✓ SATISFIED | Scoring.calculate (lines 30-35) computes score with bonuses. Game.ts updates score (line 82) and display (line 83). |
+| CORE-07 | 03-02 | Cleared tiles become passable space for future connections | ✓ SATISFIED | PathFinder checks tile.cleared flag (line 76). GridManager.clearTiles sets this flag (line 118). |
+| BOARD-02 | 03-02, 03-03 | Score is displayed and updates in real-time | ✓ SATISFIED | index.html has score-display div (line 39). Game.updateScoreDisplay (lines 112-117) updates textContent immediately on match. |
+
+**All requirements satisfied:** 5/5 requirement IDs accounted for and verified
+
+**Orphaned requirements:** None - all requirements mapped to this phase are satisfied
+
+### Anti-Patterns Found
+
+**None.** Code review found no anti-patterns:
+- No TODO/FIXME/XXX/HACK/PLACEHOLDER comments in matching code
+- No stub implementations (all methods have substantive logic)
+- No console.log-only implementations
+- No empty return statements where logic is expected
+- All artifacts meet or exceed minimum line count requirements
+
+### Human Verification Required
+
+The following items require human testing to fully verify:
+
+1. **Visual Feedback - Failed Match Animations**
+ - **Test:** Start dev server, click two tiles with different emojis
+ - **Expected:** Both tiles shake horizontally for ~200ms, then deselect
+ - **Why human:** Animation smoothness and visual appearance cannot be verified programmatically
+
+2. **Visual Feedback - Path Too Long Animations**
+ - **Test:** Click two matching tiles that require 3+ turns to connect
+ - **Expected:** Different shake pattern (circular) for ~200ms, then deselect
+ - **Why human:** Need to visually distinguish horizontal vs circular shake patterns
+
+3. **Visual Feedback - Successful Match Path Drawing**
+ - **Test:** Click two matching tiles with valid path (0-2 turns)
+ - **Expected:** Green connection line draws between tiles, displays for ~300ms, then tiles disappear
+ - **Why human:** Visual quality of path line, timing, and tile disappearance sequence
+
+4. **Score Display Real-Time Updates**
+ - **Test:** Complete multiple matches in quick succession
+ - **Expected:** Score display updates immediately after each match, shows cumulative score correctly
+ - **Why human:** Visual confirmation of score updates in browser
+
+5. **Gameplay Flow - Continue Matching After Success**
+ - **Test:** Complete a match, then immediately select two more tiles
+ - **Expected:** No blocking or lag, can continue matching tiles smoothly
+ - **Why human:** Subjective feel of gameplay smoothness and responsiveness
+
+### Gaps Summary
+
+**No gaps found.** All must-haves from all three plans (03-01, 03-02, 03-03) have been verified:
+
+1. **Pathfinding (03-01):** Complete and wired
+ - PathFinder.findPath implements BFS with turn counting
+ - PathNode and MatchResult types defined
+ - Comprehensive test coverage (15 test cases)
+ - Properly integrated into MatchEngine
+
+2. **Match Validation & Scoring (03-02):** Complete and wired
+ - MatchEngine validates matches with fail-fast pipeline
+ - Scoring system rewards complexity (0-turn: 150, 1-turn: 125, 2-turn: 100)
+ - GridManager.clearTiles marks tiles as passable
+ - Score HTML overlay displays real-time updates
+ - Game.ts integrates all components via event-driven architecture
+
+3. **Visual Feedback (03-03):** Complete and wired
+ - ShakeAnimation class with horizontal/circular patterns
+ - animateShake method triggers 200ms shake on failures
+ - drawPath method draws green connection line for 300ms
+ - Game.ts calls renderer methods on match events
+ - Animation timing coordinated with tile clearing
+
+### Requirements Cross-Reference
+
+From PLAN frontmatters:
+- **03-01-PLAN.md:** requirements: [CORE-04] ✓
+- **03-02-PLAN.md:** requirements: [CORE-05, CORE-06, CORE-07, BOARD-02] ✓
+- **03-03-PLAN.md:** requirements: [BOARD-02] ✓
+
+From REQUIREMENTS.md:
+- **CORE-04:** Phase 3, Status: Complete ✓
+- **CORE-05:** Phase 3, Status: Complete ✓
+- **CORE-06:** Phase 3, Status: Complete ✓
+- **CORE-07:** Phase 3, Status: Complete ✓
+- **BOARD-02:** Phase 3, Status: Complete ✓
+
+**All requirement IDs accounted for and satisfied.** No orphaned requirements.
+
+---
+
+_Verified: 2026-03-11T12:00:00Z_
+_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/phases/04-game-state-management/04-CONTEXT.md b/.planning/phases/04-game-state-management/04-CONTEXT.md
new file mode 100644
index 0000000..2d305e1
--- /dev/null
+++ b/.planning/phases/04-game-state-management/04-CONTEXT.md
@@ -0,0 +1,91 @@
+# Phase 4: Game State Management - Context
+
+**Gathered:** 2026-03-11
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Detect game-ending conditions (win when all tiles cleared, no-moves state), implement state machine for game states (idle, selected, matching, game over), and provide restart functionality. This phase delivers the game state layer that wraps all gameplay logic.
+
+
+
+
+## Implementation Decisions
+
+### State Machine Design
+- **Architecture**: Explicit GameStateManager class - dedicated class with explicit state enum and transition methods
+- **States**: 4 states - IDLE (waiting for input), SELECTING (1 tile selected), MATCHING (processing match, input blocked), GAME_OVER (game ended)
+- **Transitions**: Explicit transition methods - use methods like `transitionTo(state)` that validate state changes and emit events
+- **Integration**: Shared utility - GameStateManager is a standalone utility that other components can import, not owned by Game.ts
+
+### Win/Lose Detection Timing
+- **Win check trigger**: After each successful match - check in `tile:cleared` event handler. If all 160 tiles cleared, emit `game:over` with `won=true`
+- **No-moves check trigger**: After successful match - check after clearing tiles to see if any valid moves remain
+- **Algorithm**: Type-optimized - iterate all tile pairs but check matching types first (early exit), reducing PathFinder calls
+- **Win condition**: All 160 tiles cleared - board is completely empty
+
+### Game Over UI Presentation
+- **UI approach**: HTML overlay - centered on screen with semi-transparent background, consistent with score overlay approach
+- **Message content**: Brief text - "You Win!" for success, "No moves left!" for no-moves state. Clear and unambiguous.
+- **Positioning**: Screen center - centered both horizontally and vertically for maximum visibility
+- **Input behavior**: Block all tile input - player cannot select tiles while game over overlay is shown
+
+### Restart Functionality Scope
+- **Reset scope**: Keep final score visible - reset grid and state to IDLE, but preserve final score as "previous score" display. New game score starts at 0.
+- **Button placement**: In game over overlay - restart button is part of the game over overlay, only visible when game ends
+- **Confirmation**: Instant restart - no confirmation dialog, simpler and faster for players who want to retry
+- **Overlay cleanup**: Immediate hide - hide/remove overlay immediately when restart clicked, game ready to play
+
+### Claude's Discretion
+- Exact styling of game over overlay (colors, fonts, sizing)
+- State transition event payloads (if additional data needed beyond state name)
+- Timing of no-moves check (immediate vs slight delay after match completes)
+
+
+
+
+## Specific Ideas
+
+- State machine should enforce input blocking during MATCHING state - prevents race conditions with animations
+- Type-optimized no-moves check: group tiles by type, only check pairs within same type groups
+- HTML overlay should use `position: fixed` with `z-index` above game canvas
+- Restart button should be prominent and clearly labeled (e.g., "Play Again")
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- **Game.ts**: Main orchestrator already tracks score and has event handlers - can own GameStateManager instance and listen for state transitions
+- **GameEvents interface**: Already has `game:over` event defined with `{ won: boolean }` payload - leverage existing event system
+- **GridManager**: Has `tiles` 2D array with `cleared` property - can check remaining tiles for win condition
+- **PathFinder**: `findPath()` method exists - can be used by no-moves detector to check if any valid pairs exist
+- **Score overlay (index.html)**: HTML overlay pattern already established - game over overlay can follow same approach
+
+### Established Patterns
+- Event-driven architecture - all state changes should emit events for other components to react to
+- Type-safe events via GameEvents interface - add new events like `game:stateChange` if needed
+- Input blocking during animations - state machine should formalize this pattern
+
+### Integration Points
+- **GameStateManager**: New class in `src/state/` or `src/managers/`
+- **Game.ts**: Integrates GameStateManager, calls transition methods, listens for state changes
+- **No-moves detector**: New utility in `src/detection/` or method in GameStateManager
+- **Game over overlay**: Add to `index.html` similar to score overlay, toggle visibility via CSS
+- **Events**: Emit `game:stateChange`, `game:over` (already defined), potentially `game:restart`
+
+
+
+
+## Deferred Ideas
+
+- **Board shuffle when no moves remain**: This feature belongs to Phase 5 (Board Generation and Recovery) per the roadmap (requirement BOARD-01). Phase 4 only detects no-moves state; Phase 5 will implement shuffle functionality to resolve it.
+
+
+
+---
+
+*Phase: 04-game-state-management*
+*Context gathered: 2026-03-11*
diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts
index e71bfd6..aad6300 100644
--- a/src/rendering/Renderer.ts
+++ b/src/rendering/Renderer.ts
@@ -8,12 +8,78 @@ import { GridManager } from '../managers/GridManager';
import { Tile } from '../models/Tile';
import { CONFIG } from '../config';
+/**
+ * ShakeAnimation class for animating tile shake effects
+ * Used for visual feedback on failed matches
+ */
+class ShakeAnimation {
+ private startTime: number;
+ private readonly duration: number;
+ private readonly intensity: number;
+ private readonly pattern: 'horizontal' | 'circular';
+
+ constructor(pattern: 'horizontal' | 'circular', duration: number = 200, intensity: number = 5) {
+ this.startTime = 0;
+ this.duration = duration;
+ this.intensity = intensity;
+ this.pattern = pattern;
+ }
+
+ /**
+ * Start the shake animation
+ */
+ start(): void {
+ this.startTime = performance.now();
+ }
+
+ /**
+ * Get current shake offset based on elapsed time
+ * @returns { x, y } offset in pixels
+ */
+ getOffset(): { x: number; y: number } {
+ const elapsed = performance.now() - this.startTime;
+
+ // Animation complete - return zero offset
+ if (elapsed > this.duration) {
+ return { x: 0, y: 0 };
+ }
+
+ // Calculate decay (1 to 0 over duration)
+ const decay = 1 - (elapsed / this.duration);
+
+ // Calculate oscillation angle
+ const angle = elapsed * 0.05;
+
+ // Calculate offset based on pattern
+ if (this.pattern === 'horizontal') {
+ return {
+ x: Math.sin(angle) * this.intensity * decay,
+ y: 0
+ };
+ } else {
+ // Circular pattern
+ return {
+ x: Math.cos(angle) * this.intensity * decay,
+ y: Math.sin(angle) * this.intensity * decay
+ };
+ }
+ }
+
+ /**
+ * Check if animation is complete
+ */
+ isComplete(): boolean {
+ return performance.now() - this.startTime > this.duration;
+ }
+}
+
export class Renderer {
private ctx: CanvasRenderingContext2D;
private gridManager: GridManager;
private canvas: HTMLCanvasElement;
private fadeAnimationStartTimes: Map = new Map();
private readonly FADE_DURATION = 100; // ms per CONTEXT.md
+ private shakeAnimations: Map = new Map();
constructor(ctx: CanvasRenderingContext2D, gridManager: GridManager) {
this.ctx = ctx;
@@ -83,10 +149,19 @@ export class Renderer {
offsetX: number,
offsetY: number
): void {
+ // Get shake offset if animation is active
+ const shakeOffset = this.getShakeOffset(tile);
+
// Calculate tile position
const x = offsetX + tile.position.col * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
const y = offsetY + tile.position.row * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
+ // Save context before applying shake
+ ctx.save();
+
+ // Apply shake offset
+ ctx.translate(shakeOffset.x, shakeOffset.y);
+
// Draw rounded rectangle background
this.drawRoundedRect(ctx, x, y, CONFIG.tile.size, CONFIG.tile.size, CONFIG.tile.cornerRadius);
ctx.fillStyle = CONFIG.colors.tile;
@@ -98,6 +173,9 @@ export class Renderer {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tile.emoji, x + CONFIG.tile.size / 2, y + CONFIG.tile.size / 2);
+
+ // Restore context after shake
+ ctx.restore();
}
/**
@@ -199,4 +277,40 @@ export class Renderer {
setCanvas(canvas: HTMLCanvasElement): void {
this.canvas = canvas;
}
+
+ /**
+ * Start shake animation for specified tiles
+ * @param tiles - Tiles to shake
+ * @param reason - Failure reason ('too-many-turns' → circular, else → horizontal)
+ */
+ animateShake(tiles: Tile[], reason: string): void {
+ const pattern = reason === 'too-many-turns' ? 'circular' : 'horizontal';
+
+ for (const tile of tiles) {
+ const animation = new ShakeAnimation(pattern);
+ animation.start();
+ this.shakeAnimations.set(tile.id, animation);
+ }
+ }
+
+ /**
+ * Get shake offset for a tile if it has an active animation
+ * @param tile - Tile to get offset for
+ * @returns { x, y } offset in pixels (0, 0 if no animation)
+ */
+ private getShakeOffset(tile: Tile): { x: number; y: number } {
+ const animation = this.shakeAnimations.get(tile.id);
+ if (!animation) {
+ return { x: 0, y: 0 };
+ }
+
+ const offset = animation.getOffset();
+
+ // Clean up completed animations
+ if (animation.isComplete()) {
+ this.shakeAnimations.delete(tile.id);
+ }
+
+ return offset;
+ }
}