Tweening Library (Inspired by Godot)

I quickly put together a partial recreation of Godot’s tweening system for the Playdate SDK in Lua! It definitely has room for improvement, but it served my purposes, and it might for you as well.

--[[
MIT License

Copyright (c) 2026 quackandcheese

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]

import "CoreLibs/object"

local tweens = {}

Tween = {}
class("Tween").extends()

local dt = 0
local previousTime = nil

function Tween:init()
    self.tweeners = {}
    self.tweenersProcessed = 0
    table.insert(tweens, self)
end

---Creates and appends a PropertyTweener. This method tweens a property of an object between an initial value and finalValue in a span of time equal to duration, in seconds. The initial value by default is the property's value at the time the tweening of the PropertyTweener starts.
---@param object any
---@param propertyName any
---@param finalValue any
---@param duration any
---@return PropertyTweener
function Tween:tweenProperty(object, propertyName, finalValue, duration)
    local tweener = PropertyTweener(object, propertyName, finalValue, duration)
    if self.parallel then
        tweener:setParallel()
    end
    table.insert(self.tweeners, tweener)
    return tweener
end

function Tween:tweenSprite(object, finalXValue, finalYValue, duration)
    local tweener = SpriteTweener(object, finalXValue, finalYValue, duration)
    if self.parallel then
        tweener:setParallel()
    end
    table.insert(self.tweeners, tweener)
    return tweener
end

function Tween:tweenCallback(callback)
    local tweener = CallbackTweener(callback)
    if self.parallel then
        tweener:setParallel()
    end
    table.insert(self.tweeners, tweener)
    return tweener
end

function Tween:setParallel(value)
    if value == nil then
        value = true
    end

    self.parallel = value

    if #self.tweeners > 0 and value == true then
        self.tweeners[#self.tweeners]:setParallel(value) -- sets the most recently added tweener to parallel, matching Godot's implementation
    end

    return self
end

function Tween:finished()
    return self.tweenersProcessed >= #self.tweeners
end

function Tween:update()
    for i=self.tweenersProcessed, #self.tweeners - 1 do
        local currentTweener = self.tweeners[i + 1]

        if not currentTweener.began then
            currentTweener:beginProcessing()
        end

        if not currentTweener.finished then
            currentTweener:update()
        else
            self.tweenersProcessed += 1
        end

        if not self:finished() and not self.tweeners[self.tweenersProcessed + 1].parallel then
            break
        end
    end
    
    if not self:finished() then return end

    for i, tween in ipairs(tweens) do
        if tween == self then
            table.remove(tweens, i)
            break
        end
    end
end

function Tween.updateAllTweens()
    dt = 0
    local currentTime <const> = playdate.getCurrentTimeMilliseconds() / 1000
	if previousTime ~= nil then
		dt = currentTime - previousTime
	end
	previousTime = currentTime

    for _, tween in pairs(tweens) do
        tween:update()
    end
end

Tweener = {}

class('Tweener').extends()

function Tweener:init()
    self.finished = false
    self.began = false
    self.parallel = false
end

function Tweener:beginProcessing()
    self.began = true
end

function Tweener:setParallel(value)
    if value == nil then
        value = true
    end

    self.parallel = value

    return self
end

function Tweener:update()
end

---Interpolates an Object's property over time.
PropertyTweener = {}
class('PropertyTweener').extends(Tweener)

function PropertyTweener:init(object, propertyName, finalValue, duration)
    self.object = object
    self.propertyName = propertyName
    self.finalValue = finalValue
    self.age = 0.0
    self.duration = duration
    self:setEasing(playdate.easingFunctions.linear)
end

function PropertyTweener:from(initialValue)
    self.initialValue = initialValue
end

function PropertyTweener:beginProcessing()
    PropertyTweener.super.beginProcessing(self)

    if not self.initialValue then
        self.initialValue = self.object[self.propertyName]
    end
end

---@param easingFunction function
function PropertyTweener:setEasing(easingFunction)
    self.easingFunction = easingFunction
    return self
end

function PropertyTweener:update()
    if self.finished then return end

    self.age += dt
    
    local newValue = 
        self.easingFunction(
            self.age, 
            self.initialValue, 
            self.finalValue - self.initialValue, 
            self.duration
        )
    self.object[self.propertyName] = newValue
    
    if self.age >= self.duration then
        self.finished = true
    end
end


SpriteTweener = {}
class('SpriteTweener').extends(Tweener)

function SpriteTweener:init(sprite, finalXValue, finalYValue, duration)
    self.object = sprite

    self.finalXValue = finalXValue
    self.finalYValue = finalYValue

    self.age = 0.0
    self.duration = duration
    self:setEasing(playdate.easingFunctions.linear)
end

function SpriteTweener:from(initialXValue, initialYValue)
    self.initialXValue = initialXValue
    self.initialYValue = initialYValue
end

function SpriteTweener:beginProcessing()
    PropertyTweener.super.beginProcessing(self)

    if not self.initialValue then
        self.initialXValue = self.object.x
        self.initialYValue = self.object.y
    end
end

function SpriteTweener:update()
    if self.finished then return end

    self.age += dt

    local newX, newY = 
        self.easingFunction(
            self.age, 
            self.initialXValue, 
            self.finalXValue - self.initialXValue, 
            self.duration
        ),
        self.easingFunction(
            self.age,
            self.initialYValue, 
            self.finalYValue - self.initialYValue, 
            self.duration
        )
    
    self.object:moveTo(newX, newY)
    
    if self.age >= self.duration then
        self.finished = true
    end
end

---@param easingFunction function
function SpriteTweener:setEasing(easingFunction)
    self.easingFunction = easingFunction
    return self
end


CallbackTweener = {}
class('CallbackTweener').extends(Tweener)

function CallbackTweener:init(callback)
    CallbackTweener.super.init(self)

    self.callback = callback
end

function CallbackTweener:update()
    self.callback()
    self.finished = true
end
1 Like