> ## Documentation Index
> Fetch the complete documentation index at: https://285e39fd5e337e58f16290.sightscreen.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Replay system

> Test the full live scoring pipeline on demand using pre-recorded match tapes.

## What is the replay system

SportMonks has no sandbox mode. When no matches are live, `/livescores` returns `{"data": []}`. The replay system solves this by letting you exercise the full data flow — poll, parse, write, push — using pre-recorded match data whenever you need it.

A replay runs through the exact same pipeline as a real match. The backend polls for scores, writes to DynamoDB, runs the MatchEventBus detection pipeline, and sends APNs pushes. The only difference is the data source: a standalone mock service instead of the real SportMonks API.

## Architecture

Three services participate in a replay:

| Service                  | Role                                                                                              | Deployment                    |
| ------------------------ | ------------------------------------------------------------------------------------------------- | ----------------------------- |
| **Backend**              | Creates tapes from SportMonks data, runs the ReplayScore poller                                   | App Runner                    |
| **mock-sportmonks**      | Standalone Express service that stores tapes in S3 and serves them as a SportMonks-compatible API | App Runner (separate service) |
| **Pavilion** (Admin iOS) | UI for picking a fixture and triggering tape creation                                             | iOS app                       |

The backend talks to mock-sportmonks using the same `SportMonksClient` interface — a `MockSportMonksClient` that points at a different base URL. Zero code changes on the consumer side.

<Tip>
  The replay system is completely independent from live scoring. If mock-sportmonks goes down, real scores are unaffected. If SportMonks goes down, replays keep running.
</Tip>

## Tape lifecycle

A tape is a snapshot of a completed match — all balls, batting, bowling, and runs — stored in S3 and served back ball-by-ball on a timer.

```mermaid theme={null}
sequenceDiagram
    participant P as Pavilion (Admin iOS)
    participant BE as Backend
    participant SM as SportMonks API
    participant MS as mock-sportmonks
    participant S3 as S3

    P->>BE: POST /admin/tapes/:fixtureId
    BE->>SM: Fetch fixture with all includes<br/>(runs, batting, bowling, balls)
    SM-->>BE: Full match data
    BE->>BE: Transform to tape format
    BE->>MS: POST /tapes {fixtureId, msPerBall, fixtureData}
    MS->>S3: Write tapes/:fixtureId/tape.json
    MS->>S3: Write tapes/:fixtureId/state.json<br/>(cursor: 0, status: playing)
    MS-->>BE: 201 {created: true, tapeId}
    BE->>BE: Create replay fixture in DynamoDB<br/>(matchId: -fixtureId, status: NS, ttl: 12h)
    BE-->>P: 200 {tapeId, created: true}

    Note over MS: Replay auto-starts with a 15-minute delay<br/>(simulates "Not Started" → first ball)
```

### S3 tape structure

Each tape is two files:

```
s3://sightscreen-tapes/
  tapes/
    69475/
      tape.json      # Immutable — fixture metadata + all balls + batting + bowling
      state.json     # Mutable sidecar — cursor, startedAt, msPerBall, status
```

### Clock-based cursor

The replay does not use a timer that increments a cursor on each tick. Instead, it derives the current position from wall clock time:

```
rawCursor = floor((now - startedAt) / msPerBall)
```

This survives restarts. If mock-sportmonks goes down for 5 minutes and comes back, the cursor jumps to the correct ball position on the next request.

### Innings break simulation

When the raw cursor reaches the end of the first innings, `computeEffectiveCursor()` freezes the effective cursor at the last S1 ball. The freeze lasts for `ceil(20 minutes / msPerBall)` virtual ticks. During the break, `/livescores` returns `status: "Innings Break"`. After the break window passes, the effective cursor resumes from the first S2 ball.

### Post-match window

SportMonks keeps finished fixtures in `/livescores` for about 15 minutes after completion. The replay mirrors this — after all balls are played, the fixture stays visible in `/livescores` for 15 minutes, then is evicted.

## Replay data flow

Once a tape is active, the ReplayScore poller picks it up on its next tick.

