Skip to content

Tween

tecs2d.tween is an animation system for Tecs. It provides typed interpolators, timeline composition, and easing functions for smooth entity animations. The tween plugin is automatically registered by tecs2d.

teal
local tween = require("tecs2d.tween")

-- Move an entity to x=200 over 0.5 seconds
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :once()
    :apply(world, entity)

Interpolators

Interpolators are reusable function references that tween a single numeric field on a component. The target value is passed separately to :to(), so the same interpolator can be used with different targets without creating new closures.

Canned interpolators

Pre-built interpolator references for common Transform and Color fields. These are created once at module load time and shared everywhere.

InterpolatorEquivalent
tween.translateXtween.field(Transform, "x")
tween.translateYtween.field(Transform, "y")
tween.translateXYtween.field2(Transform, "x", "y")
tween.rotationtween.field(Transform, "rotation")
tween.scaleXtween.field(Transform, "scaleX")
tween.scaleYtween.field(Transform, "scaleY")
tween.scaleXYtween.field2(Transform, "scaleX", "scaleY")
tween.alphatween.field(Color, "a")
tween.colorTweens RGBA in a single slot
tween.rotationShortestShortest-path angle interpolation

You can tween the X and Y position of an entity using tween.translateXY.

teal
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateXY, 200, 100)
    :once()
    :apply(world, entity)

To tween multiple fields, use a parallel group:

teal
-- Move and scale simultaneously
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateXY, 200, 100)
    :to(0.5, tween.quadOut, tween.scaleXY, 2)
    :once()
    :apply(world, entity)

tween.color makes it easy to tween the color of an entity to a target color.

teal
tween.timeline()
    :to(0.5, tween.linear, tween.color, 1, 0, 0, 1) -- tween to red
    :once()
    :apply(world, entity)

tween.field(component, fieldName)

Creates a reusable interpolator for a single numeric field. Uses state slots s1 (start) and d1 (delta).

teal
function tween.field(component: Component, fieldName: string): Interpolator

Example:

teal
local tweenHealth = tween.field(HealthBar, "fill")

-- Reuse the same interpolator with different targets
tween.timeline()
    :to(0.3, tween.quadOut, tweenHealth, 1)
    :step()
    :to(0.3, tween.quadOut, tweenHealth, 0.5)
    :once()
    :apply(world, entity)

tween.field2(component, fieldA, fieldB)

Creates a reusable interpolator for two numeric fields on the same component. Uses state slots s1, s2 (starts) and d1, d2 (deltas). Accepts two target values via :to().

teal
function tween.field2(component: Component, fieldA: string, fieldB: string): Interpolator

Example:

teal
local tweenSize = tween.field2(MyWidget, "width", "height")

tween.timeline()
    :to(0.5, tween.quadOut, tweenSize, 200, 100)
    :once()
    :apply(world, entity)

tween.track(base, getTargetFn)

Wraps any interpolator to dynamically track a moving target each frame instead of tweening to a fixed value. The getTargetFn returns up to 4 target values; the wrapper recalculates deltas from the captured starting state every frame.

teal
function tween.track(base: Interpolator, getTargetFn: TrackTargetFn): Interpolator

This works because all interpolators follow the standardized slot convention (s1-s4 for starts, d1-d4 for deltas), so track can generically override the deltas regardless of the underlying interpolator.

Example: homing projectile

teal
local follow = tween.track(tween.translateXY, function(world: tecs.World, _entity: integer)
    local p = world:get(playerEntity, Transform)
    if p then return p.x, p.y end
    return nil, nil  -- nil falls back to original delta
end)

tween.timeline()
    :to(2.0, tween.linear, follow, 0, 0)
    :loop()
    :apply(world, missileEntity)

Custom interpolators

For advanced use cases, you can write a custom interpolator. An interpolator is a record with two methods: init captures starting state, apply writes the interpolated value each frame.

All interpolators follow the slot convention: s1-s4 hold starting values, d1-d4 hold deltas. This uniform layout enables generic composition (e.g., dynamic tracking wrappers).

init(world, entity, relative, t1, t2, t3, t4) -> s1, s2, s3, s4, d1, d2, d3, d4

