diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 7f9eff6..672f828 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -113,13 +113,13 @@ Plans:
2. Game responds accurately to touch input on mobile devices without lag
3. Grid layout adapts responsively to phone and desktop screen sizes
4. Connection path is drawn visually when a match succeeds
-**Plans**: TBD
+**Plans**: 4 plans
Plans:
-- [ ] 06-01: Tile match animations and visual effects
-- [ ] 06-02: Connection path visualization
-- [ ] 06-03: Mobile touch optimization
-- [ ] 06-04: Responsive grid layout refinements
+- [ ] 06-01-PLAN.md — Tile match animations with scale+fade effect (MatchAnimation class, CONFIG.animation.matchDuration)
+- [ ] 06-02-PLAN.md — Path glow effect and animation wiring (shadowBlur, animateMatch integration)
+- [ ] 06-03-PLAN.md — Mobile touch optimization (RippleAnimation, touch-action: none, ripple triggering)
+- [ ] 06-04-PLAN.md — Responsive canvas scaling (CSS transform, scale-down-only, aspect ratio preservation)
## Progress
@@ -133,9 +133,9 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6
| 3. Core Matching Mechanics | 3/3 | Complete | 2026-03-11 |
| 4. Game State Management | 5/5 | Complete | 04-00, 04-01, 04-02, 04-03, 04-04 |
| 5. Board Generation and Recovery | 0/3 | Not started | - |
-| 6. Polish and UX | 0/4 | Not started | - |
+| 6. Polish and UX | 0/4 | Planned | - |
---
*Roadmap created: 2026-03-10*
*Granularity: standard*
-*Last updated: 2026-03-11 after Phase 5 planning*
+*Last updated: 2026-03-11 after Phase 6 planning*
diff --git a/.planning/phases/06-polish-and-ux/06-01-PLAN.md b/.planning/phases/06-polish-and-ux/06-01-PLAN.md
new file mode 100644
index 0000000..76121ed
--- /dev/null
+++ b/.planning/phases/06-polish-and-ux/06-01-PLAN.md
@@ -0,0 +1,241 @@
+---
+phase: 06-polish-and-ux
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified: [src/rendering/Renderer.ts, src/config.ts]
+autonomous: true
+requirements: [UX-01]
+user_setup: []
+
+must_haves:
+ truths:
+ - "Matched tiles animate with scale+fade effect before disappearing"
+ - "Animation feels satisfying with 'pop' effect (grow then shrink)"
+ - "Animation runs concurrently with path display (not sequential)"
+ artifacts:
+ - path: "src/rendering/Renderer.ts"
+ provides: "MatchAnimation class and integration"
+ contains: "class MatchAnimation"
+ exports: ["animateMatch"]
+ - path: "src/config.ts"
+ provides: "Animation duration constant"
+ contains: "MATCH_ANIMATION_DURATION"
+ key_links:
+ - from: "Game.ts tilesMatched event"
+ to: "Renderer.animateMatch()"
+ via: "event handler"
+ pattern: "tilesMatched.*animateMatch"
+ - from: "Renderer.renderTile()"
+ to: "MatchAnimation.getScaleAndAlpha()"
+ via: "animation lookup and transform"
+ pattern: "matchAnimations.get"
+---
+
+
+Implement tile match animations with scale+fade effect for satisfying visual feedback when tiles are cleared.
+
+Purpose: Creates the satisfying "aha!" moment when players successfully match tiles - the core emotional reward of the game.
+Output: MatchAnimation class integrated into Renderer with scale+fade transforms applied during tile rendering.
+
+
+
+@./.claude/get-shit-done/workflows/execute-plan.md
+@./.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/06-polish-and-ux/06-CONTEXT.md
+@.planning/phases/06-polish-and-ux/06-RESEARCH.md
+
+
+
+
+From src/rendering/Renderer.ts (existing patterns):
+```typescript
+// Existing ShakeAnimation pattern to follow:
+class ShakeAnimation {
+ private startTime: number;
+ private readonly duration: number;
+ start(): void;
+ getOffset(): { x: number; y: number };
+ isComplete(): boolean;
+}
+
+// Renderer properties to extend:
+private shakeAnimations: Map = new Map();
+private readonly PATH_DISPLAY_DURATION = 300;
+
+// Existing methods:
+render(): void;
+renderTile(ctx, tile, offsetX, offsetY): void;
+drawPath(path: TilePosition[]): void;
+```
+
+From src/game/Game.ts:
+```typescript
+// Event emission pattern:
+this.events.emit('tilesMatched', { tile1, tile2, path, turns, score });
+
+// Current timing (line 86-97):
+setTimeout(() => {
+ this.gridManager.clearTiles([tile1, tile2]);
+}, 300); // Wait for path animation (300ms)
+```
+
+From src/config.ts:
+```typescript
+export const CONFIG = {
+ // Add animation constant here
+} as const;
+```
+
+
+
+
+
+
+ Task 1: Create MatchAnimation class
+ src/rendering/Renderer.ts
+
+ - MatchAnimation starts with startTime=0 and duration=250ms
+ - getScaleAndAlpha() returns {scale, alpha} based on elapsed time
+ - Scale phases: 0-50% = grow to 1.2, 50-100% = shrink to 0
+ - Alpha phases: linear fade from 1.0 to 0
+ - isComplete() returns true when elapsed > duration
+ - easeOutBack easing for "pop" feel in grow phase
+ - easeInQuad easing for fade
+
+
+ Create MatchAnimation class following ShakeAnimation pattern in Renderer.ts:
+
+ 1. Add class MatchAnimation after ShakeAnimation class (line 75):
+ - Properties: startTime (number), duration (number, default 250)
+ - Constructor(duration = 250): sets this.duration, startTime = 0
+ - start(): sets startTime = performance.now()
+ - getScaleAndAlpha(): returns { scale: number, alpha: number }
+ - elapsed = performance.now() - startTime
+ - If elapsed > duration, return { scale: 0, alpha: 0 }
+ - progress = elapsed / duration
+ - Grow phase (progress < 0.5): scale = 1 + 0.2 * easeOutBack(progress * 2)
+ - Shrink phase (progress >= 0.5): scale = 1.2 * (1 - (progress - 0.5) * 2)
+ - Alpha: 1 - easeInQuad(progress)
+ - isComplete(): returns performance.now() - startTime > duration
+ - Private easeOutBack(t): c1 = 1.70158, c3 = c1 + 1, return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
+ - Private easeInQuad(t): return t * t
+
+ Use standard easing coefficients. The "pop" feel comes from easeOutBack overshooting slightly.
+
+
+ npm run test -- --run src/__tests__/Renderer.test.ts
+
+ MatchAnimation class exists with start(), getScaleAndAlpha(), and isComplete() methods. Tests verify scale grows then shrinks, alpha fades, and isComplete returns true after duration.
+
+
+
+ Task 2: Add animation constant to CONFIG
+ src/config.ts
+
+ - CONFIG has animation.MATCH_ANIMATION_DURATION = 250
+ - Value matches research recommendation (200-300ms range)
+
+
+ Add animation configuration to CONFIG in src/config.ts:
+
+ 1. Add 'animation' property to CONFIG object:
+ ```typescript
+ animation: {
+ matchDuration: 250, // ms - scale+fade duration per CONTEXT.md
+ },
+ ```
+
+ 2. Place after 'colors' property, before closing brace.
+
+
+ npm run test -- --run src/__tests__/config.test.ts
+
+ CONFIG.animation.matchDuration exists with value 250. Existing tests pass.
+
+
+
+ Task 3: Integrate MatchAnimation into Renderer
+ src/rendering/Renderer.ts
+
+ - Renderer has matchAnimations Map<string, MatchAnimation>
+ - animateMatch(tiles) creates and starts animations for each tile
+ - renderTile() applies scale and alpha transforms when animation exists
+ - Completed animations are cleaned up from the map
+
+
+ Integrate MatchAnimation into Renderer class:
+
+ 1. Add property after shakeAnimations (line 82):
+ ```typescript
+ private matchAnimations: Map = new Map();
+ ```
+
+ 2. Add constant after PATH_DISPLAY_DURATION (line 84):
+ ```typescript
+ private readonly MATCH_ANIMATION_DURATION = CONFIG.animation.matchDuration;
+ ```
+
+ 3. Add animateMatch() method after animateShake() (after line 299):
+ ```typescript
+ /**
+ * Start match animation for specified tiles
+ * @param tiles - Tiles to animate with scale+fade effect
+ */
+ animateMatch(tiles: Tile[]): void {
+ for (const tile of tiles) {
+ const animation = new MatchAnimation(this.MATCH_ANIMATION_DURATION);
+ animation.start();
+ this.matchAnimations.set(tile.id, animation);
+ }
+ }
+ ```
+
+ 4. Modify renderTile() method to apply animation transforms (lines 151-184):
+ - After calculating x, y position, calculate centerX and centerY
+ - Check for matchAnimation before drawing
+ - If animation exists:
+ - Get { scale, alpha } from animation
+ - ctx.save()
+ - ctx.globalAlpha = alpha
+ - ctx.translate(centerX, centerY)
+ - ctx.scale(scale, scale)
+ - ctx.translate(-centerX, -centerY)
+ - Draw tile (existing code)
+ - ctx.restore()
+ - If animation.isComplete(), delete from matchAnimations
+ - If no animation, draw tile normally (existing code)
+
+
+ npm run test -- --run src/__tests__/Renderer.test.ts
+
+ animateMatch() method exists. renderTile() applies scale/alpha transforms for animating tiles. Tests verify animation integration.
+
+
+
+
+
+- Unit tests pass for MatchAnimation class
+- Unit tests pass for Renderer.animateMatch() integration
+- Visual verification: matched tiles show scale+fade effect before clearing
+- Animation timing: 250ms concurrent with 300ms path display
+
+
+
+1. MatchAnimation class follows ShakeAnimation pattern with easeOutBack easing
+2. CONFIG.animation.matchDuration = 250ms constant added
+3. Renderer.animateMatch(tiles) method available for Game.ts to call
+4. renderTile() applies scale/alpha transforms for animating tiles
+5. All existing tests continue to pass
+
+
+
diff --git a/.planning/phases/06-polish-and-ux/06-02-PLAN.md b/.planning/phases/06-polish-and-ux/06-02-PLAN.md
new file mode 100644
index 0000000..176a8dd
--- /dev/null
+++ b/.planning/phases/06-polish-and-ux/06-02-PLAN.md
@@ -0,0 +1,168 @@
+---
+phase: 06-polish-and-ux
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified: [src/rendering/Renderer.ts]
+autonomous: true
+requirements: [UX-01]
+user_setup: []
+
+must_haves:
+ truths:
+ - "Connection path displays with visible glow effect behind green line"
+ - "Glow effect makes path more visible and satisfying"
+ - "Path animation duration remains at 300ms"
+ artifacts:
+ - path: "src/rendering/Renderer.ts"
+ provides: "Enhanced path drawing with glow"
+ contains: "shadowBlur"
+ min_lines: 10
+ key_links:
+ - from: "drawPathLine()"
+ to: "Canvas shadowBlur API"
+ via: "ctx.shadowBlur, ctx.shadowColor"
+ pattern: "shadowBlur.*15"
+---
+
+
+Add glow effect to connection path visualization for more visible and satisfying match feedback.
+
+Purpose: Makes the successful connection path more prominent and rewarding, enhancing the visual satisfaction of finding valid matches.
+Output: Enhanced drawPathLine() method with canvas shadowBlur glow effect behind green line.
+
+
+
+@./.claude/get-shit-done/workflows/execute-plan.md
+@./.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/06-polish-and-ux/06-CONTEXT.md
+@.planning/phases/06-polish-and-ux/06-RESEARCH.md
+
+
+
+
+From src/rendering/Renderer.ts (current drawPathLine implementation):
+```typescript
+private drawPathLine(path: TilePosition[]): void {
+ if (path.length < 2) return;
+
+ // Calculate grid offset
+ const { size, gap } = CONFIG.tile;
+ const gridWidth = CONFIG.grid.cols * (size + gap) + gap;
+ const gridHeight = CONFIG.grid.rows * (size + gap) + gap;
+ const offsetX = (this.canvas.width - gridWidth) / 2;
+ const offsetY = (this.canvas.height - gridHeight) / 2;
+
+ // Set path style
+ this.ctx.strokeStyle = '#00ff00'; // Green color per CONTEXT.md
+ this.ctx.lineWidth = 3;
+ this.ctx.lineCap = 'round';
+ this.ctx.lineJoin = 'round';
+
+ // Begin path
+ this.ctx.beginPath();
+ // ... coordinate calculations ...
+ this.ctx.stroke();
+}
+```
+
+Canvas glow API (from RESEARCH.md):
+```typescript
+ctx.shadowColor = '#00ff00';
+ctx.shadowBlur = 15; // Glow intensity (keep under 20 for performance)
+ctx.strokeStyle = '#00ff00';
+ctx.lineWidth = 3;
+```
+
+
+
+
+
+
+ Task 1: Add glow effect to drawPathLine()
+ src/rendering/Renderer.ts
+
+ - Path line has shadowBlur = 15 for glow effect
+ - shadowColor matches strokeColor (#00ff00)
+ - Glow is drawn first, then solid line on top for crispness
+ - ctx.save() and ctx.restore() preserve context state
+
+
+ Enhance drawPathLine() method in Renderer.ts (lines 357-394):
+
+ 1. Wrap the entire path drawing in ctx.save()/ctx.restore():
+ - Add ctx.save() before setting styles (after early return check)
+ - Add ctx.restore() after ctx.stroke()
+
+ 2. Add glow effect before drawing the path:
+ - Set ctx.shadowColor = '#00ff00' (same as stroke color)
+ - Set ctx.shadowBlur = 15 (per RESEARCH.md recommendation)
+ - Keep existing ctx.strokeStyle = '#00ff00'
+ - Keep existing ctx.lineWidth = 3
+ - Keep existing ctx.lineCap = 'round'
+ - Keep existing ctx.lineJoin = 'round'
+
+ 3. The existing single stroke() call will now draw with glow
+
+ No changes to coordinate calculations or path logic. Only adding glow effect properties.
+
+
+ npm run test -- --run src/__tests__/Renderer.test.ts
+
+ drawPathLine() sets shadowBlur and shadowColor before stroking path. Tests verify glow properties are set. Visual: path has soft green glow.
+
+
+
+ Task 2: Wire animateMatch call in Game.ts
+ src/game/Game.ts
+
+ Connect the animateMatch method to the tilesMatched event in Game.ts:
+
+ 1. Find the tilesMatched event handler (around line 69-84)
+
+ 2. Add animateMatch call after drawPath:
+ ```typescript
+ // Successful match - draw path first
+ this.renderer.drawPath(result.path!);
+
+ // Start match animation (concurrent with path display)
+ this.renderer.animateMatch([tile1, tile2]);
+ ```
+
+ 3. The animation runs concurrently with path display (both 250-300ms)
+
+ This wires the MatchAnimation from plan 06-01 into the game flow.
+
+
+ npm run test -- --run src/__tests__/Game.test.ts
+
+ Game.ts calls renderer.animateMatch([tile1, tile2]) when tilesMatched. Animation starts immediately, concurrent with path display.
+
+
+
+
+
+- Unit tests pass for drawPathLine glow effect
+- Visual verification: green path line has visible soft glow around it
+- Path remains crisp with glow enhancing visibility
+- Performance: shadowBlur under 20px as recommended
+
+
+
+1. drawPathLine() uses canvas shadowBlur API for glow effect
+2. shadowBlur = 15, shadowColor = '#00ff00'
+3. Context state preserved with save/restore
+4. Game.ts wires animateMatch call to tilesMatched event
+5. All existing tests continue to pass
+
+
+
diff --git a/.planning/phases/06-polish-and-ux/06-03-PLAN.md b/.planning/phases/06-polish-and-ux/06-03-PLAN.md
new file mode 100644
index 0000000..fca515d
--- /dev/null
+++ b/.planning/phases/06-polish-and-ux/06-03-PLAN.md
@@ -0,0 +1,299 @@
+---
+phase: 06-polish-and-ux
+plan: 03
+type: execute
+wave: 2
+depends_on: [06-01, 06-02]
+files_modified: [src/rendering/Renderer.ts, src/game/Game.ts, index.html]
+autonomous: true
+requirements: [UX-02]
+user_setup: []
+
+must_haves:
+ truths:
+ - "Touch input on mobile does not trigger zoom or scroll"
+ - "Visual ripple effect appears at touch point when tile is selected"
+ - "Game remains playable on mobile without accidental gestures"
+ artifacts:
+ - path: "src/rendering/Renderer.ts"
+ provides: "RippleAnimation class"
+ contains: "class RippleAnimation"
+ exports: ["addRipple"]
+ - path: "src/game/Game.ts"
+ provides: "Touch prevention and ripple triggering"
+ contains: "touch-action: none"
+ - path: "index.html"
+ provides: "CSS touch-action property"
+ contains: "touch-action: none"
+ key_links:
+ - from: "Game.handleInput()"
+ to: "Renderer.addRipple()"
+ via: "touch event handling"
+ pattern: "addRipple.*clientX.*clientY"
+ - from: "index.html #game style"
+ to: "Touch prevention"
+ via: "CSS touch-action"
+ pattern: "touch-action.*none"
+---
+
+
+Add mobile touch optimization with ripple feedback and zoom/scroll prevention.
+
+Purpose: Makes the game feel responsive and polished on mobile devices, preventing accidental browser gestures during gameplay.
+Output: RippleAnimation class, touch-action CSS, and ripple triggering on touch input.
+
+
+
+@./.claude/get-shit-done/workflows/execute-plan.md
+@./.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/06-polish-and-ux/06-CONTEXT.md
+@.planning/phases/06-polish-and-ux/06-RESEARCH.md
+
+
+
+
+From src/game/Game.ts (current touch handling):
+```typescript
+private handleTouch = (event: TouchEvent): void => {
+ this.handleInput(event);
+};
+
+public setupInputListeners(): void {
+ this.canvas.addEventListener('click', this.handleClick);
+ this.canvas.addEventListener('touchstart', this.handleTouch, { passive: true });
+ window.addEventListener('resize', this.handleResize);
+}
+```
+
+From index.html (current canvas CSS):
+```css
+#game {
+ border-radius: 8px;
+}
+```
+
+From RESEARCH.md (RippleAnimation pattern):
+```typescript
+class RippleAnimation {
+ private startTime: number;
+ private readonly x: number;
+ private readonly y: number;
+ private readonly duration: number = 300;
+ private readonly maxRadius: number = 40;
+
+ constructor(x: number, y: number) { ... }
+ render(ctx: CanvasRenderingContext2D): boolean { ... }
+}
+```
+
+
+
+
+
+
+ Task 1: Add touch-action CSS to canvas
+ index.html
+
+ - Canvas has touch-action: none to prevent zoom/scroll
+ - Body has overflow: hidden to prevent page scroll
+
+
+ Update CSS in index.html:
+
+ 1. Add touch-action to #game style (line 21-22):
+ ```css
+ #game {
+ border-radius: 8px;
+ touch-action: none; /* Prevent all touch gestures */
+ }
+ ```
+
+ 2. Add overflow: hidden to body style (line 13-19):
+ ```css
+ body {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ background-color: #1a1a2e;
+ overflow: hidden; /* Prevent scroll */
+ }
+ ```
+
+ This is the simplest and most reliable approach for touch prevention per RESEARCH.md.
+
+
+ grep -q "touch-action: none" index.html
+
+ Canvas has touch-action: none CSS. Body has overflow: hidden. Visual: no zoom/scroll on mobile touch.
+
+
+
+ Task 2: Create RippleAnimation class
+ src/rendering/Renderer.ts
+
+ - RippleAnimation starts with x, y coordinates and startTime
+ - render(ctx) draws expanding circle with fading alpha
+ - Returns false when animation complete (elapsed > duration)
+ - Duration = 300ms, maxRadius = 40px
+ - Color matches selection color (rgba(233, 69, 96, alpha))
+
+
+ Create RippleAnimation class in Renderer.ts after MatchAnimation class:
+
+ 1. Add class RippleAnimation (after MatchAnimation, before Renderer class):
+ ```typescript
+ /**
+ * RippleAnimation class for touch feedback effect
+ * Creates expanding circle at touch point
+ */
+ class RippleAnimation {
+ private startTime: number;
+ private readonly x: number;
+ private readonly y: number;
+ private readonly duration: number = 300;
+ private readonly maxRadius: number = 40;
+
+ constructor(x: number, y: number) {
+ this.startTime = performance.now();
+ this.x = x;
+ this.y = y;
+ }
+
+ /**
+ * Render ripple and return whether animation is still active
+ * @returns true if still animating, false if complete
+ */
+ render(ctx: CanvasRenderingContext2D): boolean {
+ const elapsed = performance.now() - this.startTime;
+ if (elapsed > this.duration) return false;
+
+ const progress = elapsed / this.duration;
+ const radius = this.maxRadius * progress;
+ const alpha = 0.3 * (1 - progress); // Fade out
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(this.x, this.y, radius, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(233, 69, 96, ${alpha})`; // Selection color
+ ctx.fill();
+ ctx.restore();
+
+ return true;
+ }
+ }
+ ```
+
+ 2. Add property to Renderer class (after matchAnimations):
+ ```typescript
+ private rippleAnimations: RippleAnimation[] = [];
+ ```
+
+ 3. Add addRipple() method to Renderer class (after animateMatch):
+ ```typescript
+ /**
+ * Add ripple effect at touch/click coordinates
+ * @param x - Canvas X coordinate
+ * @param y - Canvas Y coordinate
+ */
+ addRipple(x: number, y: number): void {
+ this.rippleAnimations.push(new RippleAnimation(x, y));
+ }
+ ```
+
+ 4. Add ripple rendering in render() method (after path animation, before tiles):
+ ```typescript
+ // Draw ripple animations
+ this.rippleAnimations = this.rippleAnimations.filter(ripple =>
+ ripple.render(this.ctx)
+ );
+ ```
+
+
+ npm run test -- --run src/__tests__/Renderer.test.ts
+
+ RippleAnimation class exists. addRipple() method available. Ripples render and auto-cleanup on completion.
+
+
+
+ Task 3: Trigger ripple on touch input
+ src/game/Game.ts
+
+ - Touch input triggers ripple at touch coordinates
+ - Ripple coordinates match tile selection coordinates
+ - Works alongside existing tile selection logic
+
+
+ Update handleInput() in Game.ts to trigger ripple effect:
+
+ 1. Modify handleInput() method (around line 239-272) to add ripple:
+ - After extracting clientX, clientY from event
+ - Convert to canvas coordinates (existing logic)
+ - Call this.renderer.addRipple(x, y) with canvas coordinates
+
+ ```typescript
+ private handleInput(event: MouseEvent | TouchEvent): void {
+ // Block input if game is in GAME_OVER state
+ if (!this.gameStateManager.canSelectTile()) {
+ return;
+ }
+
+ const rect = this.canvas.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+
+ // Extract client coordinates
+ let clientX: number, clientY: number;
+ if ('changedTouches' in event) {
+ clientX = event.changedTouches[0].clientX;
+ clientY = event.changedTouches[0].clientY;
+ } else {
+ clientX = event.clientX;
+ clientY = event.clientY;
+ }
+
+ // Convert to canvas coordinates
+ const x = (clientX - rect.left) * (this.canvas.width / rect.width / dpr);
+ const y = (clientY - rect.top) * (this.canvas.height / rect.height / dpr);
+
+ // Add ripple effect at touch/click point
+ this.renderer.addRipple(x, y);
+
+ // ... rest of existing tile selection logic ...
+ }
+ ```
+
+ 2. The ripple appears at the same coordinates used for tile hit detection.
+
+
+ npm run test -- --run src/__tests__/Game.test.ts
+
+ handleInput() calls renderer.addRipple(x, y). Ripple appears at touch/click point before tile selection.
+
+
+
+
+
+- Unit tests pass for RippleAnimation class
+- Unit tests pass for addRipple() integration
+- Visual: ripple effect appears on touch/click
+- Mobile: no zoom/scroll when touching canvas
+- Touch selection still works correctly
+
+
+
+1. index.html has touch-action: none on canvas
+2. RippleAnimation class with 300ms duration, 40px max radius
+3. Renderer.addRipple(x, y) method available
+4. Game.handleInput() triggers ripple at touch coordinates
+5. All existing tests continue to pass
+
+
+
diff --git a/.planning/phases/06-polish-and-ux/06-04-PLAN.md b/.planning/phases/06-polish-and-ux/06-04-PLAN.md
new file mode 100644
index 0000000..08aebb1
--- /dev/null
+++ b/.planning/phases/06-polish-and-ux/06-04-PLAN.md
@@ -0,0 +1,269 @@
+---
+phase: 06-polish-and-ux
+plan: 04
+type: execute
+wave: 2
+depends_on: [06-01, 06-02]
+files_modified: [src/game/Game.ts, index.html]
+autonomous: true
+requirements: [UX-03]
+user_setup: []
+
+must_haves:
+ truths:
+ - "Game grid fits within viewport on mobile devices"
+ - "Canvas scales down on small screens, never scales up on large screens"
+ - "Aspect ratio is preserved (no stretching)"
+ - "Both portrait and landscape orientations work"
+ artifacts:
+ - path: "src/game/Game.ts"
+ provides: "Responsive canvas scaling logic"
+ contains: "scale down only"
+ min_lines: 20
+ - path: "index.html"
+ provides: "Viewport-relative positioning"
+ contains: "transformOrigin"
+ key_links:
+ - from: "Game.setupCanvas()"
+ to: "CSS transform scale"
+ via: "canvas.style.transform"
+ pattern: "scale.*displayWidth.*nativeWidth"
+ - from: "handleResize()"
+ to: "Responsive recalculation"
+ via: "setupCanvas() call"
+ pattern: "setupCanvas"
+---
+
+
+Implement responsive canvas scaling for mobile and desktop compatibility.
+
+Purpose: Ensures the game is playable on all screen sizes by scaling the canvas to fit the viewport while maintaining aspect ratio and visual quality.
+Output: Enhanced setupCanvas() with CSS transform scaling, scale-down-only logic.
+
+
+
+@./.claude/get-shit-done/workflows/execute-plan.md
+@./.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/06-polish-and-ux/06-CONTEXT.md
+@.planning/phases/06-polish-and-ux/06-RESEARCH.md
+
+
+
+
+From src/game/Game.ts (current setupCanvas):
+```typescript
+private setupCanvas(): void {
+ const { cols, rows } = CONFIG.grid;
+ const { size, gap } = CONFIG.tile;
+ const dpr = window.devicePixelRatio || 1;
+
+ // Calculate logical canvas size
+ const width = cols * (size + gap) + gap; // 832px
+ const height = rows * (size + gap) + gap; // 528px
+
+ // Set actual canvas size (accounting for device pixel ratio)
+ this.canvas.width = width * dpr;
+ this.canvas.height = height * dpr;
+
+ // Set display size (CSS)
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+
+ // Scale context to account for device pixel ratio
+ this.ctx.scale(dpr, dpr);
+}
+```
+
+From RESEARCH.md (responsive scaling pattern):
+```typescript
+// Native canvas size: 832 x 528
+const nativeWidth = 832;
+const nativeHeight = 528;
+const aspectRatio = nativeWidth / nativeHeight;
+
+// Scale down only on small screens
+if (viewportWidth < nativeWidth || viewportHeight < nativeHeight) {
+ const scale = Math.min(viewportWidth / nativeWidth, viewportHeight / nativeHeight, 1);
+ displayWidth = nativeWidth * scale;
+ displayHeight = nativeHeight * scale;
+}
+
+// Use CSS transform for display scaling (preserves coordinates)
+this.canvas.style.transform = `scale(${scale})`;
+this.canvas.style.transformOrigin = 'center center';
+```
+
+From index.html (current body CSS):
+```css
+body {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ background-color: #1a1a2e;
+}
+```
+
+
+
+
+
+
+ Task 1: Add responsive scaling to setupCanvas()
+ src/game/Game.ts
+
+ - Canvas scales down to fit viewport on small screens
+ - Never scales up beyond native size (832x528)
+ - Aspect ratio is preserved
+ - CSS transform used for display scaling
+ - DPR handling remains unchanged
+
+
+ Enhance setupCanvas() method in Game.ts (lines 153-172):
+
+ 1. Replace the entire method with responsive version:
+
+ ```typescript
+ /**
+ * Sets up canvas dimensions with responsive scaling
+ * Scales down to fit viewport on small screens, never scales up
+ */
+ private setupCanvas(): void {
+ const { cols, rows } = CONFIG.grid;
+ const { size, gap } = CONFIG.tile;
+ const dpr = window.devicePixelRatio || 1;
+
+ // Calculate native canvas size
+ const nativeWidth = cols * (size + gap) + gap; // 832px
+ const nativeHeight = rows * (size + gap) + gap; // 528px
+
+ // Get viewport dimensions (with padding for UI elements)
+ const viewportWidth = window.innerWidth - 40; // 20px padding each side
+ const viewportHeight = window.innerHeight - 80; // Space for score display
+
+ // Calculate scale (scale down only, never up)
+ let scale = 1;
+ if (viewportWidth < nativeWidth || viewportHeight < nativeHeight) {
+ const scaleByWidth = viewportWidth / nativeWidth;
+ const scaleByHeight = viewportHeight / nativeHeight;
+ scale = Math.min(scaleByWidth, scaleByHeight, 1);
+ }
+
+ // Calculate display size
+ const displayWidth = nativeWidth * scale;
+ const displayHeight = nativeHeight * scale;
+
+ // Set canvas internal size (with DPR for sharp rendering)
+ this.canvas.width = nativeWidth * dpr;
+ this.canvas.height = nativeHeight * dpr;
+
+ // Set display size via CSS (native size, scaled with transform)
+ this.canvas.style.width = `${nativeWidth}px`;
+ this.canvas.style.height = `${nativeHeight}px`;
+ this.canvas.style.transform = `scale(${scale})`;
+ this.canvas.style.transformOrigin = 'center center';
+
+ // Scale context for DPR (reset first to avoid accumulation)
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+ this.ctx.scale(dpr, dpr);
+ }
+ ```
+
+ Key changes:
+ - Added viewport dimension calculation with padding
+ - Added scale calculation (scale down only)
+ - Added CSS transform for display scaling
+ - Added ctx.setTransform() to prevent scale accumulation on resize
+
+
+ npm run test -- --run src/__tests__/Game.test.ts
+
+ setupCanvas() scales canvas down to fit viewport. Transform used for display. Tests verify scale calculation logic.
+
+
+
+ Task 2: Update coordinate mapping for scaled canvas
+ src/game/Game.ts
+
+ - Touch/click coordinates correctly map to tiles on scaled canvas
+ - Works with CSS transform scaling
+ - DPR handling preserved
+
+
+ Update handleInput() in Game.ts to account for CSS transform scaling:
+
+ 1. The current coordinate calculation already works correctly with CSS transform
+ because getBoundingClientRect() returns the actual displayed size.
+
+ 2. Verify the calculation is correct (lines 259-260):
+ ```typescript
+ const x = (clientX - rect.left) * (this.canvas.width / rect.width / dpr);
+ const y = (clientY - rect.top) * (this.canvas.height / rect.height / dpr);
+ ```
+
+ This formula accounts for:
+ - rect.left/top: position relative to viewport (accounts for CSS transform)
+ - rect.width/height: displayed size (accounts for CSS transform scale)
+ - this.canvas.width/height: internal size (native size * DPR)
+ - DPR: device pixel ratio
+
+ 3. No changes needed - the existing formula handles CSS transform correctly.
+ The transform scales the visual display but getBoundingClientRect() reports
+ the transformed dimensions, so the ratio calculation works.
+
+ This is a verification task - confirm the existing code handles scaling correctly.
+
+
+ npm run test -- --run src/__tests__/Game.test.ts
+
+ Coordinate mapping verified to work with CSS transform scaling. No code changes needed - existing formula is correct.
+
+
+
+ Task 3: Add human verification checkpoint
+ index.html
+
+ This task is a checkpoint for human verification of responsive layout.
+
+ After implementation, test on:
+ 1. Desktop browser - canvas should be at native 832x528 size, centered
+ 2. Resize browser to mobile width - canvas should scale down proportionally
+ 3. Mobile device (or Chrome DevTools mobile emulation) - canvas should fit screen
+ 4. Both portrait and landscape orientations should work
+
+ No code changes for this task - visual verification only.
+
+
+ Manual: Test responsive scaling in browser by resizing window and using mobile emulation.
+
+ Visual verification complete - canvas scales correctly on different screen sizes.
+
+
+
+
+
+- Unit tests pass for setupCanvas() scale calculation
+- Desktop: canvas displays at native 832x528
+- Mobile (narrow viewport): canvas scales down to fit
+- Aspect ratio preserved (no stretching)
+- Touch/click coordinates still map correctly
+
+
+
+1. setupCanvas() calculates scale based on viewport dimensions
+2. CSS transform used for display scaling (not canvas.width/height)
+3. Scale down only - never scales up beyond native size
+4. ctx.setTransform() prevents scale accumulation on resize
+5. Coordinate mapping verified to work with scaling
+6. All existing tests continue to pass
+
+
+