Skip to main content

Messenger & Transcripts

@react-text-game/messenger gives you a persistent, append-only message log that knows what the player has and has not seen. That single primitive powers both a messenger simulator - chat list, unread badges, group chats, media albums, messages arriving later - and a Ren'Py-style visual novel - a backlog screen and skip-already-read.

This release is headless: state, delivery and selectors, with no components. UI ships in a later release.

Install

bun add @react-text-game/messenger

Why not the story conversation component?

Core already renders dialogue with h.conversation. The difference is where the state lives:

h.conversation@react-text-game/messenger
Lives ina passage's display()an engine entity
Survives save / loadnoyes
Knows what was shownno (resets on remount)yes, persistently
Scopeone dialogue scenean ongoing conversation

Use h.conversation for a one-off scene. Reach for this package when the history has to persist and unread state matters.

Core idea: script and transcript

Two things, deliberately separate:

  • a script is authored, static and addressable, like a passage;
  • a transcript is runtime state: what was actually delivered, in what order, with a cursor and per-entry seen flags.

Because the transcript lives in an engine entity, it survives saves, loads and remounts for free, and participates in auto-save and migrations like any other entity.

Quick start

import { MINUTE } from "@react-text-game/core/clock";
import {
defineChat,
defineContact,
defineMessenger,
defineScript,
m,
} from "@react-text-game/messenger";

const anna = defineContact("anna", {
name: m.t("contacts.anna"),
avatar: "/avatars/anna.webp",
});

export const annaChat = defineChat("anna", { peer: anna });

export const opener = defineScript("anna/opener", (m) => [
m.from(anna).text(m.t("anna.opener")),
m.typing(anna, 1200),
m.from(anna).media([m.image("/park.webp", { alt: m.t("alt.park") })], {
caption: m.t("anna.park"),
}),
m.wait(30 * MINUTE),
m.choice("anna/opener/reply", [
{ content: m.t("reply.yes") },
{ content: m.t("reply.no") },
]),
]);

export const messenger = defineMessenger({ chats: [annaChat] });

Then, from an action handler:

annaChat.play(opener); // set the cursor
annaChat.advance(); // deliver the next message

The one rule

Never change a chat while a passage or component renders. A passage's display() runs again on every React render, on every save load and on every remount, so a delivery triggered during render would repeat and duplicate messages. See Handling Side Effects.

Every state-changing method - play, advance, deliverDue, push, choose, markSeen, addParticipant, rename, setAvatar, setReadOnly, clear - belongs in an event handler. Reading during render is safe, and the hooks never materialize state.

Text comes in three forms

Which form is stored is decided by what you pass, not by a flag:

You passStored asBehaviour
"hey"rawfrozen forever
m.t("anna.hi", { name })i18nre-translates on language change; interpolation frozen on delivery
<>hey <b>you</b></>refre-read from the script beat, since a node cannot be serialized

In a localized game write everything through m.t(). Saves stay small, old messages re-translate when the player switches language, and interpolation values are captured at delivery - so "you have 100 gold" never becomes "you have 20 gold" retroactively.

A ref is the only way to put a React node in a message, and its content is re-evaluated live from the script. Anything dynamic inside one should be frozen with m.t() or a plain string instead.

Read text back with:

import { previewText, resolveText } from "@react-text-game/messenger";

resolveText(richText); // renderable content
previewText(richText); // plain string, for list previews

Chats

Direct and group

// one-to-one: title and avatar fall back to the peer
const annaChat = defineChat("anna", { peer: anna });

// group: its own title, its own picture, several members
const squad = defineChat("squad", {
title: m.t("chats.squad"),
participants: [anna, boris],
avatar: "/avatars/squad.webp",
});

Membership changes during play and records an in-fiction notice:

squad.addParticipant(carol); // appends a "member.joined" system entry
squad.removeParticipant(boris); // appends a "member.left" system entry
squad.rename(m.t("chats.squadRenamed"));
squad.setAvatar("/avatars/squad-2.webp");
squad.setAvatar(null); // no picture at all

Anything that can change during play lives in state; defineChat() only supplies the starting values.

The chat picture is optional

resolvedAvatar walks a fallback chain: an in-fiction change (where null means "removed"), then the definition's avatar, then - for a direct chat - the peer's avatar, and finally undefined.

Read-only chats

Two different things, kept apart on purpose:

const news = defineChat("news", { title: m.t("chats.news"), readOnly: true });

news.canReply; // false - structurally barred, a channel
annaChat.canReply; // true
annaChat.pendingChoice; // null - nothing to answer right now

readOnly can be toggled in-fiction with setReadOnly(), for a group that gets closed by an administrator.

Messages

Media, captions and albums

One payload kind covers all of it; the item count is what distinguishes a single photo from an album:

m.from(anna).media([m.image("/park.webp")]); // one photo
m.from(anna).image("/park.webp", { caption: "look" }); // photo with a comment
m.from(anna).video("/clip.mp4", { poster: "/clip.jpg", durationMs: 8000 });

m.from(anna).media(
[
m.image("/1.webp", { alt: m.t("alt.first") }),
m.video("/2.mp4", { spoiler: true }),
],
{ caption: m.t("anna.album") }
); // a mixed album

Items carry alt, poster, durationMs and spoiler.

Forwarding

Forwarding is metadata on the message, so any payload can be forwarded, and the source does not have to be a real message:

m.from(anna).text(m.t("boris.rumor"), { forwardedFrom: boris });
m.from(anna).text("...", { forwardedFrom: { label: m.t("unknown.number") } });
m.from(anna).media([m.image("/1.webp")], {
forwardedFrom: { id: "boris", at: earlierTimestamp },
});

