292 lines
8.8 KiB
Markdown
292 lines
8.8 KiB
Markdown
# 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
|
||
|
||
Written next to the match log with the **same basename**, only the extension differs:
|
||
|
||
```
|
||
{ApplicationDirectory}/Logs/{matchId}.txt ← match log
|
||
{ApplicationDirectory}/Logs/{matchId}.json ← replay
|
||
```
|
||
|
||
| Match type | `matchId` | Example pair |
|
||
|------------|-----------|--------------|
|
||
| Ranked / dedicated | Positive matchmaker id | `Logs/228.txt` + `Logs/228.json` |
|
||
| Practice / tutorial | Negative unix timestamp | `Logs/-1769539200.txt` + `Logs/-1769539200.json` |
|
||
|
||
Admin panel: given a log at `Logs/{id}.txt`, load `Logs/{id}.json` beside it.
|
||
|
||
## 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
|