8.7 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
{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.jsonLogs/-1769539200_replay.json
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