docs(06): add phase plans for Polish and UX

This commit is contained in:
2026-03-11 11:18:34 +00:00
parent c0340cea07
commit d41c5d9e8a
5 changed files with 984 additions and 7 deletions
+7 -7
View File
@@ -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*
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key types and contracts from existing codebase -->
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<string, ShakeAnimation> = 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;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create MatchAnimation class</name>
<files>src/rendering/Renderer.ts</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
</verify>
<done>MatchAnimation class exists with start(), getScaleAndAlpha(), and isComplete() methods. Tests verify scale grows then shrinks, alpha fades, and isComplete returns true after duration.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add animation constant to CONFIG</name>
<files>src/config.ts</files>
<behavior>
- CONFIG has animation.MATCH_ANIMATION_DURATION = 250
- Value matches research recommendation (200-300ms range)
</behavior>
<action>
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.
</action>
<verify>
<automated>npm run test -- --run src/__tests__/config.test.ts</automated>
</verify>
<done>CONFIG.animation.matchDuration exists with value 250. Existing tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Integrate MatchAnimation into Renderer</name>
<files>src/rendering/Renderer.ts</files>
<behavior>
- Renderer has matchAnimations Map&lt;string, MatchAnimation&gt;
- 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
</behavior>
<action>
Integrate MatchAnimation into Renderer class:
1. Add property after shakeAnimations (line 82):
```typescript
private matchAnimations: Map<string, MatchAnimation> = 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)
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
</verify>
<done>animateMatch() method exists. renderTile() applies scale/alpha transforms for animating tiles. Tests verify animation integration.</done>
</task>
</tasks>
<verification>
- 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
</verification>
<success_criteria>
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
</success_criteria>
<output>
After completion, create `.planning/phases/06-polish-and-ux/06-01-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key types and contracts from existing codebase -->
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;
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add glow effect to drawPathLine()</name>
<files>src/rendering/Renderer.ts</files>
<behavior>
- 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
</behavior>
<action>
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.
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
</verify>
<done>drawPathLine() sets shadowBlur and shadowColor before stroking path. Tests verify glow properties are set. Visual: path has soft green glow.</done>
</task>
<task type="auto">
<name>Task 2: Wire animateMatch call in Game.ts</name>
<files>src/game/Game.ts</files>
<action>
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.
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
</verify>
<done>Game.ts calls renderer.animateMatch([tile1, tile2]) when tilesMatched. Animation starts immediately, concurrent with path display.</done>
</task>
</tasks>
<verification>
- 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
</verification>
<success_criteria>
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
</success_criteria>
<output>
After completion, create `.planning/phases/06-polish-and-ux/06-02-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key types and contracts from existing codebase -->
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 { ... }
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add touch-action CSS to canvas</name>
<files>index.html</files>
<behavior>
- Canvas has touch-action: none to prevent zoom/scroll
- Body has overflow: hidden to prevent page scroll
</behavior>
<action>
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.
</action>
<verify>
<automated>grep -q "touch-action: none" index.html</automated>
</verify>
<done>Canvas has touch-action: none CSS. Body has overflow: hidden. Visual: no zoom/scroll on mobile touch.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Create RippleAnimation class</name>
<files>src/rendering/Renderer.ts</files>
<behavior>
- 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))
</behavior>
<action>
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)
);
```
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
</verify>
<done>RippleAnimation class exists. addRipple() method available. Ripples render and auto-cleanup on completion.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Trigger ripple on touch input</name>
<files>src/game/Game.ts</files>
<behavior>
- Touch input triggers ripple at touch coordinates
- Ripple coordinates match tile selection coordinates
- Works alongside existing tile selection logic
</behavior>
<action>
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.
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
</verify>
<done>handleInput() calls renderer.addRipple(x, y). Ripple appears at touch/click point before tile selection.</done>
</task>
</tasks>
<verification>
- 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
</verification>
<success_criteria>
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
</success_criteria>
<output>
After completion, create `.planning/phases/06-polish-and-ux/06-03-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Key types and contracts from existing codebase -->
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;
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add responsive scaling to setupCanvas()</name>
<files>src/game/Game.ts</files>
<behavior>
- 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
</behavior>
<action>
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
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
</verify>
<done>setupCanvas() scales canvas down to fit viewport. Transform used for display. Tests verify scale calculation logic.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Update coordinate mapping for scaled canvas</name>
<files>src/game/Game.ts</files>
<behavior>
- Touch/click coordinates correctly map to tiles on scaled canvas
- Works with CSS transform scaling
- DPR handling preserved
</behavior>
<action>
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.
</action>
<verify>
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
</verify>
<done>Coordinate mapping verified to work with CSS transform scaling. No code changes needed - existing formula is correct.</done>
</task>
<task type="auto">
<name>Task 3: Add human verification checkpoint</name>
<files>index.html</files>
<action>
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.
</action>
<verify>
Manual: Test responsive scaling in browser by resizing window and using mobile emulation.
</verify>
<done>Visual verification complete - canvas scales correctly on different screen sizes.</done>
</task>
</tasks>
<verification>
- 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
</verification>
<success_criteria>
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
</success_criteria>
<output>
After completion, create `.planning/phases/06-polish-and-ux/06-04-SUMMARY.md`
</output>