# optimize-2048 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build a 2048 game AI optimization demo for crucible that showcases iterative improvement with web-based visual replay.

**Architecture:** A crucible experiment project following the existing pattern (game engine readonly, strategy editable, evaluate hidden). The game engine supports variable board size via `BOARD_SIZE` env var. Evaluation outputs sub-metrics to guide the agent toward both algorithmic and parameter-tuning improvements. A single-file HTML viewer provides replay with comparison mode.

**Tech Stack:** Python 3 (stdlib only for game/strategy/evaluate), vanilla HTML/CSS/JS for viewer.

---

### Task 1: Game Engine (`game.py`)

**Files:**
- Create: `src/crucible/examples/optimize-2048/game.py`

**Step 1: Write the 2048 game engine**

```python
"""2048 game engine — DO NOT MODIFY.

Variable board size (default 4×4). Standard rules: slide, merge, spawn.

Interface:
    Game2048(size=4, seed=None)
        .board        — list[list[int]], 0 = empty
        .score        — int
        .is_over      — bool
        .max_tile     — int
        .move_count   — int
        .history      — list of board snapshots (for replay)
        .get_board()  — returns deep copy of board
        .legal_moves() — list of valid directions
        .move(direction) — apply move; direction in 'up'/'down'/'left'/'right'
        .clone()      — deep copy for search simulation (no history)
"""

import copy
import os
import random

DIRECTIONS = ('up', 'down', 'left', 'right')


class Game2048:
    def __init__(self, size=None, seed=None):
        if size is None:
            size = int(os.environ.get('BOARD_SIZE', '4'))
        self.size = size
        self._rng = random.Random(seed)
        self.board = [[0] * size for _ in range(size)]
        self.score = 0
        self.is_over = False
        self.max_tile = 0
        self.move_count = 0
        self.history = []  # list of (board_snapshot, score, move_direction)

        # Spawn two initial tiles
        self._spawn()
        self._spawn()
        self._update_max_tile()
        self.history.append((self._snapshot(), self.score, None))

    def _snapshot(self):
        return [row[:] for row in self.board]

    def _spawn(self):
        """Place a 2 (90%) or 4 (10%) on a random empty cell."""
        empty = [(r, c) for r in range(self.size) for c in range(self.size)
                 if self.board[r][c] == 0]
        if not empty:
            return
        r, c = self._rng.choice(empty)
        self.board[r][c] = 2 if self._rng.random() < 0.9 else 4

    def _update_max_tile(self):
        self.max_tile = max(self.board[r][c]
                           for r in range(self.size) for c in range(self.size))

    def get_board(self):
        return self._snapshot()

    def legal_moves(self):
        """Return list of directions that would change the board."""
        moves = []
        for d in DIRECTIONS:
            clone = self.clone()
            old = clone._snapshot()
            clone._apply_move(d)
            if clone.board != old:
                moves.append(d)
        return moves

    def clone(self):
        """Deep copy for search simulation. No history to save memory."""
        g = Game2048.__new__(Game2048)
        g.size = self.size
        g._rng = random.Random()  # independent RNG
        g._rng.setstate(self._rng.getstate())
        g.board = self._snapshot()
        g.score = self.score
        g.is_over = self.is_over
        g.max_tile = self.max_tile
        g.move_count = self.move_count
        g.history = []  # no history in clones
        return g

    def move(self, direction):
        """Apply a move. Returns True if the board changed."""
        if self.is_over:
            return False
        direction = direction.lower()
        if direction not in DIRECTIONS:
            return False

        old_board = self._snapshot()
        self._apply_move(direction)

        if self.board == old_board:
            return False  # illegal move, nothing changed

        self.move_count += 1
        self._spawn()
        self._update_max_tile()
        self.history.append((self._snapshot(), self.score, direction))

        # Check game over
        if not self.legal_moves():
            self.is_over = True

        return True

    def _apply_move(self, direction):
        """Slide and merge tiles in the given direction."""
        if direction == 'left':
            for r in range(self.size):
                self.board[r], gained = self._merge_line(self.board[r])
                self.score += gained
        elif direction == 'right':
            for r in range(self.size):
                rev, gained = self._merge_line(self.board[r][::-1])
                self.board[r] = rev[::-1]
                self.score += gained
        elif direction == 'up':
            for c in range(self.size):
                col = [self.board[r][c] for r in range(self.size)]
                merged, gained = self._merge_line(col)
                for r in range(self.size):
                    self.board[r][c] = merged[r]
                self.score += gained
        elif direction == 'down':
            for c in range(self.size):
                col = [self.board[r][c] for r in range(self.size - 1, -1, -1)]
                merged, gained = self._merge_line(col)
                for i, r in enumerate(range(self.size - 1, -1, -1)):
                    self.board[r][c] = merged[i]
                self.score += gained

    @staticmethod
    def _merge_line(line):
        """Slide non-zero tiles left and merge adjacent equal tiles.

        Returns (new_line, score_gained).
        """
        # Remove zeros
        tiles = [x for x in line if x != 0]
        merged = []
        score = 0
        skip = False
        for i in range(len(tiles)):
            if skip:
                skip = False
                continue
            if i + 1 < len(tiles) and tiles[i] == tiles[i + 1]:
                val = tiles[i] * 2
                merged.append(val)
                score += val
                skip = True
            else:
                merged.append(tiles[i])
        # Pad with zeros
        merged.extend([0] * (len(line) - len(merged)))
        return merged, score
```

