Calculon™ Episode 4 | Dynamics Beyond Interpolation using Post-Grad Mathematics | (Free + Open Source)

Calculon™ | Episode 4

Dynamics Beyond Interpolation using Post-Grad Mathematics

left middle right

Table of Contents


What this episode is about

If you have spent any time writing motion systems in Roblox, you have reached for TweenService, :Lerp(), or an easing curve. These tools are not wrong. For doors, UI fades, cinematic paths, and deterministic animations, they are the correct choice: fast to author, predictable in output, and zero runtime overhead beyond a per-frame interpolation.

This episode is not about replacing them everywhere. It is about identifying the boundary where they stop being enough, and explaining precisely what goes wrong when you push past that boundary without changing your mental model.

That boundary is state. An interpolation function tells an object where to be. It does not tell the object how fast it is moving when it arrives, whether it has momentum, whether there is a force acting on it, or what happens if the destination changes mid-flight. A dynamic system does all of that automatically, because it does not prescribe positions over time; it prescribes laws, and the positions follow.

CORE CLAIM
The moment velocity matters, position-only thinking becomes incomplete. The moment acceleration matters, easing becomes insufficient. The moment stability matters, motion becomes mathematics.

This episode builds a complete second-order dynamical system library for Roblox, grounded in classical mechanics. The module is over 1.1k lines with full documentation, three damping regime integrators (underdamped, critically damped, overdamped), energy tracking, C0/C1/C2 continuity analysis, frame-rate independence tests, and a preset library of 13 pre-tuned configurations. Three standalone visualizer LocalScripts demonstrate the concepts directly in-engine with live displacement graphs.


Why interpolation is not motion

A tween defines a function:


Position Prescription

x(t); position is a function of time alone

This is a parametric path. Time is the input; position is the output. Nothing else is stored. The representation does not contain:

WHAT INTERPOLATION DISCARDS
velocity · acceleration · force · mass · damping · momentum · energy · constraints · stability

The consequences are concrete. Consider two objects at the same position:


The State Identification Problem

Object A → x = 10, v = 0; stationary
Object B → x = 10, v = 100; moving at 100 studs/s

A position-only system sees A and B as identical.
A dynamic system treats them as entirely different states.

This is not an abstract concern. In practice, it manifests as: a camera that snaps hard when the character changes direction (because the tween restarts from zero velocity), a recoil that plays backward if interrupted mid-motion, a UI panel that teleports when rapidly re-triggered, or a hover system that jitters because velocity is never preserved across frames.

Every one of these is the same root failure: the system knows where the object is, but not how fast it is moving or why.

Two Objects, Two Timelines

A tween from A to B prescribes a timeline. If the target changes to C before the tween finishes, you have three choices:

Approach Velocity at transition Result
Cancel and restart Zeroed Visible snap in motion direction
Blend into new tween Approximated Duration math breaks, is often ugly
Spring system Preserved Seamless, physics handles it automatically

PhET interactive simulation

Before reading equations, feel what damping does. The University of Colorado Boulder’s PhET simulation project (founded by Nobel Laureate Carl Wieman, 2002) provides a free, browser-based mass-and-spring laboratory with fully adjustable stiffness, damping, and gravity. Run it alongside this post:

‪Masses and Springs‬; University of Colorado Boulder

Set the damping slider to None and pull the mass down. It oscillates forever; this is ζ = 0, a marginally stable system. Energy never leaves. Now drag the damping slider slowly toward Lots and pull again. At some point, the bouncing disappears, and the mass drops straight to rest in minimum time; that is, ζ = 1, critical damping. Push the slider further, and the return becomes sluggish and slow; that is, ζ > 1, overdamped. Those three behaviors are not three different systems. They are the same ODE with one parameter changed. The transition between them is the entire mathematical story this episode tells, and you just watched it happen in real time.


Position space vs Phase space

Classical mechanics describes a system not just by where it is, but by the complete state needed to predict its future. For a point mass, that is:

State Vector

state = (x, v)

x -> position
v -> velocity (the derivative of position)

In Roblox 3D:
state = (Vector3 position, Vector3 velocity)

The space of all possible (x, v) pairs is called phase space (Strang, MIT 18.03). A trajectory in phase space is not a path through 3D world coordinates; it is a path through the space of all possible states. An oscillating spring, viewed in phase space, traces an ellipse. A damped spring traces an inward spiral. A critically-damped spring traces a straight line into the origin.

