Skip to main content

Core Concepts

Understanding the core concepts of React Text Game will help you build powerful interactive narratives.

Architecture Overview

React Text Game uses a registry pattern with reactive state management (Valtio) to create a seamless game development experience.

┌──────────────────────────────────────┐
│ Game (Central) │
│ - Entity Registry │
│ - Passage Registry │
│ - Navigation │
│ - State Management │
└──────────────────────────────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Entities │ │ Passages │ │ Audio System │
│ (Valtio) │ │ (Screens) │ │ (Valtio) │
└─────────────┘ └─────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Storage (JSONPath) │
│ - Session Storage │
│ - IndexedDB (Saves) │
│ - Audio State Persistence │
└─────────────────────────────────────────────────────┘

Game Initialization

IMPORTANT: You must call Game.init() before using any other Game methods or creating entities.

import { Game } from "@react-text-game/core";

await Game.init({
gameName: "My Adventure",
isDevMode: true,
});

The Game class is the central orchestrator that:

  • Manages entity and passage registries
  • Handles navigation between passages
  • Provides save/load functionality
  • Wraps all objects in Valtio proxies for reactivity

Entities

Entities represent game state (player, inventory, quest system, etc.). React Text Game offers two approaches:

The createEntity factory is the simplest way to create reactive game objects:

import { createEntity } from "@react-text-game/core";

const player = createEntity("player", {
name: "Hero",
health: 100,
inventory: {
gold: 50,
items: [] as string[],
},
});

// Direct property access - automatically reactive
player.health -= 10;
player.inventory.items.push("sword");

// Persist changes when needed
player.save();

Key Features:

  • Automatic registration with Game
  • Direct property access (no .variables)
  • Deep reactivity for nested objects/arrays
  • Explicit save() calls for controlled persistence

IMPORTANT: All properties in the variables object must be required (non-optional). Optional properties are not supported because the Proxy-based implementation cannot distinguish between undefined optional values and missing properties. If you need optional-like behavior, use explicit undefined with a union type:

// ❌ Wrong - Optional properties will cause TypeScript errors
const player = createEntity('player', {
health: 100,
mana?: 50 // Error: optional keys are not allowed
});

// ✅ Correct - Use explicit undefined for optional-like behavior
const player = createEntity('player', {
health: 100,
mana: undefined as number | undefined
});

Advanced Entities (Class-Based)

For more complex scenarios, extend BaseGameObject:

import { BaseGameObject } from "@react-text-game/core";

class Inventory extends BaseGameObject<{ items: string[] }> {
constructor() {
super({
id: "inventory",
variables: { items: [] },
});
}

addItem(item: string) {
this._variables.items.push(item);
this.save();
}

hasItem(item: string): boolean {
return this._variables.items.includes(item);
}
}

const inventory = new Inventory();

Passages

Passages represent different screens or scenes in your game. Three types are available:

Story Passages

Text-based narrative passages with rich components:

import { newStory, Game } from "@react-text-game/core";

const chapter1 = newStory("chapter1", () => [
{
type: "header",
content: "The Beginning",
props: { level: 1 },
},
{
type: "text",
content: "You find yourself in a dark forest...",
},
{
type: "image",
content: "/assets/forest.jpg",
props: { alt: "Dark forest" },
},
{
type: "actions",
content: [
{
label: "Go North",
action: () => Game.jumpTo("north-path"),
color: "primary",
},
{
label: "Go South",
action: () => Game.jumpTo("south-path"),
color: "secondary",
},
],
},
]);

Available Components:

  • text - Text content with ReactNode support
  • header - Semantic headers (h1-h6)
  • image - Images with modal viewer
  • video - HTML5 video with controls
  • actions - Interactive button groups
  • conversation - Dialogue with chat/messenger variants
  • anotherStory - Embed other story passages

HTML Content in Text Components

For simple HTML content without needing JSX/TSX files, use the isHTML prop:

// In a .ts file (no JSX needed)
newStory("example", () => [
{
type: "text",
content: "<strong>Bold</strong> and <em>italic</em> text",
props: { isHTML: true },
},
]);
note

