Basketball and the Competition Engine
How the basketball domain is built over the Competition Engine: the data areas, caching, live data, the Game Centre and widgets.
The basketball domain
basketball is Euro League's own domain, not one the template ships. It covers
the competition data areas of the site map — teams, players, coaches, games
(with the Game Centre), standings and stats — for each competition ELB runs
(EuroLeague, EuroCup, NextGen). It was added to packages/model/domains.json as
this project's own bolt-on (19 October 2026) so pnpm compose would mount its
routes; it declares no content types of its own and requires no other domain.
Three packages
| Package | Role |
|---|---|
@v4/ghost-basketball |
The UI: 139 components built from ELB – Web – Basketball (8Jl1miPEIZslMda8jZ8w0C), page templates included. |
@v4/competition-engine |
The data: the only importer of the Competition Engine's contract (see the next section). |
@v4/basketball-app |
The routes: per-route factories over the two, plus the API route factories and the widgets other pages borrow. |
The Contentful side is small and editorial: the site's sports types (competition,
season, team, player, venue, game sides, season sponsors) are created by
migrations/club/, read through the eight fragments in
packages/model/src/basketball/fragments/, and merged over the engine's answers.
The routes
packages/basketball-app/routes.json registers twelve routes, an area and its
catch-all for each of teams, players, coaches, games, standings and
stats. Under the locale segment they give:
| Address | What it is |
|---|---|
/en/teams |
Redirects to the default competition's list, /en/teams/euroleague |
/en/teams/eurocup |
A competition's list |
/en/games/eurocup/season/2025-26/round/12 |
A list with its filter pairs (games by season and round, standings by season) |
/en/teams/<team>, /en/players/<player> |
One team's or player's page — one address for every competition |
/en/teams/<team>/roster |
A page declared under a team's |
/en/games/<game> |
The Game Centre — pre-match, live and final at one address |
A list takes only the filters its area declares, and an entity's page nothing after its slug but the pages declared under it; anything else is not found. The address rules are in Competition sections.
Every basketball route takes the same dependencies:
deliveryClient— the Contentful delivery-only client. Nothing in the areas is previewed by an editor, and a draft-mode check would make every page request-time, so none could be cached.localeandcompetitionEngine(apps/web/src/lib/competition-engine.ts, which also callsconfigureCompetitionEnginefor its side effect).media, for the four areas that show video (teams, players, coaches, games).
Each route declares a loading skeleton from @v4/basketball-app/loading
(TeamsLoading, TeamPathLoading, …), used as the shim's Suspense fallback and
the segment's loading.tsx, and each catch-all prerenders its pages at build
("staticParams": true).
The data-area layouts (app/[locale]/{teams,players,coaches,games,standings,stats}/layout.tsx)
load the basketball and video theme parts — see
Theme emission.
The switch
BASKETBALL_ROUTES=false (server environment) turns the whole domain dark without
changing code or generated files: the middleware answers the data-area pages with
the site's not-found page and their API routes (/api/players/, /api/stats/,
/api/games/, /api/live/) with 404, and the sitemap, the header's club-crest
strip and the article and home-hero widgets leave them out. Unset or true, the
routes are on. The header and sitemap are cached at build, so set it in the build
environment as well as the runtime one (apps/web/src/lib/basketball-routes.ts).
When the engine is down
A page never waits indefinitely for the engine: every read gives up after 10
seconds (ENGINE_TIMEOUT_MS) and becomes an EngineError, which the area's
failure guard (guardEngine, packages/basketball-app/src/lib/engine-failure.tsx)
turns into that area's unavailable state rather than the site's error page.
The Competition Engine integration
The Competition Engine (stadionHQ/stadion-euroleague) is ELB's competition data
service — competitions, seasons, clubs, people, games, standings, statistics and
the live game feed. @v4/competition-engine is the only package that imports
its contract; everything else reads its normalised views. Package reference:
@v4/competition-engine.
The contract is vendored and typed
spec/openapi.yamlis the engine'sapi/openapi.yamlat a pinned commit, recorded inspec/SOURCE.pnpm --filter @v4/competition-engine sync-specrefreshes it;check-specreports drift against the engine'smain.generaterunsopenapi-typescriptintosrc/__generated__/api.d.ts; the package'sverifyscript regenerates and fails on a diff.- Calls go through
openapi-fetch, so a path, parameter or response field the contract does not have is a type error.
Configuration
apps/web/club.config.ts → competitionEngine holds the engine's baseUrl (a
server env marker, COMPETITION_ENGINE_BASE_URL, defaulting to
http://localhost:8080 for a locally running engine) and competitions, each
section slug mapped to its engine code: euroleague: "E", eurocup: "U",
nextgen: "J". apps/web/src/lib/competition-engine.ts builds the settings,
calls configureCompetitionEngine once as a side effect, and is what every
basketball shim imports as competitionEngineSettings. deriveImageHosts
admits the engine's crest and headshot CDNs only when the block is present.
Reads are cached by class, through one use cache boundary
The server fetchers (getTeams, getGames, getGame, getStandings, …,
wrapped in React.cache so a render asks once) pass openapi-fetch a custom
fetch. That seam sends the request to cachedEngineGet
(src/cached-fetch.ts), a use cache boundary keyed on the URL, tagged and
lived by the read's class (src/tags.ts):
| Class | What | revalidate |
|---|---|---|
structural |
Seasons, filters, team lists and details, rosters, profiles, coaches | 300 s |
computed |
Standings, leaders, stats tables, records, game logs, head-to-head | 300 s |
games |
The schedule | 60 s |
gameDetail |
One game until it is played | 60 s |
played |
One game once final | 300 s |
Each class's CE_LIFE profile also holds a stale answer for an hour
(expire: 3600) while a fresh one is fetched. Every read is tagged ce plus its
entity (ce:competition:<code>, ce:season:<code>, ce:team:<club>,
ce:player:<person>, ce:coach:<person>, ce:game:<season>:<code> — CE_TAG).
POST /api/revalidate already accepts the engine ingester's post-run call
({ "source": "competition-engine", … }, mapped to ce:* tags by
tagsForEngineRun in apps/web/src/lib/revalidation.ts), expiring the engine
reads and purging the data areas at the edge; until the ingester makes that call,
every answer expires by its TTL, and once it does the TTLs become a ceiling.
A use cache boundary rather than Next's fetch hint is deliberate: under Cache
Components an uncached fetch makes the render request-time, and every
basketball page was rendered per request until the boundary replaced it
(26 October 2026). The cached value is the response's record (status, type,
body) — a Response cannot be serialised — and a failure is recorded too, living
half a minute so an outage shows the unavailable state briefly rather than for
five minutes.
Live endpoints are never cached on the server
Box score, team stats, plays, shots, score flow and the day's scoreboard read
with noStore while a game can still move (GameDataLife = "live"); once a game
is final the same reads are cached under played. The browser polls the moving
parts — see Live data.
Views and the editorial merge
@v4/competition-engine/viewsmaps raw responses onto camelCase view types (TeamDetailView,GameDetailView,StandingsView,ScoreboardView, …). It carries noserver-only, so client islands can use the same view functions on a live answer.@v4/competition-engine/mergeputs Contentful's editorial layer over the engine's view, by the rule editorial wins when set: a non-empty editorial field replaces the engine's value, an empty one leaves it. A missing entry is normal (a new club, a sync not yet run) and answers with the engine's view. The editorial fields come from the@v4/model/basketballfragments.@v4/competition-engine/mocksbuilds typed views from fixtures recorded against a running engine (pnpm --filter @v4/competition-engine record-fixtures), for stories, tests and the page adapters' fallbacks — real responses, not hand-idealised shapes.
Live data
Live game data is polled over HTTP, not pushed: there is no realtime
provider on this site (features.liveMatch is a schema slot nothing reads).
The design keeps every page cacheable and identical for every visitor — the
server renders the page once from cached reads — while a small client island asks
for the part that moves and patches it in.
The routes the browser polls
All three are hand-written route handlers in apps/web/src/app/api/; the factory
for each of the last two lives in @v4/basketball-app. They read their request,
so they run per request and are never served from the server cache; /api/* is
also outside the edge's cache rule.
| Route | Answers | Polled by |
|---|---|---|
/api/live/games |
A day's games with their clocks (?competition=E&date=…), forwarded to the engine's /games |
The live overlay: the schedule, a team's and a player's page, the home hero |
/api/games/live |
One game's moving parts (?season=E2025&game=4) — createGameLiveRoute |
The Game Centre and the live home hero while a game can be live |
/api/games/round |
One round of a season's schedule — createGamesRoundRoute |
The schedule's round selector, swapping a round in place |
/api/live/games forwards the visitor's If-None-Match to the engine and passes
the engine's own freshness headers straight back (Cache-Control: public, max-age=30, and its ETag when it sends one), so a 304 costs nothing and the
browser asks no more often than the engine allows. The engine origin stays
private and needs no CORS.
The live overlay
The schedule and a team's Games tab render every game card on the server,
scheduled or final. useLiveScores (packages/basketball-app/src/lib/use-live-scores.ts)
then polls /api/live/games only while a game on the page could be live (one
not yet final, tipping off within a day either way), at the interval the engine
names (next_poll_seconds, never under five seconds), honouring the ETag, and
stops while the tab is hidden. Each answer becomes a patch — Live, the score and
the clock, then Final — applied to the matching cards. A failed poll waits and
asks again; it never throws.
Rules that keep it honest
- Nothing waits for motion. A live score is in the DOM as soon as it arrives;
a count-up or cross-fade only decorates the arrival
(
.claude/rules/frontend/motion.md, rule 5; tables never count, rule 8). - The switch covers the API too. With
BASKETBALL_ROUTES=falseevery one of these routes answers 404 from the middleware.
The Game Centre and the widgets
The Game Centre
A game's page is the Game Centre, at /en/games/<game> — one address that is
pre-match, live and final in turn (PH_PAGE_GAME_CENTRE_STATES: pre, live,
final). It is built in three layers:
- The server (
packages/basketball-app/src/games/game-centre.tsx) resolves the address to a competition, season and game code, then reads the game and, once it has tipped off, its moving parts (box score, team stats, plays, the score by minute) — never cached while the game can move, cached asplayedonce it is final — plus the context around it: the two clubs' meetings, their seasons so far, their squads, the game news and video carousels. - The mapping (
game-centre-modules.tsx) turns those engine views into the props of@v4/ghost-basketball'sPageGameCentretemplate — the hero's score card and each tab's modules. It is pure and directive-free, so the server and the client run the same mapping. A module whose source is empty is left out, never filled with a placeholder; what has no source at all (lineups, the hero's own media, the partner card) is not passed. - The shell (
game-centre-shell.tsx, a client component) shows the chosen tab. Tabs are client state reached by the page's fragment (#box-score), so a link to a tab lands on it and the address stays the game's. While the game can be live it polls/api/games/liveas often as the engine says, never while the tab is hidden, and redraws through the same mapping.
The Game Centre links out to EuroLeague TV rather than playing the stream:
its call to action reads "Watch on ELTV", "Watch live on ELTV" or "Replay on
ELTV" by state, and points at NEXT_PUBLIC_ELTV_WATCH_URL (default
https://euroleague.tv). The in-page player (@v4/eltv/player) is built but
not mounted — see The ELTV livestream.
Widgets other pages borrow
Basketball data appears outside the data areas in three places. In each case the
page's own package cannot import @v4/basketball-app (which depends on it), so
@v4/content-app exposes a registration point and the app registers the
basketball implementation once, in a side-effect module the locale layout
imports — the configureMedia pattern. Nothing registered (BASKETBALL_ROUTES=false), and the page draws no widgets.
| Surface | Registration point (content-app) | Implementation (basketball-app) | App module |
|---|---|---|---|
| An article's header | configureArticleHeaderWidgets (./article-header-widgets) |
createArticleHeaderWidgets (./article-widgets) |
apps/web/src/lib/article-header-widgets.ts |
| The home page's hero | configureHomeHeroWidgets (./home-hero-widgets) |
createHomeHeroWidgets (./home-hero) |
apps/web/src/lib/home-hero-widgets.ts |
| The site header's crest strip | the chrome's teamStrips |
teamStripByCompetition (./team-strip) |
apps/web/src/lib/team-strip.ts |
- Article header widgets follow ELB's "Header widgets ·
EuroLeague" rules as written, encoded in
packages/basketball-app/src/lib/article-widgets.ts: the first widget is the article's game right now, chosen by the most specific association the article carries — Match, then Round, then Teams, then Players, then Competition — and the second adds who is in the article or more on the game. Never more than two; with nothing to show, the media spans the header. An article's route loads the basketball theme part for them (app/[locale]/news/[...path]/layout.tsx). - Home hero widgets fill the hero's three areas with the standings, the round's MVP and the league leaders or the day's games; while a game is live the hero becomes that game, polled like the Game Centre.
- The crest strip lists every current-season club of the section's
competition from the engine, cached under the chrome's life and the
cetag. It waits at most 1.5 seconds for the engine; past that, or with no competition readable, the strip authored in Site Settings stands.