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:

tsx
// 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):

css
.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). An emit while 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:

tsx
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()
  }
}
tsx
<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:

tsx
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:

tsx
<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.