Skip to main content

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

  1. lumio.config.json — declares "server": true to enable server functions, and lists site.api.espn.com in allowHosts so ctx.fetch() can reach it.

  2. server/functions.ts — defines a getScoreboard action that accepts sport and league strings, fetches the ESPN scoreboard, and returns a simplified list of game objects.

  3. editor.tsx — renders a Select dropdown in the editor panel. The selected value is saved via setConfig() and becomes available in the layer via useLumioConfig().

  4. layer.tsx — calls useLumioAction("getScoreboard") to get an invoke function, 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 layer
  • useLumioConfig — reading editor-set configuration in the layer
  • ctx.fetch() — calling an external API from a server function
  • egress.allowHosts — declaring which external hosts a server function may reach