Files
soccar2d/Assets/Scripts/Replay/REPLAY_FORMAT.md
T
2026-08-12 13:45:19 +05:30

8.8 KiB
Raw 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

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 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