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.
Connecting to an event
Section titled “Connecting to an event”signal.connect(handler)callshandlerevery time the signal fires, with the event’s arguments. It returns a connection.connection.disconnect()stops it;connection.connectedtells you whether it’s still connected.signal.once(handler)callshandlerthe 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.
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`);});How handlers run
Section titled “How handlers run”- 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 anawaitare logged the same way. - Destroying an instance disconnects every handler connected to its events.
The events you’ll use most
Section titled “The events you’ll use most”| 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.
Watching properties and attributes
Section titled “Watching properties and attributes”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).
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.
Handle what’s already there
Section titled “Handle what’s already there”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:
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:
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);Add a cooldown to touch events
Section titled “Add a cooldown to touch events”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:
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.
Clean up connections
Section titled “Clean up connections”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:
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);Making your own events
Section titled “Making your own events”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:
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`); }});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}`);});fireruns 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
unknownto 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().
Inside one script: a typed event
Section titled “Inside one script: a typed event”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:
// 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); }, };}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.
Next steps
Section titled “Next steps”- Tasks and timing — waits, delays and per-step events.
- Multiplayer — events between the server and players.
- Players and characters — the player and character events in depth.