sitemap.xml llms.txt
Skip to main content

Understand Chats

This guide explains how to use the Chat adapter to implement real-time messaging between the users of an Epicenter application.

Implementing chat rooms

The code examples are drawn from a template application in the chat branch of the dev-base-build repo. The template app supports episode- and world-scoped group chats, as well as direct messages (DMs) between participants.

Define your conversations

The application defines two types of conversation - a group chat and a DM conversation:

src/routes/play/index/chat/types.ts
import { GenericScope } from 'epicenter-libs';

type ChannelConversation = {
kind: 'episode' | 'world';
room: string; // Unique room name within the scope.
scope: GenericScope;
label: string; // Display name shown in the sidebar.
};

type DmConversation = {
kind: 'dm';
room: string;
scope: GenericScope;
label: string;
peerKey: string; // The userKey of the DM recipient.
};

export type Conversation = ChannelConversation | DmConversation;

// Helper: produce a stable, symmetric room name for a DM between two users.
export const dmRoom = (a: string, b: string): string =>
`dm:${[a, b].sort().join(':')}`;

Then three types of conversations are constructed from the available session data:

  • A chat for everyone participating in this episode.
  • A chat for everyone assigned to this world.
  • One-on-one conversations between users in the current episode.
src/routes/play/index/index.tsx
import { SCOPE_BOUNDARY } from 'epicenter-libs';
import { Conversation, dmRoom } from './chat/types';

const conversations = useMemo(() => {
const list: Conversation[] = [
// Episode-wide chat — visible to everyone in the episode.
{
kind: 'episode',
room: 'episode-chat',
scope: { scopeBoundary: SCOPE_BOUNDARY.EPISODE, scopeKey: episode.episodeKey },
label: 'Episode Chat',
},
// World-scoped chat — visible only to participants in this world.
{
kind: 'world',
room: 'world-chat',
scope: { scopeBoundary: SCOPE_BOUNDARY.WORLD, scopeKey: world.worldKey },
label: 'World Chat',
},
// DM — one room per pair of participants, scoped to the episode.
...participants
.filter((p) => p.user.userKey !== session.userKey)
.map((p) => ({
kind: 'dm' as const,
room: dmRoom(session.userKey, p.user.userKey),
scope: { scopeBoundary: SCOPE_BOUNDARY.EPISODE, scopeKey: episode.episodeKey },
label: p.user.displayName ?? p.user.detail?.handle ?? p.user.userKey,
peerKey: p.user.userKey,
})),
];
return list;
}, [episode.episodeKey, world, participants, session.userKey]);
Scoping rooms

Use SCOPE_BOUNDARY.EPISODE for rooms that all participants share and SCOPE_BOUNDARY.WORLD for rooms limited to one team. DMs can be scoped to either — the reference application scopes them to the episode so they survive world changes.


Find or create a chat room

Before fetching messages, you need a chatKey, the server-side identifier for a room. Use chatAdapter.query() to find an existing room by its name and scope, and chatAdapter.create() to create it if it doesn't exist yet:

src/query/chat.ts
import { queryOptions } from '@tanstack/react-query';
import { GenericScope, ROLE, chatAdapter } from 'epicenter-libs';

const byRoom = ({ room, scope }: { room: string; scope: GenericScope }) =>
queryOptions({
queryKey: ['chat', 'byRoom', room, scope],
queryFn: async () => {
// Search for an existing room matching the name and scope.
const {
values: [found],
} = await chatAdapter.query({
filter: [
`room=${room}`,
`scopeBoundary=${scope.scopeBoundary}`,
`scopeKey=${scope.scopeKey}`,
],
});

if (found) return found;

// Room doesn't exist yet — create it.
return chatAdapter.create(room, scope, {
readLock: ROLE.PARTICIPANT, // Who can read messages.
writeLock: ROLE.PARTICIPANT, // Who can post messages.
});
},
staleTime: Infinity, // Room identity doesn't change; never re-fetch.
});

export const ChatQuery = { byRoom, messages };
Define access levels

chatAdapter.create() accepts a readLock and writeLock drawn from the ROLE enum. Setting both to ROLE.PARTICIPANT allows any authenticated participant to read and write. Adjust these values if you need facilitator-only rooms. To learn more, read the conceptual topic on permits.


Fetch and display messages

Once you have a chatKey, call chatAdapter.getMessages() to load the message history:

src/query/chat.ts
import { ChatMessage } from '~/types/chat';

const messages = ({ chatKey }: { chatKey: string }) =>
queryOptions({
queryKey: ['chat', 'messages', chatKey],
queryFn: () => chatAdapter.getMessages(chatKey) as unknown as Promise<ChatMessage[]>,
});

Reverse message order

