# 3D Replay View — Design

**Date:** 2026-06-21
**App:** `tssbot.web` (React frontend + Node `server.cjs` + Rust/Python upstream)

## Goal

Add a **2D/3D toggle** to the game-detail Replay panel. The existing 2D
minimap replayer stays the default; a new 3D view (ported from the standalone
`REPLAY_VIEWER` three.js app) renders the same replay in an orbitable 3D scene.
Both views share one clock, the ticket bar, the battle log, and the team side
panels, so switching between them is seamless in real time.

## Requirements

1. Toggle labelled **2D / 3D** next to the "Replay" heading, **2D by default**,
   sitting alongside the existing Ground/Air toggle.
2. The 3D renderer is **pure**: only its `<canvas>`. No name labels, no axis
   lines, no map guide lines, no on-canvas controls/legend.
3. Keep the existing **team side panels** (winners/losers legends).
4. **Clicking a side-panel row focuses that unit in 3D** (camera flies to it and
   it glows). In 2D, click behaves the same as hover (no new behavior).
5. Keep the **ticket bar on top** and **battle log below**, both synced to the
   shared clock regardless of active view.
6. The **Ground/Air** toggle controls both views.
7. 3D uses **real height (Y)** from the replay data.

## Data flow

Pipeline today:
`replay_data.json[.gz]` (raw, path rows `[t, x, y, z]`)
→ `python -m BOT.render_replay <replay> <out.json>` (in `GitHub/BOTS/TSSBOT`)
→ `replay_canvas.json` (2D: path points `{t, x, z}`, plus `levelCoords`,
`tankMapCoords`, `mapCoords`, `fullMapLevel`, players, kills, captureAreas,
captureState, tickets, teamNames, winnerSlot)
→ cached + served at `GET /api/tss/games/:id/replay-canvas`
→ `ReplayCanvasEngine` (in `frontend/src/ReplayCanvas.jsx`).

**Update (verified during implementation):** `render_replay.py` *already* emits
`y` per canvas path point (`render_replay.py:4237` → `{t, x, z, y}`), and
`replay_canvas.json` already includes `captureAreas` (with `x/z/radius/center`),
`captureState` (cap timelines keyed `A/B/…`), `kills` (with `killerPos`/
`victimPos` in world coords), and all map coords. **No backend change is
needed.** 3D still falls back to `y = 0` if a point is missing.

### Runtime flow

- `ReplayCanvasPanel.jsx` fetches `/api/tss/games/:id/replay-canvas` once and
  builds `ReplayCanvasEngine` with the data object.
- The engine **eagerly** constructs both the 2D canvas and a hidden
  `ReplayCanvas3D` view from the same `data`.
- `engine._tick` advances `currentTime` and calls `activeView.setTime(currentTime)`.
  Scrubber `input` does the same. Ticket bar + battle log update from
  `currentTime` independent of the active view.
- `engine.setViewMode('2d'|'3d')` toggles visibility of the 2D canvas vs the 3D
  container, sets `activeView`, calls the new view's `setTime`/`setMode`/
  `resize`, and preserves play state + time → seamless swap.
- `engine.setMode('ground'|'air')` updates 2D coords (unchanged) **and** calls
  `view3d.setMode(mode)`.
- `rc-row` click → `engine.focus(playerId)` → routed to `activeView.focus`. 3D
  flies the camera to the unit and glows it; 2D `focus` is a no-op.

## Components

### 1. Backend / `render_replay.py`
No change — `y`, capture data, and kill positions are already emitted.

### 2. `frontend/src/ReplayCanvas3D.js` (new)
The ported three.js renderer as a UI-less class.

- **Constructor** `(container, data)`: builds renderer, scene, camera,
  OrbitControls into `container`; resolves map info from `data`
  (levelCoords/tankMapCoords/mapCoords/fullMapLevel) the same way the standalone
  viewer's `resolveMapInfo` does; builds map plane, tank/marker entities, path
  lines, kill lines, capture zones; starts its own `requestAnimationFrame` loop
  for camera damping + rendering (it renders continuously but only changes unit
  positions when `setTime` is called).