Phase Portrait; Spring-Mass-Damper (displacement vs. velocity)


image

KEY INSIGHT
Interpolation lives in position space: it traces x(t).
Dynamics lives in phase space: it evolves (x, v) jointly, subject to laws.

For a full rigid body in Roblox, the state would include position, linear velocity, orientation (CFrame rotation), and angular velocity, which is exactly what the Roblox physics engine stores internally per assembly. When you bypass the physics engine and write your own motion system, you need to decide how much of that state your system actually needs. For most procedural motion use cases (cameras, UI, spring bones, follow systems), the two-component state (position, velocity) is sufficient.


Differential equations as motion laws

A differential equation expresses a relationship between a quantity and its rates of change. For motion, the most fundamental statement is Newton’s second law:

-- Newton's Second Law

F = m · a

F -- net force acting on the object
m -- mass
a -- acceleration = d²x/dt²

a = F / m

-- Rewritten as an ODE system:
dx/dt = v -- velocity is the derivative of position
dv/dt = a(x, v, t) -- acceleration may depend on anything

This is the first-order form of the equations of motion. Note that acceleration a(x, v, t) can depend on current position (spring force), current velocity (drag force), and explicit time (externally driven systems). This generality is what makes differential equations so powerful as a motion framework.

The core shift in thinking: instead of asking “where should this object be at time t?”, you ask “what forces act on this object right now, given its current state?”. The positions follow automatically by integrating the laws forward.

Connecting to Roblox

In practice, a Roblox game loop runs at roughly 60 Hz. At each RunService.Heartbeat, you receive a time delta dt. Instead of computing a new tween alpha and calling :Lerp(), you compute the forces on your object, solve (or numerically integrate) the ODE for one step, and move the Part to the resulting position. The Part carries no velocity information; the velocity lives in your script’s state table. That is the fundamental architectural shift this episode is about.


Tweening vs Governing laws

Property Interpolation / Tweening Dynamics / Second-Order System
Author specifies Endpoints + duration + easing curve Initial state + laws (ωₙ, ζ)
Motion source Prescribed by author Emerges from physics
Velocity state Not stored; discarded each frame Part of state; preserved always
Target change Must restart or manually blend tweens Update target; velocity carries
Impulse response Not supported natively Call :Impulse(); dynamics does it
Frame-rate dependence Controllable if authored carefully Exact integrator at any FPS
Overshoot / oscillation No, the path is fixed Yes; controllable via ζ
Energy semantics None, path is not physical Full PE + KE tracking
Spring system UI, cinematic paths, static doors, cutscenes Camera, recoil, springs, hover, follow, sway

FAIR USE NOTE
TweenService is the right tool for a very large class of Roblox problems. This episode does not argue otherwise. The goal is to give you the vocabulary and tooling to correctly identify when you are past that class; and know what to reach for instead.


Easing is not physics (C0/C1/C2)

Easing functions; EasingStyle.Quad, EasingStyle.Elastic, custom Bezier curves; look smooth. But visual smoothness at the position level does not guarantee smoothness at the velocity or acceleration level. The classical framework for this is continuity classes.

The critical failure mode: a tween that completes and immediately begins the next tween. At that join, the velocity of the first tween is typically non-zero, but the second begins with a new easing curve that starts at zero velocity. The position is C⁰ (no gap), but the velocity is discontinuous; a C¹ violation. You see this as a “hitch” in camera motion or a “catch” in UI panel animation.

C¹ Failure at Tween Join

Tween A ends at t₁ with velocity v₁ ≠ 0
Tween B begins at t₁ with velocity v₂ = 0 (EasingStyle resets)

|v₁ − v₂| = |v₁| -- nonzero velocity discontinuity

A spring system has zero velocity discontinuity across any target change,
because velocity is part of the state and is never reset.

For a damped spring, the initial acceleration at any target change is given exactly by the ODE:

Spring Transition Acceleration (C² guarantee)

x''(0) = −2ζωₙ · v₀ − ωₙ² · d₀

d₀ -- displacement from target at transition
v₀ -- velocity at transition (carried over from previous state)

This is determined entirely by the current state; there is no discontinuity.

The spring’s initial acceleration is always a smooth function of its current state. It cannot “snap.” Easing functions have no such guarantee; the acceleration at the start of a new tween is whatever the easing curve’s second derivative happens to be at t=0, which is unrelated to the prior motion.


