Skip to content

Events and signals

Most game code runs in response to something: a player joins, a part is touched, a value changes, a button is clicked. Each of these is an event, and every event is a signal — an object you connect a function to. When the event happens, the signal fires and your function runs.

  • signal.connect(handler) calls handler every time the signal fires, with the event’s arguments. It returns a connection.
  • connection.disconnect() stops it; connection.connected tells you whether it’s still connected.
  • signal.once(handler) calls handler the next time only, then disconnects.
  • await signal.wait() pauses an async function until the next time the signal fires, and gives you its arguments as an array.
ServerScriptService/Basics.server.ts
import { Character, getChild, Part, Players, print, task, workspace } from '@rally/sdk';
const button = getChild(workspace, 'Button', Part);
// connect: run the handler every time the event fires. Keep the connection to stop later.
const connection = button.touched.connect((other) => {
if (other instanceof Character) print(`${other.name} pressed the button`);
});
// disconnect: stop listening (here, after 60 seconds).
task.delay(60, () => connection.disconnect());
// once: run the handler the next time only.
Players.playerAdded.once((player) => print(`First player: ${player.displayName}`));
// wait: pause an async function until the event fires. It resolves with the event's arguments.
task.spawn(async () => {
const [player] = await Players.playerAdded.wait();
print(`${player.displayName} arrived`);
});
  • When a signal fires, its handlers run right away, one after another, in the order they were connected.
  • A handler connected while the signal is firing starts with the next fire.
  • If a handler throws, the error is logged with the event’s name — such as Part.touched — and the other handlers still run. The handler stays connected.
  • Handlers can be async. Errors thrown after an await are logged the same way.
  • Destroying an instance disconnects every handler connected to its events.
Event Fires when Handler gets Where
Players.playerAdded, playerRemoving someone joins or leaves the Player both ¹
player.characterAdded, characterRemoving their character spawns or is replaced the Character both
character.died health reaches 0 — both
part.touched, touchEnded a character or part starts or stops touching it the other part or Character server only
instance.changed any property changes the property’s name both
instance.getPropertyChangedSignal(name) that property changes — both
instance.attributeChanged any attribute changes the attribute’s name both
instance.getAttributeChangedSignal(name) that attribute changes — both
childAdded, childRemoved a direct child is added or removed the child both
descendantAdded, descendantRemoving anything inside is added or removed the descendant both
ancestryChanged it or an ancestor moves the moved instance, its new parent both
destroying it’s about to be destroyed — both
remote.onServerEvent a player fires a RemoteEvent the player, then their arguments server
remote.onClientEvent the server fires a RemoteEvent to this player the arguments player’s device
bindable.event a script fires a BindableEvent the arguments same side
button.activated a TextButton is clicked or tapped — player’s device
textBox.focusLost a TextBox loses focus whether Enter did it player’s device
RunService.heartbeat, stepped every step the step’s length in seconds both
RunService.renderStepped every frame, before drawing the frame’s length in seconds player’s device
UserInputService.inputBegan, inputEnded a key or mouse button goes down or up the input, gameProcessed player’s device
tween.completed a tween finishes — both

¹ On a player’s device, playerAdded fires only for players who join after them — see Handle what’s already there.

changed fires with the name of any property that changes. getPropertyChangedSignal(name) fires for one property — and throws if the class has no property by that name, so a typo shows up right away. Attributes have the same pair: attributeChanged and getAttributeChangedSignal(name).

ServerScriptService/Watch.server.ts
import { getChild, Part, print, workspace } from '@rally/sdk';
const button = getChild(workspace, 'Button', Part);
// Any property:
button.changed.connect((property) => print(`Button.${property} changed`));
// One property:
button.getPropertyChangedSignal('color').connect(() => print('new color', button.color));
// One attribute:
button.getAttributeChangedSignal('pressed').connect(() => {
print('pressed is now', button.getAttribute('pressed'));
});
button.setAttribute('pressed', true);
button.transparency = 0.5;

These events fire only when a value really changes. On a player’s device they also fire when a change arrives from the server — that’s how a LocalScript keeps the UI in step with server state.

Events tell you about changes from now on. When your code starts, some of what it cares about may already exist, so handle that first, then connect for what comes later:

ServerScriptService/Lamps.server.ts
import { BasePart, Folder, getChild, type Instance, PointLight, workspace } from '@rally/sdk';
const lamps = getChild(workspace, 'Lamps', Folder);
function addLight(lamp: Instance): void {
if (!(lamp instanceof BasePart) || lamp.findFirstChildOfClass('PointLight')) return;
const light = new PointLight();
light.range = 24;
light.parent = lamp;
}
// Handle what's there now, then everything added later.
for (const lamp of lamps.getChildren()) addLight(lamp);
lamps.childAdded.connect(addLight);

This matters most on players’ devices. When a player joins, the players and characters already in the game arrive with the world, before their LocalScripts start — so playerAdded and characterAdded never fire for them:

