Skip to content

Animation and tweens

Rally gives you three ways to animate:

Tool Best for
Tweens (TweenService) Taking properties from where they are to a goal, with easing, repeats and reversing. A door sliding open, a platform going back and forth, a button growing when clicked.
Animations (Animation) Several steps, several properties, several objects, on one timeline. An elevator that rises, waits and comes back. You can build them in Studio without code.
Your own code (RunService) Continuous motion you compute every frame, such as a hovering marker or something that follows a player.

All three work on parts, UI, lights, sounds and anything else with number, vector or color properties. Animations can also move a whole Model.

TweenService.create(instance, info, goals) makes a tween. Call play() to start it.

Workspace/MovingPlatform/Slide.server.ts
import { BasePart, script, TweenService, Vector3 } from '@rally/sdk';
const platform = script.parent;
if (!(platform instanceof BasePart)) throw new Error('Put this script inside a part');
// Slide 20 studs along X and back, forever.
TweenService.create(
platform,
{ time: 3, easing: 'sineInOut', reverses: true, repeatCount: -1 },
{ position: platform.position.add(new Vector3(20, 0, 0)) },
).play();

goals lists each property and the value it should end at. One tween can animate several properties of the same object at once, as long as each is a number, Vector3, Vector2 or Color3 that scripts can change.

Setting Default What it does
time (required) Seconds for one run from start to goal.
easing 'quadInOut' The shape of the motion. See Easing styles.
repeatCount 0 Extra runs after the first. -1 repeats forever.
reverses false After reaching the goal, play back to the start. Each repeat goes there and back.
delay 0 Seconds to wait before the first run.
Easing Motion
linear Steady speed from start to finish.
quadIn Starts slow, speeds up.
quadOut Starts fast, slows down at the end.
quadInOut Slow at both ends. The default.
cubicInOut Like quadInOut, with a stronger speed-up in the middle.
sineInOut Gentle at both ends. Smooth for back-and-forth motion.
backOut Overshoots the goal a little, then settles.
bounceOut Bounces at the end, like a dropped ball.
elasticOut Springs past the goal and wobbles to a stop.
Member What it does
play() Starts the tween, or resumes it after pause().
pause() Stops where it is. play() continues from there.
cancel() Stops and forgets its progress. The property stays where it is; the next play() starts over from there.
playing true while it’s running.
completed Fires when the last run finishes. A tween with repeatCount: -1 never completes.

Things to know:

  • A tween reads its starting values when it first plays, so it always starts from where the object is at that moment.
  • Tweens advance with the game’s clock, every step, on whichever side played them.
  • A tween stops by itself if its object is destroyed.
  • Two tweens on the same property fight over it. Cancel the old one before starting a new one.

This sliding door opens while anyone stands in front of it. Build a Model named SlidingDoor holding a Door part and a Sensor part (the area in front of the door), and put the script in the Model:

Workspace/SlidingDoor/Open.server.ts
import { BasePart, Character, getChild, Model, script, type Tween, TweenService, Vector3 } from '@rally/sdk';
const model = script.parent;
if (!(model instanceof Model)) throw new Error('Put this script inside the SlidingDoor model');
const door = getChild(model, 'Door', BasePart);
const sensor = getChild(model, 'Sensor', BasePart);
sensor.canCollide = false;
sensor.transparency = 1;
const closed = door.position;
const opened = closed.add(new Vector3(0, door.size.y, 0)); // slide up into the wall above
const near = new Set<Character>();
let tween: Tween | null = null;
function slideTo(goal: Vector3): void {
tween?.cancel();
tween = TweenService.create(door, { time: 0.6, easing: 'quadOut' }, { position: goal });
tween.play();
}
function leave(character: Character): void {
if (near.delete(character) && near.size === 0) slideTo(closed);
}
sensor.touched.connect((other) => {
if (!(other instanceof Character) || near.has(other)) return;
near.add(other);
other.destroying.once(() => leave(other)); // the player left while standing here
if (near.size === 1) slideTo(opened);
});
sensor.touchEnded.connect((other) => {
if (other instanceof Character) leave(other);
});

And a platform that fades away a moment after you step on it, then comes back. It waits for completed before letting characters fall through:

Workspace/FadingPlatform/Fade.server.ts
import { BasePart, Character, script, task, TweenService } from '@rally/sdk';
const platform = script.parent;
if (!(platform instanceof BasePart)) throw new Error('Put this script inside a part');
let fading = false;
platform.touched.connect((other) => {
if (fading || !(other instanceof Character)) return;
fading = true;
const fade = TweenService.create(platform, { time: 1, easing: 'linear' }, { transparency: 1 });
fade.completed.connect(() => {
platform.canCollide = false; // gone: characters fall through
task.delay(3, () => {
platform.transparency = 0;
platform.canCollide = true;
fading = false;
});
});
fade.play();
});