Spring mass damper deep dive

The governing equation is the damped harmonic oscillator:

-- Standard Form; Spring-Mass-Damper

m · x(prime.prime) + c · x(prime) + k · x = k · x_target

m -- mass (inertia; resists acceleration)
c -- damping coefficient (dissipates velocity)
k -- stiffness (restoring force toward target)
x -- current position
x(prime) -- velocity (dx/dt)
x(prime.prime) -- acceleration (d²x/dt²)
x_target -- equilibrium / target position

For analysis, divide through by m and define two dimensionless parameters:

-- Canonical Form; ωₙ and ζ

x(prime.prime) + 2·ζ·ωₙ·x(prime) + ωₙ²·x = ωₙ²·x_target

ωₙ = sqrt(k / m); natural frequency (rad/s)
ζ = c / (2 · sqrt(k · m)); damping ratio (dimensionless)

-- The canonical form separates physics from scale.
-- ωₙ controls how fast the system responds.
-- ζ controls how it responds; oscillatory vs smooth.

The Three Damping Regimes

Exact Closed-Form Solutions

Unlike many ODEs in game physics, the spring-mass-damper has three exact analytic solutions; one per damping regime. These are not approximations. They are the provably correct values of x(t) and v(t) derived by separation of variables and variation of parameters (Strang, MIT 18.03, §3.3).

-- Underdamped Solution (0 < ζ < 1); let α = ζ·ωₙ, ωd = ωₙ·√(1−ζ²)
d(t) = e^(−αt) · [ d₀·cos(ωd·t) + (v₀+α·d₀)/ωd · sin(ωd·t) ]

v(t) = e^(−αt) · [ v₀·cos(ωd·t) − (α·v₀+ωₙ²·d₀)/ωd · sin(ωd·t) ]

-- d = x − x_target (displacement from equilibrium)
-- ωd = damped natural frequency; the frequency of actual oscillation


-- Critically Damped Solution (ζ = 1); let B = v₀ + ωₙ·d₀
d(t) = (d₀ + B·t) · e^(−ωₙ·t)
v(t) = (v₀ − ωₙ·B·t) · e^(−ωₙ·t)

-- Fastest approach to zero without overshoot.
-- The repeated real eigenvalue (−ωₙ, −ωₙ) produces the polynomial-exponential form.


-- Overdamped Solution (ζ > 1); let s = √(ζ²−1), r₁ = ωₙ(−ζ+s), r₂ = ωₙ(−ζ−s)
d(t) = A·e^(r₁t) + B·e^(r₂t)
v(t) = A·r₁·e^(r₁t) + B·r₂·e^(r₂t)

-- A = (v₀ − r₂·d₀)/(r₁−r₂)
-- B = (r₁·d₀ − v₀)/(r₁−r₂)

-- r₁, r₂ are both real and negative: two decaying exponentials with different rates.

All three solutions are implemented in the module as stepCritical1D, stepUnderdamped1D, and stepOverdamped1D. The public dispatcher DynamicMotion.stepExact1D(d0, v0, wn, zeta, dt) selects the correct solver automatically based on ζ, with a tolerance band |ζ−1| < 1e-4 around critical to prevent numerical edge cases.

Key Derived Quantities

-- Settling Time (2% criterion)
t_s ≈ −ln(0.02) / (ζ · ωₙ) ≈ 3.91 / (ζ · ωₙ)

-- Time for |displacement| to remain below 2% of initial step forever after.


-- Percent Overshoot (underdamped step response)
OS% = 100 · exp(−π·ζ / √(1−ζ²))

-- At ζ=0.5: OS% ≈ 16.3%
-- At ζ=0.7: OS% ≈ 4.6%
-- At ζ=1.0: OS% = 0%


-- Damped Natural Frequency
ωd = ωₙ · √(1 − ζ²) -- actual frequency of oscillation
Td = 2π / ωd -- period of one damped oscillation


-- Logarithmic Decrement (for measuring ζ from data)
δ = 2π·ζ / √(1−ζ²) -- ratio of successive peak amplitudes: A₁/A₂ = e^δ

-- If you observe two consecutive peaks in a live spring, their amplitude ratio
-- tells you ζ directly. Useful for tuning existing systems by observation.

Leonard Salomon Ornstein


Energy, stability, why motion explodes

