Notifications
Reach the player when something happens in your game — "your farm needs water", "the negotiation took a turn". Call ctx.io.notify({ title, body, image?, tag? }) from onEvent (or a tool's execute) to push a notification into the player's cross-game inbox (the nav bell) and, when they're on the site, a toast. Detect a condition in onTick and bridge to onEvent with emitInputEvent. Notifications are replay-safe, free (only the content you generate spends), and coalesce by tag.
A background or time-driven game is only useful if it can reach the player when
something happens — a farm about to dry out, a turn timer about to expire, a negotiation
that took a turn. ctx.io.notify(...) is how a game does that.
A notification lands in the player's cross-game inbox (the bell in the site nav, with an unread badge) and, when they're currently on the site, as a toast. The inbox is per-player, so a farm notification shows up even while they're playing a different game. Clicking it deep-links back to the session that sent it.
Sending a notification
notify lives on ctx.io, alongside the other outward capabilities. It's available in
onEvent and inside a tool's execute (both receive the event context). It is
not available in onTick — a tick has no io. Detect the condition in onTick and
bridge into onEvent with emitInputEvent:
import { defineBackend } from '@curtain/sdk'
defineBackend<InputEvent, OutputEvent, State>({
initialState: { water: 100, warned: false },
tickInterval: 60_000, // check once a (game) minute
onTick: ({ state, emitInputEvent }) => {
const s = state.get()
state.set(d => { d.water -= 1 })
// Cross the threshold once, then route to onEvent to compose + send.
if (!s.warned && s.water <= 20) {
state.set(d => { d.warned = true })
emitInputEvent({ type: 'low-water' })
}
},
onEvent: async (event, { state, io }) => {
if (event.type === 'low-water') {
io.notify({
title: 'Your farm needs water',
body: 'The crops will wilt soon — come tend them.',
})
}
},
})Rich notifications
Compose the content first — generate copy with an agent, a thumbnail with
generateImage — then pass the results in. The thumbnail's image must be a platform
image URL from a prior io.activities.generateImage(...) (or a game asset), the same
path normal generated game images take:
onEvent: async (event, { io }) => {
if (event.type === 'low-water') {
const art = await io.activities.generateImage('low-water-thumb', {
prompt: 'a wilting tomato plant in dry soil, soft morning light',
})
io.notify({
title: 'Your farm needs water',
body: 'The tomatoes are wilting.',
image: art.url, // a platform URL from generateImage
tag: 'farm-needs-water', // see coalescing, below
})
}
}Coalescing with tag
For a recurring condition, pass a tag. A new notification with the same tag
replaces the prior unread one for that player+game, so "your farm needs water" doesn't
pile up ten entries deep while the player is away. Omit tag for one-off events (each is a
new inbox entry).
Good to know
- Replay-safe. A
notifyis recorded on its turn and reproduced on a rewind/replay without re-sending — editing or retrying a turn never re-notifies the player. notifyis free. Only the work you do to build the content (an agent call, agenerateImage) costs credits — the same as any turn. The notify call itself isn't billed.- Throttled. Notifications are rate-limited per session, so a runaway loop can't notification-storm; over-limit calls still land in the inbox but skip the live toast.
- Use it for events, not chatter. A notification is a "come back, something happened" pointer — not a running commentary. The turn-by-turn detail still lives in the session; the notification is the summary that pulls the player back to it.
See also time-and-ticks for the onTick → emitInputEvent
pattern and background play (backgroundExecution), which is what makes a notification
worth sending while the player is away.