isHTML only works when content is a string. For complex interactive content with event handlers or React state, use .tsx files with React components.

Interactive Map Passages

Map-based interactive passages with hotspots:

import { newInteractiveMap, Game } from "@react-text-game/core";

const worldMap = newInteractiveMap("world-map", {
caption: "World Map",
image: "/maps/world.jpg",
hotspots: [
// Label hotspot on map
{
type: "label",
content: "Village",
position: { x: 30, y: 40 }, // Percentage (0-100)
action: () => Game.jumpTo("village"),
props: { color: "primary" },
},
// Simple image hotspot (just a string)
{
type: "image",
content: "/icons/treasure.png",
position: { x: 50, y: 60 },
action: () => collectTreasure(),
},
// Image hotspot with hover effect (object with states)
{
type: "image",
content: {
idle: "/icons/chest.png",
hover: "/icons/chest-glow.png",
},
position: { x: 60, y: 70 },
action: () => openChest(),
},
// Dynamic image hotspot (function)
{
type: "image",
content: () => `/icons/portal-${player.level}.png`,
position: { x: 75, y: 80 },
action: () => enterPortal(),
},
// Conditional hotspot
() =>
player.hasDiscovered("forest")
? {
type: "label",
content: "Forest",
position: { x: 80, y: 50 },
action: () => Game.jumpTo("forest"),
}
: undefined,
],
});

Hotspot Types:

  • MapLabelHotspot - Text buttons on map (x/y coordinates)
  • MapImageHotspot - Image buttons with state variants
  • SideLabelHotspot - Text buttons on edges (top/bottom/left/right)
  • SideImageHotspot - Image buttons on edges
  • MapMenu - Context menu with multiple items

Widget Passages

Custom React components as passages:

import { newWidget } from "@react-text-game/core";

// With ReactNode (static content)
const customUI = newWidget(
"custom-ui",
<div>
<h1>Custom Interface</h1>
<MyCustomComponent />
</div>
);

// With React component (supports hooks)
const MyMenu = () => {
const [selected, setSelected] = useState(null);
return <MenuUI selected={selected} onSelect={setSelected} />;
};
const menuWidget = newWidget("menu", MyMenu);
Important: Function Content Handling

When passing a function to newWidget, it is always treated as a React component and rendered via createElement. This ensures hooks work correctly even in minified production builds where function names are mangled.

If you need dynamic content without hooks (e.g., a simple render function), pre-evaluate it:

// For dynamic content without hooks, pre-evaluate the function:
const timestampWidget = newWidget("time", (() => <div>{Date.now()}</div>)());
Save System and Widget Passages

The save system caches passage display results for performance. However, Widget passages with function content (React components) cannot be reliably cached because they return React elements rather than serializable data.

Best practices:

  • Avoid saving game state while on a Widget passage
  • Navigate to a Story or InteractiveMap passage before allowing saves
  • If your Widget is a menu or settings screen, consider disabling the save button while it's displayed
// Example: Disable save while on settings widget
const SettingsWidget = () => {
const isOnSettings = Game.currentPassage?.id === "settings";
return (
<div>
<SaveButton disabled={isOnSettings} />
{/* ... */}
</div>
);
};

State Management

React Text Game uses Valtio for reactive state management and jsonpath-plus for flexible storage queries.

Reactive Updates

All entities are automatically wrapped in Valtio proxies:

const player = createEntity("player", { health: 100 });

// Changes automatically trigger React re-renders
player.health -= 10;

Storage System

The storage system uses JSONPath queries with session storage for auto-save:

import { Storage } from "@react-text-game/core";

// Get values using JSONPath queries
const health = Storage.getValue<number>("$.player.health");

// Set values
Storage.setValue("$.player.health", 75);

// Full state serialization for save/load
const state = Storage.getState();
Storage.setState(state);

// Check if a path exists
const hasInventory = Storage.hasPath("$.player.inventory");

Storage Features:

  • Uses jsonpath-plus library for flexible querying
  • Session storage for auto-save (configurable)
  • Protected system paths (prefixed with STORAGE_SYSTEM_PATH)
  • Type-safe getValue with generic support

Navigate between passages using the Game API:

import { Game } from "@react-text-game/core";