**Step 2: Verify game engine works**

Run:
```bash
cd src/crucible/examples/optimize-2048
python3 -c "
from game import Game2048
g = Game2048(size=4, seed=42)
print('Board:', g.get_board())
print('Legal:', g.legal_moves())
print('Score:', g.score)
g.move('left')
print('After left:', g.get_board())
print('Score:', g.score)
print('Max tile:', g.max_tile)
# Test clone
c = g.clone()
c.move('up')
print('Clone diverged:', c.board != g.board)
# Play to completion
moves = 0
while not g.is_over:
    legal = g.legal_moves()
    if not legal: break
    g.move(legal[0])
    moves += 1
print(f'Game over after {moves} moves, score={g.score}, max_tile={g.max_tile}')
print(f'History entries: {len(g.history)}')
# Test 5x5
g5 = Game2048(size=5, seed=0)
print(f'5x5 board: {len(g5.board)}x{len(g5.board[0])}')
print('PASS')
"
```
Expected: prints board states, clone diverges, game completes, 5×5 works, ends with `PASS`.

**Step 3: Commit**

```bash
git add src/crucible/examples/optimize-2048/game.py
git commit -m "feat(examples): add 2048 game engine with variable board size"
```

---

### Task 2: Strategy Baseline (`strategy.py`)

**Files:**
- Create: `src/crucible/examples/optimize-2048/strategy.py`

**Step 1: Write the baseline random strategy**

```python
"""2048 strategy — this is the file the agent optimizes.

Baseline: random move from legal moves.
"""

import random


def choose_move(board, legal_moves, score):
    """Choose next direction for the 2048 board.

    Args:
        board:       list[list[int]], current board state (0 = empty)
        legal_moves: list[str], valid directions ('up','down','left','right')
        score:       int, current game score

    Returns:
        direction: one of 'up', 'down', 'left', 'right'
    """
    return random.choice(legal_moves)
```

**Step 2: Commit**

```bash
git add src/crucible/examples/optimize-2048/strategy.py
git commit -m "feat(examples): add 2048 baseline random strategy"
```

---

### Task 3: Evaluation Harness (`evaluate.py`)

**Files:**
- Create: `src/crucible/examples/optimize-2048/evaluate.py`

**Step 1: Write the evaluation harness**

Key design decisions:
- 50 games with fixed seeds 0-49
- Sub-metrics: max_tile distribution, avg_moves, monotonicity, empty cells, move time
- Replay output to `replay.json` (only best and worst game to keep file small)
- Monotonicity = sum of how well rows/cols are sorted (higher = more ordered)

