Tasks and timing
Games are full of timing: a countdown before a round, a door that closes after five seconds, a
platform that moves every frame. Rally scripts handle time with the task library and the step
events of RunService. This page covers both, and how to keep your game running smoothly.
Game time
Section titled “Game time”A Rally game advances in steps. The server runs 30 steps a second. A player’s device runs 60 steps a second for its own simulation, and also draws as many frames as the device can manage. Everything time-related in scripts — waits, delays, tweens — counts game time, which moves on only as the game steps.
There’s no setTimeout or setInterval in scripts. Use the task library and RunService
instead, and timing stays in step with the rest of the game.
task.time() gives the seconds of game time since the game started. On a player’s device it starts
from the server’s game time when they join, then counts on its own, so only compare times measured
on the same side.
The task library
Section titled “The task library”| Function | What it does |
|---|---|
await task.wait(seconds?) |
Waits seconds of game time — or until the next step if you leave it out. Resolves with the time actually waited. |
task.delay(seconds, fn, ...args) |
Runs fn after seconds. Returns a function that cancels it. |
task.spawn(fn, ...args) |
Runs fn right away. Errors it throws — or rejects with, if it’s async — are logged, not thrown. |
task.defer(fn, ...args) |
Runs fn at the start of the next step. |
task.time() |
Seconds of game time since the game started. |
import { print, task } from '@rally/sdk';
print('starting at', task.time()); // seconds of game time since the server started
// delay: run a function later. It returns a function that cancels it.const cancel = task.delay(10, () => print('ten seconds later'));cancel(); // changed our mind: it never runs
// defer: run at the start of the next step.task.defer(() => print('next step'));
// spawn + wait: write timed sequences top to bottom.task.spawn(async () => { for (let i = 3; i > 0; i--) { print(i); await task.wait(1); // resolves with the game time actually waited } print('Go!');});Extra arguments after the function are passed to it: task.delay(2, explode, bomb) calls
explode(bomb) two seconds from now.
await can’t be used at the top level of a script — scripts run as plain functions. Put timed
sequences inside task.spawn(async () => { … }), as above, or inside an async event handler.
What happens in a step
Section titled “What happens in a step”Each step runs in the same order:
- Functions from
task.deferrun. - Delays and waits that are due finish, in the order they came due.
RunService.steppedfires.- Physics moves things.
RunService.heartbeatfires.
On the server, the world changes made during steps are sent to players 20 times a second.
A delay or wait finishes on the first step at or after its time. Server steps are about 33
milliseconds apart, so on the server task.wait(0.01) waits a whole step. When the exact time
matters, use the value task.wait resolves with, or the dt that step events pass you.
RunService
Section titled “RunService”RunService has the step events, and tells you which side your code runs on:
heartbeatfires every step, after physics, with the step’s length in seconds (dt). Most per-step game logic goes here.steppedfires every step, before physics.renderSteppedfires on a player’s device only, once for every frame drawn, just before drawing. Use it for the camera and visual effects.isServer()andisClient()say where the code runs. In Studio’s Play, which runs the server and your device’s scripts together in your browser, both aretrue.
| Server | Player’s device | |
|---|---|---|
| Steps per second | 30 | 60 |
stepped, heartbeat |
every step | every step |
renderStepped |
never | every frame drawn |
| Use it for | rules, timers, moving things everyone sees | the camera, UI, visual effects |
Movement you drive yourself should be scaled by dt, so its speed doesn’t depend on how long each
step took:
import { getChild, Part, RunService, Vector3, workspace } from '@rally/sdk';
const blades = getChild(workspace, 'Windmill', Part);const DEGREES_PER_SECOND = 45;
// Multiply by dt so the speed is the same however long each step takes.RunService.heartbeat.connect((dt) => { blades.rotation = blades.rotation.add(new Vector3(0, 0, DEGREES_PER_SECOND * dt));});For motion that simply repeats, a tween is less code.
Game loops
Section titled “Game loops”A round loop
Section titled “A round loop”An async loop inside task.spawn reads like the game’s rules: wait for players, count down, play,
repeat.
import { getChild, Players, ReplicatedStorage, StringValue, task } from '@rally/sdk';
const status = getChild(ReplicatedStorage, 'Status', StringValue);const ROUND_SECONDS = 90;const INTERMISSION_SECONDS = 15;
async function countdown(label: string, seconds: number): Promise<void> { for (let t = seconds; t > 0; t--) { status.value = `${label}: ${t}`; await task.wait(1); }}
task.spawn(async () => { for (;;) { while (Players.getPlayers().length < 1) { status.value = 'Waiting for players…'; await task.wait(1); } await countdown('Intermission', INTERMISSION_SECONDS); // start the round here: reset scores, load characters, open the gates… await countdown('Round', ROUND_SECONDS); // end the round here: pick a winner, hand out rewards… }});Status is a StringValue in ReplicatedStorage, so every player’s device can show it — see
User interface.
Less often than every step
Section titled “Less often than every step”Plenty of checks don’t need to run 30 times a second. Add up dt and act when enough time has
passed:
import { Players, RunService } from '@rally/sdk';
// Check every character 4 times a second instead of 30.const INTERVAL = 0.25;let elapsed = 0;
RunService.heartbeat.connect((dt) => { elapsed += dt; if (elapsed < INTERVAL) return; elapsed = 0; for (const player of Players.getPlayers()) { const character = player.character; if (character && character.position.y < -20) character.kill(); }});Timers you can cancel
Section titled “Timers you can cancel”task.delay returns a function that cancels it. Keep it when something might call the timer off:
import { Color3, getChild, Part, print, task, workspace } from '@rally/sdk';
const bomb = getChild(workspace, 'Bomb', Part);let cancelExplosion: (() => void) | null = null;
function arm(): void { bomb.color = Color3.fromHex('#ff3b30'); cancelExplosion = task.delay(10, () => { print('Boom!'); bomb.destroy(); });}
function defuse(): void { if (!cancelExplosion) return; cancelExplosion(); // the explosion never happens cancelExplosion = null; bomb.color = Color3.fromHex('#3ddc97'); print('Defused!');}
arm();bomb.touched.connect(defuse); // touching the bomb defuses itStopping a loop from outside
Section titled “Stopping a loop from outside”A loop in task.spawn runs until it decides to stop. To stop old loops when something restarts —
a new round, a reset — give each loop a number and stop when it’s out of date:
import { print, task } from '@rally/sdk';
// Bump `generation` to stop every loop started before it.let generation = 0;
function startBlinking(): void { const mine = ++generation; task.spawn(async () => { while (mine === generation) { print('blink', mine); await task.wait(0.5); } });}
startBlinking();task.delay(1.2, startBlinking); // the first loop stops; only the new one keeps goingKeep every step short
Section titled “Keep every step short”Everything in a game runs in turn on one thread. While your code runs, nothing else does: no physics, no other scripts, no messages to or from players. Code that runs for a long time without waiting holds up the whole game.
- On the server, if a step takes too long, Rally stops the server to protect everyone else: players are removed with the message The game server stopped: a script ran too long. A game that keeps the server almost constantly busy for several seconds is stopped the same way, even if each step is quick.
- In a player’s browser, the game freezes, and after a few seconds Rally shows This game isn’t responding.
So:
-
Always wait inside loops that repeat over time:
await task.wait()gives the rest of the game its turn. -
Spread big jobs over several steps. Building a large map, say, a hundred pieces at a time:
ServerScriptService/Chunks.server.ts import { Color3, Part, task, Vector3, workspace } from '@rally/sdk';// Build a 40 × 40 floor of tiles, 100 tiles per step, so no single step takes too long.task.spawn(async () => {let built = 0;for (let x = 0; x < 40; x++) {for (let z = 0; z < 40; z++) {const tile = new Part();tile.size = new Vector3(4, 1, 4);tile.position = new Vector3(x * 4, -1, z * 4);tile.color = Color3.fromHSV(((x + z) % 10) / 10, 0.5, 0.9);tile.parent = workspace;if (++built % 100 === 0) await task.wait();}}}); -
Don’t search the whole world every step.
workspace.getDescendants()inheartbeatgets slow as your world grows. Find what you need once, keep it in a list, and update the list withchildAddedanddestroying. -
Throttle expensive checks to a few times a second, as in the example above.
-
Do purely visual motion on players’ devices. Every property the server changes is sent to every player. A spinning coin or a bobbing light that’s just for looks costs nothing on the network when a LocalScript animates it — and it’s smoother, too:
StarterPlayer/StarterPlayerScripts/Bob.client.ts import { Folder, Part, RunService, task, Vector3, waitForChild, workspace } from '@rally/sdk';// Purely visual motion belongs on the client: it's smooth (every frame) and costs no network.task.spawn(async () => {const lights = await waitForChild(workspace, 'Lights', Folder);let t = 0;RunService.renderStepped.connect((dt) => {t += dt;for (const light of lights.getChildren()) {if (light instanceof Part) light.position = light.position.withY(2 + Math.sin(t * 2) * 0.5);}});});
Sandbox and performance explains the limits games run within and how to find what’s slow.
Next steps
Section titled “Next steps”- Events and signals — the other way code gets to run.
- Animation and tweens — motion without writing loops.
- Players and characters — spawning, respawning and rounds.