// Jump to a passage by ID
Game.jumpTo("chapter1");

// Jump to a passage object
Game.jumpTo(chapter1);

// Set current without navigation effects
Game.setCurrent("chapter1");

// Get current passage
const current = Game.currentPassage;

Save System

React Text Game includes a comprehensive save/load system using Dexie (IndexedDB wrapper) with encryption support via crypto-js.

Using Hooks

import { useSaveSlots } from "@react-text-game/core/saves";

function SavesList() {
const slots = useSaveSlots({ count: 5 });

return (
<div>
{slots.map((slot, index) => (
<div key={index}>
<p>
Slot {index + 1}: {slot.data ? "Saved" : "Empty"}
</p>
{slot.data && <p>{slot.data.description}</p>}
<button onClick={() => slot.save()}>Save</button>
<button onClick={() => slot.load()} disabled={!slot.data}>
Load
</button>
<button onClick={() => slot.delete()} disabled={!slot.data}>
Delete
</button>
</div>
))}
</div>
);
}

Available Hooks

All save-related hooks are available from @react-text-game/core/saves:

  • useSaveSlots - Manage multiple save slots with save/load/delete actions
  • useSaveGame - Save current game state
  • useLoadGame - Load saved game state
  • useDeleteGame - Delete a specific save
  • useDeleteAllSlots - Delete all saves (except system save)
  • useLastLoadGame - Load the most recent save
  • useExportSaves - Export saves to encrypted file
  • useImportSaves - Import saves from encrypted file
  • useRestartGame - Restart game from initial state

Direct API

The save system also provides direct database functions from @react-text-game/core/saves:

import {
saveGame,
loadGame,
getAllSaves,
deleteSave,
} from "@react-text-game/core/saves";

// Save manually with optional description and screenshot
await saveGame("slot-1", gameData, "Before boss fight", screenshotBase64);

// Load by slot ID
const save = await loadGame(1);

// Get all saves (excluding system saves)
const allSaves = await getAllSaves();

// Delete a save
await deleteSave(1);

Note: Save IDs are auto-incremented. The system also maintains a special SYSTEM_SAVE_NAME for initial state restoration.

Audio System

React Text Game includes a comprehensive audio system with reactive state management, automatic persistence, and global controls. Perfect for background music, sound effects, and voice-over audio.

Features

  • Reactive State - Valtio-powered state for seamless React integration
  • Automatic Persistence - Audio state saved and restored automatically
  • Global Controls - Master volume, mute all, pause/resume all tracks
  • Fade Effects - Built-in fade in/out for smooth transitions
  • Multiple Tracks - Manage multiple audio files independently
  • Browser-friendly - Handles autoplay policies gracefully

Creating Audio Tracks

Use the createAudio factory function from @react-text-game/core/audio:

import { createAudio, AudioManager } from "@react-text-game/core/audio";

// Basic audio track
const bgMusic = createAudio("/audio/background.mp3", {
id: "bg-music", // Required for persistence
volume: 0.7, // 0.0 to 1.0 (default: 1.0)
loop: true, // Auto-loop (default: false)
autoPlay: false, // Auto-play on creation (default: false)
});

// Play the track
await bgMusic.play();

// Control playback
bgMusic.pause();
bgMusic.resume();
bgMusic.stop();

// Adjust settings
bgMusic.setVolume(0.5);
bgMusic.setLoop(true);
bgMusic.seek(30); // Seek to 30 seconds

// Fade effects
await bgMusic.fadeIn(2000); // Fade in over 2 seconds
await bgMusic.fadeOut(1500); // Fade out over 1.5 seconds

Global Audio Manager

Control all audio tracks globally with the AudioManager:

import { AudioManager } from "@react-text-game/core/audio";

// Master volume control
AudioManager.setMasterVolume(0.5); // Set to 50%
const volume = AudioManager.getMasterVolume();

// Global playback control
AudioManager.pauseAll(); // Pause all playing tracks
AudioManager.resumeAll(); // Resume all paused tracks
AudioManager.stopAll(); // Stop all tracks

// Global mute control
AudioManager.muteAll();
AudioManager.unmuteAll();

