Skip to content

Custom UI (rootComponent)

The rootComponent is a virtual filesystem of React/TSX files that run in a sandboxed iframe. It provides the world's visual layer and has full access to game state, chat control, session management, AI completions, and audio.

rootComponent Schema

json
{
  "rootComponent": {
    "id": "uuid",
    "name": "My World UI",
    "entryFile": "index.tsx",
    "files": {
      "index.tsx": "export default function App() { ... }",
      "bubble.tsx": "export default function Bubble({ content, role }) { ... }"
    },
    "updatedAt": "2024-01-01T00:00:00Z"
  }
}

Entry Point

index.tsx must export a default component:

tsx
export default function App() {
  return <Chat />;
}

With custom message bubbles:

tsx
import Bubble from "./bubble";

export default function App() {
  return <Chat renderBubble={Bubble} />;
}

Full app mode:

tsx
export default function App() {
  var api = useYumina();
  
  return (
    <div style={ {display: "flex", flexDirection: "column", height: "100vh"} }>
      <header>HP: {api.variables.health}</header>
      <MessageList />
      <MessageInput />
    </div>
  );
}

useYumina() — Complete API Reference

State Reads

typescript
interface SandboxedYuminaAPI {
  // Game state
  variables: Record<string, unknown>;
  globalVariables: Record<string, unknown>;
  
  // World info
  worldName: string;
  worldId: string;
  sessionId: string;
  
  // User identity
  currentUser: { id: string; name?: string; image?: string | null } | null;
  user: { name: string; avatar: string | null };  // Persona-aware
  
  // Chat state
  messages: SandboxMessage[];
  isStreaming: boolean;
  streamingContent: string;
  streamingReasoning: string;
  pendingChoices: string[];
  error: string | null;
  readOnly: boolean;
  
  // Lorebook
  entries: ReadonlyArray<SandboxEntry>;
  getEntry(name: string): SandboxEntry | null;
  
  // Session
  checkpoints: Array<{ id: string; name: string; messageCount: number; createdAt: string }>;
  greetingContent: string | null;
  mode: "session" | "guest-preview";
  capabilities: {
    canSendMessage: boolean;
    canPersistSession: boolean;
    canUseSessionApis: boolean;
    requiresAuth: boolean;
  };
  
  // UI state
  canvasMode: "chat" | "custom" | "fullscreen";
  selectedModel: string;
  userPlan: string;
  preferredProvider: "official" | "private";
  language: string;
  bgmVolume: number;
  sfxVolume: number;
}

Chat Actions

typescript
sendMessage(text: string): void;
editMessage(messageId: string, content: string): Promise<boolean>;
deleteMessage(messageId: string): Promise<boolean>;
regenerateMessage(messageId: string): void;
continueLastMessage(): void;
stopGeneration(): void;
restartChat(): void;
swipeMessage(messageId: string, direction: "left" | "right"): Promise<Record<string, unknown>>;
setComposerDraft(text: string): void;
clearPendingChoices(): void;

Session Management

typescript
revertToMessage(messageId: string): Promise<void>;
branchFromMessage(messageId: string): Promise<string | null>;
getBranchContext(): Promise<BranchContext>;
createSession(worldId: string): Promise<string>;
deleteSession(sessionId: string): Promise<void>;
listSessions(worldId: string): Promise<Array<Record<string, unknown>>>;
navigate(path: string): void;

Checkpoints

typescript
saveCheckpoint(): Promise<void>;
loadCheckpoints(): Promise<void>;
restoreCheckpoint(checkpointId: string): Promise<void>;
deleteCheckpoint(checkpointId: string): Promise<void>;

AI Completions

typescript
ai.complete(params: {
  messages: Array<{ role: string; content: string }>;
  onDelta?: (text: string) => void;
  model?: string;
  maxTokens?: number;
  temperature?: number;
  includeLorebook?: boolean | "all" | "matched";
}): Promise<string>;

Make raw LLM calls with optional streaming and lorebook injection. Use for NPC generators, dynamic descriptions, hint systems, or any AI logic outside the main chat flow.

Game Actions

typescript
setVariable(id: string, value: unknown, options?: {
  scope?: string;
  targetUserId?: string;
}): void;
executeAction(actionId: string): void;
injectContext(message: string, options?: { role?: "system" | "user" }): void;

Audio

typescript
playAudio(trackId: string, opts?: {
  volume?: number;
  fadeDuration?: number;
  chainTo?: string;
  maxDuration?: number;
  duckBgm?: boolean;
}): void;
stopAudio(trackId?: string, fadeDuration?: number): void;
setAudioVolume(type: "bgm" | "sfx", volume: number): void;
getAudioVolume(type: "bgm" | "sfx"): number;