Called once on first tick. Returns starting values and deltas.

ParameterTypeDescription
worldWorldThe ECS world
entityintegerTarget entity ID
relativebooleantrue from :adjust(), false from :to()
t1-t4numberTarget values passed from :to() or :adjust()

apply(world, entity, t, s1, s2, s3, s4, d1, d2, d3, d4)

Called every frame. s1-s4 are starting values, d1-d4 are deltas.

ParameterTypeDescription
worldWorldThe ECS world
entityintegerTarget entity ID
tnumberEased progress, 0 to 1
s1-s4numberStarting values captured by init
d1-d4numberDeltas computed by init
teal
local tweenInnerRadius: tween.Interpolator = {
    init = function(
        world: tecs.World, entity: integer, relative: boolean, t1: number
    ): number, number, number, number, number, number, number, number
        local shape = world:get(entity, MyRingComponent)
        if shape then
            local startVal = shape.inner.radius
            local delta = relative and t1 or (t1 - startVal)
            return startVal, 0, 0, 0, delta, 0, 0, 0
        end
        return 0, 0, 0, 0, 0, 0, 0, 0
    end,
    apply = function(
        world: tecs.World, entity: integer, t: number,
        s1: number, _s2: number, _s3: number, _s4: number, d1: number
    )
        local shape = world:get(entity, MyRingComponent)
        if shape then
            shape.inner.radius = s1 + d1 * t
            world:markComponentDirty(entity, MyRingComponent)
        end
    end
}

tween.timeline()
    :to(0.5, tween.quadOut, tweenInnerRadius, 50)
    :once()
    :apply(world, entity)

Timeline builder

Timelines are created with tween.timeline() and built using a fluent API. Timelines essentially act as animation templates that are applied to entities.

TimelineBuilder:to

Adds an interpolation to the current parallel group. The interpolator is a reusable function reference and t1-t4 are the target values. Multiple :to() calls without a :step() between them run simultaneously.

teal
function TimelineBuilder:to(
    self,
    duration: number,
    easingFn: EasingFunction,
    interpolator: Interpolator,
    t1: number, t2?: number, t3?: number, t4?: number
): TimelineBuilder

Example:

teal
-- These two run in parallel (same start time)
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :to(0.5, tween.linear, tween.alpha, 0)
    :once()

TimelineBuilder:adjust

Like :to(), but relative: the values are added to the current values rather than replacing them. Uses the same interpolators and participates in parallel groups the same way.

teal
function TimelineBuilder:adjust(
    self,
    duration: number,
    easingFn: EasingFunction,
    interpolator: Interpolator,
    t1: number, t2?: number, t3?: number, t4?: number
): TimelineBuilder

Example:

teal
-- Move 100 units right from wherever the entity currently is
tween.timeline()
    :adjust(0.5, tween.quadOut, tween.translateX, 100)
    :once()
    :apply(world, entity)

-- Chain relative adjustments: right 100, then down 50
tween.timeline()
    :adjust(0.5, tween.quadOut, tween.translateX, 100)
    :step()
    :adjust(0.5, tween.quadOut, tween.translateY, 50)
    :once()
    :apply(world, entity)

TimelineBuilder:step

A step is a barrier that waits for the current parallel group to finish, then starts the next group. Without :step(), multiple :to() calls run simultaneously. With it, they run sequentially.

step, no args

step can be called with no arguments.

teal
function TimelineBuilder:step(self): TimelineBuilder

Example:

teal
-- Move right, THEN move down (sequential)
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :step()
    :to(0.5, tween.quadOut, tween.translateY, 100)
    :once()

step with delay

You can provide an optional delay in seconds to wait between groups.

teal
function TimelineBuilder:step(self, delay: number): TimelineBuilder

Example:

teal
-- Move right, wait 0.2s, then fade out
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :step(0.2)
    :to(0.5, tween.linear, tween.alpha, 0)
    :once()

step with notification

You can provide an optional notification callback function(world, entity) that fires when playback reaches the step boundary. This can be useful for triggering sounds, spawning particles, or changing animation frames at transition points.

teal
function TimelineBuilder:step(
    self,
    notify: function(world: tecs.World, entity: integer, handle: tween.TweenHandle)
): TimelineBuilder

