diff --git a/package-lock.json b/package-lock.json
index 65a50dc..ad3f680 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -601,9 +601,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -620,9 +617,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -639,9 +633,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -658,9 +649,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -677,9 +665,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -696,9 +681,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -715,9 +697,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -734,9 +713,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -753,9 +729,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -778,9 +751,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -803,9 +773,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -828,9 +795,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -853,9 +817,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -878,9 +839,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -903,9 +861,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -928,9 +883,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1140,9 +1092,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1159,9 +1108,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1178,9 +1124,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1197,9 +1140,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -4757,7 +4697,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5178,7 +5117,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -5395,7 +5333,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5693,7 +5630,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5757,7 +5693,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/react-simple-maps": {
diff --git a/schemas/replays.md b/schemas/replays.md
new file mode 100644
index 0000000..827f0f5
--- /dev/null
+++ b/schemas/replays.md
@@ -0,0 +1,291 @@
+# Match Replay JSON Format
+
+Guide for implementing the admin-panel replay viewer. Files are written by the Unity dedicated server (and practice host) to disk only.
+
+## Where files live
+
+```
+{ApplicationDirectory}/Logs/{matchId}_replay.json
+```
+
+| Match type | `matchId` in filename / JSON | Notes |
+|------------|------------------------------|--------|
+| Ranked / dedicated | Positive matchmaker id (e.g. `228`) | Same id as `Logs/228.txt` |
+| Practice / tutorial | Negative unix timestamp (e.g. `-1769539200`) | `isPractice: true`; unique per session |
+
+Example paths:
+
+- `Logs/228_replay.json`
+- `Logs/-1769539200_replay.json`
+
+## Top-level object
+
+```json
+{
+ "version": 1,
+ "matchId": 228,
+ "isPractice": false,
+ "fixedDeltaTime": 0.02,
+ "duration": 184.32,
+ "entities": [ /* ... */ ],
+ "frames": [ /* ... */ ],
+ "events": [ /* ... */ ]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `version` | int | Schema version. Currently `1`. |
+| `matchId` | int | Match id (positive = ranked, negative = practice). |
+| `isPractice` | bool | `true` for localhost practice / tutorial host. |
+| `fixedDeltaTime` | float | Physics step used while recording (typically `0.02` → ~50 Hz). |
+| `duration` | float | Length of the recording in seconds (`t` of last sample / end). |
+| `entities` | array | Ball + pucks. Order defines pose array indices in every frame. |
+| `frames` | array | Time-ordered pose samples (~every FixedUpdate). |
+| `events` | array | Time-ordered discrete events (hits, goals, resets). |
+
+Time `t` everywhere is **seconds since recording start** (match start), not wall-clock UTC.
+
+---
+
+## Entities
+
+```json
+{ "id": 0, "type": "ball", "team": "" }
+{ "id": 1, "type": "puck", "team": "Red" }
+{ "id": 2, "type": "puck", "team": "Blue" }
+```
+
+| Field | Type | Values |
+|-------|------|--------|
+| `id` | int | Stable index `0 .. N-1`. Matches array index in each frame’s `x`/`y`/`vx`/`vy`. |
+| `type` | string | `"ball"` or `"puck"` |
+| `team` | string | `"Red"`, `"Blue"`, or `""` for ball |
+
+Entity list is fixed for the whole file. **Index `i` in a frame’s pose arrays always refers to `entities[i]`.**
+
+Typical order: ball first, then pucks sorted by team then spawn position.
+
+---
+
+## Frames (motion samples)
+
+```json
+{
+ "t": 12.40,
+ "x": [0.0, 1.2, -0.5],
+ "y": [0.1, -0.3, 2.0],
+ "vx": [0.0, 3.1, -1.0],
+ "vy": [0.2, 0.0, 0.5]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `t` | float | Sample time in seconds (monotonic, increasing). |
+| `x`, `y` | float[] | World position per entity (Unity 2D world units). |
+| `vx`, `vy` | float[] | Linear velocity per entity at that sample (world units / second). |
+
+- Array length always equals `entities.length`.
+- Samples are dense (~`1 / fixedDeltaTime` Hz). Do **not** treat them as render frames.
+
+### Coordinate system
+
+Unity 2D world space:
+
+- Origin at field center
+- `+X` right, `+Y` up (as in Unity)
+- Scale matches the in-game pitch (see game `fieldSize` / art for admin canvas mapping)
+
+---
+
+## Events
+
+```json
+{ "t": 12.4, "type": "hit", "kind": "ball_wall", "vol": 0.6, "id": 0, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
+{ "t": 30.1, "type": "goal", "kind": "", "vol": 0, "id": -1, "team": "Blue", "redScore": 1, "blueScore": 0, "kickoff": false }
+{ "t": 32.2, "type": "reset", "kind": "", "vol": 0, "id": -1, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
+```
+
+All event objects share the same fields (Unity `JsonUtility` shape). Unused fields are empty / zero / `-1`.
+
+| Field | Used by | Description |
+|-------|---------|-------------|
+| `t` | all | Event time in seconds. |
+| `type` | all | `"hit"` \| `"goal"` \| `"reset"` |
+| `kind` | `hit` | `"puck_puck"` \| `"ball_puck"` \| `"ball_wall"` |
+| `vol` | `hit` | SFX volume `0..1` (from relative impact speed). |
+| `id` | `hit` | Entity id involved (spatial cue). `-1` if unknown. |
+| `team` | `goal` | **Conceding** side / goal owner (`"Red"` or `"Blue"`). The other team scored. |
+| `redScore` | `goal` | Score after this goal. |
+| `blueScore` | `goal` | Score after this goal. |
+| `kickoff` | `goal`, `reset` | Kickoff-goal / kickoff-style reset when `true`. |
+
+### Event semantics
+
+| `type` | Meaning for the viewer |
+|--------|-------------------------|
+| `hit` | Play collision SFX at `vol`. Optional: flash near entity `id`. |
+| `goal` | Goal scored against `team`. Update scoreboard to `redScore`/`blueScore`. Play goal SFX. **Discontinuity** — do not interpolate motion across this time. |
+| `reset` | Pieces are lerping / teleporting back. **Discontinuity** — snap poses; do not blend across. |
+
+First-to-3 wins; a completed match usually ends shortly after a goal that makes a score `3`.
+
+---
+
+## Playback contract (required for smooth motion)
+
+Recording is FixedUpdate (~50 Hz). Snapping happens if the viewer steps sample-by-sample without blending. Follow this:
+
+### 1. Drive time with wall clock
+
+```text
+playbackTime += (deltaMs / 1000) * playbackSpeed
+playbackTime = clamp(playbackTime, 0, duration)
+```
+
+Use `requestAnimationFrame` (or equivalent). Do **not** advance one JSON frame per rAF tick.
+
+### 2. Interpolate poses between samples
+
+Binary-search `frames` for neighbors where:
+
+```text
+frames[i].t <= playbackTime < frames[i+1].t
+```
+
+Then for each entity index `e`:
+
+**Minimum (good):** linear lerp of `x`/`y`:
+
+```text
+u = (playbackTime - t0) / (t1 - t0)
+x = lerp(x0[e], x1[e], u)
+y = lerp(y0[e], y1[e], u)
+```
+
+**Recommended (smooth under acceleration):** cubic Hermite using velocities:
+
+```text
+dt = t1 - t0
+u = (playbackTime - t0) / dt
+u2 = u*u
+u3 = u2*u
+
+h00 = 2*u3 - 3*u2 + 1
+h10 = u3 - 2*u2 + u
+h01 = -2*u3 + 3*u2
+h11 = u3 - u2
+
+x = h00*x0 + h10*dt*vx0 + h01*x1 + h11*dt*vx1
+y = h00*y0 + h10*dt*vy0 + h01*y1 + h11*dt*vy1
+```
+
+(Same for each entity index.)
+
+### 3. Snap across discontinuities
+
+Treat every `goal` and `reset` event as a cut:
+
+- When `playbackTime` crosses that event’s `t`, **do not** interpolate from the previous sample across the cut.
+- Seek to the frame at or immediately after `t` and snap positions.
+
+Practical approach: build a sorted list of discontinuity times from events where `type === "goal" || type === "reset"`. If `t0` and `t1` straddle a discontinuity, snap to the post-cut sample instead of lerping.
+
+### 4. Fire events once when crossed
+
+Keep `lastEventIndex` (or last fired `t`). When `playbackTime` advances past an event’s `t`, fire it once:
+
+- `hit` → SFX by `kind` / `vol`
+- `goal` → score UI + goal audio
+- `reset` → optional reset cue (usually silent)
+
+On seek / scrub: reset event cursor; either skip SFX or replay only from the new time forward.
+
+---
+
+## Suggested viewer pipeline
+
+```text
+Load JSON
+ → map entities to sprites (ball / red pucks / blue pucks)
+ → sort events by t (already ordered, but verify)
+ → each animation frame:
+ advance playbackTime
+ resolve poses (Hermite + discontinuity snaps)
+ draw entities
+ dispatch newly crossed events
+```
+
+### Seeking / scrubbing
+
+1. Set `playbackTime` to the scrub value.
+2. Find the nearest frame (or interpolate as above).
+3. Recompute score from the last `goal` event with `t <= playbackTime` (else `0–0`).
+4. Reset the event-fire cursor to that time so SFX don’t spam.
+
+### Scoreboard without scrubbing
+
+Start at `0–0`. On each `goal` event, set scores from that event’s `redScore` / `blueScore`.
+
+---
+
+## Minimal TypeScript types
+
+```ts
+export interface ReplayFile {
+ version: number;
+ matchId: number;
+ isPractice: boolean;
+ fixedDeltaTime: number;
+ duration: number;
+ entities: ReplayEntity[];
+ frames: ReplayFrame[];
+ events: ReplayEvent[];
+}
+
+export interface ReplayEntity {
+ id: number;
+ type: "ball" | "puck" | string;
+ team: "Red" | "Blue" | "" | string;
+}
+
+export interface ReplayFrame {
+ t: number;
+ x: number[];
+ y: number[];
+ vx: number[];
+ vy: number[];
+}
+
+export interface ReplayEvent {
+ t: number;
+ type: "hit" | "goal" | "reset" | string;
+ kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
+ vol: number;
+ id: number; // entity id, or -1
+ team: "Red" | "Blue" | "" | string; // conceding team on goal
+ redScore: number;
+ blueScore: number;
+ kickoff: boolean;
+}
+```
+
+---
+
+## Checklist for a correct viewer
+
+- [ ] Time driven by real elapsed time × speed, not frame index
+- [ ] Poses interpolated between samples (Hermite preferred)
+- [ ] No interpolation across `goal` / `reset`
+- [ ] Entity index `i` always matches `entities[i]`
+- [ ] Hit SFX use `kind` + `vol`
+- [ ] Goal `team` means **conceding** side
+- [ ] Practice files filtered via `isPractice` or negative `matchId`
+
+## Out of scope (not in the JSON)
+
+- Player names / user ids (use matchmaker / match API by `matchId` for ranked games)
+- Camera / UI layout
+- Launch aim lines / turn timer
+- Network latency or client prediction
diff --git a/src/app/replays/page.tsx b/src/app/replays/page.tsx
new file mode 100644
index 0000000..ba6d29c
--- /dev/null
+++ b/src/app/replays/page.tsx
@@ -0,0 +1,50 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { ReplayViewer } from "@/components/replay-viewer";
+import { canReadPage } from "@/lib/auth/permissions";
+import { logPageAccess } from "@/lib/auth/log-page-access";
+import { requireSession } from "@/lib/auth/require-session";
+
+export const metadata: Metadata = {
+ title: "Replay viewer · Kick Kings Admin",
+};
+
+export default async function ReplaysPage() {
+ const account = await requireSession();
+ if (
+ !canReadPage(account, "matches") &&
+ !canReadPage(account, "analysis")
+ ) {
+ redirect("/");
+ }
+
+ await logPageAccess(account, "replay-viewer");
+
+ return (
+
+ );
+}
diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx
index a6540e9..1812284 100644
--- a/src/components/admin-dashboard.tsx
+++ b/src/components/admin-dashboard.tsx
@@ -564,6 +564,15 @@ export function AdminDashboard({
System logs
) : null}
+ {pageAccess.matches || pageAccess.analysis ? (
+
+ Replays
+
+ ) : null}
{isAdmin ? (
{
+ const a = worldToCanvas(x0, y0, view);
+ const b = worldToCanvas(x1, y1, view);
+ ctx.beginPath();
+ ctx.moveTo(a.cx, a.cy);
+ ctx.lineTo(b.cx, b.cy);
+ ctx.strokeStyle = "rgba(255,255,255,0.4)";
+ ctx.lineWidth = width;
+ ctx.stroke();
+ };
+
+ // Sidelines
+ strokeLine(pitch.minX, pitch.minY, pitch.minX, pitch.maxY, 2.5);
+ strokeLine(pitch.maxX, pitch.minY, pitch.maxX, pitch.maxY, 2.5);
+
+ // Top end: end-line with goal mouth notch
+ strokeLine(pitch.minX, pitch.maxY, topG.left, pitch.maxY, 2.5);
+ strokeLine(topG.right, pitch.maxY, pitch.maxX, pitch.maxY, 2.5);
+ strokeLine(topG.left, pitch.maxY, topG.left, topG.top, 2.5);
+ strokeLine(topG.right, pitch.maxY, topG.right, topG.top, 2.5);
+ strokeLine(topG.left, topG.top, topG.right, topG.top, 2.5);
+
+ // Bottom end: end-line with goal mouth notch
+ strokeLine(pitch.minX, pitch.minY, botG.left, pitch.minY, 2.5);
+ strokeLine(botG.right, pitch.minY, pitch.maxX, pitch.minY, 2.5);
+ strokeLine(botG.left, pitch.minY, botG.left, botG.bottom, 2.5);
+ strokeLine(botG.right, pitch.minY, botG.right, botG.bottom, 2.5);
+ strokeLine(botG.left, botG.bottom, botG.right, botG.bottom, 2.5);
+
+ // Center line + circle
+ strokeLine(pitch.minX, 0, pitch.maxX, 0, 2);
+ const r = Math.min(worldW, worldH) * 0.14;
+ const c = worldToCanvas(0, 0, view);
+ const rx = (r / worldW) * view.w;
+ const ry = (r / worldH) * view.h;
+ ctx.beginPath();
+ ctx.ellipse(c.cx, c.cy, rx, ry, 0, 0, Math.PI * 2);
+ ctx.strokeStyle = "rgba(255,255,255,0.35)";
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // Goal fills (exact size/position)
+ fillWorldRect(
+ ctx,
+ view,
+ topG.left,
+ topG.bottom,
+ topG.right,
+ topG.top,
+ "rgba(229, 72, 77, 0.45)",
+ );
+ strokeWorldRect(
+ ctx,
+ view,
+ topG.left,
+ topG.bottom,
+ topG.right,
+ topG.top,
+ "rgba(229, 72, 77, 0.85)",
+ 1.5,
+ );
+ fillWorldRect(
+ ctx,
+ view,
+ botG.left,
+ botG.bottom,
+ botG.right,
+ botG.top,
+ "rgba(59, 130, 246, 0.45)",
+ );
+ strokeWorldRect(
+ ctx,
+ view,
+ botG.left,
+ botG.bottom,
+ botG.right,
+ botG.top,
+ "rgba(59, 130, 246, 0.85)",
+ 1.5,
+ );
+}
+
+function drawEntities(
+ ctx: CanvasRenderingContext2D,
+ replay: ReplayFile,
+ poses: { x: number; y: number }[],
+ view: ViewRect,
+) {
+ const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
+ const worldW = maxX - minX;
+ const worldH = maxY - minY;
+ const unit = Math.min(view.w / worldW, view.h / worldH);
+ const ballR = Math.max(4, unit * 0.12);
+ const puckR = Math.max(6, unit * 0.18);
+
+ for (let i = 0; i < replay.entities.length; i++) {
+ const ent = replay.entities[i]!;
+ const pose = poses[i];
+ if (!pose) continue;
+ const { cx, cy } = worldToCanvas(pose.x, pose.y, view);
+ const r = ent.type === "ball" ? ballR : puckR;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.fillStyle = entityFill(ent.type, ent.team);
+ ctx.fill();
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = entityStroke(ent.type, ent.team);
+ ctx.stroke();
+
+ if (ent.type === "ball") {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
+ ctx.fillStyle = "rgba(0,0,0,0.12)";
+ ctx.fill();
+ }
+ }
+}
+
+function eventMarkerColor(e: ReplayEvent): string {
+ if (e.type === "goal") {
+ return e.team === "Red" ? "#3b82f6" : "#e5484d";
+ }
+ if (e.type === "reset") return "#a1a1aa";
+ return "transparent";
+}
+
+export function ReplayViewer({
+ initialReplay = null,
+ initialFileName = null,
+}: Props) {
+ const [replay, setReplay] = useState(initialReplay);
+ const [fileName, setFileName] = useState(initialFileName);
+ const [error, setError] = useState(null);
+ const [playing, setPlaying] = useState(false);
+ const [speed, setSpeed] = useState(1);
+ const [time, setTime] = useState(0);
+ const [score, setScore] = useState({ red: 0, blue: 0 });
+ const [dragOver, setDragOver] = useState(false);
+
+ const canvasRef = useRef(null);
+ const cutsRef = useRef([]);
+ const timeRef = useRef(0);
+ const playingRef = useRef(false);
+ const speedRef = useRef(1);
+ const lastTsRef = useRef(null);
+ const eventCursorRef = useRef(0);
+ const replayRef = useRef(null);
+
+ useEffect(() => {
+ timeRef.current = time;
+ }, [time]);
+ useEffect(() => {
+ playingRef.current = playing;
+ }, [playing]);
+ useEffect(() => {
+ speedRef.current = speed;
+ }, [speed]);
+ useEffect(() => {
+ replayRef.current = replay;
+ }, [replay]);
+
+ const loadReplay = useCallback((file: ReplayFile, name: string) => {
+ setReplay(file);
+ setFileName(name);
+ setError(null);
+ setTime(0);
+ timeRef.current = 0;
+ setPlaying(false);
+ setScore({ red: 0, blue: 0 });
+ cutsRef.current = discontinuityTimes(file.events);
+ eventCursorRef.current = 0;
+ }, []);
+
+ const onFileText = useCallback(
+ (text: string, name: string) => {
+ try {
+ const parsed = parseReplayJsonText(text);
+ loadReplay(parsed, name);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to parse replay");
+ setReplay(null);
+ setFileName(null);
+ }
+ },
+ [loadReplay],
+ );
+
+ const onPickFile = useCallback(
+ (file: File | null) => {
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const text = typeof reader.result === "string" ? reader.result : "";
+ onFileText(text, file.name);
+ };
+ reader.onerror = () => setError("Could not read file");
+ reader.readAsText(file);
+ },
+ [onFileText],
+ );
+
+ const seekTo = useCallback((t: number) => {
+ const r = replayRef.current;
+ if (!r) return;
+ const clamped = Math.min(Math.max(0, t), r.duration);
+ timeRef.current = clamped;
+ setTime(clamped);
+ setScore(scoreAtTime(r.events, clamped));
+ eventCursorRef.current = eventCursorAfter(r.events, clamped);
+ }, []);
+
+ const paint = useEffectEvent(() => {
+ const canvas = canvasRef.current;
+ const r = replayRef.current;
+ if (!canvas || !r) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const cssW = canvas.clientWidth;
+ const cssH = canvas.clientHeight;
+ if (cssW < 2 || cssH < 2) return;
+
+ const needW = Math.floor(cssW * dpr);
+ const needH = Math.floor(cssH * dpr);
+ if (canvas.width !== needW || canvas.height !== needH) {
+ canvas.width = needW;
+ canvas.height = needH;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const view = fieldViewRect(cssW, cssH);
+ const poses = resolvePoses(
+ r.frames,
+ r.entities.length,
+ timeRef.current,
+ cutsRef.current,
+ );
+ drawPitch(ctx, cssW, cssH, view);
+ drawEntities(ctx, r, poses, view);
+ });
+
+ useEffect(() => {
+ let raf = 0;
+ const tick = (ts: number) => {
+ const r = replayRef.current;
+ if (r && playingRef.current) {
+ if (lastTsRef.current == null) lastTsRef.current = ts;
+ const dt = (ts - lastTsRef.current) / 1000;
+ lastTsRef.current = ts;
+ let next = timeRef.current + dt * speedRef.current;
+ if (next >= r.duration) {
+ next = r.duration;
+ playingRef.current = false;
+ setPlaying(false);
+ }
+
+ // Fire events crossed this frame (SFX later; update score for goals)
+ const events = r.events;
+ let cursor = eventCursorRef.current;
+ while (cursor < events.length && events[cursor]!.t <= next) {
+ const e = events[cursor]!;
+ if (e.type === "goal") {
+ setScore({ red: e.redScore, blue: e.blueScore });
+ }
+ cursor++;
+ }
+ eventCursorRef.current = cursor;
+
+ timeRef.current = next;
+ setTime(next);
+ } else {
+ lastTsRef.current = null;
+ }
+ paint();
+ raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [paint]);
+
+ const goalEvents =
+ replay?.events.filter((e) => e.type === "goal") ?? [];
+
+ return (
+
+ {!replay ? (
+
+ ) : (
+ <>
+
+
+
+ {fileName ?? `match_${replay.matchId}_replay.json`}
+
+
+ Match {replay.matchId}
+ {replay.isPractice ? " · practice" : ""}
+ {" · "}
+ {replay.entities.length} entities
+ {" · "}
+ {replay.frames.length.toLocaleString("en-US")} samples
+ {" · "}
+ v{replay.version}
+
+
+
+
+ {score.red}
+ –
+ {score.blue}
+
+
+
+
+
+ {error ? (
+
{error}
+ ) : null}
+
+
+
+
+
+
+
+ {/* Event markers */}
+
+ {goalEvents.map((e, i) => (
+ 0 ? (e.t / replay.duration) * 100 : 0}%`,
+ backgroundColor: eventMarkerColor(e),
+ }}
+ />
+ ))}
+
+
{
+ setPlaying(false);
+ seekTo(Number(e.target.value));
+ }}
+ className="w-full accent-emerald-500"
+ aria-label="Replay timeline"
+ />
+
+
+
+
+
+
+ {formatReplayTime(time)} / {formatReplayTime(replay.duration)}
+
+
+ Speed
+ {SPEEDS.map((s) => (
+
+ ))}
+
+
+
+ {goalEvents.length > 0 ? (
+
+
+ Goals
+
+ {goalEvents.map((e, i) => {
+ const scorer =
+ e.team === "Red"
+ ? "Blue"
+ : e.team === "Blue"
+ ? "Red"
+ : "?";
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/lib/auth/page-access-labels.ts b/src/lib/auth/page-access-labels.ts
index f84bc6b..210ece4 100644
--- a/src/lib/auth/page-access-labels.ts
+++ b/src/lib/auth/page-access-labels.ts
@@ -10,6 +10,7 @@ const PAGE_LABELS: Record = {
"ledger-book": "Ledger book",
"match-log": "Match log",
"matchmaker-logs": "Matchmaker logs",
+ "replay-viewer": "Replay viewer",
};
export function pageAccessLabel(page: string): string {
diff --git a/src/lib/replay-parse.ts b/src/lib/replay-parse.ts
new file mode 100644
index 0000000..92959f0
--- /dev/null
+++ b/src/lib/replay-parse.ts
@@ -0,0 +1,147 @@
+import type {
+ ReplayEntity,
+ ReplayEvent,
+ ReplayFile,
+ ReplayFrame,
+} from "@/types/replay";
+
+function isRecord(v: unknown): v is Record {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+function asNumber(v: unknown, label: string): number {
+ if (typeof v !== "number" || !Number.isFinite(v)) {
+ throw new Error(`Invalid ${label}: expected a finite number`);
+ }
+ return v;
+}
+
+function asBoolean(v: unknown, label: string): boolean {
+ if (typeof v !== "boolean") {
+ throw new Error(`Invalid ${label}: expected a boolean`);
+ }
+ return v;
+}
+
+function asString(v: unknown, label: string): string {
+ if (typeof v !== "string") {
+ throw new Error(`Invalid ${label}: expected a string`);
+ }
+ return v;
+}
+
+function asNumberArray(v: unknown, label: string, len: number): number[] {
+ if (!Array.isArray(v)) {
+ throw new Error(`Invalid ${label}: expected an array`);
+ }
+ if (v.length !== len) {
+ throw new Error(
+ `Invalid ${label}: expected length ${len}, got ${v.length}`,
+ );
+ }
+ const out: number[] = [];
+ for (let i = 0; i < v.length; i++) {
+ const n = v[i];
+ if (typeof n !== "number" || !Number.isFinite(n)) {
+ throw new Error(`Invalid ${label}[${i}]: expected a finite number`);
+ }
+ out.push(n);
+ }
+ return out;
+}
+
+function parseEntity(raw: unknown, index: number): ReplayEntity {
+ if (!isRecord(raw)) {
+ throw new Error(`entities[${index}] must be an object`);
+ }
+ return {
+ id: asNumber(raw.id, `entities[${index}].id`),
+ type: asString(raw.type, `entities[${index}].type`),
+ team: asString(raw.team, `entities[${index}].team`),
+ };
+}
+
+function parseFrame(
+ raw: unknown,
+ index: number,
+ entityCount: number,
+): ReplayFrame {
+ if (!isRecord(raw)) {
+ throw new Error(`frames[${index}] must be an object`);
+ }
+ return {
+ t: asNumber(raw.t, `frames[${index}].t`),
+ x: asNumberArray(raw.x, `frames[${index}].x`, entityCount),
+ y: asNumberArray(raw.y, `frames[${index}].y`, entityCount),
+ vx: asNumberArray(raw.vx, `frames[${index}].vx`, entityCount),
+ vy: asNumberArray(raw.vy, `frames[${index}].vy`, entityCount),
+ };
+}
+
+function parseEvent(raw: unknown, index: number): ReplayEvent {
+ if (!isRecord(raw)) {
+ throw new Error(`events[${index}] must be an object`);
+ }
+ return {
+ t: asNumber(raw.t, `events[${index}].t`),
+ type: asString(raw.type, `events[${index}].type`),
+ kind: asString(raw.kind ?? "", `events[${index}].kind`),
+ vol: asNumber(raw.vol ?? 0, `events[${index}].vol`),
+ id: asNumber(raw.id ?? -1, `events[${index}].id`),
+ team: asString(raw.team ?? "", `events[${index}].team`),
+ redScore: asNumber(raw.redScore ?? 0, `events[${index}].redScore`),
+ blueScore: asNumber(raw.blueScore ?? 0, `events[${index}].blueScore`),
+ kickoff: asBoolean(raw.kickoff ?? false, `events[${index}].kickoff`),
+ };
+}
+
+/** Parse and lightly validate a replay JSON value from disk / upload. */
+export function parseReplayFile(raw: unknown): ReplayFile {
+ if (!isRecord(raw)) {
+ throw new Error("Replay root must be a JSON object");
+ }
+
+ const entitiesRaw = raw.entities;
+ if (!Array.isArray(entitiesRaw) || entitiesRaw.length === 0) {
+ throw new Error("Replay must include a non-empty entities array");
+ }
+ const entities = entitiesRaw.map(parseEntity);
+
+ const framesRaw = raw.frames;
+ if (!Array.isArray(framesRaw) || framesRaw.length === 0) {
+ throw new Error("Replay must include a non-empty frames array");
+ }
+ const frames = framesRaw.map((f, i) => parseFrame(f, i, entities.length));
+
+ const eventsRaw = raw.events;
+ if (!Array.isArray(eventsRaw)) {
+ throw new Error("Replay must include an events array");
+ }
+ const events = eventsRaw.map(parseEvent);
+
+ const duration = asNumber(raw.duration, "duration");
+ if (duration < 0) {
+ throw new Error("duration must be >= 0");
+ }
+
+ return {
+ version: asNumber(raw.version, "version"),
+ matchId: asNumber(raw.matchId, "matchId"),
+ isPractice: asBoolean(raw.isPractice ?? false, "isPractice"),
+ fixedDeltaTime: asNumber(raw.fixedDeltaTime, "fixedDeltaTime"),
+ duration,
+ entities,
+ frames,
+ events,
+ };
+}
+
+export function parseReplayJsonText(text: string): ReplayFile {
+ let raw: unknown;
+ try {
+ raw = JSON.parse(text) as unknown;
+ } catch {
+ throw new Error("File is not valid JSON");
+ }
+ return parseReplayFile(raw);
+}
diff --git a/src/lib/replay-playback.ts b/src/lib/replay-playback.ts
new file mode 100644
index 0000000..c05f944
--- /dev/null
+++ b/src/lib/replay-playback.ts
@@ -0,0 +1,150 @@
+import type {
+ ReplayEvent,
+ ReplayFrame,
+ ReplayPose,
+} from "@/types/replay";
+
+function lerp(a: number, b: number, u: number): number {
+ return a + (b - a) * u;
+}
+
+function hermite(
+ p0: number,
+ v0: number,
+ p1: number,
+ v1: number,
+ dt: number,
+ u: number,
+): number {
+ const u2 = u * u;
+ const u3 = u2 * u;
+ const h00 = 2 * u3 - 3 * u2 + 1;
+ const h10 = u3 - 2 * u2 + u;
+ const h01 = -2 * u3 + 3 * u2;
+ const h11 = u3 - u2;
+ return h00 * p0 + h10 * dt * v0 + h01 * p1 + h11 * dt * v1;
+}
+
+/** Sorted discontinuity times from goal / reset events. */
+export function discontinuityTimes(events: ReplayEvent[]): number[] {
+ const out: number[] = [];
+ for (const e of events) {
+ if (e.type === "goal" || e.type === "reset") {
+ out.push(e.t);
+ }
+ }
+ out.sort((a, b) => a - b);
+ return out;
+}
+
+/** Cut time in (t0, t1], or null if none. */
+function cutBetween(t0: number, t1: number, cuts: number[]): number | null {
+ for (const c of cuts) {
+ if (c > t0 && c <= t1) return c;
+ }
+ return null;
+}
+
+/**
+ * Largest i such that frames[i].t <= time.
+ * Returns 0 if time is before the first sample.
+ */
+export function frameIndexAtOrBefore(
+ frames: ReplayFrame[],
+ time: number,
+): number {
+ let lo = 0;
+ let hi = frames.length - 1;
+ if (time <= frames[0]!.t) return 0;
+ if (time >= frames[hi]!.t) return hi;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ if (frames[mid]!.t <= time) lo = mid;
+ else hi = mid - 1;
+ }
+ return lo;
+}
+
+/** First event index with t > time (cursor after last fired). */
+export function eventCursorAfter(events: ReplayEvent[], time: number): number {
+ let lo = 0;
+ let hi = events.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1;
+ if (events[mid]!.t <= time) lo = mid + 1;
+ else hi = mid;
+ }
+ return lo;
+}
+
+export function scoreAtTime(
+ events: ReplayEvent[],
+ time: number,
+): { red: number; blue: number } {
+ let red = 0;
+ let blue = 0;
+ for (const e of events) {
+ if (e.t > time) break;
+ if (e.type === "goal") {
+ red = e.redScore;
+ blue = e.blueScore;
+ }
+ }
+ return { red, blue };
+}
+
+export function resolvePoses(
+ frames: ReplayFrame[],
+ entityCount: number,
+ time: number,
+ cuts: number[],
+): ReplayPose[] {
+ const poses: ReplayPose[] = Array.from({ length: entityCount }, () => ({
+ x: 0,
+ y: 0,
+ }));
+
+ if (frames.length === 0 || entityCount === 0) return poses;
+
+ const i = frameIndexAtOrBefore(frames, time);
+ const a = frames[i]!;
+
+ if (i >= frames.length - 1 || time <= a.t) {
+ for (let e = 0; e < entityCount; e++) {
+ poses[e] = { x: a.x[e]!, y: a.y[e]! };
+ }
+ return poses;
+ }
+
+ const b = frames[i + 1]!;
+ const dt = b.t - a.t;
+ const cut = cutBetween(a.t, b.t, cuts);
+ if (dt <= 1e-9 || cut != null) {
+ // No blend across goal/reset: pre-cut → sample a, post-cut → sample b.
+ const snap = cut != null && time >= cut ? b : a;
+ for (let e = 0; e < entityCount; e++) {
+ poses[e] = { x: snap.x[e]!, y: snap.y[e]! };
+ }
+ return poses;
+ }
+
+ const u = (time - a.t) / dt;
+ for (let e = 0; e < entityCount; e++) {
+ poses[e] = {
+ x: hermite(a.x[e]!, a.vx[e]!, b.x[e]!, b.vx[e]!, dt, u),
+ y: hermite(a.y[e]!, a.vy[e]!, b.y[e]!, b.vy[e]!, dt, u),
+ };
+ }
+ return poses;
+}
+
+export function formatReplayTime(seconds: number): string {
+ const s = Math.max(0, seconds);
+ const m = Math.floor(s / 60);
+ const rem = s - m * 60;
+ const whole = Math.floor(rem);
+ const frac = Math.floor((rem - whole) * 10);
+ return `${m}:${String(whole).padStart(2, "0")}.${frac}`;
+}
+
+export { lerp };
diff --git a/src/types/replay.ts b/src/types/replay.ts
new file mode 100644
index 0000000..ed98842
--- /dev/null
+++ b/src/types/replay.ts
@@ -0,0 +1,99 @@
+export interface ReplayFile {
+ version: number;
+ matchId: number;
+ isPractice: boolean;
+ fixedDeltaTime: number;
+ duration: number;
+ entities: ReplayEntity[];
+ frames: ReplayFrame[];
+ events: ReplayEvent[];
+}
+
+export interface ReplayEntity {
+ id: number;
+ type: "ball" | "puck" | string;
+ team: "Red" | "Blue" | "" | string;
+}
+
+export interface ReplayFrame {
+ t: number;
+ x: number[];
+ y: number[];
+ vx: number[];
+ vy: number[];
+}
+
+export interface ReplayEvent {
+ t: number;
+ type: "hit" | "goal" | "reset" | string;
+ kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
+ vol: number;
+ id: number;
+ team: "Red" | "Blue" | "" | string;
+ redScore: number;
+ blueScore: number;
+ kickoff: boolean;
+}
+
+export type ReplayPose = {
+ x: number;
+ y: number;
+};
+
+export type ReplayWorldBounds = {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+};
+
+/** Axis-aligned goal box in Unity world space (center + size). */
+export type ReplayGoalBox = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+/**
+ * Playable pitch corners (origin at center, +Y up):
+ * TL (-2.1, 4.3) TR (2.1, 4.3)
+ * BL (-2.1, -4.3) BR (2.1, -4.3)
+ */
+export const REPLAY_PITCH_BOUNDS: ReplayWorldBounds = {
+ minX: -2.1,
+ maxX: 2.1,
+ minY: -4.3,
+ maxY: 4.3,
+};
+
+export const REPLAY_TOP_GOAL: ReplayGoalBox = {
+ x: 0,
+ y: 4.87,
+ width: 1.92,
+ height: 0.61,
+};
+
+export const REPLAY_BOTTOM_GOAL: ReplayGoalBox = {
+ x: 0,
+ y: -4.9135,
+ width: 1.92,
+ height: 0.61,
+};
+
+function goalMinY(g: ReplayGoalBox): number {
+ return g.y - g.height / 2;
+}
+
+function goalMaxY(g: ReplayGoalBox): number {
+ return g.y + g.height / 2;
+}
+
+/** Full view extents: pitch plus goal boxes beyond the end lines. */
+export const REPLAY_FIELD_BOUNDS: ReplayWorldBounds = {
+ minX: REPLAY_PITCH_BOUNDS.minX,
+ maxX: REPLAY_PITCH_BOUNDS.maxX,
+ minY: goalMinY(REPLAY_BOTTOM_GOAL),
+ maxY: goalMaxY(REPLAY_TOP_GOAL),
+};
+