The Chat adapter returns messages in reverse-chronological order (newest first). If you want the newest messages to appear at the bottom, reverse the array before rendering:

src/routes/play/index/chat/chat-messages.tsx (excerpt)
const { data: rawMessages } = useQuery({
...ChatQuery.messages({ chatKey: chat.chatKey }),
enabled: !!chat.chatKey,
});

// Reverse for display: oldest at the top, newest at the bottom.
const messages = useMemo(() => [...(rawMessages ?? [])].reverse(), [rawMessages]);

Subscribe to live updates

To ensure that chat messages appear in real time without polling, subscribe to the episode's and world's CHAT push channels. When a push arrives, append the new message directly to the query cache or fetch it by ID, then mark the room as unread if it isn't currently open.

src/routes/play/index/chat/chat-layout.tsx
import { chatAdapter, PUSH_CATEGORY, SCOPE_BOUNDARY } from 'epicenter-libs';
import { useChannel, useChannelEffect } from '~/query/channel';
import { ChatChannelPush } from '~/types/push';
import { ChatMessage } from '~/types/chat';

// Create channel handles for episode-scoped and world-scoped chat.
const episodeChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.EPISODE,
scopeKey: episodeKey,
pushCategory: PUSH_CATEGORY.CHAT,
});

const worldChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.WORLD,
scopeKey: worldKey,
pushCategory: PUSH_CATEGORY.CHAT,
});

const onChatPush = async (data: ChatChannelPush) => {
const { queryKey } = ChatQuery.messages({ chatKey: data.content.chatKey });
const { chatMessage, room } = data.content;

// Own messages are already shown via the optimistic update — just reconcile.
if (chatMessage.senderKey === currentUserKey) {
return queryClient.invalidateQueries({ queryKey });
}

if (data.type === 'BROADCAST') {
// Append the broadcast message directly into the cache.
queryClient.setQueryData(queryKey, (prev: ChatMessage[] | undefined) => {
if (!prev) return prev;
if (prev.some((m) => m.id === chatMessage.id)) return prev; // De-duplicate.
return [{ ...chatMessage, receiverKey: chatMessage.receiverKey || null }, ...prev];
});
} else if (data.type === 'TARGETED') {
// Targeted (DM) pushes carry only an ID — fetch the full message.
const fetched = await chatAdapter
.getMessages(data.content.chatKey, { horizon: chatMessage.id, maxRecords: 1 })
.then((response) => (response as unknown as ChatMessage[])[0]);

if (fetched) {
queryClient.setQueryData(queryKey, (prev: ChatMessage[] | undefined) => {
if (!prev) return prev;
if (prev.some((m) => m.id === fetched.id)) return prev;
return [fetched, ...prev];
});
}
}

// Mark the room as unread if the user isn't currently viewing it.
store.set(markChatRoomUnreadAtom, room);
return queryClient.invalidateQueries({ queryKey });
};

useChannelEffect({ token, channel: episodeChannel, callback: onChatPush });
useChannelEffect({ token, channel: worldChannel, callback: onChatPush });
Important

The ChatChannelPush type in src/types/push.ts describes the wire format. Always check data.type: "BROADCAST" for group messages, "TARGETED" for DMs. They require different cache-update strategies.

channel push data type

BROADCAST pushes contain the full message body, so they can be inserted directly into the cache. TARGETED pushes (DMs) contain only the message ID, so the handler fetches the full content using chatAdapter.getMessages() with a horizon and maxRecords: 1.


Send a message

To post a message, call chatAdapter.sendMessage(). For DMs, pass the recipient's userKey in the options object. For group channels, pass an empty object.

src/routes/play/index/chat/chat-messages.tsx (send)
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { chatAdapter } from 'epicenter-libs';

const sendMutation = useMutation({
mutationFn: (message: string) =>
chatAdapter.sendMessage(
chat.chatKey,
message,
conversation.kind === 'dm'
? { userKey: conversation.peerKey } // Addressed to one recipient.
: {} // Broadcast to the room.
),

// Optimistic update: show the message immediately before the server confirms.
onMutate: async (message) => {
await queryClient.cancelQueries(ChatQuery.messages({ chatKey: chat.chatKey }));
const prev = queryClient.getQueryData(ChatQuery.messages({ chatKey: chat.chatKey }).queryKey);

queryClient.setQueryData(
ChatQuery.messages({ chatKey: chat.chatKey }).queryKey,
(old: typeof prev) => [
{
id: -Date.now(), // Temporary negative ID flags the optimistic entry.
senderKey: currentUserKey,
receiverKey: conversation.kind === 'dm' ? conversation.peerKey : null,
message,
created: Date.now(),
},
...(old ?? []),
]
);
return { prev };
},

// Roll back if the request fails.
onError: (_err, _msg, context) => {
if (context?.prev) {
queryClient.setQueryData(
ChatQuery.messages({ chatKey: chat.chatKey }).queryKey,
context.prev
);
}
},

// Reconcile with the server's confirmed state once settled.
onSettled: () => {
queryClient.invalidateQueries(ChatQuery.messages({ chatKey: chat.chatKey }));
},
});
Optimistic Update

