Scripting

Animation

How to make a world move. Platforms that rise, doors that swing, lights that breathe, and cameras that sweep. Each one is described once and then left to run.

The idea

Movement you describe once

A simple animation.

Almost nothing in a level moves because a script is pushing it. A script says what should happen: this platform rises two units over two seconds, easing at both ends. Then it stops being involved. The engine owns the movement from there.

That is worth understanding before writing anything, because it changes how you think about the problem. There is no update loop to write, no place where you nudge a value a little further every frame, and no risk of your movement stuttering because a script ran long. A level with a hundred moving parts costs a hundred descriptions, not a hundred callbacks a second.

It also matters that these are physical objects. When you animate the position of a mesh, its collision moves with it, on the same clock the ball is simulated on. A platform you animate is a platform the ball can actually ride.

First steps

Your first animation

Find something and tell it to move. That is the whole shape of it.

step1.animate("local-y(-0.3, 0.6, sine-inout)").reverse(0.6).repeat(0.6, -1);
step2.animate("local-y(0.3, 0.6, sine-inout)").reverse(0.6).repeat(0.6, -1);

What you see Is a pair of stairs rising and falling as they carry the ball upwards, at a steady speed, forever.

A few things are worth noticing. The call to animate happens once, at level load, and the stairs keeps churning for the rest of the level. The rise and fall is a local movement on the y axis by 0.3 units taking place over 0.6 seconds. The easing of sine-inout makes the rise and fall less robotic and smooth. The call to reverse(0.6) makes the stars pause for 0.6 seconds then reverse back up or down. And finally repeat(0.6, -1) causes the whole animation to cycle again after a 0.6 second delay forvever.

Syntax

The shape of an animation

Everything you animate is written the same way: a property, then up to four arguments in parentheses.

// animate("property(value, duration, easing, repeat)");
door.animate("rotate-y(90, 0.6, cubic-out)");
lamp.animate("intensity( 0.3, 0.5, sine-inout, repeat-flip)");

Only the property and the target value are required. Duration defaults to zero, easing defaults to linear, and the repeat is optional. Each argument is recognised by its own shape, so you can leave out the ones in the middle:

object.animate("translate-y(2)");                          // no duration, no easing
object.animate("translate-y(2, 1)");                       // one second, linear
object.animate("translate-y(2, sine-inout)");              // eased, but instant
object.animate("translate-y(2, 1, sine-inout)");           // the usual form
object.animate("translate-y(2, 1, sine-inout, repeat)");   // and again, forever

What you may not do is reorder them. Duration comes before easing, and easing before the repeat, whenever more than one is present.

Targets

Targets are absolute, with one exception

This is the single thing that catches people out most, so it is worth being blunt about. The number you write is a destination, not a distance. Despite being called translate, this does not move the platform by two units:

platform.animate("translate-y(2, 1, sine-inout)");   // moves TO y = 2

Run it twice and nothing happens the second time, because the platform is already there. If what you want is a displacement, use a local axis instead. These are the exception to the rule, and the argument really is a distance:

platform.animate("local-y(2, 1, sine-inout)");   // moves UP BY 2, from wherever it is

What you see The platform rises two units above wherever it currently sits, easing in and out.

Local axes are relative to the object's own rotation as well as its own position, which is why they are called local. On an unrotated object local-y and translate-y move in the same direction. On a ramp tilted thirty degrees, local-z slides it along its own slope rather than along the world.

Why the exception exists

Because of switches. Consider a ramp lowered by one switch and raised by another, somewhere else in the level:

const ramp = game.find("ramp01");

lowerSwitch.onPressed = () => ramp.animate("local-y(-0.5, 1.5, sine-inout)");
raiseSwitch.onPressed = () => ramp.animate("local-y(0.5, 1.5, sine-inout)");

What you see Either switch moves the ramp half a unit, and the two undo each other exactly.

Neither switch needs to know where the ramp started, or what the other one did. With absolute targets, both would need the ramp's home position written into them, and moving the ramp in CAD would silently break the level.

The cost of that convenience is that repeated triggers accumulate. Press the lowering switch twice and the ramp drops a full unit. A switch that can be pressed again needs a guard of its own. Setting autoRelease to false so it latches is often the simplest one.

Properties

What you can animate

Position, rotation and scale on any visual; tint on any doodad; and beyond those, more or less any number the object exposes.

Transforms