```python
"""Evaluation harness for 2048 strategy — DO NOT MODIFY.

Runs 50 games with fixed seeds. Measures avg_score plus diagnostic sub-metrics.

Output format (parsed by crucible):
    avg_score:          <float>   (primary metric)
    max_tile_mode:      <int>     (most common highest tile)
    avg_moves:          <float>
    avg_monotonicity:   <float>
    avg_empty_cells:    <float>
    avg_move_time_ms:   <float>
    games_played:       <int>
"""

import json
import sys
import time
from collections import Counter

from game import Game2048

NUM_GAMES = 50
REPLAY_FILE = "replay.json"


def monotonicity(board):
    """Score how well the board is monotonically ordered.

    Higher = rows and columns are more consistently increasing/decreasing.
    """
    size = len(board)
    total = 0
    for r in range(size):
        inc = dec = 0
        for c in range(size - 1):
            if board[r][c] >= board[r][c + 1]:
                dec += 1
            if board[r][c] <= board[r][c + 1]:
                inc += 1
        total += max(inc, dec)
    for c in range(size):
        inc = dec = 0
        for r in range(size - 1):
            if board[r][c] >= board[r + 1][c]:
                dec += 1
            if board[r][c] <= board[r + 1][c]:
                inc += 1
        total += max(inc, dec)
    # Normalize to [0, 1]
    max_possible = 2 * size * (size - 1)
    return total / max_possible if max_possible > 0 else 0


def empty_cells(board):
    """Count empty cells."""
    return sum(1 for row in board for cell in row if cell == 0)


def run_game(seed):
    """Run one game. Returns (score, max_tile, moves, avg_mono, avg_empty, avg_ms, history)."""
    game = Game2048(seed=seed)

    try:
        from strategy import choose_move
    except Exception as e:
        print(f"ERROR importing strategy: {e}", file=sys.stderr)
        return 0, 0, 0, 0, 0, 0, []

    total_mono = 0.0
    total_empty = 0.0
    total_time = 0.0
    steps = 0

    while not game.is_over:
        board = game.get_board()
        legal = game.legal_moves()
        if not legal:
            break

        t0 = time.perf_counter()
        try:
            direction = choose_move(board, legal, game.score)
        except Exception:
            direction = legal[0]
        elapsed = time.perf_counter() - t0

        total_time += elapsed
        total_mono += monotonicity(board)
        total_empty += empty_cells(board)
        steps += 1

        if direction not in legal:
            direction = legal[0]
        game.move(direction)

    avg_mono = total_mono / steps if steps else 0
    avg_empty = total_empty / steps if steps else 0
    avg_ms = (total_time / steps * 1000) if steps else 0

    return game.score, game.max_tile, game.move_count, avg_mono, avg_empty, avg_ms, game.history


def main():
    scores = []
    max_tiles = []
    all_moves = []
    all_mono = []
    all_empty = []
    all_ms = []
    best_game = (0, -1, [])   # (score, seed, history)
    worst_game = (float('inf'), -1, [])

    for seed in range(NUM_GAMES):
        score, max_tile, moves, mono, empty, ms, history = run_game(seed)
        scores.append(score)
        max_tiles.append(max_tile)
        all_moves.append(moves)
        all_mono.append(mono)
        all_empty.append(empty)
        all_ms.append(ms)

        if score > best_game[0]:
            best_game = (score, seed, history)
        if score < worst_game[0]:
            worst_game = (score, seed, history)

    n = len(scores)
    avg_score = sum(scores) / n
    avg_moves = sum(all_moves) / n
    avg_mono = sum(all_mono) / n
    avg_empty = sum(all_empty) / n
    avg_ms = sum(all_ms) / n

    tile_counts = Counter(max_tiles)
    tile_mode = tile_counts.most_common(1)[0][0]

    # Tile distribution
    tile_dist = sorted(tile_counts.items(), reverse=True)
    dist_str = ", ".join(f"{tile}:{count}" for tile, count in tile_dist)

    print(f"games_played: {n}")
    print(f"avg_score: {avg_score:.1f}")
    print(f"max_tile_mode: {tile_mode}")
    print(f"tile_distribution: {dist_str}")
    print(f"avg_moves: {avg_moves:.1f}")
    print(f"avg_monotonicity: {avg_mono:.3f}")
    print(f"avg_empty_cells: {avg_empty:.1f}")
    print(f"avg_move_time_ms: {avg_ms:.3f}")
    print(f"best_game: seed={best_game[1]} score={best_game[0]}")
    print(f"worst_game: seed={worst_game[1]} score={worst_game[0]}")

    # Save replay for best and worst games
    def history_to_replay(hist):
        return [{"board": board, "score": sc, "move": mv} for board, sc, mv in hist]

    replay_data = {
        "board_size": int(Game2048().size) if not scores else len(best_game[2][0][0]) if best_game[2] else 4,
        "best": {"seed": best_game[1], "score": best_game[0],
                 "moves": history_to_replay(best_game[2])},
        "worst": {"seed": worst_game[1], "score": worst_game[0],
                  "moves": history_to_replay(worst_game[2])},
    }
    with open(REPLAY_FILE, "w") as f:
        json.dump(replay_data, f)


if __name__ == "__main__":
    main()
```