The total mechanical energy of a spring system is the sum of kinetic and potential energy:

-- Mechanical Energy

E = KE + PE = (1/2)·m·v² + (1/2)·m·ωₙ²·d²

-- An undamped system (ζ=0): E is constant; energy oscillates between KE and PE forever.
-- A damped system (ζ>0): E decreases monotonically; energy dissipates as heat.
-- A negatively damped system (ζ<0): E increases; system grows without bound.

Numerical Energy Injection

This is the mechanism behind every spring system that “explodes” or “vibrates forever” in Roblox scripts. The script author did not intend ζ < 0; but the numerical integration method effectively injects energy into the system at each step.

COMMON ROBLOX SYMPTOMS OF NUMERICAL INSTABILITY
Camera shake that grows instead of decaying · Springs vibrating forever or reversing direction · Hover systems launching objects upward · Follow systems overshooting wildly and not settling · Frame-rate-dependent behavior (works at 60 FPS, breaks at 20 FPS)

These are not scripting logic bugs in most cases. They are numerical stability failures. The explicit Euler integrator has a stability bound: the system is stable only when the product ωₙ · dt is below a threshold. At high spring stiffness or low frame rates, that threshold is crossed, and the integration adds energy instead of conserving or removing it. The exact analytic integrator in DynamicMotion has no such stability bound; it is unconditionally stable for any positive ζ and any dt.

Stability Conditions

Method Stability Condition Behavior at large dt·ω
Explicit Euler ωₙ·dt < 2/(1+ζ); often < 0.1 Diverges; system explodes
Semi-implicit Euler Wider than explicit, still bounded Usually decays but with large phase error
RK4 Better but still conditional Higher accuracy within stability region
Exact (DynamicMotion) Unconditional any dt ≥ 0 Zero truncation error at any frame rate

Numerical integration in Roblox

All differential equations are continuous. Roblox runs at discrete frame intervals. The job of a numerical integrator is to approximate the continuous solution using a sequence of finite steps. The quality of that approximation differs dramatically between methods.

-- Explicit Euler; O(dt) error, conditionally stable

v_new = v + a(x, v) · dt -- acceleration from current state
x_new = x + v · dt -- position from current velocity (OLD v)

-- Semi-Implicit Euler; better stability, still O(dt) error

v_new = v + a(x, v) · dt
x_new = x + v_new · dt -- uses updated velocity (NEW v)

-- Exact Integrator; zero truncation error, unconditionally stable

d(dt), v(dt) = closed-form ODE solution evaluated at t=dt

-- Not an approximation. The analytic solution of the ODE is evaluated exactly.
-- The only error is 64-bit floating point rounding (~1e-15 relative).
-- Produces identical trajectories at 20 FPS or 300 FPS.

The exact integrator is available only because the spring-mass-damper ODE has a known closed form. For general non-linear systems (e.g., quadratic drag, coupled pendulums), RK4 or higher-order methods are necessary. The module includes stepRK4_1D and compareIntegrators for this purpose.

Why Lerp-Based Smoothing Is Not Frame-Rate Independent

The pervasive pattern pos = pos:Lerp(target, alpha) is a first-order IIR filter. Its per-frame factor is alpha, which is a constant chosen by the developer. At 60 FPS with alpha = 0.1, the system applies a 10% correction each frame. At 30 FPS, it applies 10% every other frame; half the correction rate for the same wall-clock time. The motion is frame-rate dependent.

The frame-rate-independent version is the exponential decay integrator: pos = pos:Lerp(target, 1 - math.exp(-k * dt)). But this has no velocity state, no overshoot, and no impulse response. It is an exponential drag system; covered in Calculon™ Episode 3. The spring system here is the next level up: it adds inertia, momentum, and full second-order dynamics.


DynamicMotion; The full module

Check the top of this post and choose your option of download, or just click here for the source code.

The module is organized into twelve sections. Every function is documented inline with parameter types and derivation notes.

Core Creation

local DM = require(ReplicatedStorage:WaitForChild("DynamicMotion"))

-- Method A: physical parameters (k, c, m)
local sys = DM.MotionSystem.new({
	stiffness = 120, -- k  (force/length)
	damping = 22, -- c  (force·time/length)
	mass = 1, -- m  (default = 1)
	position = startPos,
	velocity = Vector3.zero,
})

