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.

2026-11-07

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.
  • locale and competitionEngine (apps/web/src/lib/competition-engine.ts, which also calls configureCompetitionEngine for 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.yaml is the engine's api/openapi.yaml at a pinned commit, recorded in spec/SOURCE. pnpm --filter @v4/competition-engine sync-spec refreshes it; check-spec reports drift against the engine's main.
  • generate runs openapi-typescript into src/__generated__/api.d.ts; the package's verify script 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/views maps raw responses onto camelCase view types (TeamDetailView, GameDetailView, StandingsView, ScoreboardView, …). It carries no server-only, so client islands can use the same view functions on a live answer.
  • @v4/competition-engine/merge puts 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/basketball fragments.
  • @v4/competition-engine/mocks builds 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=false every 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 as played once 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's PageGameCentre template — 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/live as 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 ce tag. It waits at most 1.5 seconds for the engine; past that, or with no competition readable, the strip authored in Site Settings stands.