crate.animate("rotate-x(45, 0.5, quad-out)");        // rotate-x, rotate-y, rotate-z
crate.animate("translate-y(3, 1, sine-inout)");      // translate-x, translate-y, translate-z
crate.animate("translate(4, 2, -3, 1.5, sine-inout)");  // all three, one timeline
crate.animate("local-z(0.4, 0.5, quad-out)");        // local-x, local-y, local-z
crate.animate("scale(1.4, 0.3, back-out)");          // or scale-x, scale-y, scale-z

The grouped translate form is worth reaching for whenever a thing moves diagonally: three separate axis animations with the same duration will drift apart under different easings, while one grouped call cannot.

Colour

const light = game.find("inlay01") as InlayLight;

light.animate("tint(#ff8800, 0.5, sine-inout)");

What you see The inlay's icon warms from its authored colour to orange over half a second.

Colours may be written as hex, with or without the hash, and Godot's colour names are accepted too, so red, Red, #ff0000 and ff0000 all work. Tint belongs to doodads: asking a plain mesh to change tint logs an error and does nothing.

Anything else that is a number

Any name the engine does not recognise as a transform or a tint is converted from dashes to the property's own name and looked up on the object. If it is a readable, writable number, it animates.

light.animate("intensity(0.25, 0.5, sine-inout, repeat-flip)");
horn.animate("blow-cone(60, 1, quad-out)");
orb.animate("chase-radius(8, 2, sine-inout)");
flag.animate("sheen(0.8, 2, sine-inout)");
decal.animate("opacity(0, 1.5, quad-in)");

What you see A pulsing inlay, a horn widening its gust, an orb becoming steadily more willing to give chase, a flag catching more light, a decal fading away.

That last group is the part people underestimate. An orb's chase-radius is an ordinary number, so an enemy that grows more aggressive as a timer runs down is one animation, not a system. Booleans, strings and vectors are not covered by this: mesh.unstable can be assigned but not interpolated.

Easing

Choosing an easing

An easing is a family and a direction joined by a dash: sine-inout, bounce-out, back-in. Eleven families are available, each in four directions. linear is the exception and takes no direction, so write linear, never linear-in.

linear
sine    quad    cubic   quart   quint   expo
circ    elastic back    bounce  spring

-in     starts slowly, arrives fast
-out    starts fast, settles gently
-inout  eases at both ends
-outin  fast at the ends, slow through the middle

In practice a handful do most of the work. sine-inout, quad-inout and cubic-inout suit anything a player watches move, such as platforms, doors and camera sweeps, because starting and stopping gently is what reads as weight. linear suits continuous motion where any acceleration would look like a fault: a turning gear, a scrolling value, a seek through a sound.

The showy ones have a place, but a narrower one than they first appear. back-out overshoots slightly and settles, which is lovely on a crate popping into view. bounce-out and elastic-out are strong flavours best kept for cosmetic objects.

Be careful using overshoot on anything the ball can touch. back-out on a rising platform means the platform goes past its destination and comes back, and the ball riding it feels every bit of that. For collision geometry, prefer an easing that arrives and stops.

Inline repeats

Repeating one movement

The optional fourth argument repeats that single property animation. There are four forms:

repeat          forever, restarting from the beginning each pass
repeat-6        six passes in total, then stop
repeat-flip     forever, alternating forward and backward
repeat-flip-6   six passes, alternating direction

The difference between repeat and repeat-flip matters more than it sounds. Plain repeat jumps back to the start of every pass, so unless the end looks identical to the beginning, you get a visible snap.

// Right: 360 degrees ends exactly where it began, so restarting is invisible.
gear.animate("rotate-y(360, 2, linear, repeat)");

// Wrong: this snaps from bright back to dim every half second.
lamp.animate("intensity(0.8, 0.5, sine-inout, repeat)");

// Right: flip runs it backward instead of restarting it.
lamp.animate("intensity(0.8, 0.5, sine-inout, repeat-flip)");

What you see The gear spins evenly; the first lamp strobes; the second breathes.

With a finite flip count, an odd number leaves the object at its target and an even number returns it to where it started, which is useful when you want a thing to wobble and end up exactly as it was.

Parallel specs

Doing several things at once

Separate whole specs with semicolons and they begin together.

crate.animate("scale(1.4, 0.3, back-out); tint(#ffd166, 0.3, linear)");

What you see The crate swells and flushes gold at the same moment, as though it were reacting.

Every member of a parallel group must animate a different property, and the group finishes only when its slowest member does. That last part is the reason to use it rather than two separate calls: anything chained afterward waits for the whole group, not for whichever member happened to be written first.

gate
    .animate(
        "local-y(1.5, 1, sine-inout); " +
        "tint(#8fd694, 0.4, linear)")
    .then("rotate-y(15, 0.3, back-out)");

Handles