-- Method B: canonical parameters (ωₙ, ζ); more intuitive
local sys = DM.MotionSystem.new({
	omega_n = 14, -- ωₙ rad/s; controls response speed
	zeta = 0.85, -- ζ; controls oscillation vs smoothness
	position = startPos,
})

-- Method C: from a named preset
local sys = DM.fromPreset("CameraFollow", { position = startPos })

MotionSystem Instance API

METHOD SIGNATURE DESCRIPTION
:Step(dt) number → void Advance the system by dt seconds using the exact integrator; Call from Heartbeat.
:Step1D(dt) number → void 1D variant; steps only the X component; Use when driving scalar quantities (angles, etc).
:SetTarget(t) Vector3 → void Update equilibrium position; Velocity is preserved; This is the primary interaction method.
:SetTarget1D(t) number → void Scalar target for 1D mode.
:Impulse(v) Vector3 → void Apply an instantaneous velocity delta; Models collisions, recoil, explosions, jumps.
:Impulse1D(dv) number → void Scalar impulse for 1D mode.
:GetPosition() → Vector3 Current simulated position.
:GetVelocity() → Vector3 Current velocity vector.
:GetDisplacement() → Vector3 Signed displacement from target.
:IsSettled(eps?) number? → bool True when |disp| < eps AND |vel| < eps·ω₀; Optional override for epsilon.
:GetKineticEnergy() → number ½·m·v²; current kinetic energy.
:GetPotentialEnergy() → number ½·m·ω₀²·d²; spring potential energy.
:GetTotalEnergy() → number KE + PE; total mechanical energy for monitoring stability.
:SetParameters(k,c,m?) n,n,n? → void Hot-swap spring parameters at runtime; ω₀ and ζ are recomputed.
:Reset(pos?, vel?) V3?,V3? → void Teleport position and zero velocity; Use for initialization or respawns.
:GetState() → table Full state snapshot for serialization, debugging, or replication.
:GetODEStringASCII() → string Returns current ODE as a readable string; Useful for debug UI labels.

Static Math Utilities

FUNCTION DESCRIPTION
DM.stepExact1D(d0,v0,wn,ζ,dt) Raw exact integrator; no object overhead; For particle systems.
DM.step3D(pos,vel,target,wn,ζ,dt) Raw 3D exact step; Returns newPos, newVel; Zero allocations beyond return values.
DM.stepDecoupled3D(...) Different ω/ζ per axis; Hover systems (stiffer Y than X/Z).
DM.sampleDisplacement(d0,v0,wn,ζ,t) Exact displacement at continuous time t from t=0; For pre-drawing trajectory arcs.
DM.buildResponseCurve(d0,v0,wn,ζ,tMax,n) Returns n samples of (t, d, v) from 0 to tMax; For graph/arc visualization.
DM.toCanonical(k,c,m) Convert (k,c,m) → (ωn,ζ); Call once at initialization.
DM.fromCanonical(wn,ζ,m) Convert (ωn,ζ,m) → (k,c); Inverse mapping.
DM.settlingTime(wn,ζ) Estimated settling time to 2% of target; Useful for calculating tween durations to match.
DM.overshootPercent(ζ) OS% for a step input; Use to characterize how bouncy a configuration will be.
DM.isStable(wn,ζ) Returns (bool, description); Warns on ζ≤0 or ωn≤0.
DM.compareIntegrators(...) Step-by-step comparison; exact vs Euler vs semi-implicit; Returns error table.
DM.frameRateIndependenceTest(...) Run same system at two dt sizes; verify near-identical results.
DM.printRegimeSummary(wn,ζ) Print full parameter analysis to output console.

Video example

To view my demo on YouTube, click on this link.

To use my demo in your own way, download my resource as a .rbxm file at the top of this post.

------------IMPL 1------------------------IMPL2------------------------IMPL3-----------

left left left

Preset library

Thirteen pre-tuned (ωₙ, ζ) configurations for common use cases. Settling times and overshoot values are computed from the exact formulas. Use DM.fromPreset("Name", { position=... }) to instantiate.