Storage (World-Scoped, Persistent)

typescript
storage.get(key: string): Promise<string | null>;
storage.set(key: string, value: string): Promise<void>;
storage.remove(key: string): Promise<void>;

UI Controls

typescript
toggleImmersive(): void;
switchGreeting(index: number): void;
copyToClipboard(text: string): void;
showToast(message: string, type?: "success" | "error" | "info"): void;
resolveAssetUrl(ref: string): string;
renderMarkdown(text: string): string;

Model Selection

typescript
setModel(modelId: string): void;
getModels(): Promise<{
  models: Array<{ id: string; name: string; provider: string; contextLength: number }>;
  pinnedModels: string[];
  recentlyUsed: string[];
}>;
pinModel(modelId: string): void;
unpinModel(modelId: string): void;
setPreferredProvider(provider: "official" | "private"): Promise<{
  ok: boolean;
  provider?: string;
  error?: string;
}>;

Built-In Components

Available as globals — no imports needed. Import statements are silently stripped at compile time, so both styles work, but the components are injected into scope automatically:

tsx
// These are already in scope — just use them directly:
// Chat, MessageList, MessageInput, ChatCanvas,
// ModelPickerModal, ModelTrigger, useAssetFont, Icons

// Import statements are harmless (stripped at compile time) but unnecessary:
// import { Chat } from "yumina/Chat";  ← works but not needed

Chat Props

typescript
interface ChatProps {
  renderBubble?: (props: BubbleProps) => React.ReactNode;
  className?: string;
  children?: React.ReactNode;
}

BubbleProps

typescript
interface BubbleProps {
  contentHtml: string;
  content: string;
  rawContent: string;
  role: "user" | "assistant" | "system";
  messageIndex: number;
  isStreaming: boolean;
  stateSnapshot: Record<string, unknown> | null;
  variables: Record<string, unknown>;
  renderMarkdown: (text: string) => string;
}

SandboxMessage

typescript
interface SandboxMessage {
  id: string;
  sessionId: string;
  role: "user" | "assistant" | "system";
  content: string;
  status?: "complete" | "streaming" | "failed";
  errorMessage?: string | null;
  stateChanges?: Record<string, unknown> | null;
  stateSnapshot?: Record<string, unknown> | null;
  swipes?: Array<{ content: string; stateSnapshot?: Record<string, unknown> | null }>;
  activeSwipeIndex?: number;
  model?: string | null;
  tokenCount?: number | null;
  generationTimeMs?: number | null;
  compacted?: boolean;
  attachments?: Array<{ type: string; mimeType: string; name: string; url: string }> | null;
  createdAt: string;
}

SandboxEntry

typescript
interface SandboxEntry {
  id: string;
  name: string;
  content: string;
  keywords: string[];
  position: number;
  section: "system-presets" | "examples" | "chat-history" | "post-history";
  enabled: boolean;
  role: string;
  tags?: string[];
}

Global API (Non-React)

js
window.yumina              // Same API as useYumina()
window.yumina.onChange(cb)  // Subscribe to state changes, returns unsubscribe fn
window.yumina.offChange(cb) // Unsubscribe
// Also dispatches "yumina:statechange" event on window

Sandbox Environment

React is available globally (no import needed). Use React.useState, React.useEffect, etc.

Restrictions

  • No fetch / XMLHttpRequest
  • No direct localStorage / sessionStorage (use storage.* API instead)
  • No window.location manipulation (use navigate())
  • No window.parent access
  • No eval / new Function
  • No cookie access

Compatibility Shims

Old world code without the SDK can still use:

  • fetch('/api/*') → proxied through parent with credentials
  • localStorage / sessionStorage → world-scoped, proxied to parent
  • navigator.clipboard.writeText() → proxied
  • window.location → synthetic object

Styling

Tailwind CSS is fully available in the sandbox — use any utility classes (flex, gap-4, text-white, bg-[#1a1a2e], etc.). Inline styles also work:

tsx
// Tailwind classes (preferred)
<div className="flex flex-col gap-4 p-4 bg-[#1a1a2e] text-[#e0e0e0] font-serif">

// Inline styles (also fine)
var style = {
  background: "#1a1a2e",
  color: "#e0e0e0",
  fontFamily: '"Noto Serif SC", serif',
  padding: "16px",
};

Multi-File Structure

tsx
// index.tsx
import StatusPanel from "./status-panel";
import MapView from "./map-view";

export default function App() {
  return (
    <>
      <StatusPanel />
      <Chat />
      <MapView />
    </>
  );
}