Example:

teal
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :step(function(world: tecs.World, entity: integer, handle: tween.TweenHandle)
        playSound("whoosh")
    end)
    :to(0.5, tween.quadOut, tween.translateY, 100)
    :once()

step with delay and notification

Pause and callback can be combined.

teal
function TimelineBuilder:step(
    self,
    delay: number,
    notify: function(world: tecs.World, entity: integer, handle: tween.TweenHandle)
): TimelineBuilder

Example:

teal
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :step(0.2, function(world: tecs.World, entity: integer, handle: tween.TweenHandle)
        spawnParticles(world, entity)
    end)
    :to(0.3, tween.linear, tween.alpha, 0)
    :once()

TimelineBuilder:run

Inlines another timeline at the current time offset. The sub-timeline's full behavior is preserved: its entries, looping, and pingPong all run as defined. Sub-timelines can themselves contain :run() calls, enabling arbitrary nesting.

teal
function TimelineBuilder:run(self, schedule: Timeline, count?: integer): TimelineBuilder

Example:

teal
-- Define a reusable movement pattern
local moveRight = tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :once()

-- Embed it in a larger sequence
local moveRightThenDown = tween.timeline()
    :run(moveRight)
    :step()
    :to(0.5, tween.linear, tween.translateY, 100)
    :once()

local handle = moveRightThenDown:apply(world, entity)

The optional count parameter overrides how many cycles the sub-timeline plays. This is required for infinite sub-timelines, since their duration is unbounded (that is, timelines that loops or ping pongs).

teal
-- An infinite pulse
local pulse = tween.timeline()
    :to(0.8, tween.sineInOut, tween.scaleX, 1.5)
    :to(0.8, tween.sineInOut, tween.scaleY, 1.5)
    :pingPong()

-- Cap it to 3 cycles within a sequence
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :step()
    :run(pulse, 3)
    :step()
    :to(0.5, tween.linear, tween.alpha, 0)
    :once()
    :apply(world, entity)

TimelineBuilder:channel

Timelines may optionally declare a channel. Applying a timeline with a channel cancels any currently active tween on the same entity and channel, then starts the new playback. Timelines without a channel never automatically cancel other tweens.

teal
function TimelineBuilder:channel(self, name: string): TimelineBuilder

Example:

teal
local moveRight = tween.timeline()
    :channel("movement")
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :once()

local moveDown = tween.timeline()
    :channel("movement")
    :to(0.5, tween.quadOut, tween.translateY, 100)
    :once()

-- First apply starts the movement
moveRight:apply(world, entity)

-- Second apply on same channel cancels the first automatically
moveDown:apply(world, entity)

INFO

Channels are coarse-grained coordination, not per-field conflict detection.

TimelineBuilder:onComplete

Sets a callback for when the entire timeline finishes (after all repeats). Receives (world, entity, handle). Only meaningful for finite timelines; never fires on infinite loops.

This is distinct from a trailing :step(fn), which fires at the end of every cycle in a looping timeline.

teal
function TimelineBuilder:onComplete(
    self, fn: function(world: World, entity: integer, handle: TweenHandle)
): TimelineBuilder

Example:

teal
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :onComplete(function(world: tecs.World, entity: integer, handle: tween.TweenHandle)
        world:despawn(entity)
    end)
    :once()
    :apply(world, entity)

TimelineBuilder finalizers

Finalizers freeze the builder and return a reusable Timeline. Further :to() calls on a finalized builder will error.

TimelineBuilder:once

Play through once.

teal
function TimelineBuilder:once(self): Timeline

TimelineBuilder:loop

Loop the timeline. count is the number of additional plays, so loop(2) plays 3 times total. loop() with no argument loops infinitely.

teal
function TimelineBuilder:loop(self, count?: integer): Timeline

TimelineBuilder:pingPong

Like loop() but reverses direction each cycle. pingPong() with no argument loops infinitely.

teal
function TimelineBuilder:pingPong(self, count?: integer): Timeline

Applying timelines

After a timeline is finalized using :once, :loop, or :pingPong, it can be applied to an entity using :apply.

Timeline:apply

