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 | 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)
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 | Emerges from physics | |
| Velocity state | Part of state; preserved always | |
| Target change | Update target; velocity carries | |
| Impulse response | Call :Impulse(); dynamics does it |
|
| Frame-rate dependence | Controllable if authored carefully | Exact integrator at any FPS |
| Overshoot / oscillation | Yes; controllable via ζ | |
| Energy semantics | 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.
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 | ||
| 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-----------
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% | |
| CameraFollow | 10 | 0.80 | 0.49s | 15% | UNDERDAMPED |
| CameraLag | 6 | 0.70 | 0.93s | 4.6% | UNDERDAMPED |
| RecoilCritical | 25 | 1.00 | 0.16s | 0% | |
| RecoilSnappy | 30 | 1.20 | 0.11s | 0% | OVERDAMPED |
| DoorBouncy | 6 | 0.25 | 2.60s | 44% | UNDERDAMPED |
| DoorStiff | 10 | 1.00 | 0.39s | 0% | |
| 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% | |
| 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 | |
| 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