**Step 2: Verify evaluate runs with baseline strategy**

Run:
```bash
cd src/crucible/examples/optimize-2048
python3 -u evaluate.py
```
Expected: prints metrics (avg_score ~800-1200 for random), creates `replay.json`.

**Step 3: Verify replay.json is valid**

Run:
```bash
cd src/crucible/examples/optimize-2048
python3 -c "
import json
with open('replay.json') as f:
    data = json.load(f)
print(f'Board size: {data[\"board_size\"]}')
print(f'Best game: seed={data[\"best\"][\"seed\"]}, score={data[\"best\"][\"score\"]}, moves={len(data[\"best\"][\"moves\"])}')
print(f'Worst game: seed={data[\"worst\"][\"seed\"]}, score={data[\"worst\"][\"score\"]}, moves={len(data[\"worst\"][\"moves\"])}')
print('PASS')
"
```
Expected: shows valid replay data, ends with `PASS`.

**Step 4: Clean up and commit**

```bash
rm -f src/crucible/examples/optimize-2048/replay.json
git add src/crucible/examples/optimize-2048/evaluate.py
git commit -m "feat(examples): add 2048 evaluation harness with sub-metrics and replay"
```

---

### Task 4: Crucible Config + Instructions

**Files:**
- Create: `src/crucible/examples/optimize-2048/.crucible/config.yaml`
- Create: `src/crucible/examples/optimize-2048/.crucible/program.md`
- Create: `src/crucible/examples/optimize-2048/.gitignore`

**Step 1: Write config.yaml**

```yaml
name: "optimize-2048"

files:
  editable:
    - "strategy.py"
  readonly:
    - "game.py"
    - "view.html"
  hidden:
    - "evaluate.py"

commands:
  run: "python3 -u evaluate.py 2>&1 | tee run.log"
  eval: "cat run.log"

metric:
  name: "avg_score"
  direction: "maximize"

constraints:
  timeout_seconds: 120
  max_retries: 5

agent:
  instructions: "program.md"

git:
  branch_prefix: "crucible"
```

**Step 2: Write program.md**

```markdown
# 2048 Strategy Optimization

You are optimizing a 2048 game-playing AI.

## Goal

Maximize `avg_score` — the average game score over 50 games with fixed seeds.

## Interface

`choose_move(board, legal_moves, score)` is called each step:
- `board`: `list[list[int]]`, current board state (`0` = empty cell)
- `legal_moves`: `list[str]`, valid directions (`'up'`, `'down'`, `'left'`, `'right'`)
- `score`: `int`, current game score
- Returns: one of `'up'`, `'down'`, `'left'`, `'right'`

The board size is inferred from `len(board)`. Default is 4×4.

## What You Can Change (strategy.py)

- The `choose_move()` logic
- Any helper functions, classes, or data structures
- Any constants or weight matrices

## Available Context

The game engine (`game.py`) is readable. Key API for simulation:
- `Game2048(size=N, seed=S)` — create a game
- `.get_board()`, `.legal_moves()`, `.move(direction)`, `.score`, `.is_over`
- `.clone()` — deep copy for search (no history, saves memory)

## Sub-Metrics (diagnostic, not scored)

The evaluator reports these to help guide your strategy:
- `avg_monotonicity` — how ordered the board is (higher = tiles flow in one direction)
- `avg_empty_cells` — how well you manage board space (higher = less cluttered)
- `avg_move_time_ms` — your compute time per move (watch for timeout risk)
- `tile_distribution` — how often you reach each max tile

## Optimization Strategies (easy → hard)

1. **Random** → ~1000 avg_score
2. **Greedy**: pick move that maximizes immediate score → ~3000
3. **Corner strategy**: keep max tile in a corner → ~8000
4. **Weighted heuristic**: score = f(monotonicity, empty_cells, smoothness, max_corner) → ~15000
5. **Expectimax depth-2** + heuristic evaluation → ~25000
6. **Expectimax depth-3** + tuned heuristic weights → ~40000+
7. **Weight tuning**: once algorithm is solid, tune heuristic weights for more gains → ~50000+

## Hard Rules

- Only edit `strategy.py`
- DO NOT attempt to run or execute any scripts — the platform runs them automatically
- DO NOT modify any other file
- Keep `choose_move` fast enough that 50 games complete within 120 seconds
```

