Skip to content

React Hooks

A React hook is a function that gives a component access to shared state or behavior. Doodle Engine’s hooks provide game state, player actions, audio, and input handling.

Access the shared game state and actions from any component inside a GameProvider.

import { useGame } from '@doodle-engine/react';
function MyComponent() {
const { snapshot, actions } = useGame();
return (
<div>
<h1>{snapshot.location.name}</h1>
<button onClick={() => actions.talkTo('bartender')}>
Talk to {snapshot.charactersHere[0]?.name}
</button>
</div>
);
}
interface GameContextValue {
snapshot: Snapshot;
actions: {
selectChoice: (choiceId: string) => void;
continueDialogue: () => void;
talkTo: (characterId: string) => void;
travelTo: (locationId: string) => void;
writeNote: (title: string, text: string) => void;
deleteNote: (noteId: string) => void;
setLocale: (locale: string) => void;
setPlayerProfile: (profile: PlayerProfileInput) => void;
saveGame: () => SaveData;
loadGame: (saveData: SaveData) => void;
dismissInterlude: () => void;
};
}

Must be used inside a GameProvider. Throws an error if used outside.

Each action calls the corresponding engine method and updates the snapshot:

Action Description
selectChoice(choiceId) Pick a dialogue choice
continueDialogue() Advance past a text-only dialogue node
talkTo(characterId) Start conversation with a character
travelTo(locationId) Travel to a map location
writeNote(title, text) Add a player note
deleteNote(noteId) Remove a player note
setLocale(locale) Change language
setPlayerProfile(profile) Complete a requested player profile
saveGame() Returns SaveData (doesn’t update snapshot)
loadGame(saveData) Restores state and updates snapshot
dismissInterlude() Clears a pending interlude from the snapshot

Read and update the persistent volume settings from AudioSettingsProvider.

import { useAudioSettings } from '@doodle-engine/react';
function MusicVolume() {
const audio = useAudioSettings();
return (
<input
aria-label="Music volume"
type="range"
min="0"
max="1"
step="0.1"
value={audio.musicVolume}
onChange={(event) =>
audio.setMusicVolume(Number(event.target.value))
}
/>
);
}

The returned object contains masterVolume, musicVolume, soundVolume, and voiceVolume, plus a setter for each value. The hook must be used inside AudioSettingsProvider.


Manages audio playback as the snapshot changes. Pass the current volume values each time the component renders. Active audio updates when those values change.

Store volume state in AudioSettingsContext or another application store and pass the values to the hook.

import { useAudioManager, useAudioSettings, AudioSettingsProvider } from '@doodle-engine/react';
function MyGame() {
const { snapshot } = useGame();
const audioSettings = useAudioSettings();
const { stopAll } = useAudioManager(snapshot, {
masterVolume: audioSettings.masterVolume,
musicVolume: audioSettings.musicVolume,
soundVolume: audioSettings.soundVolume,
voiceVolume: audioSettings.voiceVolume,
});
return <button onClick={stopAll}>Stop Audio</button>;
}
// Wrap in AudioSettingsProvider for persistent volume state
<AudioSettingsProvider>
<GameProvider engine={engine} initialSnapshot={snapshot}>
<MyGame />
</GameProvider>
</AudioSettingsProvider>
Parameter Type Description
snapshot Snapshot Current game snapshot
options AudioManagerOptions Volume levels and crossfade config

Volume values are reactive: when they change, all active audio elements update immediately.

Option Type Default Description
masterVolume number 1.0 Master volume multiplier (0-1)
musicVolume number 0.7 Music channel volume (0-1)
soundVolume number 0.8 Sound effects volume (0-1)
voiceVolume number 1.0 Voice channel volume (0-1)
crossfadeDuration number 1000 Music crossfade duration in ms
interface AudioManagerControls {
stopAll: () => void;
}

The hook manages four channels:

Channel Source Behavior
Music snapshot.music Loops, crossfades between tracks
Ambient snapshot.ambient Loops, swaps on location change
Voice snapshot.dialogue?.voice Plays dialogue voice lines
Sound snapshot.pendingSounds One-shot effects, cleared after playing
  • When snapshot.music changes, the current track crossfades to the new one
  • When snapshot.ambient changes, the ambient track swaps immediately
  • When snapshot.dialogue?.voice is present, the voice file plays
  • All entries in snapshot.pendingSounds are played as one-shot effects
  • Volume levels are applied as channelVolume × masterVolume
  • Audio paths are resolved by the engine before reaching this hook. Write bare filenames in YAML content.

Hook for interface sounds such as clicks and menus opening or closing. useAudioManager handles audio from game content, while useUISounds handles the renderer’s controls.

