A React library for integrating Wavedash into your React-based game.
npm install wavedash-reactWrap your app with the WavedashProvider to enable Wavedash features:
import { WavedashProvider } from "wavedash-react";
export function App() {
return (
<WavedashProvider
config={{
debug: true,
}}
>
<YourGame />
</WavedashProvider>
);
}You can also preload assets before rendering your app. Audio assets are declared as a Record so each sound or music track has a stable id you can use later with the audio hooks:
import { WavedashProvider } from "wavedash-react";
export function App() {
return (
<WavedashProvider
preload={{
audio: {
click: "./audio/click.mp3",
title: ["./audio/title.ogg", "./audio/title.mp3"],
battle: ["./audio/battle.ogg", "./audio/battle.mp3"],
},
images: ["./images/logo.png"],
videos: ["./videos/intro.webm"],
}}
>
<YourGame />
</WavedashProvider>
);
}For audio arrays, the browser chooses the first supported source, just like a regular <audio> element with multiple <source> tags.
Access the Wavedash context to check if your game is running inside Wavedash:
import { useWavedash } from "wavedash-react";
export function GameComponent() {
const { isRunningInWavedash, wavedash } = useWavedash();
if (!isRunningInWavedash) {
return <p>Not running in Wavedash environment</p>;
}
return <p>Running in Wavedash!</p>;
}You can access the native Wavedash SDK via the wavedash object. It exposes all SDK features directly, so if a capability is not wrapped yet by wavedash-react, you can still use it through this object.
Get the current logged-in user:
import { useCurrentUser } from "wavedash-react";
export function UserProfile() {
const user = useCurrentUser();
return user ? <p>Welcome, {user.username}!</p> : <p>Not logged in</p>;
}A React component for displaying a user's avatar:
import { UserAvatar } from "wavedash-react";
export function Avatar() {
return <UserAvatar size={64} />;
}
export function OtherPlayerAvatar() {
return <UserAvatar userId="specific_userid_here" size={64} />;
}The library includes a small typed state layer built around two optional state domains:
ui: ephemeral UI/runtime state (modals, local view state, temporary flags)persistent: state that can be saved and loaded through Wavedash
import { createStateContext, defineState } from "wavedash-react";
const uiState = defineState({
initialState: {
isMenuOpen: false,
selectedTab: "inventory" as "inventory" | "stats",
},
actions: {
openMenu: (currentState) => ({ ...currentState, isMenuOpen: true }),
closeMenu: (currentState) => ({ ...currentState, isMenuOpen: false }),
selectTab: (currentState, newSelectedTab) => ({
...currentState,
selectedTab: newSelectedTab,
}),
},
});
const persistentState = defineState({
initialState: {
coins: 0,
healthPoints: 100,
maxHealthPoints: 100,
},
actions: {
addCoins: (currentState, amount) => ({
...currentState,
coins: currentState.coins + amount,
}),
heal: (currentState, healAmount) => ({
...currentState,
healthPoints: Math.min(
currentState.healthPoints + healAmount,
currentState.maxHealthPoints,
),
}),
},
});
export const {
StateProvider,
useUiSelector,
useUiActions,
usePersistentSelector,
usePersistentActions,
usePersistentMeta,
usePersistentControls,
} = createStateContext({
ui: uiState,
persistent: persistentState,
});You can create a context with only ui, only persistent, or both.
StateProvider must be rendered under WavedashProvider.
import { WavedashProvider } from "wavedash-react";
import { StateProvider } from "./gameState";
export function App() {
return (
<WavedashProvider>
<StateProvider
saveOptions={{
fileName: "main",
autoLoad: true,
autoSave: true,
autoSaveDebounceMs: 500,
}}
>
<YourGame />
</StateProvider>
</WavedashProvider>
);
}saveOptions and all its properties are optional:
fileName: the name of the file where the data will be saved. Usefull if you want your game to have multiple save slots. Default to"main".autoLoad: iftrue, the initial state will be automatically loaded from the one found on the save file. Default tofalse.autoSave: iftrue, each action performed on the persistent state will be saved on Wavedash. Default tofalse.autoSaveDebounceMs: a delay to avoid calling the Wavedash saving system too often. Default to500.
Actions are synchronous and return the new state, so you can use the result immediately if needed:
import {
useUiActions,
useUiSelector,
usePersistentActions,
usePersistentSelector,
} from "./gameState";
export function Hud() {
const isMenuOpen = useUiSelector((s) => s.isMenuOpen);
const coins = usePersistentSelector((s) => s.coins);
const { openMenu, closeMenu } = useUiActions();
const { addCoins } = usePersistentActions();
return (
<div>
<p>Menu: {isMenuOpen ? "open" : "closed"}</p>
<p>Coins: {coins}</p>
<button onClick={openMenu}>Open menu</button>
<button onClick={closeMenu}>Close menu</button>
<button
onClick={() => {
const newState = addCoins(10);
console.log("New coin count:", newState.coins);
}}
>
+10 coins
</button>
</div>
);
}Use the regular selector hooks when you read a single primitive value:
const coins = usePersistentSelector((s) => s.coins);
const isMenuOpen = useUiSelector((s) => s.isMenuOpen);Use the Shallow variants when your selector returns an object or an array created on the fly:
const hud = useUiSelectorShallow((s) => ({
isMenuOpen: s.isMenuOpen,
selectedTab: s.selectedTab,
}));Why this matters:
useUiSelectorandusePersistentSelectorcompare the selected value withObject.is.- If your selector returns a new object on every render,
Object.issees it as different, even when the contained values did not change, and it'll trigger a new render. useUiSelectorShallowandusePersistentSelectorShallowavoid that by comparing the first-level keys and values instead.
Rule of thumb:
- primitive result: use the regular selector hooks
- object or array result: use the
Shallowvariants - custom comparison logic: use
useUiSelector/usePersistentSelectorwith your own equality function
Use persistent controls for explicit save/load, and persistent meta to drive UI feedback.
usePersistentMeta() returns:
loadStatus:"idle"|"loading"|"ready"|"error"isSaving:booleanerror: the error object if the last load or save failed,nullotherwise
import { usePersistentControls, usePersistentMeta } from "./gameState";
export function SaveStatus() {
const { loadStatus, isSaving, error } = usePersistentMeta();
const { saveNow, load } = usePersistentControls();
if (loadStatus === "loading") return <p>Loading save...</p>;
if (loadStatus === "error") return <p>Failed to load save</p>;
return (
<div>
<p>Save loaded: {loadStatus === "ready" ? "yes" : "no"}</p>
<p>Saving: {isSaving ? "yes" : "no"}</p>
<button onClick={() => saveNow()}>Save now</button>
<button onClick={() => load()}>Load now</button>
</div>
);
}When autoSave is enabled, persistent actions schedule a debounced save automatically. Auto-save is held back until the initial load completes, preventing the initial state from overwriting a previous save on page reload.
When the game is not running inside Wavedash (isRunningInWavedash === false), the save system falls back to localStorage transparently. This means:
- Developers can test locally without the Wavedash environment.
- The same
saveOptionsand hooks work in both contexts. - No code changes are needed — the fallback is automatic.
Three hooks handle different leaderboard scenarios:
Fetch a range of leaderboard entries:
import { useLeaderboardEntries } from "wavedash-react";
export function TopPlayers() {
const { isLoading, entries } = useLeaderboardEntries("main-leaderboard", {
start: 0,
count: 10,
});
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{entries.map((entry) => (
<li key={entry.userId}>
{entry.username}: {entry.score} pts
</li>
))}
</ul>
);
}Fetch the current user's leaderboard entries:
import { useLeaderboardCurrentUserEntries } from "wavedash-react";
export function MyStats() {
const { isLoading, entries } =
useLeaderboardCurrentUserEntries("main-leaderboard");
return (
<div>{entries.length > 0 && <p>Your rank: {entries[0].globalRank}</p>}</div>
);
}Fetch entries around the current user (ahead and behind):
import { useLeaderboardEntriesAroundCurrentUser } from "wavedash-react";
export function NearbyPlayers() {
const { isLoading, entries } = useLeaderboardEntriesAroundCurrentUser(
"main-leaderboard",
{ countAhead: 5, countBehind: 5 },
);
return <div>{entries.length} players nearby</div>;
}Use the useLeaderboard hook to access the submitScore method:
import { useLeaderboard } from "wavedash-react";
export function GameOver({ finalScore }: { finalScore: number }) {
const { submitScore } = useLeaderboard("main-leaderboard");
const handleSubmitScore = async () => {
const entry = await submitScore(finalScore);
const globalRank = entry?.globalRank ?? null;
if (globalRank !== null) {
alert(`Your rank: #${globalRank}`);
}
};
return <button onClick={handleSubmitScore}>Submit Score</button>;
}The submitScore method accepts:
score(required): The score value to submitkeepBest(optional): Whether to keep only the best score (default: true)ugcId(optional): User-generated content ID for additional context
Returns the created or updated leaderboard entry, or null if submission failed.
The library also exposes helpers to control sound effects and music from the assets preloaded in WavedashProvider.
Sound effects and music are disabled by default. Enable them through useAudio() before calling useSound() or useMusic().
useAudio() gives access to the shared audio state and controls:
areSoundsEnabledisMusicEnabledisAudioEnabled()soundsVolumemusicVolumetoggleSounds(force?)toggleMusic(force?)toggleAudio(force?)playSound(audioId, loop?)stopSound(audioId)playMusic(musicId)pauseMusic()resumeMusic()setSoundsVolume(value)setMusicVolume(value)
import { useAudio } from "wavedash-react";
export function AudioSettings() {
const {
areSoundsEnabled,
isMusicEnabled,
isAudioEnabled,
toggleSounds,
toggleMusic,
playSound,
soundsVolume,
musicVolume,
setSoundsVolume,
setMusicVolume,
} = useAudio();
return (
<div>
<button onClick={() => toggleSounds()}>
Sound effects: {areSoundsEnabled ? "on" : "off"}
</button>
<button onClick={() => toggleMusic()}>
Music: {isMusicEnabled ? "on" : "off"}
</button>
<button onClick={() => playSound("click")}>Play click</button>
<p>Any audio enabled: {isAudioEnabled() ? "yes" : "no"}</p>
<button onClick={() => setSoundsVolume(0.5)}>
SFX volume: {soundsVolume}
</button>
<button onClick={() => setMusicVolume(0.5)}>
Music volume: {musicVolume}
</button>
</div>
);
}If you want audio enabled immediately, you can do it once when your app starts:
import { useEffect } from "react";
import { useAudio } from "wavedash-react";
export function EnableAudioOnStart() {
const { toggleSounds, toggleMusic } = useAudio();
useEffect(() => {
toggleSounds(true);
toggleMusic(true);
}, [toggleSounds, toggleMusic]);
return null;
}When music is disabled, the currently playing track is paused automatically. If it was paused by the toggle, it resumes automatically when music is re-enabled.
useSound(soundId) returns functions to control one preloaded sound effect.
import { useSound } from "wavedash-react";
export function ShootButton() {
const { playSound, stopSound } = useSound("click");
return (
<div>
<button onClick={() => playSound()}>Play once</button>
<button onClick={() => playSound(true)}>Loop</button>
<button onClick={stopSound}>Stop</button>
</div>
);
}useSounds() returns global sound controls (not bound to a single id).
import { useSounds } from "wavedash-react";
export function GenericSfxButtons() {
const { playSound, stopSound } = useSounds();
return (
<div>
<button onClick={() => playSound("click")}>Play click</button>
<button onClick={() => playSound("explosion", true)}>
Loop explosion
</button>
<button onClick={() => stopSound("explosion")}>Stop explosion</button>
</div>
);
}useMusic() controls the shared music player for the whole app.
import { useMusic } from "wavedash-react";
export function MusicControls() {
const { playMusic, pauseMusic, resumeMusic } = useMusic();
return (
<div>
<button onClick={() => playMusic("title")}>Play title music</button>
<button onClick={() => playMusic("battle")}>Play battle music</button>
<button onClick={pauseMusic}>Pause</button>
<button onClick={resumeMusic}>Resume</button>
</div>
);
}Only one music track can play at a time. Starting a new one stops the previous track first.
If you call playMusic() with the same track already playing, it is ignored (the track is not restarted from 0).
Track custom game stats:
import { useStat } from "wavedash-react";
export function StatsDisplay() {
const [stat, setStat] = useStat("player-kills");
const kills = stat ?? 0;
return (
<div>
<p>Kills: {kills}</p>
<button onClick={() => setStat(kills + 1)}>Increment</button>
</div>
);
}useStat(name) returns a tuple:
- current stat value as
number | null setStat(newValue, storeNow?)
Display and unlock achievements:
import { useAchievement } from "wavedash-react";
export function AchievementHandler() {
const [isUnlocked, unlockAchievement] = useAchievement("first-kill");
return (
<div>
<p>Status: {isUnlocked ? "Unlocked" : "Locked"}</p>
<button onClick={() => unlockAchievement()}>Unlock</button>
</div>
);
}useAchievement(name) returns a tuple:
- current unlocked state as
boolean | null unlockAchievement(storeNow?)
Gate paid content and drive the Wavedash paywall flow.
import { useEntitlement } from "wavedash-react";
export function PremiumLevel() {
const [isEntitled, triggerPaywall] = useEntitlement("level-pack-2");
if (isEntitled === null) return <p>Checking access...</p>;
if (!isEntitled) {
return (
<button onClick={() => triggerPaywall()}>Unlock this content</button>
);
}
return <p>Enjoy your premium level!</p>;
}useEntitlement(contentIdentifier) returns a tuple:
- current ownership state as
boolean | null(nullwhile it is being resolved) triggerPaywall(): opens the Wavedash paywall and resolves to abooleanindicating whether the user now owns the content. If the user already owns it, it resolves totrueimmediately without showing the paywall.
To check several items at once, use useEntitlements():
import { useEntitlements } from "wavedash-react";
export function OwnedContent() {
const { isLoading, entitlements } = useEntitlements();
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{entitlements.map((contentId) => (
<li key={contentId}>{contentId}</li>
))}
</ul>
);
}useEntitlements() returns an object:
isLoading:booleanentitlements: the list of owned content identifiers asstring[]
useEntitlement only tells you what to display — it does not deliver the content. When your premium content is stored as remote files on Wavedash, usePaidAsset chains the whole flow for you: it makes sure the user owns the content (opening the paywall when needed), downloads the remote file, and reads its bytes back. The actual access control happens server-side during the download, so a tampered client can never obtain the bytes.
import { usePaidAsset } from "wavedash-react";
export function PremiumBoss() {
const { isEntitled, isLoading, url, error, load } = usePaidAsset(
"level-pack-2",
"level-pack-2/boss.png",
{ mimeType: "image/png" },
);
if (error) return <p>Failed to load premium content</p>;
return url ? (
<img src={url} alt="Premium boss" />
) : (
<button onClick={() => load()} disabled={isLoading}>
{isEntitled ? "Load" : "Unlock and load"}
</button>
);
}usePaidAsset(contentIdentifier, filePath, options?) accepts:
contentIdentifier(required): the entitlement that gates the filefilePath(required): the path of the remote file to downloadoptions.mimeType(optional): when provided, a ready-to-use object URL is created from the downloaded bytes
It returns an object:
isEntitled: current ownership state asboolean | nullisLoading:boolean,truewhileload()is runningdata: the downloaded bytes asUint8Array | nullurl: an object URL asstring | null(only created whenmimeTypeis set)error: the error object if the last load failed,nullotherwiseload(): triggers the paywall if needed, downloads and reads the file, and resolves to abooleanindicating success
The object URL is automatically revoked when the component unmounts or before a new one is created. For non-visual assets (JSON, binary data), omit mimeType and use data directly.
The package ships with TypeScript definitions and can be consumed from both TypeScript and plain JavaScript projects.