Skip to content

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 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.

  1. Insert a Sound with nothing selected, so it goes into the Workspace.

  2. Set its soundUrl, and a volume such as 0.3.

  3. Turn on looped and playing.

Every player hears it, from the moment they join (and click).

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:

Workspace/Bell/Ring.server.ts
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.

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:

StarterPlayer/StarterPlayerScripts/Chime.client.ts
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.

volume and playbackSpeed can be tweened. To fade music out for one player and then stop it:

StarterPlayer/StarterPlayerScripts/FadeMusic.client.ts
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();
});

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:

StarterPlayer/StarterPlayerScripts/Flicker.client.ts
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 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:

ServerScriptService/DayNight.server.ts
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:

StarterPlayer/StarterPlayerScripts/CaveFog.client.ts
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;
});
});

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.

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.

  • image on 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 .glb or .gltf URL in meshUrl, scaled to fit the part’s size and then by meshScale. It still collides as a box of size.

If a model can’t be loaded, the part shows as a plain box and Output shows a warning.

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:

StarterPlayer/StarterPlayerScripts/Boom.client.ts
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);
});
});

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:

StarterPlayer/StarterPlayerScripts/HurtFlash.client.ts
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);
});

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:

StarterPlayer/StarterPlayerScripts/SpeedView.client.ts
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.

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.

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.