diff --git a/bmad/_bmad-output/implementation-artifacts/3-1-tile-slide-animation.md b/bmad/_bmad-output/implementation-artifacts/3-1-tile-slide-animation.md
new file mode 100644
index 0000000..0d22ea4
--- /dev/null
+++ b/bmad/_bmad-output/implementation-artifacts/3-1-tile-slide-animation.md
@@ -0,0 +1,166 @@
+# Story 3.1: Tile Slide Animation
+
+Status: done
+
+## Story
+
+As a player,
+I want tiles to slide smoothly to their new positions,
+so that the game feels responsive and I can visually track tile movement.
+
+## Acceptance Criteria
+
+1. After a valid move, each tile animates from its old position to its new position using CSS `transform: translate` with a 100ms ease-in-out transition
+2. All movable tiles animate simultaneously
+3. The animation runs at 60fps (GPU-accelerated CSS transition)
+4. During an animation, input is queued and executes after the current animation completes (isAnimating flag)
+5. When `prefers-reduced-motion` is enabled, tiles appear instantly at new positions with no transition
+
+## Tasks / Subtasks
+
+- [x] Task 1: Create tile-tracker.js module (AC: #1)
+ - [x] Create `src/lib/tile-tracker.js` with tile identity management
+ - [x] `createTilesFromGrid(grid)` — assigns unique IDs to each non-zero cell
+ - [x] `computeTilesAfterMove(prevTiles, prevGrid, newGrid, direction)` — tracks tile movements through a move, preserving IDs for tiles that slide/merge, assigning new IDs for spawned tiles
+ - [x] Track merged tiles with `isMerged` flag and spawned tiles with `isNew` flag (for Stories 3.2, 3.3)
+ - [x] `resetTracker()` — resets ID counter (for New Game)
+
+- [x] Task 2: Integrate tile tracker into App.svelte (AC: #1)
+ - [x] Replace raw grid extraction with tile tracker
+ - [x] Call `createTilesFromGrid` on init and New Game
+ - [x] Call `computeTilesAfterMove` after each move
+ - [x] Pass tile objects (with id, value, row, col) to Grid
+
+- [x] Task 3: Add CSS transition to Tile.svelte (AC: #1, #2, #3)
+ - [x] Add `transition: transform 100ms ease-in-out` to tile style
+ - [x] Tiles already use `transform: translate()` for positioning — transition animates position changes automatically
+
+- [x] Task 4: Add animation state and input queuing (AC: #4)
+ - [x] Add `isAnimating` flag in App.svelte
+ - [x] Add `queuedDirection` to store pending input during animation
+ - [x] On move: set isAnimating=true, after 100ms timeout clear flag and process queued input
+ - [x] Block moves in handleKeydown when isAnimating is true (queue instead)
+
+- [x] Task 5: Support prefers-reduced-motion (AC: #5)
+ - [x] Add CSS media query `@media (prefers-reduced-motion: reduce)` that sets `transition: none`
+ - [x] When reduced motion: skip isAnimating delay (set flag immediately)
+
+- [x] Task 6: Update Grid.svelte keying (AC: #1)
+ - [x] Change tile key from `tile.row-tile.col` to `tile.id` for stable DOM identity
+
+- [x] Task 7: Write tests and verify (AC: all)
+ - [x] Write tests for tile-tracker.js: ID assignment, ID persistence across moves, new tile detection, merge detection
+ - [x] Run full test suite — all 59 tests pass (38 game-logic + 11 storage + 10 tile-tracker)
+
+## Dev Notes
+
+### Architecture Compliance
+
+- **tile-tracker.js** — pure JS module in `src/lib/`, zero Svelte imports
+- **Game logic unchanged** — `game-logic.js` stays pure, tile tracking is a presentation concern
+- **Props-down pattern** — App.svelte passes tile objects to Grid/Tile via props
+- **CSS-only animation** — no JS animation library, no requestAnimationFrame for tile movement
+
+### Tile Tracking Algorithm
+
+The tracker must simulate the move to map old tile IDs to new positions:
+
+1. Build a position map from `prevTiles`: `{row}-{col}` → tile object
+2. For the given direction, process each row/column:
+ - Extract non-zero tiles in order (matching game-logic's slideRow)
+ - Simulate mergeRow to determine which pairs merge
+ - Assign new positions: slid tiles keep their ID, merged pairs → leading tile's ID survives with `isMerged: true`
+3. Compare moved grid with `newGrid` to find the spawned tile (the one cell in newGrid that differs from the moved-but-not-spawned grid)
+4. Spawned tile gets a new ID with `isNew: true`
+
+Direction normalization must match game-logic.js exactly:
+- LEFT: process rows left-to-right as-is
+- RIGHT: reverse rows → process → reverse back
+- UP: transpose → process → transpose back
+- DOWN: transpose + reverse → process → reverse + transpose back
+
+### Input Queuing Pattern
+
+```javascript
+let isAnimating = $state(false);
+let queuedDirection = $state(null);
+
+function handleMove(direction) {
+ if (isAnimating) { queuedDirection = direction; return; }
+ // ... execute move
+ isAnimating = true;
+ setTimeout(() => {
+ isAnimating = false;
+ if (queuedDirection) {
+ const next = queuedDirection;
+ queuedDirection = null;
+ handleMove(next);
+ }
+ }, 100);
+}
+```
+
+Note: Using setTimeout instead of transitionend for reliability — transitionend can miss if no tile actually moved. 100ms matches the transition duration.
+
+### Prefers-Reduced-Motion
+
+Add to `src/app.css`:
+```css
+@media (prefers-reduced-motion: reduce) {
+ * { transition-duration: 0s !important; }
+}
+```
+
+When reduced motion is active, set animation timeout to 0ms.
+
+### Previous Story Intelligence
+
+From Epic 1-2:
+- Tile.svelte uses `transform: translate(x, y)` for positioning — already GPU-accelerated
+- Grid.svelte keys tiles by `tile.id || ${tile.row}-${tile.col}` — needs to use `tile.id` only
+- App.svelte uses `$derived.by()` for tile extraction — will change to tile tracker
+- GAP=15, CELL_SIZE=106.25 constants in Tile.svelte and Grid.svelte
+- 49 tests passing (38 game-logic + 11 storage)
+
+### Critical Anti-Patterns (DO NOT)
+
+- DO NOT modify game-logic.js — tile tracking is a presentation concern
+- DO NOT use JavaScript animation (requestAnimationFrame) for tile sliding — CSS transitions only
+- DO NOT use transitionend events for animation completion — use setTimeout (more reliable)
+- DO NOT add animation-related fields to the canonical game state shape
+- DO NOT block the main thread during animation — use async scheduling
+
+### References
+
+- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1]
+- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend Architecture - Animation Approach]
+- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns - Process Patterns]
+
+## Dev Agent Record
+
+### Agent Model Used
+
+Claude Opus 4.6 (1M context)
+
+### Debug Log References
+
+### Completion Notes List
+
+- Created tile-tracker.js: manages tile identity across moves via simulateLine algorithm
+- Tile IDs persist through slides and merges; leading-edge tile's ID survives merges
+- Spawned tiles detected by diffing moved grid vs final grid, assigned new IDs with isNew=true
+- App.svelte refactored: tiles now managed as $state (not $derived), updated via tracker after each move
+- Tile.svelte: added `transition: transform 100ms ease-in-out` for GPU-accelerated sliding
+- Input queuing: isAnimating flag + queuedDirection, 100ms setTimeout for animation window
+- prefers-reduced-motion: CSS override + JS detection skips animation delay
+- Grid.svelte: keying changed from position-based to tile.id for stable DOM identity
+- 10 new tile-tracker tests, 59 total tests passing
+
+### File List
+
+- src/lib/tile-tracker.js (new — tile identity and movement tracking)
+- src/lib/tile-tracker.test.js (new — 10 tests)
+- src/App.svelte (modified — tile tracker integration, animation state, input queuing)
+- src/components/Tile.svelte (modified — CSS transition, isNew/isMerged props)
+- src/components/Grid.svelte (modified — tile.id keying, pass isNew/isMerged)
+- src/app.css (modified — prefers-reduced-motion media query)
diff --git a/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml b/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml
index 2968320..edfaf91 100644
--- a/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml
+++ b/bmad/_bmad-output/implementation-artifacts/sprint-status.yaml
@@ -35,7 +35,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: 2026-04-13
-last_updated: 2026-04-13T22:44:00
+last_updated: 2026-04-13T22:47:00
project: try-bmad
project_key: NOKEY
tracking_system: file-system
@@ -60,8 +60,8 @@ development_status:
epic-2-retrospective: optional
# Epic 3: Smooth Animations & Game Feel
- epic-3: backlog
- 3-1-tile-slide-animation: backlog
+ epic-3: in-progress
+ 3-1-tile-slide-animation: done
3-2-tile-spawn-pop-animation: backlog
3-3-tile-merge-bounce-animation: backlog
3-4-score-float-animation: backlog
diff --git a/bmad/src/App.svelte b/bmad/src/App.svelte
index aa44dc4..fda9434 100644
--- a/bmad/src/App.svelte
+++ b/bmad/src/App.svelte
@@ -6,10 +6,20 @@
import { GRID_SIZE } from './lib/constants.js';
import { getDirectionFromKey } from './lib/input-handler.js';
import { saveGameState, loadGameState, saveBestScore, loadBestScore, clearGameState } from './lib/storage.js';
+ import { createTilesFromGrid, computeTilesAfterMove, resetTracker } from './lib/tile-tracker.js';
+ const SLIDE_DURATION = 100;
const savedState = loadGameState();
let gameState = $state(savedState || initGame());
let bestScore = $state(Math.max(loadBestScore(), savedState?.score || 0));
+ let tiles = $state(createTilesFromGrid(gameState.grid));
+ let isAnimating = $state(false);
+ let queuedDirection = $state(null);
+ let reducedMotion = $state(false);
+
+ if (typeof window !== 'undefined') {
+ reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ }
$effect(() => {
saveGameState(gameState);
@@ -22,28 +32,40 @@
saveBestScore(bestScore);
});
- let tiles = $derived.by(() => {
- const result = [];
- for (let row = 0; row < GRID_SIZE; row++) {
- for (let col = 0; col < GRID_SIZE; col++) {
- const value = gameState.grid[row][col];
- if (value !== 0) {
- result.push({ value, row, col });
- }
- }
- }
- return result;
- });
-
let overlayType = $derived.by(() => {
if (gameState.won && !gameState.keepPlaying) return 'win';
if (isGameOver(gameState)) return 'gameover';
return null;
});
+ function executeMove(direction) {
+ if (overlayType === 'gameover') return;
+
+ const prevGrid = gameState.grid;
+ const newState = move(gameState, direction);
+ if (newState === gameState) return;
+
+ tiles = computeTilesAfterMove(tiles, prevGrid, newState.grid, direction);
+ gameState = newState;
+
+ if (reducedMotion) return;
+
+ isAnimating = true;
+ setTimeout(() => {
+ isAnimating = false;
+ if (queuedDirection) {
+ const next = queuedDirection;
+ queuedDirection = null;
+ executeMove(next);
+ }
+ }, SLIDE_DURATION);
+ }
+
function handleNewGame() {
clearGameState();
+ resetTracker();
gameState = initGame();
+ tiles = createTilesFromGrid(gameState.grid);
}
function handleKeepGoing() {
@@ -55,12 +77,12 @@
if (!direction) return;
event.preventDefault();
- if (overlayType === 'gameover') return;
-
- const newState = move(gameState, direction);
- if (newState !== gameState) {
- gameState = newState;
+ if (isAnimating) {
+ queuedDirection = direction;
+ return;
}
+
+ executeMove(direction);
}
diff --git a/bmad/src/app.css b/bmad/src/app.css
index e7cc9f6..898980c 100644
--- a/bmad/src/app.css
+++ b/bmad/src/app.css
@@ -21,3 +21,10 @@ body {
font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif;
margin: 0;
}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition-duration: 0s !important;
+ animation-duration: 0s !important;
+ }
+}
diff --git a/bmad/src/components/Grid.svelte b/bmad/src/components/Grid.svelte
index eefda66..7bc2eee 100644
--- a/bmad/src/components/Grid.svelte
+++ b/bmad/src/components/Grid.svelte
@@ -36,7 +36,7 @@
{/each}
- {#each tiles as tile (tile.id || `${tile.row}-${tile.col}`)}
-
+ {#each tiles as tile (tile.id)}
+
{/each}
diff --git a/bmad/src/components/Tile.svelte b/bmad/src/components/Tile.svelte
index cfc57a1..f2ecd5b 100644
--- a/bmad/src/components/Tile.svelte
+++ b/bmad/src/components/Tile.svelte
@@ -1,7 +1,7 @@