Rolling your own composer
The built-in Chat composer (textarea + Send) is deliberately minimal and cannot be styled — no class hooks, inline styles, and none are coming. For any non-trivial input (custom width or alignment, your own styling, a single-line field, extra controls beside it) pass showInput={false} to Chat and render your own form that calls emit(...). Lock it on gameHistory.streaming || submitting || suspended; for a multiline variant, submit on Enter, newline on Shift+Enter, and guard e.nativeEvent.isComposing so an IME candidate confirmation doesn't submit mid-word.
Chat's built-in composer — the textarea and Send button under the transcript — is for the
most basic cases only. It is styled inline and exposes no class hooks, deliberately: it
is not a themeable component, and there is no way to restyle it. The moment you want
anything else — a narrower composer, a different alignment, your own colors, a single-line
field, a button or picker sitting next to the input — stop fighting it and render your own.
The pattern is one prop and one form: pass showInput={false} so Chat renders the
transcript alone, then render an input of your own that calls emit(...).
The single-line composer
A complete, copy-pasteable frontend. Chat fills the space above; your form owns everything
below it:
// frontend.tsx
import { useState } from 'react'
import { defineFrontend, Chat } from '@aichatgames/sdk'
import type { InputEvent, OutputEvent, State } from './types.js'
import { Input, Output } from './components/Message.js'
defineFrontend<InputEvent, OutputEvent, State>(({ emit, gameHistory }) => {
const [text, setText] = useState('')
// A suspended session silently drops emits, so it locks the composer exactly like a
// turn in flight does.
const locked = gameHistory.streaming || gameHistory.submitting || gameHistory.suspended
const send = () => {
const trimmed = text.trim()
if (locked || !trimmed) return
emit({ type: 'say', content: trimmed })
setText('')
}
return (
<div className="mg-shell">
<Chat
gameHistory={gameHistory}
InputComponent={Input}
OutputComponent={Output}
showInput={false}
/>
<form className="mg-composer" onSubmit={e => { e.preventDefault(); send() }}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder={gameHistory.suspended ? 'Game paused' : 'Say something…'}
disabled={locked}
/>
<button type="submit" disabled={locked || !text.trim()}>Send</button>
</form>
</div>
)
})Chat is a flex column that grows to fill its parent, so the shell has to be a bounded
column — otherwise the transcript has no height to scroll within. html, body and the
mount point are already height: 100%, so this is enough (in a .css file in the game
source — don't import it from TypeScript, the platform injects it):
.mg-shell { display: flex; flex-direction: column; height: 100%; }
.mg-composer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
.mg-composer input { flex: 1; background: var(--bg-input); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; }
.mg-composer button { background: var(--accent); color: var(--text-on-accent); border: none; border-radius: 8px; padding: 10px 20px; }
.mg-composer button:disabled, .mg-composer input:disabled { opacity: 0.4; }What the lock has to cover
locked is the whole contract, and all three flags matter:
streaming— a turn is being processed. A second input mid-turn is not what the player means to do.submitting— an input has been sent but the server hasn't acknowledged it yet. Without this, an eager double-tap sends twice.suspended— the session is paused (stopped by the player, or an out-of-credits / spend-limit pause). Anemitwhile suspended is dropped, never queued — so an input that still looks live is a lie. Disable it and say so in the placeholder ("Game paused"); the platform owns the resume affordance.
Trim before sending, refuse an empty send, and clear the field on submit. Disable Send when the field is empty so the button's state matches what pressing it would do.
The multiline variant
If the game wants paragraphs rather than a line, swap the <input> for a <textarea> and
add a key handler — a textarea's Enter inserts a newline by default, so submitting on Enter
is something you wire up:
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter submits; Shift+Enter inserts a newline. The isComposing guard is essential:
// confirming an IME candidate (Japanese, Chinese, Korean input) also fires Enter, and
// without the guard that keystroke submits a half-typed word instead of accepting it.
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
send()
}
}<textarea
ref={inputRef}
rows={1}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={gameHistory.suspended ? 'Game paused' : 'Say something…'}
disabled={locked}
/>To grow the textarea with its content instead of scrolling, hold it in a
useRef<HTMLTextAreaElement>(null) (the inputRef above) and reset + re-measure on every
value change — which also snaps it back to one row when send() clears it:
useLayoutEffect(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
}, [text])showInput is dynamic
showInput is read every render, so it can also gate the built-in composer over the life of
a session — no custom input needed. Hide it once the game is over, or before it has started:
<Chat gameHistory={gameHistory} OutputComponent={Output} showInput={state.started && !state.over} />The same applies to your own composer: render it conditionally on the same terms. A game
whose every input is a tap (choices, buttons, a map) just passes showInput={false} and
never renders a text field at all.
See custom-frontend for dropping Chat altogether and building
the whole UI from state, emit and gameHistory, and for the full list of theme CSS
variables.