Understand Leaderboards
This guide explains how to use the Leaderboard adapter to add a ranked leaderboard to an Epicenter application.
The leaderboard stores the scores from all the runs in an episode. The application retrieves scores from a leaderboard to display them to all competing participants in the same episode.
To learn how to set up the project in Epicenter, read Create the Project.
Implementing a leaderboard
All code samples below are taken directly from the reference application code.
Set up the leaderboard
When creating a run, configure the leaderboard with two optional parameters of the runAdapter.create() function:
- With
executionContext.presets.leaderboardScope, set the leaderboard scope to the current episode. - With
modelContext.externalFunctions.leaderboard, instruct the model to call an Epicenter server-side function to add a new entry to the leaderboard.
In the template application, runs are created in the byUserPerEpisode query, which reuses an existing run if one exists and creates one otherwise.
const byUserPerEpisode = ({
session,
episodeKey,
}: {
session: UserSession;
episodeKey: string;
}) =>
queryOptions({
queryKey: ['run', 'per-user', episodeKey, session.userKey],
queryFn: async () => {
const scope = {
scopeBoundary: SCOPE_BOUNDARY.EPISODE,
scopeKey: episodeKey,
userKey: session.userKey,
};
const [run] = await runAdapter
.query(MODEL, {
scope,
filter: ['run.hidden=false'],
sort: ['-run.created'],
max: 1,
})
.then((response) => response.values as Array<RunReadOutView>);
if (run) return run;
return runAdapter
.create(MODEL, scope, {
executionContext: {
version: 'v1',
presets: {
leaderboardScope: {
scopeBoundary: SCOPE_BOUNDARY.EPISODE,
scopeKey: episodeKey,
},
},
},
modelContext: {
version: 'v2',
externalFunctions: {
leaderboard: {
leaderboard: {},
},
},
},
})
.then((run) => run as RunReadOutView);
},
staleTime: Infinity,
});
Subscribe to leaderboard updates
To keep every open session in sync, subscribe to the episode's LEADERBOARD push channel and invalidate the cached query when a push arrives, so TanStack Query refetches automatically.
The reference application wraps the Channel class from epicenter-libs in two hooks:
type ChannelScope = GenericScope & {
pushCategory: string;
};
export const useChannel = ({
scopeBoundary,
scopeKey,
pushCategory,
}: {
scopeBoundary: ChannelScope['scopeBoundary'];
scopeKey: ChannelScope['scopeKey'];
pushCategory: ChannelScope['pushCategory'];
}) =>
useMemo(
() =>
[scopeBoundary, scopeKey, pushCategory].every(Boolean)
? new Channel({ scopeBoundary, scopeKey: scopeKey!, pushCategory })
: undefined,
[scopeBoundary, scopeKey, pushCategory]
);
// When `channel`, subscription `callback`, or `token` changes,
// unsubscribe (cleanup) and resubscribe (body). Queue ensures most
// "up-to-date" subscribe comes last.
export const useChannelEffect = <M>({
token,
channel,
callback,
}: {
token: string;
channel: Channel | undefined;
callback: (data: M) => void;
}) => {
const queue = useRef(Promise.resolve<any>(undefined));
useEffect(() => {
if (!channel) return;
const subscribe = async () => {
try {
await channel.subscribe(callback);
} catch (error) {
console.error(`Channel subscribe failed for ${channel.path}`, error);
}
};
const unsubscribe = async () => {
try {
await channel.unsubscribe();
} catch (error) {
console.error(`Channel unsubscribe failed for ${channel.path}`, error);
}
};
queue.current = queue.current.catch(() => undefined).then(subscribe);
return () => {
queue.current = queue.current.catch(() => undefined).then(unsubscribe);
};
}, [channel, callback, token]);
};
The player component then wires the channel to cache invalidation:
const invalidateLeaderboard = useCallback(
() =>
queryClient.invalidateQueries({
queryKey: ['leaderboard', 'episode', session.groupKey, episode.episodeKey, LEADERBOARD_COLLECTION],
}),
[queryClient, session.groupKey, episode.episodeKey]
);
const leaderboardChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.EPISODE,
scopeKey: episode.episodeKey,
pushCategory: PUSH_CATEGORY.LEADERBOARD,
});
const onLeaderboardPush = useCallback(
(message: EpisodeLeaderboardPush) => {
if (message.address.key !== episode.episodeKey) return;
if (message.type !== 'UPDATED') return;
void invalidateLeaderboard();
},
[episode.episodeKey, invalidateLeaderboard]
);
useChannelEffect({
token: session.token,
channel: leaderboardChannel,
callback: onLeaderboardPush,
});
The EpisodeLeaderboardPush type (see Type reference) describes the wire format. The type field is "UPDATED" for new or changed entries. Always guard on message.address.key to avoid acting on pushes from a different episode.
Record a new score
When a new score is achieved, the model makes the Epicenter.callback("leaderboard", ...) call. Epicenter executes an equivalent of the leaderboardAdapter.update() function that updates an existing leaderboard or creates a new one.
Key points to note:
- The
scopevalue comes from an execution preset injected when the run is created (see Step 1). - Setting
allowChannel: Trueenables push notifications for the leaderboard. For example, to let the users know when new scores are added. - The
runKeytag facilitates searching for leaderboard entries created during a specified run. To learn more, read Tagging user scores.
class Play:
@staticmethod
def record_win(state: State) -> None:
worker_context = json.loads(os.environ["EPICENTER_WORKER_CONTEXT"])
scope = worker_context.get("execution", {}).get("presets", {}).get(LEADERBOARD_SCOPE_PRESET)
run_key = worker_context.get("runKey")
if not scope:
Epicenter.log("WARN", "Skipping leaderboard callback because leaderboard scope preset is missing.")
return
if not run_key:
Epicenter.log("WARN", "Skipping leaderboard callback because runKey is missing.")
return
Epicenter.callback(
"leaderboard",
[{
"collection": LEADERBOARD_COLLECTION,
"scope": scope,
"allowChannel": True,
"tags": [{
"label": "runKey",
"content": run_key,
}],
"scores": [{
"name": "attempts",
"quantity": state.attempts,
}],
}],
)
Every leaderboardAdapter.list() call filters by the leaderboard name, so it must match exactly between the model and the front end. In the code example above, the leaderboard name is stored in the LEADERBOARD_COLLECTION constant.
Fetch leaderboard entries
Call the leaderboardAdapter.list() function to retrieve the current standings. The reference application wraps it in a TanStack Query queryOptions object for caching and cache invalidation:
staleTime: Infinity means the query refetches only on explicit invalidation, which the push channel in Step 5 takes care of.
import { queryOptions } from '@tanstack/react-query';
import { leaderboardAdapter, SCOPE_BOUNDARY, UserSession } from 'epicenter-libs';
import { LeaderboardReadOutView } from '~/types/leaderboard';
export const LEADERBOARD_COLLECTION = 'binary-search';
const byEpisodeCollection = ({
session,
episodeKey,
collection = LEADERBOARD_COLLECTION,
}: {
session: UserSession;
episodeKey: string;
collection?: string;
}) =>
queryOptions({
queryKey: ['leaderboard', 'episode', session.groupKey, episodeKey, collection],
queryFn: () =>
leaderboardAdapter
.list(
collection,
{
scopeBoundary: SCOPE_BOUNDARY.EPISODE,
scopeKey: episodeKey,
},
{
sort: ['+score.attempts'],
max: 200,
}
)
.then((response) => normalizeRows(response as Array<LeaderboardReadOutView>)),
staleTime: Infinity, // The query only refetches on explicit invalidation.
});
export const LeaderboardQuery = {
byEpisodeCollection,
};
Normalize the leaderboard data
The raw response from leaderboardAdapter.list() needs a little cleanup before it is useful in the UI: extract named scores, normalize dates and tags, sort, and assign ranks. These helpers live in the same src/query/leaderboard.ts file:
Entries are sorted by attempts ascending, with ties broken by earliest lastUpdated (first to finish wins the tie), then by leaderboardKey for a stable order.
After normalization, each entry is a LeaderboardRow with a 1-based rank, a typed attempts count, and a runKey you can use to highlight the current player's own entry.
const extractAttempts = (scores: LeaderboardScore[]): number | null => {
const match = scores.find((score) => score.name === 'attempts');
return Number.isFinite(match?.quantity) ? match!.quantity : null;
};
const normalizeDate = (value: Date | string | undefined): string => {
if (value instanceof Date) return value.toISOString();
if (!value) return new Date(0).toISOString();
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? new Date(0).toISOString()
: parsed.toISOString();
};
const normalizeTags = (tags: LeaderboardReadOutView['tags']): LeaderboardTag[] =>
(tags ?? [])
.map((tag) => ({
label: tag.label ?? '',
content: tag.content ?? tag.value ?? '',
}))
.filter((tag) => tag.label.length > 0);
const compareRows = (left: LeaderboardRow, right: LeaderboardRow) => {
const leftAttempts = left.attempts ?? Number.POSITIVE_INFINITY;
const rightAttempts = right.attempts ?? Number.POSITIVE_INFINITY;
if (leftAttempts !== rightAttempts) return leftAttempts - rightAttempts;
const leftUpdated = new Date(left.lastUpdated).getTime();
const rightUpdated = new Date(right.lastUpdated).getTime();
if (leftUpdated !== rightUpdated) return leftUpdated - rightUpdated;
return left.leaderboardKey.localeCompare(right.leaderboardKey);
};
const normalizeRows = (rows: Array<LeaderboardReadOutView>): Array<LeaderboardRow> =>
rows
.map((row, index) => {
const scores = row.scores ?? [];
const tags = normalizeTags(row.tags);
const runKey = tags.find((tag) => tag.label === 'runKey')?.content;
const lastUpdated = normalizeDate(row.lastUpdated);
const leaderboardKey =
row.leaderboardKey ??
runKey ??
`${row.collection ?? LEADERBOARD_COLLECTION}:${lastUpdated}:${index}`;
return {
leaderboardKey,
collection: row.collection ?? LEADERBOARD_COLLECTION,
lastUpdated,
scope: row.scope ?? {},
scores,
tags,
user: row.scope?.user,
attempts: extractAttempts(scores),
rank: 0,
runKey,
} satisfies LeaderboardRow;
})
.sort(compareRows)
.map((row, index) => ({
...row,
rank: index + 1,
}));
Render the leaderboard
With normalized leaderboard data in hand, rendering is straightforward. Use the rank field for display order and the runKey tag to find the current player's own entry:
const labelForRow = (row: LeaderboardRow) =>
row.user?.displayName ?? row.user?.detail.handle ?? 'Anonymous';
const medal = (rank: number) =>
rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `#${rank}`;
export const PlayerHome = () => {
// ...
const { data: leaderboard = [] } = useSuspenseQuery(
LeaderboardQuery.byEpisodeCollection({
session,
episodeKey: episode.episodeKey,
collection: LEADERBOARD_COLLECTION,
})
);
const currentRunEntry = leaderboard.find((row) => row.runKey === run.runKey);
return (
// ...
<aside className={styles.board}>
<div className={styles.boardHead}>
<h2>Leaderboard</h2>
<span className={styles.count}>{leaderboard.length}</span>
</div>
{leaderboard.length > 0 ? (
<ol className={styles.list}>
{leaderboard.map((row) => (
<li key={row.leaderboardKey} className={styles.entry}>
<span className={styles.rank}>{medal(row.rank)}</span>
<span className={styles.name}>{labelForRow(row)}</span>
<strong className={styles.score}>
{row.attempts ?? '–'}
</strong>
</li>
))}
</ol>
) : (
<p className={styles.empty}>No finishes yet — be first!</p>
)}
</aside>
);
};
When the player wins, currentRunEntry shows their own rank:
<p>
{currentRunEntry
? `Rank #${currentRunEntry.rank}`
: 'Syncing to leaderboard…'}
</p>
Type reference
Leaderboard types
The leaderboard types are defined in src/types/leaderboard.ts. LeaderboardReadOutView models the raw API response. LeaderboardRow is the normalized shape the UI consumes.
import { PseudonymReadOutView } from './user';
export type LeaderboardScore = {
name: string;
quantity: number;
};
export type LeaderboardTag = {
label: string;
content: string;
};
type RawLeaderboardTag = {
label?: string;
content?: string;
value?: string;
};
type LeaderboardScope = {
scopeBoundary?: string;
scopeKey?: string;
user?: PseudonymReadOutView;
};
export type LeaderboardReadOutView = {
leaderboardKey?: string;
collection?: string;
lastUpdated?: string | Date;
scope?: LeaderboardScope;
scores?: LeaderboardScore[];
tags?: RawLeaderboardTag[];
};
export type LeaderboardRow = {
leaderboardKey: string;
collection: string;
lastUpdated: string;
scope: LeaderboardScope;
scores: LeaderboardScore[];
tags: LeaderboardTag[];
user?: PseudonymReadOutView;
attempts: number | null;
rank: number;
runKey?: string;
};
Push message
The push message type is defined in src/types/push.ts:
import { LeaderboardScore, LeaderboardTag } from './leaderboard';
export type EpisodeLeaderboardPush = {
date: string;
address: {
boundary: 'EPISODE';
category: 'LEADERBOARD';
key: string;
};
type: 'UPDATED';
content: {
leaderboardKey: string;
leaderboardScores: LeaderboardScore[];
leaderboardTags: LeaderboardTag[];
};
};