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.
Tweens
Section titled “Tweens”TweenService.create(instance, info, goals) makes a tween. Call play() to start it.
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.
Tween settings
Section titled “Tween settings”| 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 styles
Section titled “Easing styles”| 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. |
Control a tween
Section titled “Control a tween”| 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:
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 aboveconst 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:
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();});Spin something forever
Section titled “Spin something forever”A full turn loops without a seam. Use linear easing so it doesn’t slow down at the end of each
turn:
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();Animate UI
Section titled “Animate UI”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:
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.
Keyframe animations
Section titled “Keyframe animations”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.
Build one in Studio
Section titled “Build one in Studio”-
Select the part or model you want to animate.
-
Choose Insert > Light, sound & motion > Animation. Studio puts it inside your selection, and it animates its parent.
-
In Properties, paste the keyframes into
tracks(see below). -
Turn on
loopedto repeat it, andautoplayto start it when the game starts. -
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 and keys
Section titled “Tracks and keys”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.
Animation properties
Section titled “Animation properties”| 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.
Play animations from scripts
Section titled “Play animations from scripts”| 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:
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().
Your own motion
Section titled “Your own motion”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:
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.
Server or client
Section titled “Server or client”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:
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.
Keep animation cheap
Section titled “Keep animation cheap”- 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.