Sound and effects
This page covers what makes a game feel alive: sounds, lights, the sky and fog, and visual effects. Most of it you can set up in Studio without code; scripts make it react to what happens in the game.
A Sound plays an audio file from a URL.
| Property | Default | What it does |
|---|---|---|
soundUrl |
'' |
The address of the audio file. |
volume |
0.5 | Loudness, from 0 to 10. |
looped |
false |
Start over when it reaches the end. |
playing |
false |
Set it to true to play and false to stop. |
playbackSpeed |
1 | From 0.1 to 4. Faster playback also sounds higher. |
play() starts the sound from the beginning, and stop() stops it.
Where sounds play
Section titled “Where sounds play”Where you put a Sound decides where players hear it from:
| The Sound is in | Players hear it |
|---|---|
| A part in the Workspace | From that part. It’s loudest close to the player’s camera and gets quieter with distance. |
| The Workspace, but not in a part (in a Folder or Model, say) | Everywhere, at the same volume. |
| A player’s PlayerGui | Only that player hears it, at the same volume everywhere. |
| Anywhere else (ReplicatedStorage, ServerStorage, …) | Not at all. Keep sounds there as templates and clone them where they should play. |
To add a sound in Studio, select the part it should come from (or nothing, for a sound everywhere)
and choose Insert > Light, sound & motion > Sound. Then set soundUrl and the other
properties in Properties.
Each player’s browser downloads the file. The game player only loads files from sources Rally
allows, so if a sound doesn’t play, check Output: a file that can’t be loaded shows a warning such
as Sound Workspace.Bell.Ding: couldn't load …. MP3 files work in every browser.
Background music, with no code
Section titled “Background music, with no code”-
Insert a Sound with nothing selected, so it goes into the Workspace.
-
Set its
soundUrl, and avolumesuch as 0.3. -
Turn on
loopedandplaying.
Every player hears it, from the moment they join (and click).
Play a sound for everyone
Section titled “Play a sound for everyone”A Script that calls play() plays the sound for every player. This bell rings when someone
touches it. Put a Sound named Ding inside the Bell part, and this script next to it:
import { BasePart, Character, getChild, script, Sound, task } from '@rally/sdk';
const bell = script.parent;if (!(bell instanceof BasePart)) throw new Error('Put this script inside a part');const ding = getChild(bell, 'Ding', Sound);let ready = true;
bell.touched.connect((other) => { if (!ready || !(other instanceof Character)) return; ready = false; ding.play(); task.delay(1, () => { ready = true; });});Sounds played by the server reach players a moment later, with the next update. When a sound
reaches its end, playing turns false on each player’s device, but not on the server. Playing
it again from the server restarts it for players whose copy has finished.
Play a sound for one player
Section titled “Play a sound for one player”For feedback that should feel instant, such as a pickup or a button click, play the sound in a LocalScript. Only that player hears it, with no delay.
This LocalScript plays a chime whenever the server tells this player they collected something. It
expects a Sound named Chime in ReplicatedStorage (with its soundUrl set) and a RemoteEvent named
Collected there too:
import { PlayerGui, RemoteEvent, ReplicatedStorage, Sound, task, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui); const template = await waitForChild(ReplicatedStorage, 'Chime', Sound); const collected = await waitForChild(ReplicatedStorage, 'Collected', RemoteEvent);
const chime = template.clone(); chime.parent = gui; // in this player's PlayerGui: plays at full volume collected.onClientEvent.connect(() => chime.play());});The server side calls collected.fireClient(player) when that player picks something up. See
Multiplayer for remote events.
Fade sounds in and out
Section titled “Fade sounds in and out”volume and playbackSpeed can be tweened. To fade music out
for one player and then stop it:
import { Sound, task, TweenService, waitForChild, workspace } from '@rally/sdk';
task.spawn(async () => { const music = await waitForChild(workspace, 'Music', Sound); await task.wait(30); // play for 30 seconds, then fade out over 3 const fade = TweenService.create(music, { time: 3, easing: 'linear' }, { volume: 0 }); fade.completed.connect(() => music.stop()); fade.play();});Lights
Section titled “Lights”A PointLight shines in every direction from the part it’s in. It needs to be inside a part: in
Studio, select the part first, then choose Insert > Light, sound & motion > PointLight.
| Property | Default | What it does |
|---|---|---|
color |
white | The light’s color. |
brightness |
1 | From 0 to 20. |
range |
16 | How far the light reaches, in studs, from 0 to 200. |
enabled |
true |
Turn it off without removing it. |
The neon material makes a part glow, but it doesn’t light up anything around it. For a glowing
object that also lights its surroundings, give a neon part a PointLight in the same color.
This LocalScript makes every light in a Lamps folder flicker now and then, for this player only.
Mark a lamp part by giving it a flicker attribute set to true:
import { BasePart, Folder, PointLight, RunService, task, waitForChild, workspace } from '@rally/sdk';
interface Flicker { light: PointLight; base: number; /** Seconds of flickering left. */ left: number;}
task.spawn(async () => { const lamps = await waitForChild(workspace, 'Lamps', Folder); const flickers: Flicker[] = []; for (const part of lamps.getDescendants()) { if (!(part instanceof BasePart) || part.getAttribute('flicker') !== true) continue; for (const light of part.getChildren()) if (light instanceof PointLight) flickers.push({ light, base: light.brightness, left: 0 }); }
RunService.renderStepped.connect((dt) => { for (const f of flickers) { if (f.left <= 0 && Math.random() < 0.01) f.left = 0.1 + Math.random() * 0.3; if (f.left > 0) { f.left -= dt; f.light.brightness = f.base * (Math.random() < 0.5 ? 0.15 : 0.6); } else { f.light.brightness = f.base; } } });});The sky, sun and fog
Section titled “The sky, sun and fog”The Lighting service sets the look of the whole world. Select Lighting in the Explorer to change it in Properties, or change it from scripts.
| Property | Default | What it does |
|---|---|---|
skyColor |
#bfdcff |
The color of the sky behind everything. |
ambient |
#8a93a6 |
The color of the soft light that reaches everywhere, even shadows. |
brightness |
1.6 | The sun’s strength, from 0 to 10. |
sunDirection |
(-0.5, -1, -0.35) | The direction sunlight travels. Pointing down (negative Y) is the sun overhead. |
fogColor |
#bfdcff |
The fog’s color. Matching the sky makes distant things fade into it. |
fogEnd |
0 | The distance where fog becomes total. Fog starts thickening at 30% of that distance. 0 means no fog. |
shadows |
true |
Whether the sun casts shadows. 3D games only. |
A Script that changes Lighting changes it for everyone. A LocalScript changes it only for that player.
This server script runs a day and night cycle. Each phase blends into the next over 8 seconds and then holds:
import { Color3, Lighting, task, TweenService, Vector3 } from '@rally/sdk';
interface Phase { sky: string; ambient: string; brightness: number; sun: Vector3; /** Seconds to hold this phase. */ hold: number;}
const PHASES: Phase[] = [ { sky: '#bfdcff', ambient: '#8a93a6', brightness: 1.6, sun: new Vector3(-0.5, -1, -0.35), hold: 60 }, { sky: '#f4a261', ambient: '#7a6a7d', brightness: 0.9, sun: new Vector3(-1, -0.3, 0), hold: 10 }, { sky: '#0b1026', ambient: '#2a3150', brightness: 0.2, sun: new Vector3(0.5, -1, 0.35), hold: 40 }, { sky: '#ffb4a2', ambient: '#6d6875', brightness: 0.9, sun: new Vector3(1, -0.3, 0), hold: 10 },];
task.spawn(async () => { for (;;) { for (const phase of PHASES) { const blend = TweenService.create( Lighting, { time: 8, easing: 'sineInOut' }, { skyColor: Color3.fromHex(phase.sky), fogColor: Color3.fromHex(phase.sky), ambient: Color3.fromHex(phase.ambient), brightness: phase.brightness, sunDirection: phase.sun, }, ); blend.play(); await blend.completed.wait(); await task.wait(phase.hold); } }});And a LocalScript that closes in thick fog while this player is inside a Cave part, an invisible,
non-colliding part that covers the cave. Touch events don’t fire on players’ devices, so it checks
the character’s position every frame:
import { BasePart, Color3, Lighting, RunService, task, type Vector3, waitForChild, waitForLocalPlayer, workspace,} from '@rally/sdk';
/** Whether `p` is inside the part's box (ignoring the part's rotation). */function inside(zone: BasePart, p: Vector3): boolean { const d = p.sub(zone.position); return ( Math.abs(d.x) <= zone.size.x / 2 && Math.abs(d.y) <= zone.size.y / 2 && Math.abs(d.z) <= zone.size.z / 2 );}
task.spawn(async () => { const me = await waitForLocalPlayer(); const cave = await waitForChild(workspace, 'Cave', BasePart); const outside = { fogEnd: Lighting.fogEnd, fogColor: Lighting.fogColor }; let wasInside = false;
RunService.renderStepped.connect(() => { const character = me.character; const now = !!character && inside(cave, character.position); if (now === wasInside) return; wasInside = now; Lighting.fogEnd = now ? 60 : outside.fogEnd; Lighting.fogColor = now ? Color3.fromHex('#1b1b24') : outside.fogColor; });});Visual effects
Section titled “Visual effects”Rally doesn’t have particle emitters, beams, trails or UI that floats in the world yet. You build effects from parts, lights, UI and the camera, usually in LocalScripts so they cost nothing on the network and appear instantly.
Materials
Section titled “Materials”| Material | Looks |
|---|---|
plastic |
Matte. The default. |
smooth |
Glossy. |
neon |
Glows in its own color, even in the dark. |
metal |
Shiny and reflective. |
glass |
See-through: always at least 60% transparent. |
ice |
Glossy and slightly see-through. |
wood, grass, sand, brick |
A subtle texture pattern in the part’s color. |
Combine materials with color and transparency (0 is solid, 1 is invisible). Fading
transparency and changing color with tweens make good hit flashes and pickups.
Images and models
Section titled “Images and models”imageon any part draws a picture from a URL on the part’s faces, stretched to fit. Transparent areas of a PNG stay see-through, so it works for signs, decals and, in 2D games, sprites.- MeshPart shows a 3D model from a
.glbor.gltfURL inmeshUrl, scaled to fit the part’ssizeand then bymeshScale. It still collides as a box ofsize.
If a model can’t be loaded, the part shows as a plain box and Output shows a warning.
Effect parts
Section titled “Effect parts”The usual building block for an effect is a short-lived part made in a LocalScript: anchored, not colliding, not touching, no shadow, destroyed when it’s done. The server decides when (a RemoteEvent), each player’s device draws it.
This LocalScript draws a burst whenever the server fires a RemoteEvent named Boom in
ReplicatedStorage with a position (boom.fireAllClients(position)). The burst grows and fades, and
one shared light flashes with it:
import { Color3, Part, PointLight, RemoteEvent, ReplicatedStorage, task, TweenService, Vector3, waitForChild, workspace,} from '@rally/sdk';
/** A part for effects: visible, but invisible to physics. */function effectPart(at: Vector3): Part { const p = new Part(); p.anchored = true; p.canCollide = false; p.canTouch = false; p.castShadow = false; p.position = at; return p;}
// One light, reused by every burst.const flashHolder = effectPart(new Vector3(0, -100, 0));flashHolder.transparency = 1;flashHolder.parent = workspace;const flash = new PointLight();flash.color = Color3.fromHex('#ffb703');flash.range = 30;flash.enabled = false;flash.parent = flashHolder;
function burst(at: Vector3): void { const ball = effectPart(at); ball.shape = 'sphere'; ball.size = new Vector3(1, 1, 1); ball.color = Color3.fromHex('#ffb703'); ball.material = 'neon'; ball.parent = workspace; TweenService.create( ball, { time: 0.5, easing: 'quadOut' }, { size: new Vector3(12, 12, 12), transparency: 1 }, ).play(); task.delay(0.6, () => ball.destroy());
flashHolder.position = at; flash.enabled = true; flash.brightness = 8; TweenService.create(flash, { time: 0.5 }, { brightness: 0 }).play(); task.delay(0.5, () => { flash.enabled = false; });}
task.spawn(async () => { const boom = await waitForChild(ReplicatedStorage, 'Boom', RemoteEvent); boom.onClientEvent.connect((at) => { if (at instanceof Vector3) burst(at); });});Screen effects
Section titled “Screen effects”UI drawn over the whole screen makes flashes, tints and vignettes. UI frames don’t block clicks, so a full-screen overlay is safe to leave in place.
This LocalScript flashes the screen red whenever this player’s character loses health:
import { type Character, Color3, Frame, PlayerGui, ScreenGui, task, TweenService, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui);
const screen = new ScreenGui(); screen.name = 'HurtFlash'; screen.displayOrder = 10; // above the rest of the UI const flash = new Frame(); flash.sizeScale = new Vector2(1, 1); // the whole screen flash.size = Vector2.zero; // no extra pixels on top of that flash.backgroundColor = Color3.fromHex('#ff1e1e'); flash.backgroundTransparency = 1; flash.parent = screen; screen.parent = gui;
const watch = (character: Character) => { let last = character.health; character.getPropertyChangedSignal('health').connect(() => { if (character.health < last) { flash.backgroundTransparency = 0.6; TweenService.create(flash, { time: 0.4, easing: 'quadOut' }, { backgroundTransparency: 1 }).play(); } last = character.health; }); }; if (me.character) watch(me.character); me.characterAdded.connect(watch);});Camera effects
Section titled “Camera effects”In 3D games, widening the camera’s fieldOfView makes speed feel faster. This LocalScript widens
the view while this player’s character is faster than normal, for example on a speed pad:
import { type Character, StarterPlayer, task, TweenService, waitForLocalPlayer, workspace,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const watch = (character: Character) => { character.getPropertyChangedSignal('walkSpeed').connect(() => { const camera = workspace.currentCamera; if (!camera) return; const fast = character.walkSpeed > StarterPlayer.characterWalkSpeed; TweenService.create(camera, { time: 0.4, easing: 'quadOut' }, { fieldOfView: fast ? 85 : 70 }).play(); }); }; if (me.character) watch(me.character); me.characterAdded.connect(watch);});See Input for more about the camera.
Name tags and health bars
Section titled “Name tags and health bars”Characters come with a name tag over their heads, which every player sees on everyone else’s character. When a character is hurt, a health bar appears under the name. You don’t need to build these.
Server or client effects
Section titled “Server or client effects”| Do it on | When |
|---|---|
| The server (Script) | It changes the game: a light that shows which door is open, a sound every player should hear from a spot, the time of day. |
| Each player’s device (LocalScript) | It’s decoration or feedback: bursts, flashes, flickers, UI sounds, camera effects. Have the server fire a RemoteEvent to say when. |
For every property, see the API reference.