StarterPlayer/StarterPlayerScripts/Others.client.ts
import { type Character, type Player, Players, print } from '@rally/sdk';
function watch(player: Player): void {
const onCharacter = (character: Character) => {
character.died.connect(() => print(`${player.displayName} was knocked out`));
};
if (player.character) onCharacter(player.character);
player.characterAdded.connect(onCharacter);
}
// On a player's device, playerAdded only fires for people who join later, so handle the
// players who are already here first.
for (const player of Players.getPlayers()) watch(player);
Players.playerAdded.connect(watch);

touched fires every time a character starts touching a part — and a character walking across a part, or standing on its edge, can start touching it many times. Give each player a cooldown:

ServerScriptService/Debounce.server.ts
import { Character, getChild, Part, type Player, Players, task, workspace } from '@rally/sdk';
const trap = getChild(workspace, 'Trap', Part);
const lastHit = new Map<Player, number>();
// touched fires each time a character starts touching the part. Give each player a cooldown
// so standing on the edge doesn't hurt them several times a second.
trap.touched.connect((other) => {
if (!(other instanceof Character)) return;
const player = Players.getPlayerFromCharacter(other);
if (!player) return;
const now = task.time();
if (now - (lastHit.get(player) ?? -Infinity) < 1) return;
lastHit.set(player, now);
other.takeDamage(25);
});
Players.playerRemoving.connect((player) => lastHit.delete(player));

touched and touchEnded fire on the server only. A part’s canTouch property turns them off for that part. See Interaction for more ways players interact with the world.

When an instance is destroyed, the handlers on its own events are disconnected for you. Handlers you connect to things that stay — Players.playerAdded, RunService.heartbeat, another script’s part — keep running until you disconnect them. Collect the connections that belong to something temporary, like a round, and disconnect them together:

ServerScriptService/RoundCleanup.server.ts
import { type Connection, Players, print, RunService, task } from '@rally/sdk';
const roundConnections: Connection[] = [];
function startRound(): void {
roundConnections.push(
Players.playerAdded.connect((player) => print(`${player.displayName} joined mid-round`)),
RunService.heartbeat.connect(() => {
// per-step round logic goes here
}),
);
}
function endRound(): void {
for (const connection of roundConnections) connection.disconnect();
roundConnections.length = 0;
}
startRound();
task.delay(60, endRound);

Between scripts on the same side: BindableEvent

Section titled “Between scripts on the same side: BindableEvent”

A BindableEvent is an instance with an event signal and a fire(...) method. Put one where your scripts can find it — ServerStorage for server scripts — and any script on the same side can fire it or listen to it:

ServerScriptService/Rounds.server.ts
import { BindableEvent, getChild, print, ServerStorage, task } from '@rally/sdk';
const roundEnded = getChild(ServerStorage, 'RoundEnded', BindableEvent);
task.spawn(async () => {
for (let round = 1; ; round++) {
await task.wait(60);
roundEnded.fire(round, 'Ann'); // every server script listening hears it right away
print(`round ${round} over`);
}
});
ServerScriptService/Rewards.server.ts
import { BindableEvent, getChild, print, ServerStorage } from '@rally/sdk';
const roundEnded = getChild(ServerStorage, 'RoundEnded', BindableEvent);
roundEnded.event.connect((round, winner) => {
if (typeof round !== 'number' || typeof winner !== 'string') return;
print(`giving ${winner} a reward for round ${round}`);
});
  • fire runs every handler immediately, before it returns.
  • Arguments are passed as they are — the same objects, not copies.
  • A BindableEvent never crosses the network. Firing one in a LocalScript only reaches handlers on that player’s device. For messages between the server and players, use a RemoteEvent — see Multiplayer.
  • Its arguments are unknown to TypeScript, so check them, as the handler above does.

Add a BindableEvent in Studio with Insert › BindableEvent — Studio puts it in ReplicatedStorage, so move it to ServerStorage if only server scripts use it — or create one in code with new BindableEvent().

Scripts can’t create signals directly, but for events inside one script — and the modules it imports — a few lines of TypeScript give you a typed event:

ReplicatedStorage/Event.ts
// A small typed event for code inside one script (and the modules it imports).
type Handler<Args extends unknown[]> = (...args: Args) => void;
export interface Event<Args extends unknown[]> {
connect(handler: Handler<Args>): { disconnect(): void };
fire(...args: Args): void;
}
export function createEvent<Args extends unknown[]>(): Event<Args> {
const handlers = new Set<Handler<Args>>();
return {
connect(handler) {
handlers.add(handler);
return { disconnect: () => handlers.delete(handler) };
},
fire(...args) {
for (const handler of [...handlers]) handler(...args);
},
};
}
ServerScriptService/Scores.server.ts
import { type Player, Players, print } from '@rally/sdk';
import { createEvent } from '@game/ReplicatedStorage/Event';
const scoreChanged = createEvent<[player: Player, score: number]>();
const scores = new Map<Player, number>();
function addScore(player: Player, amount: number): void {
const score = (scores.get(player) ?? 0) + amount;
scores.set(player, score);
scoreChanged.fire(player, score);
}
scoreChanged.connect((player, score) => {
if (score >= 10) print(`${player.displayName} reached ${score}!`);
});
Players.playerAdded.connect((player) => addScore(player, 10));

Each script that imports Event gets its own copy of the module, and each createEvent() makes a separate event, so this only connects code within one script’s program. Between scripts, use a BindableEvent.