- **Methods:**
  - `setTime(t)` — place all entities/markers/kill-lines/zones at game time `t`
    (same interpolation/death-freeze logic as the standalone viewer).
  - `setMode('ground'|'air')` — swap coords/map image + rebuild scene extents.
  - `focus(playerId)` — move camera target to that player's current entity and
    apply the path glow / dim-others effect.
  - `setHighlight(playerId|null)` — optional, mirror hover highlight (best
    effort; may reuse `focus` glow without camera move). *MVP: `focus` only.*
  - `resize()` — match container size.
  - `dispose()` — stop loop, dispose geometries/materials/renderer, remove
    canvas.
- **Stripped vs standalone viewer:** remove name-label sprites, axis lines, map
  guide lines/annotations, the on-canvas play/speed/scrub controls, the active
  list / hover panel / meta panel DOM, and the replay-select. Keep: map plane +
  texture, tank model + sphere fallback, path lines, kill lines, capture zones,
  death-freeze, path glow + camera focus, Shift-to-boost camera movement, the
  `Plane*`-mesh removal fix, and the capture/heading/freeze fixes already made.
- **Entity identity:** each 3D entity carries its `playerId` (from `data`) so
  `focus(playerId)` can find the currently-live entity for that player.
- **Model asset:** the tank `.obj` and map images. 3D loads the tank model from
  a static path served by the app (copy `REPLAY_VIEWER/public/models/t34/` into
  `tssbot.web/frontend/public/models/t34/` or equivalent served dir). Map images
  come from the existing minimap endpoint (`/api/match/minimap/<level>` and
  `?type=full`), the same URLs the 2D engine uses.

### 3. `frontend/src/ReplayCanvas.jsx` — `ReplayCanvasEngine`
- Add a center child container for the 3D canvas next to the 2D `<canvas>`.
- New state: `this._viewMode = '2d'`, `this.view3d = null`, `this.activeView`.
- In `init()` (after map data is ready), eagerly construct `ReplayCanvas3D`.
- `setViewMode(mode)`: show/hide canvases, set `activeView`, push current
  `currentTime` + ground/air mode to the newly active view, `resize()` it.
- In `_tick` and scrubber handler: when `_viewMode === '3d'`, call
  `view3d.setTime(currentTime)` instead of (or in addition to) the 2D `render()`.
  2D `render()` is skipped while 3D is active for perf.
- `setMode(mode)`: keep existing 2D behavior, also `view3d?.setMode(mode)`.
- `focus(playerId)`: call `activeView.focus?.(playerId)`.
- `rc-row` click listener → `this.focus(pid)`.
- `destroy()`: also `view3d?.dispose()`.

### 4. `frontend/src/ReplayCanvas.jsx` — `ReplayCanvasPanel`
- State adds `viewMode: '2d'`.
- Render a 2D/3D toggle (mirroring `rc-mode-toggle` styling) next to the
  "Replay" `<h2>`, always visible once `status === 'ready'`.
- `switchView(mode)` → `engine.setViewMode(mode)` + `setState`.
- If WebGL is unavailable, disable the 3D button (dimmed, tooltip) and stay 2D.

### 5. Styles
- `.rc-view-toggle` / `.rc-view-btn` (reuse `.rc-mode-toggle` look).
- Center stacking so the 2D `<canvas>` and 3D container occupy the same slot
  (one hidden via class).

## Error handling

- **Missing `y`** (stale cache): 3D uses `y = 0` → flat scene, no crash.
- **No WebGL**: 3D button disabled; panel stays 2D.
- **Model/texture load failure**: 3D falls back to sphere markers / untextured
  map (already handled in the standalone viewer's loaders).

## Testing / verification

Manual, since this is interactive visual UI:
- 2D view byte-for-byte unchanged when 3D is never opened.
- Toggle 2D⇄3D mid-playback: time, play/pause, and ticket/battle-log stay in
  sync; no jump.
- Ground/Air toggle affects both views (use a game with aircraft).
- Clicking a side row in 3D flies the camera + glows that unit; in 2D it matches
  hover.
- `vite build` compiles.
- One game regenerated through `render_replay` includes `y`.

## Out of scope (YAGNI)

- Persistent 2D click-focus / 2D recenter (explicitly declined).
- Name labels / legend inside the 3D canvas.
- Lazy-loading three.js (eager chosen).
- Backfilling height into all cached `replay_canvas.json` (regenerate on demand).
