Three data sources, one live score: building a CS2 kiosk

The maritime kiosk on my desk tracks ships, satellites and RF spectrum around the city coast. It turns out a touchscreen that's on 24/7 has a spare seat, and I follow the CS2 pro scene — so this week it got a second dashboard: cs2-dashboard, plus a small chooser screen (kiosk-home) so the kiosk boots into neither and both. The interesting part wasn't the UI. It was discovering just how deep you have to dig to answer one question: what's the score, right now?

The easy layer

Liquipedia is how this should work everywhere: one documented API call returns every live, upcoming and finished match — teams, logos, tournaments, streams — and their terms welcome polite automation. A small DOM-tree parser, a two-minute cache that serves stale data while refreshing in the background, and the schedule half of the dashboard was done in an afternoon.

Then the catch, found during a live map: Liquipedia publishes the series score between maps. Mid-map, the honest answer is nothing at all. For a viewer that's useless — the thing you actually want mid-round is "7–4, round 12, Mirage".

Finding a source that actually knows

I tested the candidates from this Pi rather than trusting anyone's docs. HLTV and Sofascore: 403, bot protection, no conversation. PandaScore has a free tier but keeps the interesting granularity behind the paywall. Then a lucky break: bo3.gg's page shell loaded with plain curl — a JavaScript app, which meant the real data lived in an API their own frontend calls.

So I used the stealth browser (camoufox, already on this Pi) as a discovery tool rather than a runtime: loaded their site, recorded every XHR it made, and out fell a clean JSON API — live matches, map statuses, per-match streams with viewer counts, team world ranks — plus a websocket that pushes "something changed" events. The websocket even accepts a plain Python client once you send the right Origin header. The runtime needs no browser, no key, no scraping.

Two design rules fell out of that websocket. One: events are used only as a trigger to re-poll REST, never parsed — their payload schema can change and nothing breaks. Two: a failed fetch must never clobber good cached data. That second rule was learned the hard way; more below.

The scorebot

bo3.gg knows which map is live and updates the series score promptly, but the per-round score for the events I watch comes from exactly one place: HLTV's embedded scorebot. Behind bot protection. So the camoufox runtime made it in after all — as a separate feeder service that renders the match page, extracts the veto, per-map results with half scores, and the live round score, and hands them over as a JSON file. If the browser hangs or dies, the dashboard loses a feature, never availability.

The feeder's favourite bug: HLTV's scorebot renders asynchronously. On a cold page load my fixed 4-second wait happened to win the race; on warm, cached repeat loads it lost every time, and the score quietly went null mid-match. The fix is to wait for the element instead of guessing a sleep. The bigger win came after: why re-render the page at all? The page keeps HLTV's own scorebot websocket alive — so the feeder now leaves it open and just reads the numbers out of the live DOM every eight seconds. Round scores land seconds after they happen, and the Pi barely notices.

The stream that kept dying

Embedding the broadcast under the score was meant to be the easy part. It became a trilogy. First, Twitch's player hit a login wall whose buttons navigate the host page — on a kiosk with no address bar, that's a fullscreen trap. The iframe is sandboxed now; the embed can never take over the screen again. Second, the stream died every thirty seconds, which turned out to be my own UI: every poll rebuilt the featured card, destroying the iframe along with it. The card now only rebuilds on structural change — round scores and viewer counts are patched into the DOM in place. Third, the rebuilds were being triggered by data flapping: viewer counts reordering the stream list, and a transient API failure wiping cached teams and streams. Both fixed; the signature is order-insensitive and the cache keeps last-good data for half an hour.

What the screen shows now

The live tab leads with the stream, because that's why you'd walk over to it: the game video large on the left, map pills and the veto line beneath, and beside it the series score, the running round (team-ordered — the scorebot reports by CT/T side, and sides swap at halftime), world ranks, and what's on next. All three sources degrade independently; the dashboard shows whatever the survivors know.

Everything's on GitHub with the same CI/CD as the rest of the Pi: cs2-dashboard and kiosk-home. The whole stack got its first full live test during Esports World Cup playoffs — Vitality against Spirit, kickoff 18:50. If the round score on the kiosk keeps up with the crowd noise, it worked.

Back to the devlog