```mermaid theme={null}
sequenceDiagram
    participant RP as ReplayScore Poller
    participant MS as mock-sportmonks
    participant S3 as S3
    participant DB as DynamoDB
    participant EB as MatchEventBus
    participant LAC as LACoordinator
    participant APNs as APNs
    participant iOS as iOS Device

    loop Every 5 seconds
        RP->>MS: GET /livescores?include=runs,batting,bowling,balls...
        MS->>S3: Read tape.json + state.json
        MS->>MS: Compute cursor from wall clock<br/>Build SportMonks-shaped fixture
        MS-->>RP: {data: [fixture with negative matchId]}
        RP->>DB: Write fixture (matchId: -69475, ttl: 12h)
        RP->>EB: Feed to MatchEventBus
        EB->>EB: Detection pipeline<br/>(Tier0 → Tier1 → Cooldown → Staleness)
        EB->>LAC: Emit MatchBusEvent
        LAC->>APNs: Send push batch
        APNs->>iOS: Lock Screen + Dynamic Island update
    end
```

### Two pollers, one codebase

Both the LiveScore and ReplayScore pollers are instances of the same `ScorePoller` class. The only differences are configuration:

| Config       | LiveScore                          | ReplayScore                  |
| ------------ | ---------------------------------- | ---------------------------- |
| `name`       | `LiveScore`                        | `ReplayScore`                |
| `fetchFn`    | `sportMonksClient.getLivescores()` | `mockClient.getLivescores()` |
| `lockId`     | `live-score-poller`                | `replay-score-poller`        |
| `resilience` | `true` (cockatiel circuit breaker) | `false` (bare fetch)         |
| `replay`     | `false`                            | `true`                       |
| `intervalMs` | `5000`                             | `5000`                       |

The ReplayScore poller disables cockatiel resilience policies (no timeout, circuit breaker, or retries). mock-sportmonks is test infrastructure — a circuit breaker opening on it would confuse alerting.

### Complete isolation

| Scenario             | LiveScore poller           | ReplayScore poller      |
| -------------------- | -------------------------- | ----------------------- |
| SportMonks down      | Circuit opens, alerts fire | Unaffected              |
| mock-sportmonks down | Unaffected                 | Logs error, no alerts   |
| DynamoDB down        | Both fail independently    | Both fail independently |

Each poller has its own distributed lock, error counter, tick count, and log prefix.

## Negative matchId convention

Replay matches use negative IDs (e.g., `-69475`). This is the core mechanism for distinguishing replay from real data.

When `ScorePoller` runs with `replay: true`, it negates the fixture ID before writing:

```typescript theme={null}
matchId: this.config.replay ? String(-fixture.id) : String(fixture.id)
```

The `createReplayFixture` method in `FixtureService` also writes with the negative ID:

```typescript theme={null}
matchId: `-${fixtureId}`
```

<Warning>
  There is no centralized `isReplayMatch()` validator. Each file uses inline checks:

  * `matchId.startsWith('-')` in route handlers
  * `NOT begins_with(matchId, :dash)` in DynamoDB FilterExpressions
  * `String(-fixture.id)` in the ScorePoller

  This works but is fragile. A centralized helper would reduce the risk of inconsistency.
</Warning>

### Where negative IDs appear

| Location                               | How it's used                                 |
| -------------------------------------- | --------------------------------------------- |
| `FixtureService.createReplayFixture()` | Writes `matchId: -fixtureId` to DynamoDB      |
| `ScorePoller.tick()`                   | Negates fixture ID when `replay: true`        |
| `FixtureRepository.scanLiveIndex()`    | `NOT begins_with(matchId, :dash)` filter      |
| `user.ts` route handler                | `matchId.startsWith('-')` guard               |
| `matches.ts` route handler             | Passes `includeReplays: true` for admin users |

## Admin controls

The admin sends `PATCH /admin/matches/:matchId` to control a replay. Internally, this updates mock-sportmonks state (speed changes rebase `startedAt` to preserve the current cursor position at the new speed).

| Action     | Effect                                                       |
| ---------- | ------------------------------------------------------------ |
| `play`     | Start or resume replay (accepts optional `speed` multiplier) |
| `pause`    | Pause replay at current position                             |
| `seek`     | Jump to a specific ball position                             |
| `setSpeed` | Change playback speed (e.g., 2x, 5x)                         |
| `reset`    | Reset replay to the beginning                                |

With the default `msPerBall: 3000`, a full T20 (\~480 balls across both innings) replays in roughly 24 minutes plus a simulated 20-minute innings break.

<Note>
  Speed changes use a rebase approach: the system computes the current raw cursor at the old speed, then sets a new `startedAt` such that the same cursor position is maintained at the new speed. No ball is skipped or repeated.
</Note>

## Visibility

Replay matches are invisible to regular users by default.