System notices and custom payloads

m.system("member.joined", { who: anna.id }); // always translated
m.from(anna).custom("poll", { question: "?", options: [] }); // passed through

custom is the extension hatch for anything the package does not model yet - voice notes, files, stickers - without breaking saved transcripts.

Pushing outside a script

annaChat.push(m.from(anna).text(m.t("anna.reminder")));
annaChat.push(m.player.text("on my way"));

push accepts message, system and custom beats. It cannot store React nodes, because there is no script behind them to reference - use defineScript() for rich content.

Delivery and scheduling

m.wait() and m.typing() are measured in game time from Clock, and no live timers are involved: what is due is derived from the cursor and the clock.

const script = defineScript("anna/evening", (m) => [
m.from(anna).text(m.t("anna.evening.1")),
m.typing(anna, 1500), // shows "typing", holds the next beat
m.from(anna).text(m.t("anna.evening.2")),
m.wait(30 * MINUTE), // arrives half an hour later
m.from(anna).text(m.t("anna.evening.3")),
]);

Wire one call into your game and you are done:

messenger.deliverDueAll();

It is idempotent, so call it on window focus, when the player opens the messenger, after moving the clock, or on an interval.

With the default manual clock, a delayed message arrives when the game moves the clock - which is exactly Ren'Py's "three hours later". In realtime mode it arrives after that much real time.

Catching up replays the schedule rather than stretching it. A conversation written to unfold over three minutes still reads that way when the player returns an hour later: each message keeps the in-fiction time it was due at.

Replies

A choice beat blocks the script until the player answers:

m.choice("anna/opener/reply", [
{ content: m.t("reply.yes"), next: acceptBranch }, // play another script
{ content: m.t("reply.no") }, // continue this one
{ content: m.t("reply.block"), next: () => blockContact(anna) }, // run code
]);
const pending = annaChat.pendingChoice;
// { choiceId: 'anna/opener/reply', options: [{ index: 0, content: ... }, ...] }

annaChat.choose(0);

The reply is logged to the transcript as a choice payload from the player, so the history reads like a real conversation rather than a monologue.

Seen tracking, at four levels

Easy to conflate, so the package keeps them apart:

LevelWhere it livesMeaning
deliveredentriesthe message arrived, in-fiction
seenper entry, in the savethe player actually looked at it
receiptper entry, in the savethe character's ticks, author-controlled
seen-eversettings, outside the savethe player has read this beat in any playthrough
annaChat.unread; // badge count
annaChat.firstUnreadKey; // anchor for an "unread" divider and auto-scroll
annaChat.markSeen();
annaChat.markSeenUpTo(entryKey); // when you know how far they scrolled

annaChat.isSeenEver("anna/opener:3"); // skip-already-read, galleries, endings
await messenger.loadSeen(); // during bootstrap
await messenger.flushSeen(); // before the tab closes

The player's own messages count as seen immediately - you do not get an unread badge for what you just sent.

The cross-save level is what makes skip-already-read and unlocked-ending screens possible; it cannot live in the save, because it has to outlive a single slot. Swap it out with createMemorySeenStore() if your game has no use for it.

Callbacks

All optional. Register them per chat, or on defineMessenger() for every chat - chat callbacks fire first, and a callback that throws is reported without corrupting the transcript.

defineChat("anna", {
peer: anna,
onSend: ({ chat, entry }) => {
if (entry.from !== "player") incoming.play();
},
onSeen: ({ entries }) => {},
onChoice: ({ choiceId, index }) => {},
onTyping: ({ typing }) => {},
onScriptEnd: ({ scriptId }) => {},
onParticipantChange: ({ participants, added, removed }) => {},
});

onSend fires for every appended entry - from a contact, from the player, and for system notices alike.

Reading it in React

import { useChat, useChatList, useUnreadTotal } from "@react-text-game/messenger";

function ChatView({ chat }) {
const { entries, unread, firstUnreadKey, pendingChoice, canReply, typing } =
useChat(chat);

return (
<>
{entries.map((entry) => (
<Bubble key={entry.key} entry={entry} />
))}
{typing.length > 0 && <TypingIndicator />}
{canReply &&
pendingChoice?.options.map((option) => (
<button
key={option.index}
type="button"
onClick={() => chat.choose(option.index)}
>
{option.content}
</button>
))}
</>
);
}

function ChatList() {
const rows = useChatList(); // most recently active first
const total = useUnreadTotal();
...
}

Every chat's state lives in one entity, and Valtio tracks property access, so a component watching one chat does not re-render when another chat receives a message.

Ids are persistent identifiers

Contact, chat, script and beat ids end up inside saved transcripts. Treat them like passage ids: never rename one once a game has shipped.

Beat ids default to "<scriptId>:<index>", using the position the beat is written at - so a conditional beat never shifts the ids after it:

defineScript("anna/opener", (m) => [
m.from(anna).text("one"), // anna/opener:0
player.knowsBoris && m.from(anna).text("two"), // anna/opener:1, skipped when false
m.from(anna).text("three"), // anna/opener:2 either way
]);

Give an explicit id to any beat that must stay addressable across edits that reorder the script; choice beats require one. In dev mode the package warns when a script's length changed since a transcript referenced it, because that is the signature of default ids having shifted.

Transcript size

Transcripts are uncapped by default, so history is never silently lost. Auto-save serializes the whole state tree into sessionStorage, so a chat that runs long should set a cap:

defineChat("anna", { peer: anna, maxEntries: 500 });

The package warns once a transcript passes 1000 entries without one.