Custom (non-Chat) frontends
Build a bespoke UI instead of the built-in Chat. defineFrontend gives you state, emit, and gameHistory — render whatever you want from them. Derive views (e.g. per-character threads) from gameHistory.recentBlocks rather than duplicating transcripts in state, type sub-components from the SDK's exported building blocks, and theme with the host site's CSS variables.
The built-in Chat component is one option, not a requirement. defineFrontend hands your
component three things — state, emit, and gameHistory — and you render whatever the game
needs: a board, a cast sidebar, a map, tabbed interview threads. A custom frontend looks
native because it theme-matches the host site through CSS variables.
The shape
defineFrontend takes a single component. Switch on phase and delegate to sub-components:
// frontend.tsx
import { defineFrontend } from '@aichatgames/sdk'
import type { InputEvent, OutputEvent, State } from './types.js'
import { Investigation } from './components/Investigation.js'
import { Resolution } from './components/Resolution.js'
defineFrontend<InputEvent, OutputEvent, State>(({ state, emit, gameHistory }) => {
if (state.phase === 'resolved') return <Resolution state={state} />
if (state.phase === 'setup') return <div className="dp-loading">Setting the scene…</div>
return <Investigation state={state} emit={emit} gameHistory={gameHistory} />
})Typing sub-components
FrontendProps is not exported — only the defineFrontend callback receives it. Type
each sub-component's props directly from the SDK's exported building blocks plus your own
event/state types:
// components/Investigation.tsx
import type { GameHistory } from '@aichatgames/sdk'
import type { InputEvent, OutputEvent, State } from '../types.js'
export function Investigation({ state, emit, gameHistory }: {
state: State
emit: (e: InputEvent) => void
gameHistory: GameHistory<InputEvent, OutputEvent, State>
}) { /* … */ }Derive views from blocks — don't duplicate transcripts in state
gameHistory.recentBlocks is the conversation. Each block carries the input event that
produced it and the output events the backend emitted. Reconstruct views from blocks
rather than keeping a parallel transcript in state (which would be two sources of truth for
the same thing). The open next block has no input yet (block.input is undefined), so
guard it before reading block.input.*. Here a per-guest interview thread is filtered straight
out of the history:
// components/thread.ts
import type { GameBlock } from '@aichatgames/sdk'
import type { InputEvent, OutputEvent, State, GuestSpeech } from '../types.js'
type Block = GameBlock<InputEvent, OutputEvent, State>
export interface Line { who: 'player' | 'guest'; text: string }
export function guestThread(blocks: Block[], id: string): Line[] {
const lines: Line[] = []
for (const b of blocks) {
if (!b.input) continue // open `next` block — no input event yet
if (b.input.type === 'ask-question' && b.input.guestId === id) {
lines.push({ who: 'player', text: b.input.content })
const speech = b.output.find((o): o is GuestSpeech => o.type === 'guest-speech' && o.guestId === id)
if (speech) lines.push({ who: 'guest', text: speech.text })
}
}
return lines
}State holds only true game state (the scenario, portraits, phase, verdict); the dialogue
lives in blocks. Use gameHistory.streaming to gate input and show a "thinking…" indicator
while a turn is in flight.
Theming with the SDK CSS variables
Any .css file in the game source loads automatically — don't import it from
TypeScript (the platform injects it via <link>). The host site exposes CSS custom
properties on :root that track its light/dark scheme, so styling with them makes the game
look native on both sites and in both modes:
.dp-cast-card {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
}
.dp-cast-card.dp-selected {
border-color: var(--accent);
background: var(--accent-subtle);
}
.dp-line.dp-player p {
background: var(--accent);
color: var(--text-on-accent);
}Available variables: --bg, --bg-subtle, --bg-input, --border, --text,
--text-muted, --accent, --accent-hover, --accent-subtle, --text-on-accent. Use
them for every color so the game inherits the site's theme — never hard-code colors that
should follow light/dark.
Notes
emitis your only channel to the backend. A click, a form submit, an accusation — all become input events:emit({ type: 'ask-question', guestId, content }).stateis post-latest-block (or initial state before any block). Render from it for the current snapshot; readrecentBlocksfor the history/transcript.- Keep components micro. One component per file (sidebar, conversation, resolution) — the same small-files discipline as the backend.
- For the chat-style default instead, use the built-in
Chatcomponent (see the SDK reference).