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

292 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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)
```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 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
```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 `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
```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