Chaining with handles

Every call to animate returns a handle, and every method on a handle returns another one, so sequences read as a chain.

stop()              halt here, without firing completion or continuing
play(n?)            replay this handle's sequence from its beginning
then(spec)          start another animation once this step finishes
run(callback, s?)   wait s seconds, call the function, carry on
reverse(delay?)     unwind everything recorded so far
repeat(delay?, n?)  replay everything recorded so far
delay(seconds)      wait
const door = game.find("door01");

door.animate("rotate-y(90, 0.7, cubic-out)")
    .delay(2)
    .then("rotate-y(0, 0.7, cubic-inout)");

What you see The door swings open, stands open for two seconds, then closes.

A handle represents the sequence up to and including the step it came from. When you are building something long, keep the last handle if later code needs to control the whole thing.

Callbacks in the middle of a sequence

Use run to do something that is not an animation at the right moment in a chain. Note the order: it waits first, then calls.

const music = game.loadSound("music");

music.animate("volume(0, 2, sine-inout)")
     .run(() => music.stop());

What you see The music fades out over two seconds and only then actually stops.

Use run rather than passing a function to then. The two would be ambiguous to the scripting bridge, so callbacks were deliberately given a method of their own.

Reverse

The round trip

reverse is the one that changes how you write everything else. It unwinds the entire chain recorded so far, backwards, using each step's own duration and easing, so you describe a movement once and get the return leg free.

door.animate("rotate-y(90, 0.7, cubic-out)")
    .reverse(2);

What you see Open, hold two seconds, close. The hold is the reverse delay.

The delay you pass applies once, before the first backward step; it is not inserted between every step. And because a chain records its waits as real steps, reversing a chain that contains a delay replays that wait too.

The handle reverse returns represents the whole journey out and back, which is what makes the next pattern possible.

Sequence repeats

Repeating a whole sequence

Where an inline repeat loops one property, repeat on a handle replays everything the chain has recorded. Put it after reverse and you have the shape almost every moving platform in a level wants:

const lift = game.find("lift01");

lift.animate("local-y(2, 2, sine-inout)")  // up over two seconds
    .reverse(1)                             // hold one second, come back down
    .delay(1)                               // hold one second at the bottom
    .repeat(0, -1);                         // and again, forever

What you see A lift that rises, waits, descends, waits, and never stops.

The arguments trip almost everyone once, so learn them properly. The first argument is a delay, not a count.

handle.repeat()          one more replay, immediately
handle.repeat(2)         one more replay, after two seconds
handle.repeat(0, 3)      three more replays - four runs in total
handle.repeat(0, -1)     forever
handle.repeat(2, -1)     forever, pausing two seconds before each replay

So repeat(0) is not forever. It is one more replay with no delay. Infinite repetition always needs -1 as the second argument. And nothing chained after an infinite repeat will ever run, because the repeat never finishes.

Slots

Slots, conflicts and replacement

Animations are filed by object and property name. Different names on the same object run happily together:

beacon.animate("rotate-y(360, 4, linear, repeat)");
beacon.animate("intensity(0.9, 0.8, sine-inout, repeat-flip)");

Start a second animation with a name already in use and it replaces the first. This is a feature, not a collision. It is how you retarget something already moving without stopping it first:

platform.animate("translate-y(4, 3, sine-inout)");
// halfway there, the player hits a switch
platform.animate("translate-y(1, 1, quad-out)");   // it turns around from where it is

Position is the one place with extra rules, because translate, translate-x/y/z and local-x/y/z all ultimately write the same value. Mixing the forms is rejected rather than allowed to produce whichever happened to run last: grouped translate conflicts with both axis forms, and world axes conflict with local axes. Sibling axes of the same form are fine, so translate-x and translate-z can run together.

Cancelling

Stopping things

There are two ways to stop an animation, and they differ in what they leave behind.

fan.animate("none");             // cancel everything on this object
fan.animate("rotate-y(none)");   // cancel just that one slot
motion.stop();                   // stop the sequence this handle represents

All three leave the object wherever it had got to. None of them fires a completion callback, and none advances a pending then, reverse or repeat. Cancelling is deliberately not the same as finishing, so a chain cannot be tricked into continuing by being interrupted.

stop returns its own handle, so restarting is a sentence: motion.stop().play().

Completion

Knowing when something finished

For a fixed sequence, prefer the chain, because then and run say what happens next, in the order it happens, in one place. Reach for the callback when something unrelated needs to know, or when the next action depends on which animation ended.

gate.onAnimationComplete = (component, spec) => {
    game.log(`${component.name} finished ${spec}`);
    if (spec.startsWith("local-y")) sound.fire();
};