The optimistic update makes the UI feel instant. A temporary entry with a negative ID is inserted immediately. Then onSettled invalidates the cache so the confirmed server message replaces it.


Track unread rooms

To track which rooms have unread messages and which room is currently active, use lightweight Jotai atoms. This lets the sidebar display an unread indicator without re-fetching messages.

src/routes/play/index/chat/chat-state.ts
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';

// The currently open room.
export const activeChatRoomAtom = atom<string | null>(null);

// A set of room names that have unread messages.
export const unreadRoomsAtom = atom<Set<string>>(new Set<string>());

// Write-only: mark a room as unread (no-op if it is currently active).
export const markChatRoomUnreadAtom = atom(null, (get, set, room: string) => {
if (get(activeChatRoomAtom) === room) return;
const next = new Set(get(unreadRoomsAtom));
next.add(room);
set(unreadRoomsAtom, next);
});

// Write-only: clear the unread flag when the user opens a room.
export const clearChatRoomUnreadAtom = atom(null, (get, set, room: string) => {
const next = new Set(get(unreadRoomsAtom));
next.delete(room);
set(unreadRoomsAtom, next);
});

// Read a single room's unread state reactively (used by the sidebar buttons).
export const chatRoomUnreadAtomFamily = atomFamily((room: string) =>
atom((get) => get(unreadRoomsAtom).has(room))
);

Reset chat state

Reset all chat state whenever the combination of user, group, episode, world, and run changes so that stale unread indicators don't carry over between episodes:

src/routes/play/index/chat/chat-state.ts (reset)
export const syncChatStateScopeAtom = atom(null, (get, set, scope: string) => {
if (get(chatStateScopeAtom) === scope) return;
set(chatStateScopeAtom, scope);
set(activeChatRoomAtom, null);
set(unreadRoomsAtom, new Set());
});

Sync chat state

Call syncChatStateScopeAtom from a useEffect in the layout component, passing a string that uniquely identifies the current session context:

src/routes/play/index/chat/chat-layout.tsx (reset effect)
const chatStateScope = [
session.userKey,
session.groupKey ?? 'no-group',
episode.episodeKey,
world.worldKey,
run?.runKey ?? 'no-run',
].join(':');

useEffect(() => {
syncChatStateScope(chatStateScope);
}, [chatStateScope, syncChatStateScope]);

Render the sidebar and message panel

With rooms, messages, and unread state in place, all that remains is to render the messages.

List conversations in the sidebar

The sidebar lists all conversations and marks unread rooms with a dot. The message panel displays the history and the send form.

src/routes/play/index/chat/chat-sidebar.tsx (sidebar button)
const ConversationButton: FC<ConversationButtonProps> = ({ conversation, activeRoom, onSelect }) => {
const unread = useAtomValue(chatRoomUnreadAtomFamily(conversation.room));
const active = conversation.room === activeRoom;

return (
<button onClick={() => onSelect(conversation.room)}>
{conversation.kind !== 'dm' && <span>#</span>}
<span>{conversation.label}</span>
{unread && <span aria-hidden />} {/* Unread dot */}
</button>
);
};

List messages

src/routes/play/index/chat/chat-messages.tsx
{messages.map((msg) => {
const own = msg.senderKey === currentUserKey;
return (
<div key={msg.id}>
<div>
<span>{displayNames.get(msg.senderKey) ?? msg.senderKey}</span>
<span>{formatRelativeTime(msg.created)}</span>
</div>
<div>{msg.message}</div>
</div>
);
})}

Auto-scroll to the latest messages

Because our app lists messages in chronological order, auto-scroll to the bottom whenever the message count increases.

src/routes/play/index/chat/chat-messages.tsx
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages.length]);

Summary

StepWhereWhat
1types.ts / index.tsxDefine Conversation objects (kind, room name, scope, label).
2chat.tsLook up or create a room with chatAdapter.query() and chatAdapter.create().
3chat.ts / chat-messages.tsxFetch message history with chatAdapter.getMessages().
4chat-layout.tsxSubscribe to PUSH_CATEGORY.CHAT channels. Handle BROADCAST and TARGETED pushes.
5chat-messages.tsxSend with chatAdapter.sendMessage(). Use optimistic updates for instant feedback.
6chat-state.tsTrack active and unread rooms with Jotai atoms. Reset on context change.
7chat-sidebar.tsx / chat-messages.tsxRender the conversation list, unread indicators, message history, and send form.