**Step 3: Write .gitignore**

```
replay.json
run.log
__pycache__/
*.pyc
```

**Step 4: Commit**

```bash
git add src/crucible/examples/optimize-2048/.crucible/ src/crucible/examples/optimize-2048/.gitignore
git commit -m "feat(examples): add 2048 crucible config and agent instructions"
```

---

### Task 5: Web Replay Viewer (`view.html`)

**Files:**
- Create: `src/crucible/examples/optimize-2048/view.html`

**Step 1: Write the HTML replay viewer**

This is a single-file HTML with inline CSS/JS. Features:
- File picker to load `replay.json`
- Game selector (best/worst)
- Animated board with 2048-style tile colors
- Play/pause/step/speed controls
- Score + move counter display
- Comparison mode: load two files, play side-by-side

The file is large (~400 lines of HTML/CSS/JS). Key implementation notes:

- **Tile colors**: match original 2048 palette (2=`#eee4da`, 4=`#ede0c8`, 8=`#f2b179`, ... 2048=`#edc22e`)
- **Animation**: requestAnimationFrame loop, configurable speed (1-20 moves/sec)
- **Layout**: CSS Grid for the board, responsive sizing
- **Comparison**: two boards side-by-side with synchronized playback
- **No dependencies**: everything inline, works by opening the HTML file directly

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2048 Replay Viewer</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
         background: #faf8ef; color: #776e65; display: flex; flex-direction: column;
         align-items: center; min-height: 100vh; padding: 20px; }
  h1 { font-size: 2em; margin-bottom: 10px; }
  .controls { display: flex; gap: 10px; align-items: center; margin: 15px 0; flex-wrap: wrap; justify-content: center; }
  button { padding: 8px 16px; border: none; border-radius: 6px; background: #8f7a66;
           color: white; font-size: 14px; cursor: pointer; font-weight: bold; }
  button:hover { background: #9f8b77; }
  button:disabled { opacity: 0.5; cursor: not-allowed; }
  select, input[type=range] { padding: 6px; border-radius: 4px; border: 1px solid #ccc; }
  .info { font-size: 18px; font-weight: bold; margin: 10px 0; display: flex; gap: 30px; }
  .info span { display: inline-block; }
  .boards { display: flex; gap: 40px; flex-wrap: wrap; justify-content: center; }
  .board-container { display: flex; flex-direction: column; align-items: center; }
  .board-label { font-size: 16px; font-weight: bold; margin-bottom: 8px; }
  .board { display: grid; gap: 8px; padding: 8px; background: #bbada0; border-radius: 8px; }
  .tile { display: flex; align-items: center; justify-content: center;
          border-radius: 6px; font-weight: bold; transition: none; }
  .file-input { margin: 10px 0; }
  .comparison { display: flex; gap: 20px; align-items: center; margin: 10px 0; }
</style>
</head>
<body>
<h1>2048 Replay Viewer</h1>

<div class="controls">
  <label>Replay 1: <input type="file" id="file1" accept=".json" class="file-input"></label>
  <label>Replay 2 (compare): <input type="file" id="file2" accept=".json" class="file-input"></label>
</div>

<div class="controls">
  <label>Game: <select id="gameSelect"><option value="">Load a replay file</option></select></label>
  <label>Game 2: <select id="gameSelect2"><option value="">—</option></select></label>
</div>

<div class="controls">
  <button id="btnStart">⏮</button>
  <button id="btnPrev">◀</button>
  <button id="btnPlay">▶ Play</button>
  <button id="btnNext">▶</button>
  <button id="btnEnd">⏭</button>
  <label>Speed: <input type="range" id="speed" min="1" max="20" value="5"></label>
  <span id="speedLabel">5 moves/s</span>
</div>

<div class="info">
  <span>Move: <strong id="moveNum">0</strong></span>
  <span>Score: <strong id="scoreVal">0</strong></span>
  <span>Max Tile: <strong id="maxTile">0</strong></span>
</div>

<div class="boards">
  <div class="board-container">
    <div class="board-label" id="label1">—</div>
    <div class="board" id="board1"></div>
  </div>
  <div class="board-container" id="container2" style="display:none">
    <div class="board-label" id="label2">—</div>
    <div class="board" id="board2"></div>
  </div>
</div>

<script>
const TILE_COLORS = {
  0:'#cdc1b4', 2:'#eee4da', 4:'#ede0c8', 8:'#f2b179', 16:'#f59563',
  32:'#f67c5f', 64:'#f65e3b', 128:'#edcf72', 256:'#edcc61', 512:'#edc850',
  1024:'#edc53f', 2048:'#edc22e', 4096:'#3c3a32', 8192:'#3c3a32',
  16384:'#3c3a32', 32768:'#3c3a32', 65536:'#3c3a32'
};
const TILE_TEXT = {
  0:'', 2:'#776e65', 4:'#776e65'
};

let data1 = null, data2 = null;
let moves1 = [], moves2 = [];
let frame = 0, playing = false, animId = null;

function tileColor(v) { return TILE_COLORS[v] || '#3c3a32'; }
function textColor(v) { return TILE_TEXT[v] || '#f9f6f2'; }
function tileSize(boardSize) {
  const s = Math.min(100, Math.floor(400 / boardSize));
  return s;
}

function renderBoard(boardEl, board) {
  const size = board.length;
  const ts = tileSize(size);
  boardEl.style.gridTemplateColumns = `repeat(${size}, ${ts}px)`;
  boardEl.style.gridTemplateRows = `repeat(${size}, ${ts}px)`;
  boardEl.innerHTML = '';
  for (let r = 0; r < size; r++) {
    for (let c = 0; c < size; c++) {
      const v = board[r][c];
      const tile = document.createElement('div');
      tile.className = 'tile';
      tile.style.width = ts + 'px';
      tile.style.height = ts + 'px';
      tile.style.background = tileColor(v);
      tile.style.color = textColor(v);
      tile.style.fontSize = (v >= 1024 ? ts * 0.3 : v >= 128 ? ts * 0.35 : ts * 0.45) + 'px';
      tile.textContent = v || '';
      boardEl.appendChild(tile);
    }
  }
}

function loadFile(input, slot) {
  const file = input.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = (e) => {
    const d = JSON.parse(e.target.result);
    if (slot === 1) {
      data1 = d;
      populateSelect('gameSelect', d, file.name);
    } else {
      data2 = d;
      populateSelect('gameSelect2', d, file.name);
      document.getElementById('container2').style.display = 'flex';
    }
  };
  reader.readAsText(file);
}

function populateSelect(id, data, filename) {
  const sel = document.getElementById(id);
  sel.innerHTML = '';
  if (data.best) {
    sel.innerHTML += `<option value="best">Best (seed=${data.best.seed}, score=${data.best.score})</option>`;
  }
  if (data.worst) {
    sel.innerHTML += `<option value="worst">Worst (seed=${data.worst.seed}, score=${data.worst.score})</option>`;
  }
  selectGame();
}

function selectGame() {
  const sel1 = document.getElementById('gameSelect').value;
  if (data1 && sel1) {
    moves1 = data1[sel1]?.moves || [];
    document.getElementById('label1').textContent =
      `${sel1} (seed=${data1[sel1]?.seed}, final=${data1[sel1]?.score})`;
  } else {
    moves1 = [];
  }

  const sel2 = document.getElementById('gameSelect2').value;
  if (data2 && sel2) {
    moves2 = data2[sel2]?.moves || [];
    document.getElementById('label2').textContent =
      `${sel2} (seed=${data2[sel2]?.seed}, final=${data2[sel2]?.score})`;
  } else {
    moves2 = [];
  }

  frame = 0;
  stop();
  showFrame();
}

function maxFrame() { return Math.max(moves1.length, moves2.length) - 1; }

function showFrame() {
  const f = Math.max(0, Math.min(frame, maxFrame()));
  frame = f;
  if (moves1.length > 0) {
    const idx = Math.min(f, moves1.length - 1);
    const m = moves1[idx];
    renderBoard(document.getElementById('board1'), m.board);
    document.getElementById('moveNum').textContent = idx;
    document.getElementById('scoreVal').textContent = m.score;
    const maxT = Math.max(...m.board.flat());
    document.getElementById('maxTile').textContent = maxT;
  }
  if (moves2.length > 0) {
    const idx = Math.min(f, moves2.length - 1);
    renderBoard(document.getElementById('board2'), moves2[idx].board);
  }
}

function stop() { playing = false; document.getElementById('btnPlay').textContent = '▶ Play'; if (animId) cancelAnimationFrame(animId); }

function play() {
  if (frame >= maxFrame()) frame = 0;
  playing = true;
  document.getElementById('btnPlay').textContent = '⏸ Pause';
  let last = 0;
  function tick(ts) {
    if (!playing) return;
    const spd = parseInt(document.getElementById('speed').value);
    const interval = 1000 / spd;
    if (ts - last >= interval) {
      last = ts;
      frame++;
      if (frame > maxFrame()) { stop(); return; }
      showFrame();
    }
    animId = requestAnimationFrame(tick);
  }
  animId = requestAnimationFrame(tick);
}

document.getElementById('file1').addEventListener('change', (e) => loadFile(e.target, 1));
document.getElementById('file2').addEventListener('change', (e) => loadFile(e.target, 2));
document.getElementById('gameSelect').addEventListener('change', selectGame);
document.getElementById('gameSelect2').addEventListener('change', selectGame);
document.getElementById('btnPlay').addEventListener('click', () => playing ? stop() : play());
document.getElementById('btnNext').addEventListener('click', () => { stop(); frame++; showFrame(); });
document.getElementById('btnPrev').addEventListener('click', () => { stop(); frame--; showFrame(); });
document.getElementById('btnStart').addEventListener('click', () => { stop(); frame = 0; showFrame(); });
document.getElementById('btnEnd').addEventListener('click', () => { stop(); frame = maxFrame(); showFrame(); });
document.getElementById('speed').addEventListener('input', (e) => {
  document.getElementById('speedLabel').textContent = e.target.value + ' moves/s';
});
</script>
</body>
</html>
```

**Step 2: Verify viewer works**

Run:
```bash
cd src/crucible/examples/optimize-2048
python3 -u evaluate.py
open view.html
```
Expected: browser opens, can load `replay.json`, see board animation with tile colors.

**Step 3: Clean up and commit**

```bash
rm -f src/crucible/examples/optimize-2048/replay.json
git add src/crucible/examples/optimize-2048/view.html
git commit -m "feat(examples): add 2048 web replay viewer with comparison mode"
```

---

### Task 6: End-to-End Verification

**Step 1: Run evaluate with baseline and check all output**

```bash
cd src/crucible/examples/optimize-2048
python3 -u evaluate.py
```
Expected: ~5-10 seconds runtime, avg_score ~800-1200, all sub-metrics printed, `replay.json` created.

**Step 2: Test with BOARD_SIZE=5**

```bash
cd src/crucible/examples/optimize-2048
BOARD_SIZE=5 python3 -u evaluate.py
```
Expected: runs with 5×5 boards, different scores, replay works.

**Step 3: Validate crucible config**

```bash
cd src/crucible/examples/optimize-2048
crucible validate
```
Expected: validation passes (or, if running from source: `cd /Users/suzuke/Documents/Hack/crucible && uv run crucible validate --project src/crucible/examples/optimize-2048`).

**Step 4: Test web viewer with both replays**

```bash
cd src/crucible/examples/optimize-2048
python3 -u evaluate.py  # creates replay.json for 4x4
cp replay.json replay_4x4.json
BOARD_SIZE=5 python3 -u evaluate.py
cp replay.json replay_5x5.json
open view.html
```
Load both files in viewer, verify comparison mode works with different board sizes.

**Step 5: Clean up test artifacts and final commit**

```bash
rm -f src/crucible/examples/optimize-2048/replay*.json src/crucible/examples/optimize-2048/run.log
git add -A src/crucible/examples/optimize-2048/
git status
git commit -m "feat(examples): complete optimize-2048 demo project"
```
