Files
2026-07-29 12:47:29 +05:30

8.7 KiB
Raw Permalink Blame History

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

{
  "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 frames 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 frames 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 / 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

{ "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 playbackTime crosses that events 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 events 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

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 00).
  4. Reset the event-fire cursor to that time so SFX dont spam.

Scoreboard without scrubbing

Start at 00. On each goal event, set scores from that events 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 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