System ωₙ ζ Settling Time Overshoot Type
UIPanel 18 1.00 0.22s 0% CRITICAL
CameraFollow 10 0.80 0.49s 15% UNDERDAMPED
CameraLag 6 0.70 0.93s 4.6% UNDERDAMPED
RecoilCritical 25 1.00 0.16s 0% CRITICAL
RecoilSnappy 30 1.20 0.11s 0% OVERDAMPED
DoorBouncy 6 0.25 2.60s 44% UNDERDAMPED
DoorStiff 10 1.00 0.39s 0% CRITICAL
SpringPlatform 8 0.60 0.82s 9.5% UNDERDAMPED
HoverStabilizer 12 0.85 0.38s 0.5% UNDERDAMPED
WeaponSway 5 0.85 1.42s 14% UNDERDAMPED
UISoft 14 0.90 0.31s 0.2% UNDERDAMPED
HeavyGate 3 1.00 1.30s 0% CRITICAL
JiggleBone 20 0.40 0.49s 25% UNDERDAMPED

Folder tree & Studio setup

ReplicatedStorage/
└── DynamicMotion ModuleScript; 1.1k-line physics engine (§10)

StarterPlayer/
└── StarterPlayerScripts/
├── Impl1_RecoilImpulse LocalScript; Impl 1: critically-damped impulse
├── Impl2_SpringDoor LocalScript; Impl 2: underdamped angular spring
└── Impl3_CameraFollow LocalScript; Impl 3: 3D follow + lerp comparison

Workspace/ (created at runtime)
├── DM_Impl1; Folder: recoil ball + wire
├── DM_Impl2; Folder: spring door parts
└── DM_Impl3; Folder: follower + ghost + connector

Studio Setup; Step by Step

1. In Explorer, right-click ReplicatedStorage → Insert Object → ModuleScript. Rename to DynamicMotion. Paste the full module source.

2. Navigate to StarterPlayer → StarterPlayerScripts. Insert three LocalScript instances, named Impl1_RecoilImpulse, Impl2_SpringDoor, and Impl3_CameraFollow. Paste each script’s source.

3. Press Play. All three systems run simultaneously. Use E (Impl 1), R (Impl 2), and T (Impl 3) to interact. Each has its own displacement graph at the bottom-left of the screen. Note: if running all three at once, the graphs will overlap; disable the scripts you are not currently testing.

Rojo Setup

Shell


mkdir -p src/ReplicatedStorage
mkdir -p src/StarterPlayerScripts

cp DynamicMotion.lua -> src/ReplicatedStorage/DynamicMotion.lua
cp Impl1_RecoilImpulse.lua -> src/StarterPlayerScripts/Impl1_RecoilImpulse.lua
cp Impl2_SpringDoor.lua -> src/StarterPlayerScripts/Impl2_SpringDoor.lua
cp Impl3_CameraFollow.lua -> src/StarterPlayerScripts/Impl3_CameraFollow.lua

rojo serve default.project.json

The default.project.json maps all source files to their correct locations automatically. Live-sync is active; any save in your editor reflects in Studio instantly.


Implementation guides & visualizers

01 - Recoil Impulse Recovery; Critically Damped

Press Eζ = 1.00ωₙ = 25

A critically-damped spring (ζ=1.0, ωₙ=25) responds to a velocity impulse of +18 studs/s upward. The ball returns to origin without oscillation, the fastest possible return with zero overshoot. This is the correct model for weapon recoil recovery, camera kick, or any sudden force that must decay cleanly.

Impl1_RecoilImpulse (LocalScript)

local DM = require(ReplicatedStorage:WaitForChild("DynamicMotion"))

local sys = DM.fromPreset("RecoilCritical", {
    position = origin,
    velocity = Vector3.zero,
})

-- On E key: fire impulse
UserInputService.InputBegan:Connect(function(input, gpe)
    if gpe then return end
    if input.KeyCode == Enum.KeyCode.E then
        sys:Impulse(Vector3.new(0, 18, 0))
    end
end)

RunService.Heartbeat:Connect(function(dt)
    sys:Step(dt)
    ball.Position = sys:GetPosition()
    -- Graph: Y displacement vs time
    addGraphPoint(elapsed, sys:GetPosition().Y - origin.Y)
end)

GRAPH DESCRIPTION
The displacement graph shows a characteristic critically-damped step response: a sharp peak immediately after the impulse, then a single clean exponential return to zero. No overshoot. No oscillation. The decay envelope is exactlye^(−ωₙ·t). The live graph is drawn at the bottom-left using pure Roblox ScreenGui Frames.

ODE active: x’’ + 2·(1.0)·(25)·x’ + 25²·x = 0 (free response)

02- Spring Door; Underdamped Angular Spring

