Appearance
Sounds Guide
This guide explains how to work with sounds in RPG-JS, including static sound configuration, playing sounds from the server, and dynamic sound resolution.
Overview
Sounds are audio files that can be played during gameplay to provide audio feedback for actions, events, and ambient effects. RPG-JS supports both pre-loaded static sounds and dynamic sound creation through a resolver system.
Static Sounds
Basic Configuration
Add sounds to your client module configuration:
typescript
import { defineModule, RpgClient } from '@rpgjs/client';
export default defineModule<RpgClient>({
sounds: [
{
id: "typewriter",
src: "typewriter.wav",
},
{
id: "cursor",
src: "cursor.wav",
},
{
id: "item-pickup",
src: "sounds/item-pickup.mp3",
},
],
});Sound Object Structure
A sound object can have the following properties:
typescript
interface SoundDefinition {
id: string; // Unique identifier for the sound
src: string; // Path to the audio file
// Optional: If the sound object has a play() method, it will be used directly
play?: () => void; // Custom play method (optional)
stop?: () => void; // Custom stop method (optional)
pause?: () => void; // Custom pause method (optional)
}If a sound object has a play() method, it will be used directly. Otherwise, the engine will create an Audio element from the src property.
Playing Sounds from Server
Using player.playSound()
You can play sounds from the server side using the playSound() method on a player instance. This method plays the sound only for the specific player, making it ideal for personal feedback sounds.
typescript
/**
* Play a sound on the client side for this player only
* @param soundId - Sound identifier, defined on the client side
* @param options - Optional sound configuration
* @param options.volume - Volume level (0.0 to 1.0, default: 1.0)
* @param options.loop - Whether the sound should loop (default: false)
*/
player.playSound(soundId: string, options?: { volume?: number; loop?: boolean }): voidUsing player.stopAllSounds()
Stop all currently playing sounds for this specific player. This is useful when changing maps or resetting the audio state for a player.
typescript
/**
* Stop all currently playing sounds for this player
* Useful when changing maps to prevent sound overlap.
*/
player.stopAllSounds(): voidUsing map.playSound()
To play a sound for all players on the map, use the playSound() method on the map instance. This is ideal for environmental sounds, battle music, or map-wide events.
typescript
/**
* Play a sound for all players on the map
* @param soundId - Sound identifier, defined on the client side
* @param options - Optional sound configuration
* @param options.volume - Volume level (0.0 to 1.0, default: 1.0)
* @param options.loop - Whether the sound should loop (default: false)
*/
map.playSound(soundId: string, options?: { volume?: number; loop?: boolean }): voidUsing map.stopSound()
Stop a specific sound for all players on the map.
typescript
/**
* Stop a sound for all players on the map
* @param soundId - Sound identifier to stop
*/
map.stopSound(soundId: string): voidExamples
Play Sound for Current Player Only
typescript
// Play item pickup sound only for this player
player.playSound("item-pickup");
// Play a notification sound with custom volume
player.playSound("notification", { volume: 0.5 });
// Play background music for this player with loop
player.playSound("background-music", { volume: 0.7, loop: true });Play Sound for All Players on Map
typescript
// Play an explosion sound for everyone on the map
map.playSound("explosion");
// Play battle music for all players with volume and loop
map.playSound("battle-theme", { volume: 0.8, loop: true });
// Play a door opening sound at low volume
map.playSound("door-open", { volume: 0.4 });Stopping Sounds
You can stop sounds using stopSound() on either the player or map:
typescript
// Stop a sound for this player
player.stopSound("background-music");
// Stop a sound for all players on the map
map.stopSound("battle-theme");Stop All Sounds
You can also stop all currently playing sounds at once:
typescript
// Stop all sounds for this player
player.stopAllSounds();
// To stop all sounds for all players on a map, iterate through players
const map = player.getCurrentMap();
map?.getPlayers().forEach(p => p.stopAllSounds());This is particularly useful when changing maps to prevent sound overlap. See the Map Sounds Configuration section below for automatic sound stopping when joining maps.
Map Sounds Configuration
Maps can automatically play sounds when a player joins. These sounds are configured using the sounds property in the map configuration.
Basic Map Sounds
The simplest way to configure map sounds is using a plain object with provideServerModules:
typescript
import { provideServerModules } from '@rpgjs/server';
provideServerModules([
{
maps: [
{
id: 'town',
sounds: ['town-bgm', 'town-ambience'] // These sounds play when player joins
}
]
}
])Stop All Sounds Before Joining
By default, sounds from the previous map continue playing when a player changes maps. If you want to stop all sounds before playing the new map's sounds, use the stopAllSoundsBeforeJoin option:
typescript
import { provideServerModules } from '@rpgjs/server';
provideServerModules([
{
maps: [
{
id: 'battle-map',
sounds: ['battle-theme'],
stopAllSoundsBeforeJoin: true // Stop all previous sounds before playing battle theme
}
]
}
])When to use stopAllSoundsBeforeJoin: true:
- Battle maps (to cut off peaceful background music)
- Important cutscenes or story moments
- Areas where you want a clean audio transition
- Maps with their own distinct audio atmosphere
When to keep it false (default):
- Smooth transitions between similar areas
- Continuous ambient sounds that should carry over
- When you want sounds to layer naturally
Complete Map Sound Example
typescript
import { provideServerModules } from '@rpgjs/server';
provideServerModules([
{
maps: [
// Peaceful town - sounds continue from previous map
{
id: 'town',
sounds: ['town-bgm']
},
// Battle arena - stops all sounds for dramatic effect
{
id: 'battle-arena',
sounds: ['battle-theme'],
stopAllSoundsBeforeJoin: true
},
// Boss room - stops all sounds and plays boss music
{
id: 'boss-room',
sounds: ['boss-theme'],
stopAllSoundsBeforeJoin: true
}
]
}
])Using defineModule (Alternative)
You can also use defineModule to define your server configuration separately:
typescript
import { defineModule, type RpgServer } from '@rpgjs/common';
import { provideServerModules } from '@rpgjs/server';
// Define your server module
const serverModule = defineModule<RpgServer>({
maps: [
{
id: 'town',
sounds: ['town-bgm'],
stopAllSoundsBeforeJoin: false
}
]
});
// Then use it with provideServerModules
provideServerModules([serverModule])Alternative: Using @MapData or @RpgModule (Legacy)
For backward compatibility, you can still use decorators, though plain objects are now the recommended approach:
typescript
import { MapData, RpgMap } from '@rpgjs/server';
@MapData({
id: 'town',
sounds: ['town-bgm', 'town-ambience'],
stopAllSoundsBeforeJoin: false
})
class TownMap extends RpgMap {}Or with @RpgModule:
typescript
import { RpgServer, RpgModule } from '@rpgjs/server';
@RpgModule<RpgServer>({
maps: [
{
id: 'town',
sounds: ['town-bgm']
}
]
})
class MyServerModule {}Note: Using plain objects with
provideServerModulesordefineModuleis the recommended approach. Decorators are still supported for backward compatibility but not required.
Use Cases
Using map.playSound() (for all players):
- Environmental sounds (explosions, doors opening)
- Battle sounds (combat start, victory fanfare)
- Map-wide events (boss spawn, treasure chest opening)
- Ambient effects that should be synchronized
- Background music that everyone should hear
Using player.playSound() (for current player only):
- UI feedback sounds (menu navigation, button clicks)
- Personal notifications (level up, achievement unlocked)
- Item pickup sounds (only the player who picked it up hears it)
- Private messages or alerts
- Personal background music
Complete Example
typescript
import { provideServerModules, RpgPlayer, RpgPlayerHooks, RpgMap, RpgEvent } from '@rpgjs/server';
provideServerModules([
{
player: {
async onConnected(player: RpgPlayer) {
// Stop all sounds when player first connects (clean state)
player.stopAllSounds();
// Play welcome sound for this player only
player.playSound("welcome", { volume: 0.6 });
},
async onLevelUp(player: RpgPlayer) {
// Play level up sound for this player only
player.playSound("level-up", { volume: 0.8 });
// Show level up animation
player.showComponentAnimation("level-up", {
text: `Level ${player.level}`,
color: "gold"
});
},
async onTouchEvent(player: RpgPlayer, event: RpgEvent) {
if (event.name === "Treasure Chest") {
const map = player.getCurrentMap();
// Play chest opening sound for everyone on the map
map?.playSound("chest-open", { volume: 0.7 });
// Play item pickup sound only for this player
player.playSound("item-pickup", { volume: 0.5 });
}
},
async onBattleStart(player: RpgPlayer) {
const map = player.getCurrentMap();
// Play battle music for everyone with loop
map?.playSound("battle-theme", { volume: 0.8, loop: true });
},
async onBattleEnd(player: RpgPlayer) {
const map = player.getCurrentMap();
// Stop battle music for everyone
map?.stopSound("battle-theme");
// Play victory sound for this player
player.playSound("victory", { volume: 1.0 });
}
},
maps: [
{
id: 'town',
sounds: ['town-bgm'],
stopAllSoundsBeforeJoin: false
},
{
id: 'battle-arena',
sounds: ['battle-theme'],
stopAllSoundsBeforeJoin: true
}
]
}
]);Dynamic Sound Resolver
The sound resolver allows you to create sounds on-the-fly when they are requested but not found in the cache. This is useful for:
- Loading sounds from external APIs
- Generating sounds programmatically
- Creating sounds based on runtime data
- Lazy loading sounds to reduce initial load time
Configuration
Add a soundResolver function to your client module:
typescript
import { defineModule, RpgClient } from '@rpgjs/client';
export default defineModule<RpgClient>({
soundResolver: (id: string) => {
// Synchronous resolver
if (id === 'dynamic-sound') {
return {
id: 'dynamic-sound',
src: 'path/to/sound.mp3'
};
}
return undefined; // Return undefined if sound cannot be created
},
});Asynchronous Resolver
The resolver can also be asynchronous, useful for loading sounds from APIs or files:
typescript
export default defineModule<RpgClient>({
soundResolver: async (id: string) => {
// Load from API
try {
const response = await fetch(`/api/sounds/${id}`);
if (!response.ok) {
return undefined;
}
const soundData = await response.json();
return {
id: id,
src: soundData.url,
};
} catch (error) {
console.error(`Failed to load sound ${id}:`, error);
return undefined;
}
},
});Programmatic Generation
You can also generate sounds programmatically:
typescript
export default defineModule<RpgClient>({
soundResolver: (id: string) => {
// Generate sound path based on ID pattern
if (id.startsWith('footstep-')) {
const surface = id.split('-')[1]; // e.g., 'footstep-grass'
return {
id: id,
src: `sounds/footsteps/${surface}.mp3`,
};
}
// Generate sound for weapon types
if (id.startsWith('weapon-')) {
const weaponType = id.split('-')[1];
return {
id: id,
src: `sounds/weapons/${weaponType}/swing.mp3`,
};
}
return undefined;
},
});How It Works
- When a sound is requested (e.g., via
player.playSound()), the engine first checks the cache - If the sound is not found and a resolver is configured, the resolver is called with the sound ID
- The resolved sound is automatically cached for future use
- If the resolver returns
undefinedornull, the sound is not found and a warning is logged
Resolver Function Signature
typescript
type SoundResolver = (id: string) => SoundDefinition | Promise<SoundDefinition> | undefined | null;Parameters:
id: string- The sound ID that was requested
Returns:
SoundDefinition- A sound configuration object (synchronous)Promise<SoundDefinition>- A Promise that resolves to a sound (asynchronous)undefined | null- Indicates the sound cannot be created
Programmatic API
You can also set a resolver programmatically using the engine:
typescript
import { RpgClientEngine, inject } from '@rpgjs/client';
// In a hook or initialization code
const engine = inject(RpgClientEngine);
engine.setSoundResolver((id: string) => {
// Your resolver logic
return soundDefinition;
});Example: Loading from CDN
typescript
export default defineModule<RpgClient>({
soundResolver: async (id: string) => {
const cdnUrl = `https://cdn.example.com/sounds/${id}.mp3`;
try {
// Verify the sound exists
const response = await fetch(cdnUrl, { method: 'HEAD' });
if (!response.ok) {
return undefined;
}
return {
id: id,
src: cdnUrl,
};
} catch (error) {
console.warn(`Sound ${id} not found on CDN`);
return undefined;
}
},
});Example: Fallback Chain
typescript
export default defineModule<RpgClient>({
soundResolver: async (id: string) => {
// Try local assets first
try {
const localPath = `/assets/sounds/${id}.mp3`;
const response = await fetch(localPath, { method: 'HEAD' });
if (response.ok) {
return {
id: id,
src: localPath,
};
}
} catch (error) {
// Continue to fallback
}
// Fallback to CDN
try {
const cdnUrl = `https://cdn.example.com/sounds/${id}.mp3`;
const response = await fetch(cdnUrl, { method: 'HEAD' });
if (response.ok) {
return {
id: id,
src: cdnUrl,
};
}
} catch (error) {
// Continue to fallback
}
// Fallback to default sound
if (id.startsWith('footstep-')) {
return {
id: id,
src: '/assets/sounds/default-footstep.mp3',
};
}
return undefined;
},
});Combining Static and Dynamic
You can use both static sounds and a resolver together. Static sounds are loaded first, and the resolver is only called for sounds not found in the static list:
typescript
export default defineModule<RpgClient>({
// Pre-loaded sounds
sounds: [
{ id: 'menu-click', src: 'sounds/menu-click.mp3' },
{ id: 'notification', src: 'sounds/notification.mp3' },
],
// Resolver for dynamic sounds
soundResolver: async (id: string) => {
// Only called for sounds not in the static list above
if (id.startsWith('dynamic-')) {
return await loadDynamicSound(id);
}
return undefined;
},
});Client-Side Sound API
You can also play sounds directly from the client side using the engine:
typescript
import { RpgClientEngine, inject } from '@rpgjs/client';
// In a client hook or component
const engine = inject(RpgClientEngine);
// Play a sound
await engine.playSound('item-pickup');
// Play a sound with volume and loop
await engine.playSound('background-music', { volume: 0.6, loop: true });
// Stop a sound
engine.stopSound('background-music');
// Get a sound (with resolver support)
const sound = await engine.getSound('dynamic-sound');
if (sound && sound.play) {
sound.play();
}Best Practices
1. Sound File Formats
- Use compressed formats (MP3, OGG) for better performance
- Provide multiple formats for browser compatibility:typescript
{ id: 'background-music', src: 'music.ogg', // Fallback to MP3 if OGG not supported }
2. Error Handling
Always handle errors gracefully in async resolvers:
typescript
soundResolver: async (id: string) => {
try {
// Load sound
return sound;
} catch (error) {
console.error(`Error loading sound ${id}:`, error);
return undefined;
}
}3. Performance
- Use static sounds for frequently played sounds (UI feedback, common actions)
- Use resolvers for sounds that are rarely used or loaded on-demand
- Pre-load critical sounds in the static
soundsarray
4. ID Patterns
Use consistent ID patterns to make resolver logic easier:
typescript
soundResolver: (id: string) => {
// Pattern: category-type-variant
// Example: weapon-sword-swing, weapon-axe-swing
const [category, type, variant] = id.split('-');
if (category === 'weapon') {
return {
id: id,
src: `sounds/${category}/${type}/${variant}.mp3`,
};
}
return undefined;
}5. Volume Management
Consider implementing volume controls:
typescript
// In your sound resolver or sound object
{
id: 'background-music',
src: 'music.mp3',
volume: 0.5, // 50% volume
}6. Memory Management
Resolved sounds are automatically cached. If you need to invalidate the cache:
typescript
engine.sounds.delete('sound-id');Common Use Cases
Dialog Box Sounds
typescript
// In your client module
sounds: [
{
id: "typewriter",
src: "typewriter.wav",
},
{
id: "cursor",
src: "cursor.wav",
},
],
// In your global config
box: {
sounds: {
typewriter: "typewriter",
cursorMove: "cursor",
cursorSelect: "cursor",
}
}Footstep Sounds
typescript
// Server-side: Play footstep sound based on terrain
const player: RpgPlayerHooks = {
async onMove(player: RpgPlayer) {
const terrain = player.getCurrentMap().getTerrainAt(player.x(), player.y());
player.playSound(`footstep-${terrain}`, false); // Only for this player
}
};
// Client-side: Resolver for footstep sounds
soundResolver: (id: string) => {
if (id.startsWith('footstep-')) {
const surface = id.split('-')[1];
return {
id: id,
src: `sounds/footsteps/${surface}.mp3`,
};
}
return undefined;
}Weapon Sounds
typescript
// Server-side: Play weapon sound when attacking
const player: RpgPlayerHooks = {
async onAttack(player: RpgPlayer, target: RpgPlayer) {
const weapon = player.getEquippedWeapon();
if (weapon) {
player.playSound(`weapon-${weapon.type}-swing`, true); // Everyone hears it
player.playSound(`weapon-${weapon.type}-hit`, true); // Everyone hears it
}
}
};See Also
- Spritesheets Guide - Learn about dynamic spritesheet resolution (similar pattern)
- Display Animations Guide - Combine sounds with visual effects
- Dialog Box Guide - Using sounds in dialog boxes