A full turn loops without a seam. Use linear easing so it doesn’t slow down at the end of each turn:

Workspace/Spinner/Spin.server.ts
import { BasePart, script, TweenService, Vector3 } from '@rally/sdk';
const spinner = script.parent;
if (!(spinner instanceof BasePart)) throw new Error('Put this script inside a part');
TweenService.create(
spinner,
{ time: 4, easing: 'linear', repeatCount: -1 },
{ rotation: spinner.rotation.add(new Vector3(0, 360, 0)) },
).play();

UI objects tween like anything else: position, size, positionScale and sizeScale are Vector2s, and colors, transparency and textSize are tweenable too. Animate UI in LocalScripts, since each player’s UI lives on their device.

This LocalScript makes the coin counter pop whenever the player’s Coins value changes:

StarterPlayer/StarterPlayerScripts/CoinPop.client.ts
import {
Color3, Folder, NumberValue, PlayerGui, ScreenGui, task, TextLabel, 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 = 'CoinCounter';
const label = new TextLabel();
label.position = new Vector2(16, 16);
label.size = new Vector2(160, 48);
label.backgroundColor = Color3.fromHex('#1d2b53');
label.cornerRadius = 12;
label.textColor = Color3.fromHex('#ffd23f');
label.fontWeight = 'bold';
label.textSize = 24;
label.parent = screen;
screen.parent = gui;
const stats = await waitForChild(me, 'leaderstats', Folder);
const coins = await waitForChild(stats, 'Coins', NumberValue);
const show = () => {
label.text = `● ${coins.value}`;
label.textSize = 32; // jump big, then ease back
TweenService.create(label, { time: 0.3, easing: 'backOut' }, { textSize: 24 }).play();
};
coins.getPropertyChangedSignal('value').connect(show);
show();
});

It expects a server script to give players a Coins value, for example with createLeaderstats(player, { Coins: 0 }). See User interface for building UI.

An Animation plays keyframes on properties. It’s an object in your world, so you can build it in Studio, it saves with your game, and it can play on its own when the game starts.

  1. Select the part or model you want to animate.

  2. Choose Insert > Light, sound & motion > Animation. Studio puts it inside your selection, and it animates its parent.

  3. In Properties, paste the keyframes into tracks (see below).

  4. Turn on looped to repeat it, and autoplay to start it when the game starts.

  5. Select Play to watch it.

These tracks make an elevator rise 20 studs over 3 seconds, wait 2 seconds at the top, come back down, and wait 2 seconds at the bottom before looping:

[
{
"property": "position",
"relative": true,
"keys": [
{ "t": 0, "value": [0, 0, 0], "easing": "sineInOut" },
{ "t": 3, "value": [0, 20, 0] },
{ "t": 5, "value": [0, 20, 0], "easing": "sineInOut" },
{ "t": 8, "value": [0, 0, 0] },
{ "t": 10, "value": [0, 0, 0] }
]
}
]

tracks is a list. Each track animates one property of one object:

Field What it is
property The property to animate: position, rotation, size, color, transparency, brightness, … Any property scripts can change.
keys The keyframes: { "t": seconds, "value": …, "easing": … }. They’re sorted by time for you.
path Optional. Names from the animated object down to a child, such as "Lid" or "Lid/Glow". Leave it out to animate the object itself.
relative Optional. true makes values offsets from where the property was when the animation started, so the same animation works wherever the object is. Works with numbers and vectors.

Values are written the way the property takes them: numbers, [x, y, z] for vectors, "#rrggbb" for colors, and text for choices such as a material. Numbers, vectors and colors blend smoothly between keys, shaped by the earlier key’s easing (default linear, with the same names as tweens). Everything else, such as material or canCollide, switches when the next key is reached.

Before the first key the object holds the first value, and after the last key it holds the last. On a Model, position moves the whole model.

Property Default What it does
tracks [] The keyframes.
looped false Start over at the end instead of stopping.
speed 1 Playback rate, from 0 to 10. 2 is twice as fast.
length 0 Length in seconds, up to 3,600. 0 means the time of the last key. A longer length holds the end pose.
autoplay false Play on the server as soon as the game runs. No script needed.
target none What to animate. Empty means the animation’s parent.
playing true while it plays. Read-only.

An animation can have up to 200 tracks and 2,000 keys in total. If a track points at something missing, a property that doesn’t exist or a value of the wrong type, Output shows a warning and that track is skipped. The others still play.