Press Rζ = 0.25ωₙ = 6

An underdamped spring(ζ=0.25, ωₙ=6)drives the opening angle of a door. Pressing R toggles the target angle between 0° (closed) and 80° (open). The door oscillates past the target and bounces several times before settling; a 44% overshoot as predicted by the formula. The same ODE governs angular motion as linear motion; only the state variable changes from position to angle.

Impl2_SpringDoor (LocalScript)

-- Spring drives a scalar angle (degrees)
-- Uses 1D mode: X component = angle, Y/Z unused
local sys = DM.MotionSystem.new({
	omega_n = 6,
	zeta = 0.25, -- underdamped: bouncy
	position = Vector3.new(ANGLE_SHUT, 0, 0),
})

-- Toggle target on R key
if input.KeyCode == Enum.KeyCode.R then
	isOpen = not isOpen
	sys:SetTarget1D(isOpen and ANGLE_OPEN or ANGLE_SHUT)
end

-- Each frame: step and apply angle to door CFrame
sys:Step1D(dt)
local angle = sys:GetPosition1D()
doorPart.CFrame = CFrame.new(doorOrigin) * CFrame.Angles(0, math.rad(angle), 0) * CFrame.new(1.5, 0, 0)

GRAPH DESCRIPTION
The displacement graph shows the door angle over time. Points above the target are coloured blue; points below the target are coloured orange. The oscillation decays over ~2.6 seconds (predicted settling time). You can see the exponential decay envelope shrinking around the target line.

Predicted overshoot: OS% = 100·exp(−π·0.25/√(1−0.25²)) ≈ 44%

03 - Camera / Object Follow; Spring vs. Lerp Comparison

Press Tζ = 0.80ωₙ = 10

A spring system(ζ=0.80, ωₙ=10)and a naive per-frame lerp (alpha=0.08) both track the same moving target. Pressing T cycles the target through six predefined positions. The red follower (spring) preserves velocity across every target change; it accelerates smoothly out of the old trajectory. The grey ghost (lerp) always re-starts from zero effective velocity, producing a characteristic “sticky” initial response and frame-rate-dependent speed.

Impl3_CameraFollow (LocalScript)

local sys = DM.fromPreset("CameraFollow", {
	position = initPos,
	velocity = Vector3.zero,
	target = initPos,
})

-- Naive lerp; no velocity state, frame-rate-dependent
local lerpPos = initPos

RunService.Heartbeat:Connect(function(dt)
	-- Spring: just update target; velocity carries over
	sys:Step(dt)
	followerPart.Position = sys:GetPosition()

	-- Lerp: alpha is frame-rate-dependent (wrong at non-60 FPS)
	lerpPos = lerpPos:Lerp(currentTarget, 0.08)
	ghostPart.Position = lerpPos
end)

-- Target change: spring carries velocity, lerp silently restarts
sys:SetTarget(newTarget) -- velocity-continuous
currentTarget = newTarget -- lerp: no notion of velocity to carry

GRAPH DESCRIPTION
The displacement graph shows distance-to-target over time for both systems. Red is the spring system; grey is the lerp. After a target change, both initially rise. The lerp always decays at the same exponential rate regardless of the prior trajectory. The spring system’s decay curve reflects the accumulated velocity from the previous motion; it may initially move faster if it had momentum in the target’s direction, or slower if it was moving away.

C¹ violation in lerp: at every target change, the lerp’s effective velocity resets because alpha is a fixed fraction of the current displacement, not a function of motion history. The spring’s effective velocity at each target change is whatever v(t) was when SetTarget() was called.


Benchmarks

Per-Frame Cost (Hypothetical Example)

CONFIGURATION COST / SYSTEM / FRAME 100 SYSTEMS 1000 SYSTEMS
MotionSystem:Step(dt) – 3D ~0.5–0.8 μs < 0.1 ms ~0.5–0.8 ms
DM.step3D() – raw 3D ~0.3–0.5 μs < 0.05 ms < 0.5 ms
DM.stepExact1D() – 1 axis ~0.1–0.2 μs ~0.01 ms ~0.1 ms

Note: these are representative estimates based on Luau interpreter overhead for transcendental function calls. Measure with tick() in your target environment. A 16.7 ms frame budget (60 FPS) comfortably supports hundreds of active systems.

Limitations

When to use TweenService instead

