Sports Scoreboard
A live sports scoreboard overlay that fetches current game scores from the ESPN API. Demonstrates server functions, external API calls, editor configuration, and the OBS layer surface.
Project structure
sports-scoreboard/
├── lumio.config.json
├── src/
│ ├── editor.tsx
│ ├── layer.tsx
│ └── server/
│ └── functions.ts
└── package.json
lumio.config.json
{
"extensionId": "ext_placeholder",
"name": "Sports Scoreboard",
"version": "1.0.0",
"targets": ["layer", "editor"],
"server": true,
"egress": {
"allowHosts": [
"site.api.espn.com"
]
},
"permissions": ["actions:invoke"]
}
src/server/functions.ts
The server function fetches live scoreboard data from ESPN's public API.
import { action, v } from "@zaflun/lumio-sdk/server";
interface ESPNCompetitor {
team: { displayName: string; abbreviation: string };
score: string;
}
interface ESPNEvent {
name: string;
status: { type: { description: string; completed: boolean } };
competitions: Array<{
competitors: ESPNCompetitor[];
}>;
}
export const getScoreboard = action({
args: {
sport: v.string(),
league: v.string(),
},
handler: async (ctx, args) => {
const res = await ctx.fetch(
`https://site.api.espn.com/apis/site/v2/sports/${encodeURIComponent(args.sport)}/${encodeURIComponent(args.league)}/scoreboard`
);
if (!res.ok) {
throw new Error(`ESPN API error: ${res.status}`);
}
const data = await res.json();
const events: ESPNEvent[] = data.events ?? [];
return events.slice(0, 6).map((event) => {
const comp = event.competitions[0];
const [away, home] = comp.competitors;
return {
name: event.name,
status: event.status.type.description,
completed: event.status.type.completed,
homeTeam: home.team.abbreviation,
awayTeam: away.team.abbreviation,
homeScore: home.score,
awayScore: away.score,
};
});
},
});
src/editor.tsx
The editor lets the streamer choose which sport and league to display.
import { Lumio, Box, Text, Select, useLumioConfig } from "@zaflun/lumio-sdk";
const SPORT_OPTIONS = [
{ value: "basketball/nba", label: "NBA Basketball" },
{ value: "football/nfl", label: "NFL Football" },
{ value: "baseball/mlb", label: "MLB Baseball" },
{ value: "hockey/nhl", label: "NHL Hockey" },
{ value: "soccer/usa.1", label: "MLS Soccer" },
];
function ScoreboardEditor() {
const { config, setConfig } = useLumioConfig<{ league: string }>();
const selectedLeague = config?.league ?? "basketball/nba";
return (
<Box style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
<Text content="Scoreboard Settings" variant="heading" />
<Box style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Text content="Sport / League" variant="label" />
<Select
value={selectedLeague}
options={SPORT_OPTIONS}
onChange={(value) => setConfig({ league: value })}
/>
</Box>
</Box>
);
}
Lumio.render(<ScoreboardEditor />, { target: "editor" });
src/layer.tsx
The layer displays scores, refreshing every 60 seconds.
import { Lumio, Box, Text, useLumioConfig, useLumioAction } from "@zaflun/lumio-sdk";
import { useState, useEffect, useCallback } from "react";
interface GameScore {
name: string;
status: string;
completed: boolean;
homeTeam: string;
awayTeam: string;
homeScore: string;
awayScore: string;
}
function Scoreboard() {
const { config } = useLumioConfig<{ league: string }>();
const { invoke } = useLumioAction("getScoreboard");
const [games, setGames] = useState<GameScore[]>([]);
const [loading, setLoading] = useState(true);
const league = config?.league ?? "basketball/nba";
const [sport, leagueName] = league.split("/");
const refresh = useCallback(async () => {
try {
const result = await invoke({ sport, league: leagueName });
setGames(result as GameScore[]);
} catch {
// Silently retain previous data on error
} finally {
setLoading(false);
}
}, [invoke, sport, leagueName]);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 60_000);
return () => clearInterval(interval);
}, [refresh]);
if (loading) return null;
if (games.length === 0) return null;
return (
<Box
style={{
position: "absolute",
top: 20,
right: 20,
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 200,
}}
>
{games.map((game, i) => (
<Box
key={i}
style={{
background: "rgba(0, 0, 0, 0.75)",
borderRadius: 6,
padding: "8px 12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<Text content={`${game.awayTeam} ${game.awayScore}`} variant="default" />
<Text content={game.status} variant="muted" style={{ fontSize: 10 }} />
<Text content={`${game.homeScore} ${game.homeTeam}`} variant="default" />
</Box>
))}
</Box>
);
}
Lumio.render(<Scoreboard />, { target: "layer" });
Step-by-step walkthrough
-
lumio.config.json— declares"server": trueto enable server functions, and listssite.api.espn.cominallowHostssoctx.fetch()can reach it. -
server/functions.ts— defines agetScoreboardaction that acceptssportandleaguestrings, fetches the ESPN scoreboard, and returns a simplified list of game objects. -
editor.tsx— renders aSelectdropdown in the editor panel. The selected value is saved viasetConfig()and becomes available in the layer viauseLumioConfig(). -
layer.tsx— callsuseLumioAction("getScoreboard")to get aninvokefunction, then calls it with the configured sport/league on mount and every 60 seconds. Results are stored in local React state and rendered as a list of score rows.
Key concepts demonstrated
useLumioAction— calling a server function from the layeruseLumioConfig— reading editor-set configuration in the layerctx.fetch()— calling an external API from a server functionegress.allowHosts— declaring which external hosts a server function may reach