Member What it does
play(target?) Plays from the start. target overrides what it animates.
stop() Stops where it is.
timePosition Seconds into the animation. Set it to jump there; the object takes that pose even when stopped.
getLength() The animation’s length in seconds.
stopped Fires when it reaches the end (unless looped) or stop() is called.
didLoop Fires each time a looped animation starts over.

This treasure chest opens when someone touches it. In Studio, build a Model named Chest with a Lid part and a PointLight named Glow inside the lid (brightness 0). Add an Animation named Open to the Model with these tracks:

[
{
"path": "Lid",
"property": "position",
"relative": true,
"keys": [
{ "t": 0, "value": [0, 0, 0], "easing": "backOut" },
{ "t": 0.6, "value": [0, 2.5, 0] }
]
},
{
"path": "Lid/Glow",
"property": "brightness",
"keys": [
{ "t": 0, "value": 0 },
{ "t": 0.6, "value": 4 }
]
}
]

Then add this script to the Model:

Workspace/Chest/OpenChest.server.ts
import { Animation, Character, getChild, Model, print, script, task } from '@rally/sdk';
const chest = script.parent;
if (!(chest instanceof Model)) throw new Error('Put this script inside the Chest model');
const open = getChild(chest, 'Open', Animation);
let busy = false;
for (const part of chest.parts()) {
part.touched.connect((other) => {
if (busy || !(other instanceof Character)) return;
busy = true;
open.play();
});
}
open.stopped.connect(() => {
print('The chest is open');
task.delay(5, () => {
open.timePosition = 0; // back to the first pose: closed and dark
busy = false;
});
});

You can also create animations in code: new Animation(), set tracks to the same list (vectors can be Vector3s, colors Color3s), parent it, and call play().

For motion you compute yourself, update properties every frame. In a LocalScript, RunService.renderStepped fires before each frame is drawn. The easings export holds the same curves tweens use, as functions from 0–1 to 0–1:

StarterPlayer/StarterPlayerScripts/HoverMarker.client.ts
import { BasePart, easings, RunService, task, Vector3, waitForChild, workspace } from '@rally/sdk';
task.spawn(async () => {
const marker = await waitForChild(workspace, 'Marker', BasePart);
const base = marker.position;
let t = 0;
RunService.renderStepped.connect((dt) => {
t = (t + dt / 2) % 1; // one cycle every 2 seconds
const rise = t < 0.5 ? easings.sineInOut(t * 2) : easings.sineInOut(2 - t * 2);
marker.position = base.add(new Vector3(0, rise * 1.5, 0));
});
});

On the server, use RunService.heartbeat instead. See Tasks and timing.

Where you play an animation decides who sees it:

Played by a Script (server) Played by a LocalScript (a player’s device)
Who sees it Everyone. Only that player.
Smoothness Changes reach players 20 times a second, and parts are smoothed between updates. Every frame.
Network cost Every change is sent to every player. None.
Gameplay It’s real: the server’s physics and touch events follow it. Only on that device. The server still sees the object where it was.

Use the server for anything that matters to gameplay: moving platforms, doors, elevators, obstacles. Characters standing on a moving part ride along with it. Use LocalScripts for decoration: spinning coins, bobbing pickups, pulsing lights, UI.

A change a LocalScript makes stays on that device, even to an object the server created. This LocalScript spins and bobs every coin in a Coins folder, for this player only:

StarterPlayer/StarterPlayerScripts/CoinBob.client.ts
import { BasePart, Folder, type Instance, task, TweenService, Vector3, waitForChild, workspace } from '@rally/sdk';
function animate(coin: Instance, i: number): void {
if (!(coin instanceof BasePart)) return;
TweenService.create(
coin,
{ time: 0.9, easing: 'sineInOut', reverses: true, repeatCount: -1, delay: (i % 6) * 0.15 },
{ position: coin.position.add(new Vector3(0, 0.5, 0)) },
).play();
TweenService.create(
coin,
{ time: 2, easing: 'linear', repeatCount: -1 },
{ rotation: coin.rotation.add(new Vector3(0, 360, 0)) },
).play();
}
task.spawn(async () => {
const coins = await waitForChild(workspace, 'Coins', Folder);
coins.getChildren().forEach(animate);
coins.childAdded.connect((coin) => animate(coin, 0));
});

An Animation’s play() works in LocalScripts too, but autoplay only starts animations on the server.

  • Animate decoration on players’ devices, not on the server.
  • On the server, every part that moves sends its new position to every player, 20 times a second. Animate only what gameplay needs, and stop animations nobody can see.
  • Setting a property to the value it already has costs nothing to send, so motion that has stopped is free.

For every property and method, see the API reference.