Skip to content

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.

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.

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

Each step runs in the same order:

  1. Functions from task.defer run.
  2. Delays and waits that are due finish, in the order they came due.
  3. RunService.stepped fires.
  4. Physics moves things.
  5. RunService.heartbeat fires.

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 has the step events, and tells you which side your code runs on:

  • heartbeat fires every step, after physics, with the step’s length in seconds (dt). Most per-step game logic goes here.
  • stepped fires every step, before physics.
  • renderStepped fires on a player’s device only, once for every frame drawn, just before drawing. Use it for the camera and visual effects.
  • isServer() and isClient() say where the code runs. In Studio’s Play, which runs the server and your device’s scripts together in your browser, both are true.
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:

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

An async loop inside task.spawn reads like the game’s rules: wait for players, count down, play, repeat.

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

Plenty of checks don’t need to run 30 times a second. Add up dt and act when enough time has passed:

ServerScriptService/Throttle.server.ts
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();
}
});

task.delay returns a function that cancels it. Keep it when something might call the timer off:

ServerScriptService/Bomb.server.ts
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 it

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:

ServerScriptService/Generation.server.ts
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 going

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() in heartbeat gets slow as your world grows. Find what you need once, keep it in a list, and update the list with childAdded and destroying.

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