// Track management
const allTracks = AudioManager.getAllTracks();
const music = AudioManager.getTrackById("bg-music");

Master Volume Behavior:

  • Master volume multiplies with individual track volumes
  • Does not modify track volume settings
  • Example: Track at 0.8 volume × 0.5 master = 0.4 effective volume

React Integration

The audio system includes dedicated hooks for React components:

useAudio Hook

Monitor individual audio track state:

import { createAudio } from "@react-text-game/core/audio";
import { useAudio } from "@react-text-game/core";

const bgMusic = createAudio("/audio/background.mp3", {
id: "bg-music",
loop: true,
});

function MusicPlayer() {
const audioState = useAudio(bgMusic);

return (
<div>
<p>Status: {audioState.isPlaying ? "Playing" : "Stopped"}</p>
<p>
Time: {audioState.currentTime.toFixed(1)}s /{" "}
{audioState.duration.toFixed(1)}s
</p>
<p>Volume: {(audioState.volume * 100).toFixed(0)}%</p>

<button onClick={() => bgMusic.play()}>Play</button>
<button onClick={() => bgMusic.pause()}>Pause</button>
<button onClick={() => bgMusic.stop()}>Stop</button>

<input
type="range"
min="0"
max="1"
step="0.01"
value={audioState.volume}
onChange={(e) => bgMusic.setVolume(parseFloat(e.target.value))}
/>
</div>
);
}

useAudioManager Hook

Access global audio controls:

import { useAudioManager } from "@react-text-game/core";

function AudioSettings() {
const audioManager = useAudioManager();

return (
<div>
<h2>Audio Settings</h2>

<label>
Master Volume: {(audioManager.masterVolume * 100).toFixed(0)}%
<input
type="range"
min="0"
max="1"
step="0.01"
value={audioManager.masterVolume}
onChange={(e) =>
audioManager.setMasterVolume(parseFloat(e.target.value))
}
/>
</label>

<div>
<button onClick={audioManager.muteAll}>Mute All</button>
<button onClick={audioManager.unmuteAll}>Unmute All</button>
<button onClick={audioManager.pauseAll}>Pause All</button>
<button onClick={audioManager.resumeAll}>Resume All</button>
</div>

<p>Active Tracks: {audioManager.getAllTracks().length}</p>
</div>
);
}

Automatic Persistence

Audio tracks with an id automatically save and restore their state:

// Create audio with ID
const music = createAudio("/audio/theme.mp3", {
id: "theme-music",
volume: 0.7,
loop: true,
});

// State is automatically saved when it changes
await music.play();
music.setVolume(0.5);
// State persisted automatically

// On game restart/reload
const music = createAudio("/audio/theme.mp3", {
id: "theme-music", // Same ID
});
music.load(); // Restores volume, position, playing state

What Gets Persisted:

  • Volume level
  • Loop setting
  • Playback rate
  • Muted status
  • Current playback position
  • Playing/paused state

Common Patterns

Background Music with Crossfade

const oldMusic = AudioManager.getTrackById("current-music");
const newMusic = createAudio("/audio/new-theme.mp3", {
id: "current-music",
loop: true,
});

// Crossfade between tracks
if (oldMusic) {
await Promise.all([oldMusic.fadeOut(1000), newMusic.fadeIn(1000)]);
oldMusic.dispose();
}

Sound Effects Pool

// Create one-time sound effect without persistence
function playSoundEffect(src: string) {
const sfx = createAudio(src, { volume: 0.8 });

sfx.play();

// Auto-cleanup when finished
const audio = (sfx as any).audioElement;
audio.addEventListener("ended", () => {
sfx.dispose();
});
}

playSoundEffect("/audio/click.mp3");

Pause Audio During Dialogue

function showDialogue() {
// Pause background music
AudioManager.pauseAll();

// Show dialogue...

// Resume when done
AudioManager.resumeAll();
}

Browser Autoplay Policies

Modern browsers restrict audio autoplay. Handle this gracefully:

const music = createAudio("/audio/theme.mp3", {
autoPlay: true, // May be blocked by browser
});