The dynamic system is not always the better tool. Use interpolation for: simple UI fades, cinematic paths with authored timing, deterministic cutscene playback, static door one-shot opens, simple decorative motion, and any motion where the exact duration must be controlled precisely.

Non-linear forces

The exact integrator is specific to the linear spring-mass-damper ODE. If your force law is non-linear; quadratic drag, a spring with hard stops, a pendulum with large angles, coupled systems; there is no closed-form solution, and you must use stepRK4_1D or an external solver. The module’s stepRK4_1D function provides a starting point, but you would need to modify the accel function inside it for custom force laws.

Rotation / angular dynamics

The module treats each axis as independent. This is valid for position and for scalar angles (as shown in Impl 2). It is not valid for full 3D orientation: rotations in SO(3) do not commute, and applying independent springs per Euler angle produces gimbal lock and incorrect dynamics. For orientation springs, you need quaternion-based SLERP dynamics or a proper SO(3) integrator.

Constraints and contacts

The spring system has no notion of contact, collision, or hard constraints. If the ball in Impl 1 should stop at the ground, you must add that check manually (if pos.Y < 0 then pos = Vector3.new(pos.X, 0, pos.Z) end) and optionally apply a restitution impulse. The module does not handle physics engine contacts; it is a scripted motion layer above Roblox physics.

Network replication

All implementations run on the client (LocalScript). The simulated positions are not automatically replicated to the server or other clients. For server-visible dynamic motion, you need to either run the simulation server-side or replicate state via RemoteEvents. Because the integrator is deterministic and exact, you can replicate just the initial state (pos, vel, wn, zeta, target) and each client will produce an identical trajectory; a useful property for multiplayer consistency.


Closing thoughts

Tweening is not wrong.

Interpolation is not bad.

But interpolation is not dynamics.


A tween knows where an object should be.

A spring knows how it got there; and where it’s going next.


The moment velocity matters, position-only thinking becomes incomplete.

The moment acceleration matters, easing becomes insufficient.

The moment stability matters, motion becomes mathematics.


The spring-mass-damper ODE is not a complex tool for complex problems.

It is a simple, exact, unconditionally stable answer to a question

That interpolation was never designed to answer: how does this object move?


References

Author(s) Title / Topic Source
Strang, G. MIT 18.03 Differential Equations; Second-Order ODEs, Damped Oscillators, §3.3–3.7 https://ocw.mit.edu
Dourmashkin, P. MIT 8.01 Classical Mechanics; Simple Harmonic Motion, Damped Oscillations, Chapter 23 https://ocw.mit.edu
Eberly, D.H. Game Physics, 2nd Edition; Numerical Methods, Spring Systems, Chapter 4 https://www.crcpress.com
Fiedler, G. Spring Physics; Exact Integration of the Damped Harmonic Oscillator https://gafferongames.com
Urone, P.P. & Hinrichs, R. University Physics Vol. 1, §15.1–15.5; Simple Harmonic Motion, Damped Oscillations https://openstax.org
Ogata, K. Modern Control Engineering, 5th Edition; Second-Order System Response, Settling Time, Overshoot https://www.pearson.com
Hairer, E.; Norsett, S.P.; Wanner, G. Solving Ordinary Differential Equations I; Nonstiff Problems, RK4 derivation https://link.springer.com
Roblox Corporation RunService.Heartbeat; Delta time parameter, frame timing guarantees https://create.roblox.com

Calculon™ is an ongoing series. Command Center: Calculon™ Command Center

Previous episodes:
Episode 1: Ornstein-Uhlenbeck Camera Shake
Episode 2: Designed Octave Gerstner Ocean Waves
Episode 3: Exact Exponential Drag Integrator

Alvionics Labs™

10 Likes

Curious if you’re planning a future episode on quaternion-based spring dynamics for the SO(3) case? The per-axis approach covers most situations, but a tangent space formulation would be sick for camera rigs where gimbal edge cases actually matter.

1 Like

Head on over to the calculon command center and post your question there. I’d be happy to look into it.

1 Like

I read like 70% of it and my brain is deciding to stop functioning correctly…
That aside great ressource :slight_smile:

1 Like

Thanks man, this one was a little difficult to implement, but I had a nice time making it, see you in episode 5.

1 Like

I have read most of the documents and I am very impressed and fascinated, thank you for the information. An aerodynamic documentation would be very cool. Thank you again.

1 Like