import { useUISounds } from '@doodle-engine/react';
function MyUI() {
const uiSounds = useUISounds({
basePath: 'assets/audio/ui',
volume: 0.5,
sounds: {
click: 'click.ogg',
menuOpen: 'menu_open.ogg',
menuClose: 'menu_close.ogg',
},
});
return (
<button
onClick={() => {
uiSounds.playClick();
doSomething();
}}
>
Click Me
</button>
);
}
Option Type Default Description
enabled boolean true Enable/disable UI sounds
basePath string 'assets/audio/ui' Base path for UI sound files
volume number 0.5 Volume level (0-1)
sounds object Custom sound file names
sounds.click string 'click.ogg' Click sound file
sounds.hover string 'hover.ogg' Hover sound file
sounds.menuOpen string 'menu_open.ogg' Menu open sound file
sounds.menuClose string 'menu_close.ogg' Menu close sound file
interface UISoundControls {
playClick: () => void;
playHover: () => void;
playMenuOpen: () => void;
playMenuClose: () => void;
playSound: (key: string) => void;
setEnabled: (enabled: boolean) => void;
setVolume: (volume: number) => void;
enabled: boolean;
volume: number;
}

GameShell uses useUISounds internally. Configure via the uiSounds prop:

<GameShell
registry={registry}
config={config}
manifest={manifest}
projectId={PROJECT_ID}
uiSounds={{
basePath: 'assets/audio/ui',
volume: 0.5,
sounds: { click: 'click.ogg' },
}}
/>

Pass uiSounds={false} to disable UI sounds entirely.


Register a renderer input command handler. Use this for keyboard commands in custom renderer surfaces, panels, and overlays.

import { InputProvider, useInputAction } from '@doodle-engine/react';
function DialogueControls({ choices, onChoice, onContinue }) {
useInputAction(
({ command, choiceIndex }) => {
if (command === 'confirm' && choices.length === 0) {
onContinue();
return true;
}
if (
choiceIndex !== undefined &&
choiceIndex < choices.length
) {
onChoice(choices[choiceIndex].id);
return true;
}
return false;
},
{ priority: 0 }
);
return null;
}
<InputProvider>
<DialogueControls
choices={snapshot.choices}
onChoice={actions.selectChoice}
onContinue={actions.continueDialogue}
/>
</InputProvider>;

GameShell already includes InputProvider. GameRenderer creates a provider boundary when used standalone, so its built-in keyboard handling works with or without GameShell.

Keyboard input is translated into these commands:

Command Default keyboard input
confirm Enter, Space
cancel Escape
choice1-choice9 Number keys 1-9
next ArrowDown, ArrowRight
previous ArrowUp, ArrowLeft

The command type also includes continue, openInventory, openJournal, openMap, and openMenu so custom renderers can dispatch higher-level UI commands when they need them.

Higher-priority handlers receive commands first. Return true to consume the command, which stops it from reaching lower-priority handlers.

Recommended priorities:

Surface Priority
Full-screen video/interlude 300
Modal or panel overlay 150
Shell pause/settings 50
Dialogue choices/Continue 0

Input events from text fields, textareas, selects, and contenteditable elements are ignored by the default keyboard adapter so typing into player notes does not trigger game commands.

Access the current input router directly. Use useInputAction for component commands and useInputRouter when connecting another input source.

import { useInputRouter } from '@doodle-engine/react';
function CustomInputBridge() {
const router = useInputRouter();
useEffect(() => {
if (!router) return;
// When your custom input layer detects the confirm action:
router.dispatchCommand({
command: 'confirm',
source: 'programmatic',
});
}, [router]);
return null;
}

Use these helpers when a custom renderer needs its own save interface. Generate the storage key from the project’s stable ID, then use that same key for every operation.

import {
latestSave,
saveStorageKeyForProject,
useGame,
writeSave,
} from '@doodle-engine/react';
import { PROJECT_ID } from './project';
function SaveButtons() {
const { actions } = useGame();
const saveKey = saveStorageKeyForProject(PROJECT_ID);
const save = () => {
writeSave(localStorage, saveKey, actions.saveGame(), 'manual');
};
const load = () => {
const savedGame = latestSave(localStorage, saveKey);
if (savedGame) actions.loadGame(savedGame);
};
return (
<>
<button onClick={save}>Save</button>
<button onClick={load}>Load latest</button>
</>
);
}
Helper Result
saveStorageKeyForProject(id) Validated storage key for one project
listSaves(storage, key) All quick, auto, and manual slots in display order
hasSaves(storage, key) Whether at least one slot exists
writeSave(storage, key, save, kind?, options?) Written slot; quick and auto overwrite their existing kind
loadSave(storage, key, id) Saved game for one slot, or null
deleteSave(storage, key, id) Removes one slot
latestSave(storage, key) Most recent saved game, or null

See Save & Load for project isolation, slot behavior, and migration details.