Skip to main content

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.

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:
ServiceRoleDeployment
BackendCreates tapes from SportMonks data, runs the ReplayScore pollerApp Runner
mock-sportmonksStandalone Express service that stores tapes in S3 and serves them as a SportMonks-compatible APIApp Runner (separate service)
Pavilion (Admin iOS)UI for picking a fixture and triggering tape creationiOS 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.
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.

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.

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.

Two pollers, one codebase

Both the LiveScore and ReplayScore pollers are instances of the same ScorePoller class. The only differences are configuration:
ConfigLiveScoreReplayScore
nameLiveScoreReplayScore
fetchFnsportMonksClient.getLivescores()mockClient.getLivescores()
lockIdlive-score-pollerreplay-score-poller
resiliencetrue (cockatiel circuit breaker)false (bare fetch)
replayfalsetrue
intervalMs50005000
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

ScenarioLiveScore pollerReplayScore poller
SportMonks downCircuit opens, alerts fireUnaffected
mock-sportmonks downUnaffectedLogs error, no alerts
DynamoDB downBoth fail independentlyBoth 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:
matchId: this.config.replay ? String(-fixture.id) : String(fixture.id)
The createReplayFixture method in FixtureService also writes with the negative ID:
matchId: `-${fixtureId}`
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.

Where negative IDs appear

LocationHow 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 handlermatchId.startsWith('-') guard
matches.ts route handlerPasses 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).
ActionEffect
playStart or resume replay (accepts optional speed multiplier)
pausePause replay at current position
seekJump to a specific ball position
setSpeedChange playback speed (e.g., 2x, 5x)
resetReset 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.
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.

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.
EnvironmentLiveScore pollerReplayScore poller
ProductionEnabledEnabled (both run side by side)
SandboxDisabled (DISABLE_SPORTMONKS_POLLING=true)Enabled
Local devDisabledDisabled (no MOCK_SPORTMONKS_URL)

Gotchas

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

Key files

FilePurpose
backend/src/services/tapeService.tsFetches match from SportMonks, posts tape to mock-sportmonks, creates replay fixture
backend/src/clients/MockSportMonksClient.tsHTTP client for mock-sportmonks (same interface as SportMonksClient)
backend/src/jobs/replayScoreJob.tsConfigures and starts the ReplayScore poller
backend/src/jobs/ScorePoller.tsShared poller class used by both LiveScore and ReplayScore
backend/src/services/FixtureService.tscreateReplayFixture() — writes negative-ID fixture to DynamoDB
mock-sportmonks/src/services/replayManager.tsCore replay logic — clock-based cursor, S3 state management, post-match window
mock-sportmonks/src/services/fixtureBuilder.tsReconstructs SportMonks-shaped fixture from tape data at current cursor
mock-sportmonks/src/repositories/TapeRepository.tsS3 read/write for tape.json and state.json