Clones the frozen definition into a live timeline targeting a specific entity. Returns a handle for cancellation. The same definition can be applied to multiple entities independently.

teal
function Timeline:apply(
    self,
    world: World,
    entity: integer,
    speed?: number,
    delay?: number
): TweenHandle

The optional speed parameter sets the playback rate multiplier. Default is 1. Use 2 for double speed, 0.5 for half speed.

The optional delay parameter postpones the start of playback by the given number of seconds. This is useful for staggering animations across multiple entities while sharing the same schedule.

teal
local fadeOut = tween.timeline()
    :to(0.5, tween.linear, tween.alpha, 0)
    :once()

-- Apply to multiple entities
fadeOut:apply(world, entityA)
fadeOut:apply(world, entityB)

-- Same animation, different speeds
local fall = tween.timeline()
    :to(2.0, tween.bounceOut, tween.translateY, 0)
    :once()

fall:apply(world, entityA, 0.5)  -- slow
fall:apply(world, entityB, 2.0)  -- fast

-- Stagger 5 buttons sliding in, 0.1s apart, sharing one schedule
local slideIn = tween.timeline()
    :to(0.3, tween.quadOut, tween.translateX, 0)
    :once()

for i = 0, 4 do
    slideIn:apply(world, buttons[i + 1], 1, i * 0.1)
end

Tween handles

Applying a tween returns a handle. You can pause, resume, and cancel a tween from this handle.

TweenHandle:cancel

Removes the timeline from the active list, stopping the tween immediately.

teal
function TweenHandle:cancel(self)

Example:

teal
local handle = pulse:apply(world, entity)
-- Later...
handle:cancel()

TweenHandle:pause

Pauses playback. While paused, the timeline doesn't advance but remains in the active list.

teal
function TweenHandle:pause(self)

Example:

teal
local handle = tween.timeline()
    :to(1.0, tween.quadOut, tween.translateX, 200)
    :once()
    :apply(world, entity)

handle:pause()   -- freezes at current position
handle:resume()  -- continues from where it left off

TweenHandle:resume

Resumes playback.

teal
function TweenHandle:resume(self)

TweenHandle:getElapsed

Returns the total elapsed play time of the tween in seconds.

teal
function TweenHandle:getElapsed(self): number

TweenHandle:getRate

Gets the rate, or speed modifier, of the tween.

teal
function TweenHandle:getRate(self): number

TweenHandle:getDirection

Get the current playback direction: 1 for forward, -1 for reverse.

teal
function TweenHandle:getDirection(self): integer

Self-modifying tweens

Step callbacks and onComplete receive the handle as a third argument. This enables tweens that react to game state without capturing external variables.

teal
tween.timeline()
    :to(0.5, tween.quadOut, tween.translateX, 200)
    :step(function(world: tecs.World, entity: integer, handle: tween.TweenHandle)
        print("elapsed:", handle:getElapsed(), "rate:", handle:getRate())

        if not isAttacking then
            handle:cancel()
        end
    end)
    :to(0.5, tween.quadOut, tween.translateX, 0)
    :once()
    :apply(world, entity)

Cleanup

Timelines are automatically removed when:

  • The timeline finishes (finite timelines)
  • The target entity is no longer alive (checked each frame)

Easing functions

All easing functions live directly on tween and have the signature function(t: number): number where f(0) = 0 and f(1) = 1.

tween.linear has no acceleration. All other families have four variants:

  • In: Accelerates from zero velocity
  • Out: Decelerates to zero velocity
  • InOut: Accelerates in the first half, decelerates in the second
  • OutIn: Decelerates in the first half, accelerates in the second
FamilyInOutInOutOutIn
QuadquadInquadOutquadInOutquadOutIn
CubiccubicIncubicOutcubicInOutcubicOutIn
QuartquartInquartOutquartInOutquartOutIn
QuintquintInquintOutquintInOutquintOutIn
SinesineInsineOutsineInOutsineOutIn
ExpoexpoInexpoOutexpoInOutexpoOutIn
BackbackInbackOutbackInOutbackOutIn
ElasticelasticInelasticOutelasticInOutelasticOutIn
BouncebounceInbounceOutbounceInOutbounceOutIn