What you see A latch sound plays the moment the gate settles, whichever way it moved.

It is one assignable callback, not a list of listeners: assigning again replaces the previous one, and null clears it. Only natural completion fires it. An animation repeating forever never completes, so it never reports.

Platforms

Moving platforms

Animating a mesh moves its collision with it, on the physics clock, which is what makes scripted platforms real. Two things are worth knowing before you build one.

The first is that the ball is normally carried by a moving surface, a correction that keeps it planted rather than sliding off a slowly moving platform. On something like a staircase, where you want the ball to roll about naturally instead of being tethered, turn that off:

step1.unstable = true;
step2.unstable = true;

This changes only the carry correction. Friction, damping, and the protections against being crushed or launched by a moving wall all stay exactly as they were.

The second is that a direct property write happens immediately, while an animation with no duration does not. That matters when you need to offset something before starting its loop:

// Two staircases breathing in opposite phase.
step1.unstable = true;
step2.unstable = true;

step1.position.y -= 0.3;   // immediate, unlike animate("local-y(-0.3)")

step1.animate("local-y(0.3, 1, sine-inout)").reverse(1).delay(1).repeat(0, -1);
step2.animate("local-y(-0.3, 1, sine-inout)").reverse(1).delay(1).repeat(0, -1);

What you see Two strips of staircase rising and falling against each other, one always high while the other is low.

Timing

Zero duration is not a setter

Leaving the duration out gives you an animation that reaches its target on the next tick, which is useful inside a chain and a trap outside one. These two lines fight, because both want the same slot and the first has not been advanced yet when the second replaces it:

object.animate("local-y(-0.3)");                 // replaced before it ever runs
object.animate("local-y(0.3, 1, sine-inout)");

When you want a value set now, set it:

object.position.y -= 0.3;

When it genuinely has to be an animation, wait for it to finish before starting the next thing:

object.animate("local-y(-0.3)")
      .run(() => {
          object.animate("local-y(0.3, 1, sine-inout)")
                .reverse(1)
                .repeat(0, -1);
      });

Camera

A scripted moment

Everything so far applies to the camera and to sounds as well as to things in the world, which is what an opening sequence is made of. The camera can be pointed at any visual, including an invisible anchor you create and move yourself.

const viewpoint = game.createNullPoint();
viewpoint.relocate(12, 6, -20);

game.camera.suspendInput = true;
game.camera.target = viewpoint;

viewpoint
    .animate("translate(0, 4, 0, 6, sine-inout)")
    .run(() => {
        game.camera.target = null;
        game.camera.suspendInput = false;
        viewpoint.free();
    });

game.camera.animate("zoom(9, 6, sine-inout); orbit-angle(200, 6, sine-inout)");

What you see The view drifts across the course while the camera circles and pulls back, then hands control to the player and lets the anchor go.

suspendInput silences the player's controls without disabling Escape or the function keys. A script that sets it owns the job of clearing it. If your sequence can be interrupted, make sure every exit from it hands control back.

Camera and sound animations advance on rendered frames rather than physics ticks, so a slow camera move stays smooth rather than being quantised to the simulation rate.

Mistakes

Mistakes worth knowing in advance

Nearly everyone makes at least one of these. None of them throws an error. The animation simply does something other than what you meant.

handle.repeat(0)                  // one more replay, not forever. Use repeat(0, -1)
"translate-y(2)"                  // goes TO 2. For "up by 2", use local-y(2)
"repeat"                          // snaps back at each pass. For ping-pong use repeat-flip
animate("local-y(-0.3)")          // not a setter. Assign position.y instead
run(fn, 2)                        // waits 2s THEN calls fn, not the other way round
then(fn)                          // callbacks go in run(), not then()
"linear-in"                       // there is no such easing. Just "linear"
scale + scale-x together          // both write the whole scale. Pick one form
after an infinite repeat          // unreachable; nothing chained past it will run

Two more that are less about syntax. A delay is a real recorded step, so reversing or repeating a chain replays its waits, which is usually what you want and occasionally a surprise. And a run callback is recorded too, so reversing a chain calls it again on the way back; that is useful for toggling something at both ends and startling if you expected it once.

When something is wrong, look at the game's own output. Bad specs are logged rather than thrown: an unknown property, a malformed number, an easing that does not exist, or a tint on something that has no tint will each report themselves and start nothing.

Next steps

Where to go next

This guide covers how movement is described. For the objects themselves, the reference lists every one: what a spinner exposes, which numbers an inlay light will let you animate, and what an event hands your callback.

TypeScript reference