Files
TSSBOT-web/docs/superpowers/specs/2026-06-21-3d-replay-view-design.md
T
FURRO404 2e75909f2d Add 3D replay view design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:17:15 -07:00

7.8 KiB

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-canvasReplayCanvasEngine (in frontend/src/ReplayCanvas.jsx).

The raw rows already carry y (confirmed: e.g. [29031, -10218.59, 69.74, -2626.99]); the renderer drops it. The map coords needed by 3D are already in replay_canvas.json.

Change

render_replay.py: include y in each emitted canvas path point (add a y field, e.g. {t, x, y, z}). Backwards compatible — the 2D engine reads only t/x/z. Cached JSONs without y regenerate on next mtime mismatch; until then 3D falls back to y = 0.

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. BOT/render_replay.py (in GitHub/BOTS/TSSBOT)

Emit y per canvas path point. Single-field additive change at the point where path points are serialized for the canvas JSON.

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.jsxReplayCanvasEngine

  • 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.jsxReplayCanvasPanel

  • 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).