# 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