Understand Episodes
This guide explains how to use the Episode adapter to organize runs into episodes — for example, a practice round followed by an instruction session and a second, comparable round.
Implementing episode-scoped play
The code examples are drawn from the base template application in the example branch of the dev-base-build repo. The template starts every group on its first episode automatically, lets a facilitator create new episodes on demand, and scopes each participant's run and world assignment to whichever episode is current.
Describe an episode
The app mirrors the server's EpisodeReadOutView with a local type so components get type-checked access to episode fields:
export type EpisodeCreateInView = {
name: string;
runLimit?: number;
draft?: boolean;
category?: string;
};
export type EpisodeReadOutView = {
lastUpdated: string;
runLimit: number;
created: string;
draft: boolean;
name: string;
episodeKey: string;
category: string;
};
An episode's name must be unique for a group and cannot contain `$%^*={}[]|;\"<>?\r\n. The template avoids collisions by generating names from a timestamp: 'ep'.concat(Date.now().toString()).
Find or create the current episode
Most participant-facing screens need "whichever episode is active right now" rather than a specific episodeKey. Use episodeAdapter.query() to look up the most recently created episode for the group, and fall back to episodeAdapter.create() if a facilitator is loading the app for the first time and none exists yet:
import { queryOptions } from '@tanstack/react-query';
import { Fault, UserSession, episodeAdapter } from 'epicenter-libs';
import { EpisodeReadOutView } from '~/types/episode';
const current = ({ session }: { session: UserSession }) =>
queryOptions({
queryKey: ['episode', 'current', session.groupName, session.groupRole],
queryFn: async () => {
const [current] = await episodeAdapter
.query({
sort: ['-episode.created'], // Newest episode first.
max: 1,
})
.then((response) => response.values as Array<EpisodeReadOutView>);
if (current) return current;
// No episode exists yet. Only a facilitator is allowed to create one.
if (session.groupRole === 'FACILITATOR') {
const episodeName = 'ep'.concat(Date.now().toString());
return episodeAdapter
.create(episodeName, session.groupName!)
.then((episode) => episode as unknown as EpisodeReadOutView);
}
throw new Fault({ status: 404, message: 'No episode found' });
},
staleTime: Infinity, // A facilitator action (see below) invalidates this explicitly.
retry(failureCount, error) {
// Don't retry "no episode yet" — that's an expected state, not a transient failure.
if (error instanceof Fault && error.status === 404) return false;
return failureCount < 3;
},
});
episodeAdapter.create() requires a role of FACILITATOR or higher. A participant who reaches this query before any episode exists gets a thrown Fault rather than an implicit create attempt that would fail server-side.
Any screen a participant sees can then suspend on this query to get the active episode:
const session = useGuardedSession();
const { data: episode } = useSuspenseQuery(EpisodeQuery.current({ session }));
Scope other data to an episode
Once you have an episodeKey, pass it as the scopeKey with SCOPE_BOUNDARY.EPISODE to scope a run to the current episode. The template creates (or reuses) one run per user per episode this way:
import { queryOptions } from '@tanstack/react-query';
import { runAdapter, SCOPE_BOUNDARY, UserSession } from 'epicenter-libs';
import { RunReadOutView } from '~/types/run';
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).then((run) => run as RunReadOutView);
},
staleTime: Infinity,
});
Because worlds are assigned to either a group or a group's episode, they don't use scope. Instead, they have an orbit which is defined by groupName and episodeName:
const bySessionPerEpisode = ({
session,
episode,
}: {
session: UserSession;
episode: EpisodeReadOutView;
}) =>
queryOptions({
queryKey: ['world', 'bySessionPerEpisode', session.token, session.groupName, episode.name],
queryFn: () =>
worldAdapter
.get({
mine: true, // Only the caller's own world assignment.
groupName: session.groupName,
episodeName: episode.name,
})
.then((response) => response as unknown as Array<WorldReadOutView>)
.then(([mine]) => {
if (!mine) throw new Fault({ status: 404, message: 'World not found' });
return mine;
}),
staleTime: Infinity,
retry(failureCount, error) {
if (error instanceof Fault && error.status === 404) return false;
return failureCount < 3;
},
});
Subscribe to episode-creation pushes
When a facilitator creates a new episode (see below), every connected participant needs to stop treating the old episode as current. The template subscribes to the group's push channel and invalidates the current-episode query whenever an EPISODE create event arrives:
import { PUSH_CATEGORY, SCOPE_BOUNDARY } from 'epicenter-libs';
import { useChannel, useChannelEffect } from '~/query/channel';
import { EpisodeQuery } from '~/query/episode';
import { EpisodeReadOutView } from '~/types/episode';
import { GroupChannelPush } from '~/types/push';
const groupChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.GROUP,
scopeKey: session.groupKey!,
pushCategory: PUSH_CATEGORY.GROUP,
});
const onGroupChannelPush = useCallback(
(
message: GroupChannelPush<{
type: 'EPISODE';
content: {
activity: 'create';
episode: EpisodeReadOutView;
groupKey: string;
objectType: 'episode';
};
}>
) => {
switch (message.content.activity) {
case 'create':
return queryClient.invalidateQueries(EpisodeQuery.current({ session }));
default:
console.warn('Unknown group channel message', message);
}
},
[queryClient, session]
);
useChannelEffect({
token: session.token,
channel: groupChannel,
callback: onGroupChannelPush,
});
allowChannel on the groupGroup-level pushes require allowChannel: true on the group, or project.allowChannelGroupDefault: true so new groups get the flag automatically.
List episodes and let a facilitator switch between them
A facilitator's dashboard needs every episode, not just the current one. Add a sibling query for that, plus a plain (non-queryOptions) helper that creates a new episode on demand:
const list = ({ session }: { session: UserSession }) =>
queryOptions({
queryKey: ['episode', 'list', session.groupName],
queryFn: async () =>
episodeAdapter
.query({ sort: ['-episode.created'] })
.then((response) => response.values as Array<EpisodeReadOutView>),
staleTime: Infinity,
});
const push = (groupName: string) =>
episodeAdapter
.create('ep'.concat(Date.now().toString()), groupName)
.then((episode) => episode as unknown as EpisodeReadOutView);
export const EpisodeQuery = { current, push, list };
The facilitator route renders that list in a <select>, defaulting to whatever episode is current, and re-derives the runs table whenever the selection changes:
const { data: currentEpisode } = useSuspenseQuery(EpisodeQuery.current({ session }));
const { data: episodes = [] } = useSuspenseQuery(EpisodeQuery.list({ session }));
const [selectedEpisodeKey, setSelectedEpisodeKey] = useState(currentEpisode.episodeKey);
const selectedEpisode = episodes.find((ep) => ep.episodeKey === selectedEpisodeKey);
invariant(selectedEpisode, 'Selected episode not found in episode list');
const { data: runs = [] } = useQuery(
RunQuery.byEpisode({ session, episode: selectedEpisode })
);
return (
<select
value={selectedEpisodeKey}
onChange={(e) => setSelectedEpisodeKey(e.target.value)}
>
{episodes.map((ep) => (
<option key={ep.episodeKey} value={ep.episodeKey}>
{new Date(ep.created).toLocaleString()}
</option>
))}
</select>
);
RunQuery.byEpisode fetches runs by groupName and episode.name and is guarded so only a facilitator calls it:
const byEpisode = ({
session,
episode,
}: {
session: UserSession;
episode: EpisodeReadOutView;
}) => {
invariant(session.groupRole === 'FACILITATOR', 'Only Facilitator should call RunQuery.byEpisode');
invariant(session.groupName, 'Reached authenticated route without session.groupName');
return queryOptions({
queryKey: ['run', 'per-episode', session.groupName, episode.name, RANGES],
queryFn: () =>
runAdapter
.query(MODEL, {
filter: ['run.hidden=false'],
variables: [...RANGES],
groupName: session.groupName,
episodeName: episode.name,
})
.then((body) => body.values as unknown as Array<RunReadOutView>),
});
};
Create a new episode
To start a fresh round, call EpisodeQuery.push(), then refetch (not just invalidate) both the list and current-episode queries so the newly created episode is selected immediately in the facilitator's own view — the push described above handles updating everyone else:
const newEpisode = () =>
EpisodeQuery.push(session.groupName!).then(() =>
Promise.all([
queryClient.refetchQueries(EpisodeQuery.list({ session })),
queryClient.refetchQueries(EpisodeQuery.current({ session })),
]).then(() => {
const current = queryClient.getQueryData(EpisodeQuery.current({ session }).queryKey);
invariant(current, 'Just created an episode but none found in cache');
setSelectedEpisodeKey(current.episodeKey);
})
);
// <Button onClick={newEpisode}>New Episode</Button>
invalidateQueries() only marks a query stale, but doesn't guarantee the new data is in the cache before the next line runs. refetchQueries() awaits the network call, so reading current.episodeKey immediately afterward is safe.
Render episode-scoped data
With the current episode and its run in hand, the player's home screen reads the run's variables and, on the model's last step, lets the participant start a brand-new run in the same episode:
const { data: episode } = useSuspenseQuery(EpisodeQuery.current({ session }));
const { data: run } = useSuspenseQuery(
RunQuery.byUserPerEpisode({ session, episodeKey: episode.episodeKey })
);
const handleRestart = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
return runAdapter
.create(MODEL, {
scopeKey: episode.episodeKey,
scopeBoundary: 'EPISODE',
userKey: session.userKey,
})
.then(() =>
queryClient.invalidateQueries(
RunQuery.byUserPerEpisode({ session, episodeKey: episode.episodeKey })
)
);
};
Because RunQuery.byUserPerEpisode is keyed on episodeKey, invalidating it after handleRestart re-runs the query, finds the freshly created run, and the UI moves on without a page reload.
Summary
| Step | Where | What |
|---|---|---|
| 1 | types/episode.ts | Describe an episode with a local EpisodeReadOutView type. |
| 2 | query/episode.ts | Find or create the current episode with episodeAdapter.query() and episodeAdapter.create(). |
| 3 | query/run.ts / query/world.ts | Scope runs and worlds to the episode via SCOPE_BOUNDARY.EPISODE or episodeName. |
| 4 | play.tsx | Subscribe to the group's PUSH_CATEGORY.GROUP channel and invalidate the current episode on an EPISODE create event. |
| 5 | facilitator/index/index.tsx | List all episodes with EpisodeQuery.list() and let a facilitator select one. |
| 6 | facilitator/index/index.tsx | Create a new episode with EpisodeQuery.push(), then refetchQueries() to select it immediately. |
| 7 | play/index/index.tsx | Render episode-scoped data. Fetch the user's run for the current episode and start a new one on restart. |