Score Ticker
A point-by-point tennis scoreboard replay — fed nothing but who won each rally, it derives games, deuce, tiebreaks and sets with real scoring rules, then plays the whole sequence back over a growing momentum strip with derived break, set and match points.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-score-ticker.jsonPrompt
Build a React + TypeScript + Tailwind "ChartScoreTicker" component — a
point-by-point tennis scoreboard replay — with zod for the contract. No chart
library: it is one HTML scoreboard table plus a hand-rolled svg strip.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
players: { a: { name, seed? }, b: { name, seed? } };
server: "a" | "b"; // who serves the FIRST point
config?: { bestOf?: 3 | 5; // default 3
tiebreakTo?: number; // default 7, win by 2 (10 = match TB)
finalSetTiebreak?: boolean }; // default true
points: { winner: "a" | "b" }[]; // in played order — and NOTHING else
context?: string }.
- The feed deliberately carries no scores, no game/set indices, no flags. The
DERIVATION is the product: the component replays the points through real
tennis rules — 0/15/30/40, deuce and advantage, game closure at four points
two clear, the tiebreak at 6-6 played to `tiebreakTo` win-by-2 with the
1-then-2 serve rotation, set closure at six games two clear or 7-6 via the
tiebreak, an advantage decider when finalSetTiebreak is false, match closure
at the bestOf majority. Service alternates by game and never resets between
sets; whoever opens a tiebreak receives first in the next set.
- Component props = z.infer of the schema plus title?, stripHeight? (clamped
64–160), beatMs? (ms per point at 1×, clamped 40–1000), onRetry?,
emptyState? and className. forwardRef, rest props spread on the root.
Behavior
- Four first-class branches in one bg-card panel: loading (skeleton keeping
the scoreline/board/strip/transport silhouette), empty (dashed strip and one
line explaining that only point winners are needed), error (message + Try
again only when onRetry exists), ready.
- ONE timeline index — points played, 0..n — is the sole source of truth.
The scoreboard, the strip, the scrubber and the readout all render from the
snapshot at that index; nothing else stores a position.
- No autoplay: the first ready paint is the FINAL score, parked at the end.
Pressing Play there rewinds to 0 and rolls the match forward; pressing it
mid-timeline continues from the scrub position; pressing again pauses.
- Playback is requestAnimationFrame with timestamp deltas against a beat of
beatMs ÷ speed (1× / 2× / 4× as a radiogroup) — never setInterval — and the
frame is cancelled on pause, on speed change and on unmount. A slow frame
advances multiple points rather than stretching time.
- The scrubber is a labelled native range input over the point index whose
aria-valuetext speaks the board ("Point 87 of 203 — 6-4 2-3, 30-40");
touching it pauses the transport.
- Derived chances, never fed: before every point the engine asks whether one
player is a single point from the game — a returner's game point is a BREAK
point, a game point that would take the set is a SET point, one that would
take the match is a MATCH point (at most one player can hold one, so a mark
has exactly one owner).
- Tail guard, both directions: points arriving after the match is decided are
ignored and reported out loud ("4 trailing points ignored — match decided");
a feed that stops early parks the board mid-match honestly and says so.
- Hover or focus a strip tick for its tooltip sentence — point number, who
served, who won, the exact score it left behind, and any chance riding on
it; the strip is a single-tab-stop listbox whose arrow keys walk the played
points (Home/End jump). The same sentence is the option's aria-label, its
svg <title> and the visible readout line.
- Throttled aria-live: one polite status region announces game and set
closures crossed during playback, pause (with the parked score) and the end
of the replay — never rally by rally.
- prefers-reduced-motion: stepping is inherently discrete and stays; the
decorative layer (the serving-dot pulse, hover transitions) is killed via
motion-reduce: classes AND a matchMedia subscription (useSyncExternalStore,
listener removed on unmount).
Rendering & styling
- Scoreboard: a real table — one column per completed/current set showing
games per player with tiebreak points as superscripts (7-6(5) prints as 7⁷
over 6⁵), a divided end column with the live game score (15/30/40, Ad at
advantage, raw points inside a tiebreak), a serving dot beside whoever
serves the next point, set winners bolded, sr-only caption stating the
grammar.
- Strip: fixed 1000-unit viewBox stretched with preserveAspectRatio="none",
axis-aligned rects only. One thin tick per played point — above the centre
line in var(--chart-1) for player a, below in var(--chart-2) for b, with a
~2px gap clamped so a 250-point feed keeps visible ticks. The centre line
spans the WHOLE feed so the unplayed remainder is visibly still to come.
Chance marks hang in a lane under the strip, coloured by their owner and
sized by stake: break point shortest, set point taller, match point tallest.
- Readout "point k / n" beside the scrubber; a legend keys both player colours
and the three mark heights.
- Semantic tokens only: bg-card, border, text-muted-foreground,
text-destructive, ring, accent-primary for the range input, plus
var(--chart-1)/var(--chart-2). No hex, no rgb(), no oklch().
- cn() merges className; decorative nodes are aria-hidden; every control has
focus-visible rings; a sr-only summary paragraph narrates the whole card.
Customization levers
- Format: config alone covers bo3/bo5, a 10-point match tiebreak and an
advantage decider — no code changes. For Fast4 or pro sets, the set-closure
predicate (six games, two clear) is the one function to swap.
- Pace: beatMs is the 1× beat; the SPEEDS tuple ([1, 2, 4]) is the radiogroup.
A broadcast-style slow replay is beatMs 400; a highlights skim is 40.
- Density: stripHeight (64–160) scales the strip; the lane heights record
what a break/set/match mark measures if you want a flatter lane.
- Blocks: the scoreboard table, the strip + readout, the transport row and
the legend are independent slices — a compact widget can keep only the
scoreboard and the transport.
- Palette: the two players read var(--chart-1)/var(--chart-2) from one
SLOT_COLOR map; re-key it to team tokens in one place.
- Voice: every spoken surface funnels through three sentence builders
(pointSentence, boardSentence, the announcer) — localise or re-tone the
card there without touching geometry.Concepts
- The derivation is the product — the feed knows nothing but who won each point, and every figure on the card (games, deuce, the tiebreak bracket, the set columns, the winner) is computed by the same rules engine. A feed that carried its own scores could contradict its own points; this one cannot.
- One index, three handles — points-played is the single source of truth, and the play button, the scrubber and the rAF loop are just three ways to move it. The scoreboard and the strip can never disagree about "when" because neither owns a clock.
- Parked at the end, never autoplaying — the first paint is the final score, which is what a scoreboard is for; replay is something the reader asks for, and Play from the end means "take me back to 0-0 and roll it".
- Chances are read off the standing score — a break point is the returner one point from the game, a set point is a game point that would take the set, a match point one that would take the match. At most one player can hold one at a time, so every mark under the strip has exactly one owner and one height.
- The tail is guarded in both directions — points after the match is decided are counted and reported, not drawn (a decided match cannot score again), and a feed that stops early parks mid-match with the serving dot still out, saying so instead of pretending a result.
- Announce closures, not rallies — the live region speaks when a game or set closes, when the transport pauses and when the replay ends. A 203-point match narrated point-by-point would be noise; the closures are the story.
Serve Sequence Replay
A replayable tennis serve chart — the receiver's half court in hand-rolled SVG, dots landing serve by serve under play/scrub/speed controls, with running in%, ace and fault counters recomputed at every position.
Points Race
A four-state pickleball points-race replay in hand-rolled SVG — two staircase step-lines climb one point per rally under derived side-out or rally scoring, the gap tinted with the leader's colour, side-out and game-point ticks on the axis, and rAF playback with play/pause, a rally scrubber and 1×/2×/4× speeds that parks on the final score.