**DynamoDB filtering:** The `GET /matches` query applies a FilterExpression `NOT begins_with(matchId, :dash)` to exclude negative-ID fixtures. No GSI required.

**Admin visibility:** The `matches.ts` route passes `includeReplays: true` when the request comes from an admin user (checked via Cognito group membership). Admin users see replay fixtures alongside real matches in the iOS app with a "Match Replay" label.

**Replay fixture TTL:** Every replay fixture is written with a `ttl` field set to `now + 12 hours`. While the replay is active, the poller extends the TTL on each tick. After the replay stops, the TTL expires and DynamoDB auto-deletes the item. No cleanup job needed.

## Startup gating

The ReplayScore poller only starts when `MOCK_SPORTMONKS_URL` is set. If the env var is absent, no `MockSportMonksClient` is created and the poller never starts.

| Environment    | LiveScore poller                             | ReplayScore poller                  |
| -------------- | -------------------------------------------- | ----------------------------------- |
| **Production** | Enabled                                      | Enabled (both run side by side)     |
| **Sandbox**    | Disabled (`DISABLE_SPORTMONKS_POLLING=true`) | Enabled                             |
| **Local dev**  | Disabled                                     | Disabled (no `MOCK_SPORTMONKS_URL`) |

## Gotchas

<AccordionGroup>
  <Accordion title="15-minute start delay">
    When a tape is created, both the backend fixture and the mock-sportmonks state use a `startedAt` 15 minutes in the future. This simulates the "Not Started" window that real matches have. The backend creates the fixture with `status: NS` and `startTime` 15 minutes out. mock-sportmonks computes `rawCursor = floor((now - startedAt) / msPerBall)` — so no balls are served until `startedAt` arrives.
  </Accordion>

  <Accordion title="Replay fixtures use current date, not original match date">
    The `createReplayFixture` method sets `startTime` and `date` to the current timestamp (plus 15 minutes), not the original match date from SportMonks. This is intentional — it lets the fixture appear in "today's matches" queries — but means the date shown in the app will not match the original match.
  </Accordion>

  <Accordion title="mock-sportmonks does respect include filtering">
    Unlike the old in-process replay system, mock-sportmonks correctly handles the `include` query parameter. It parses SportMonks-style nested includes (e.g., `balls.score,balls.bowler`) and only returns requested sub-resources. A bare `/livescores` call returns the fixture shell without runs, batting, bowling, or balls.
  </Accordion>

  <Accordion title="Batting and bowling are filtered to balls played">
    The fixture builder filters batting and bowling arrays to only include players who have appeared in balls up to the current cursor. A batsman who enters at ball 100 will not appear when the replay is at ball 45. This prevents leaking future player data.
  </Accordion>

  <Accordion title="mock-sportmonks resumes after restarts">
    On startup, mock-sportmonks reads all `state.json` files from S3 with `status: playing` and resumes them. The clock-based cursor catches up to where the match should be. If the match finished during downtime, it is marked as finished immediately.
  </Accordion>

  <Accordion title="Idempotent tape creation">
    Calling `POST /admin/tapes/:fixtureId` twice does not create a duplicate. The first call stores the tape and starts the replay. Subsequent calls update `msPerBall` (rebasing `startedAt` to preserve cursor position) and return `{created: false}`.
  </Accordion>
</AccordionGroup>

## Key files

| File                                                 | Purpose                                                                              |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `backend/src/services/tapeService.ts`                | Fetches match from SportMonks, posts tape to mock-sportmonks, creates replay fixture |
| `backend/src/clients/MockSportMonksClient.ts`        | HTTP client for mock-sportmonks (same interface as SportMonksClient)                 |
| `backend/src/jobs/replayScoreJob.ts`                 | Configures and starts the ReplayScore poller                                         |
| `backend/src/jobs/ScorePoller.ts`                    | Shared poller class used by both LiveScore and ReplayScore                           |
| `backend/src/services/FixtureService.ts`             | `createReplayFixture()` — writes negative-ID fixture to DynamoDB                     |
| `mock-sportmonks/src/services/replayManager.ts`      | Core replay logic — clock-based cursor, S3 state management, post-match window       |
| `mock-sportmonks/src/services/fixtureBuilder.ts`     | Reconstructs SportMonks-shaped fixture from tape data at current cursor              |
| `mock-sportmonks/src/repositories/TapeRepository.ts` | S3 read/write for tape.json and state.json                                           |
