Development instance — data may be reset at any time

Generating images

Generate images during gameplay with io.activities.generateImage(callId, params), which returns { mediaId } at dispatch and can still fail minutes later — so every rendering of a mediaId must handle its status (RenderMedia does; with useMedia, url is absent in every status but 'done', and silently rendering nothing is a decision you have to make deliberately). Render a mediaId with RenderMedia or useMedia, store it in state or emit it as an event, and drive prompts from an illustrator agent. Covers the model prompting guide (each model's exact size limits — generating outside them fails), idempotency, credits, and pre-authored image assets (which may be looping video clips).

Games generate images at runtime with io.activities.generateImage. The call returns at dispatch with a { mediaId } while the image renders in the background — a turn never blocks on the image model. The frontend renders the mediaId with RenderMedia, showing a placeholder until the bytes are ready.

Do not build your own image cache. generateImage is already content-addressed and deduplicated across the whole game. Call it whenever you need an image: identical generation arguments return the existing mediaId at no extra charge, while changed arguments create a new image. Store a mediaId only when it is part of gameplay state (for example, the portrait currently on screen), not as an expression-to-mediaId or prompt-to-mediaId cache.

generateImage

Call it during event processing (onEvent or a tool's execute). The first argument is a callId — a unique, descriptive name for the call site, like the first argument to agent.send.

ts
const { mediaId } = await io.activities.generateImage('scene-img', {
  prompt: 'A cozy tavern interior with warm firelight and a bard playing a lute',
  width: 1024,
  height: 1024,
})
emit({ type: 'illustration', mediaId })

Parameters:

| Field | Type | Default | Description | |-------|------|---------|-------------| | prompt | string | (required) | The whole instruction to the image model | | width | number | model default | Image width in pixels | | height | number | model default | Image height in pixels | | negativePrompt | string | — | What to avoid (model-dependent) | | model | 'flux' | 'gpt-image-2' | site default (flux) | Which image model to use | | referenceMediaIds | string[] | — | Prior generated images to use as visual references — see reference-images |

Returns { mediaId } — an opaque handle to the media item. Render it with RenderMedia / useMedia, store it in state, emit it as an event, or pass it as a reference to a later generation.

  • Idempotent and already cached. The mediaId is derived from the complete generation args (prompt, model, dimensions, references, and other options), not from callId. Re-calling from any turn or session in the same game with identical args returns the same mediaId at no extra charge. Make the call normally and let the platform deduplicate it; a hand-built map in game state is redundant and can hide the activity from the Raw inspector. A revised prompt or any other changed generation argument produces a new mediaId. Do not append a counter or timestamp merely to force a fresh image; change the prompt meaningfully instead.
  • Errors surface on the media, not as a throw. Invalid arguments (a size the model doesn't support, an over-long prompt) throw at the call and cost nothing, but a generation that fails downstream resolves the media to a failed status: rejected when the content filter denies it (terminal), error for anything else (a failed reference, a transient outage). RenderMedia shows a broken-image state for both — with a built-in retry control when the error is retryable — and useMedia reports the status plus retryable/retry().
  • Credits depend on the model and size, and each reference adds a surcharge. A dispatched generation is charged whether or not it succeeds; re-running a failed one is free either way — the built-in retry control (retry()) and calling generateImage again with the same args are the same operation, re-arming the failed image and any failed reference under it at no extra charge. Don't put mediaIds in agent prompts — they're opaque handles, meaningless to an LLM.

Model prompting guide

<!-- Each model's size limits mirror the gateway (image-gen/gateway/src/validate.ts) via web's model catalog (web/sites/<site>/config.ts); web/src/lib/__tests__/platform-docs-image-models.test.ts pins the numbers below to that catalog. Change a limit in all three places. -->

flux (default):

  • Natural-language prompting — describe the scene in full sentences.
  • Example: "A cozy tavern interior with wooden beams, warm firelight, and a bard playing a lute in the corner".
  • Resolutions 512–2048 on each side, in multiples of 16; aim for ~1MP (e.g. 1024×1024) for the best quality. Anything outside that fails.
  • Negative prompts have no effect.
  • Supports referenceMediaIds.

gpt-image-2:

  • OpenAI-backed, natural-language prompting, exceptional quality — a good choice for hero shots and cover art.
  • Fixed sizes only: 1024×1024, 1024×1536, 1536×1024. Any other dimensions fail fast.
  • Slower and more expensive per image than flux; prefer flux for frequent in-turn generation.

Choosing between them. The cost axis is once-off vs per-turn, not 1 credit vs 15–20. For prominent, persistent, once-off art — title banners, covers, backgrounds, key character portraits — prefer gpt-image-2 even when the display aspect isn't on its menu: generate the nearest supported size and crop to the display aspect in CSS (object-fit: cover on the <img>). A 4:1 title banner is a 1536×1024 generation cropped by its container, not a reason to reach for a wider flux size. For frequent in-turn generation — an illustration per scene — use flux.

Rendering a mediaId

RenderMedia is the drop-in way to display a generated image — it renders the <img> once ready, a pulsing placeholder while generating (and for a null mediaId), and a broken-image state on failure, with a small built-in retry control when the failure is retryable (a rejected image never is). It behaves like an img and takes an optional alt and className. A looping-clip image asset (see below) plays as a muted, looping, control-less <video> — no code change needed. When media changes it holds its last picture, under a light shimmer, until the new one is ready to draw — so a regenerated image never flashes blank. If a component reuses one RenderMedia for a different subject (another character's card), give it a key for the subject so it starts fresh.

tsx
import { RenderMedia } from '@aichatgames/sdk'

<RenderMedia media={output.mediaId} alt="The scene" />

For custom UI, subscribe to a mediaId's load state with useMedia. It is discriminated on status — narrow before reading, because url exists only on done and error only on the failure states:

tsx
import { RenderMedia, useMedia } from '@aichatgames/sdk'

const media = useMedia(mediaId)
if (media.status === 'done') {
  return media.type === 'video'   // a looping-clip image asset
    ? <RenderMedia media={mediaId} alt="The scene" />   // RenderMedia plays it
    : <img src={media.url} alt="The scene" />
}
if (media.status === 'error') return <button onClick={media.retry}>Retry — {media.error}</button>
if (media.status === 'rejected') return <p>That image wasn't allowed.</p>
return <div className="placeholder" />   // loading | generating

A clip needs RenderMedia (or the same muted, inline, autoplaying loop): Safari autoplays a <video> only with the muted attribute, which React's muted prop never writes — a hand-drawn clip must set it through a ref, with playsInline autoPlay loop.

retry() re-runs a failed generation for free when retryable is true; rejected is a content verdict, terminal and never retryable. Decide deliberately what each failure looks like — falling back to something neutral is fine for purely decorative art, but it should be a choice, not the accident you get from reading url without checking status. Branch on type as well: an <img> shows nothing for a clip.

Image on state (persistent, mutable)

Store a mediaId in state so it persists across events and updates over time — a character portrait, a scene background, a map that changes as the state does.

ts
// State
interface State {
  portraitMediaId: string | null
}
initialState: { portraitMediaId: null }

// Backend — update the portrait
const { mediaId } = await io.activities.generateImage('portrait', { prompt })
state.set(draft => { draft.portraitMediaId = mediaId })

// Frontend — render from state (updates automatically when state changes)
{state.portraitMediaId && <RenderMedia media={state.portraitMediaId} alt="Portrait" />}

Image as event (inline chat image)

Emit a mediaId as an output event so it appears inline in the chat history — a one-off illustration that flows with the narrative rather than persisting in a panel.

ts
// Backend — emit the mediaId
emit({ type: 'illustration', mediaId })

// Frontend — render inline in chat
function Output({ output }: { output: OutputEvent }) {
  switch (output.type) {
    case 'illustration':
      return <RenderMedia media={output.mediaId} alt="Scene illustration" className="chat-image" />
    // ...
  }
}

Driving generation from the story: an illustrator agent

For AI-driven illustration, use a dedicated illustrator agent that follows the story and composes prompts on demand. Feed it story events with addMessages so it accumulates narrative context. Make it a structured agent (a fixed responseFormat in its config), then use transient: true sends to request a prompt without polluting its history — the caller runs generateImage and decides what to do with the mediaId.

write_file({ path: "assets/Illustrator Prompt", content: "You are an illustrator following along with a story.\nWhen asked to illustrate, compose a natural language image prompt capturing the scene.\nBe specific about composition, lighting, style, and mood." })
ts
const imagePromptSchema = z.object({
  prompt: z.string().describe('Natural language image description'),
})

// Create the illustrator once, where the game starts (a second createAgent throws)…
io.createAgent('illustrator', {
  systemPromptAsset: 'Illustrator Prompt',
  model: 'fast',
  responseFormat: imagePromptSchema,
})

// …and reach it by name in every handler after.
const illustrator = io.getAgent('illustrator', imagePromptSchema)

// Keep the illustrator informed as the story progresses (persistent)
emit({ type: 'narration', text: narration })
illustrator.addMessages({ role: 'user', content: `[Story] ${narration}` })

// Query for a prompt (transient — doesn't pollute the story context)
const { data } = await illustrator.send('illustrate-scene', {
  messages: [{ role: 'user', content: 'Illustrate the current scene.' }],
  transient: true,
})

// Caller controls generation — size, model, and destination
const { mediaId } = await io.activities.generateImage('scene', {
  prompt: data.prompt,
  width: 832,
  height: 1216,
})
state.set(draft => { draft.sceneMediaId = mediaId })

The illustrator's history stays clean — only story events accumulate, so compaction summarizes the narrative rather than a mix of story and prompt requests. (If the illustrator needs to keep a character looking the same across a long game, that is what agent topics are for — though a mediaId in game state is the cheaper answer; see reference-images.)

Pre-authored image assets

Game assets (import assets from '@game/assets') bundle pre-authored images — character portraits, backgrounds, UI graphics — that the game creator manages outside of code. Use assets for content that should be consistent across plays, and runtime generation for dynamic content. An image asset's .mediaId is string | null (null for an unfilled placeholder), and it renders and references exactly like a generated mediaId.

Animated images. Any image asset may hold a short looping video clip instead of a still — a leaf drifting down, a flickering torch — like a GIF, only far smaller. Whether it does is the uploader's choice (the Assets tab takes MP4/WebM up to 10 MB), so game code shouldn't assume a still: RenderMedia plays a clip automatically (holding its first frame under reduced motion), and hand-rendered useMedia code branches on type. A clip is refused where only a still works: the game thumbnail, a referenceMediaIds entry, and a notification image. For a good clip: loop it seamlessly, keep it to a few seconds, and export H.264 MP4 for the widest browser support. Audio is ignored.

ts
import assets from '@game/assets'

const portrait = assets["Hero Portrait"].mediaId  // string | null
// Render it: <RenderMedia media={portrait} alt="Hero" />
// …or pass it as a generateImage reference (referenceMediaIds: [portrait]) — stills only.

To keep a character visually consistent across many generated shots — the same face in new poses, or two characters sharing a frame — generate one canonical reference and pass it via referenceMediaIds. See reference-images.