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:
Entity Factory (Recommended)
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
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 supportheader
- Semantic headers (h1-h6)image
- Images with modal viewervideo
- HTML5 video with controlsactions
- Interactive button groupsconversation
- Dialogue with chat/messenger variantsanotherStory
- Embed other story passages
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' }
},
// Image hotspot with states
{
type: 'image',
content: {
idle: '/icons/chest.png',
hover: '/icons/chest-glow.png',
},
position: { x: 60, y: 70 },
action: () => openChest(),
},
// 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 variantsSideLabelHotspot
- Text buttons on edges (top/bottom/left/right)SideImageHotspot
- Image buttons on edgesMapMenu
- Context menu with multiple items
Widget Passages
Custom React components as passages:
import { newWidget } from '@react-text-game/core';
const customUI = newWidget('custom-ui', (
<div>
<h1>Custom Interface</h1>
<MyCustomComponent />
</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
Navigation
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 actionsuseSaveGame
- Save current game stateuseLoadGame
- Load saved game stateuseDeleteGame
- Delete a specific saveuseDeleteAllSlots
- Delete all saves (except system save)useLastLoadGame
- Load the most recent saveuseExportSaves
- Export saves to encrypted fileuseImportSaves
- Import saves from encrypted fileuseRestartGame
- 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 URLoptions?: AudioOptions
- Configuration options- Returns:
AudioTrack
AudioTrack Methods:
play(): Promise<void>
- Start playbackpause(): void
- Pause playbackresume(): void
- Resume from pausestop(): void
- Stop and resetsetVolume(volume: number): void
- Set volume (0.0-1.0)setLoop(loop: boolean): void
- Enable/disable loopingsetPlaybackRate(rate: number): void
- Set playback speedsetMuted(muted: boolean): void
- Mute/unmuteseek(time: number): void
- Seek to time in secondsfadeIn(duration?: number): Promise<void>
- Fade in effectfadeOut(duration?: number): Promise<void>
- Fade out effectgetState(): AudioState
- Get reactive statesave(): void
- Save state to storageload(): void
- Load state from storagedispose(): void
- Clean up and remove
AudioManager Methods:
setMasterVolume(volume: number): void
- Set master volumegetMasterVolume(): number
- Get master volumemuteAll(): void
- Mute all tracksunmuteAll(): void
- Unmute all trackspauseAll(): void
- Pause all playing tracksresumeAll(): void
- Resume all paused tracksstopAll(): void
- Stop all tracksgetAllTracks(): AudioTrack[]
- Get all tracksgetTrackById(id: string): AudioTrack | undefined
- Get track by IDdisposeAll(): 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
const player = createEntity('player', {
name: 'Hero',
inventory: [] as string[], // Explicit array type
});
Next Steps
- Core API Reference - Complete API documentation
- UI API Reference - UI components documentation
- Example Projects - See it in action
- Example Game - Full game with Vite + React 19
- Core Test App - Core package examples
- UI Test App - UI components showcase