8.8 KiB
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
{
"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
{ "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)
{
"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 / fixedDeltaTimeHz). Do not treat them as render frames.
Coordinate system
Unity 2D world space:
- Origin at field center
+Xright,+Yup (as in Unity)- Scale matches the in-game pitch (see game
fieldSize/ art for admin canvas mapping)
Events
{ "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
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:
frames[i].t <= playbackTime < frames[i+1].t
Then for each entity index e:
Minimum (good): linear lerp of x/y:
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:
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
playbackTimecrosses that event’st, do not interpolate from the previous sample across the cut. - Seek to the frame at or immediately after
tand 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 bykind/volgoal→ score UI + goal audioreset→ 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
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
- Set
playbackTimeto the scrub value. - Find the nearest frame (or interpolate as above).
- Recompute score from the last
goalevent witht <= playbackTime(else0–0). - 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
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
ialways matchesentities[i] - Hit SFX use
kind+vol - Goal
teammeans conceding side - Practice files filtered via
isPracticeor negativematchId
Out of scope (not in the JSON)
- Player names / user ids (use matchmaker / match API by
matchIdfor ranked games) - Camera / UI layout
- Launch aim lines / turn timer
- Network latency or client prediction