// Failures are logged but don't throw
// Play after user interaction:
document.addEventListener(
"click",
async () => {
await music.play(); // Works after interaction
},
{ once: true }
);

Audio API Reference

createAudio(src, options?)

  • src: string - Audio file URL
  • options?: AudioOptions - Configuration options
  • Returns: AudioTrack

AudioTrack Methods:

  • play(): Promise<void> - Start playback
  • pause(): void - Pause playback
  • resume(): void - Resume from pause
  • stop(): void - Stop and reset
  • setVolume(volume: number): void - Set volume (0.0-1.0)
  • setLoop(loop: boolean): void - Enable/disable looping
  • setPlaybackRate(rate: number): void - Set playback speed
  • setMuted(muted: boolean): void - Mute/unmute
  • seek(time: number): void - Seek to time in seconds
  • fadeIn(duration?: number): Promise<void> - Fade in effect
  • fadeOut(duration?: number): Promise<void> - Fade out effect
  • getState(): AudioState - Get reactive state
  • save(): void - Save state to storage
  • load(): void - Load state from storage
  • dispose(): void - Clean up and remove

AudioManager Methods:

  • setMasterVolume(volume: number): void - Set master volume
  • getMasterVolume(): number - Get master volume
  • muteAll(): void - Mute all tracks
  • unmuteAll(): void - Unmute all tracks
  • pauseAll(): void - Pause all playing tracks
  • resumeAll(): void - Resume all paused tracks
  • stopAll(): void - Stop all tracks
  • getAllTracks(): AudioTrack[] - Get all tracks
  • getTrackById(id: string): AudioTrack | undefined - Get track by ID
  • disposeAll(): void - Dispose all tracks

React Hooks

useCurrentPassage

Monitor the current passage with reactive updates:

import { useCurrentPassage } from "@react-text-game/core";

function GameScreen() {
const passage = useCurrentPassage();

if (!passage) return <div>Loading...</div>;

return <div>{/* Render passage */}</div>;
}

useGameEntity

Track entity changes with automatic re-renders:

import { useGameEntity } from "@react-text-game/core";

function PlayerStats({ player }) {
const reactivePlayer = useGameEntity(player);

return <div>Health: {reactivePlayer.health}</div>;
}

useGameIsStarted

Check if game has been initialized:

import { useGameIsStarted } from "@react-text-game/core";

function GameUI() {
const isStarted = useGameIsStarted();

return isStarted ? <GameScreen /> : <MainMenu />;
}

Best Practices

1. Always Initialize First

// ✅ Correct
await Game.init();
const player = createEntity("player", { name: "Hero" });

// ❌ Wrong
const player = createEntity("player", { name: "Hero" });
await Game.init();

2. Use Factory Pattern for Simple Entities

// ✅ Recommended for most cases
const player = createEntity("player", { health: 100 });

// ⚠️ Use only when you need inheritance or private methods
class Player extends BaseGameObject {
/* ... */
}

3. Organize by Feature

src/game/
├── entities/
│ ├── player.ts
│ ├── inventory.ts
│ └── index.ts
├── passages/
│ ├── story/
│ │ ├── intro.ts
│ │ └── chapter1.ts
│ └── maps/
│ └── worldMap.ts
└── index.ts

4. Keep Passage Logic Simple

// ✅ Good - Logic in entity methods
player.takeDamage(10);

// ❌ Avoid - Complex logic in passages
player.health -= 10;
if (player.health <= 0) {
/* ... */
}

5. Use TypeScript

// ✅ Type-safe entities with explicit types
const player = createEntity("player", {
name: "Hero",
inventory: [] as string[], // Explicit array type
});

6. Avoid Optional Properties in Entities

// ❌ Wrong - Optional properties are not supported
const player = createEntity('player', {
health: 100,
mana?: 50 // TypeScript will prevent this
});

// ✅ Correct - Use explicit undefined for optional-like behavior
const player = createEntity('player', {
health: 100,
mana: undefined as number | undefined,
questItem: undefined as string | undefined
});

// ✅ Also correct - All required properties
const player = createEntity('player', {
health: 100,
mana: 50, // Always provide a value
questItem: '' // Use empty string instead of optional
});

Next Steps