Skip to content

Impact

Impact is the screen-space feedback coordinator for hits, explosions, and landings — one call replaces the per-boss pile of shake scripts. pulse(origin, options) reads the distance from the current camera to a world point, localizes an intensity from it, and fans that single number out to three independent effects: a damped camera-shake spring, a side-chain duck on an AudioKit bus, and — only on devices that can pay for it — a blur/color-correction dip. The defining decision is that last part: DeviceBench gates the post-processing tier specifically, so a low-end phone still gets the shake and the duck (cheap, always-on feedback) but never the blur pass (expensive, gated).

-- CLIENT
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kernel = require(ReplicatedStorage.ChloeKernel).boot()
local Impact = require(ReplicatedStorage.ChloeKernel.Impact)
local Feedback = Impact.attach(Kernel, { AudioKit = Audio })
Feedback:pulse(explosionOrigin, { Intensity = 1, Radius = 80 })

intensityAt(base, distance, radius) is the one number every effect scales from:

function Impact.intensityAt(base: number, distance: number, radius: number): number
if radius <= 0 then
return base
end
local Falloff = 1 - math.clamp(distance / radius, 0, 1)
return base * Falloff * Falloff
end

Falloff ramps linearly from 1 at the origin to 0 at radius studs, and the function squares it — so intensity is a quadratic falloff of distance, not linear: half the radius away is a quarter the intensity (0.5² = 0.25), not half. radius <= 0 skips falloff entirely and returns the base intensity regardless of distance — useful for UI-anchored or guaranteed-full-strength pulses. pulse() measures distance from workspace.CurrentCamera.CFrame.Position (0 if there is no camera) and bails out entirely — no spring kick, no duck, no hold — once intensity drops to 0.01 or below, so a pulse far outside its radius costs nothing beyond the one distance check.

springStep(state, dt, stiffness, damping) is one semi-implicit-Euler integration step of a spring-damper on the camera offset:

function Impact.springStep(state: any, dt: number, stiffness: number, damping: number): any
local Acceleration = state.Offset * -stiffness - state.Velocity * damping
state.Velocity += Acceleration * dt
state.Offset += state.Velocity * dt
return state
end

This is a standard mass-1 damped harmonic oscillator: spring force -stiffness * Offset pulls the offset back toward zero, and a separate drag term -damping * Velocity bleeds energy. Impact.step calls it every frame with stiffness = 220, damping = 14 — genuinely underdamped: critical damping at stiffness = 220 would need damping ≈ 2 * sqrt(220) ≈ 29.7, and at 14 the damping ratio is around 0.47. The source comment agrees now, calling the spring out as underdamped by name. That matches what the spec actually asserts (Impact.spec.luau, “the spring rings down toward rest”): the offset overshoots and oscillates before settling, it doesn’t glide straight back to zero. In practice this reads as a couple of shake cycles rather than a single snap-and-return.

Pulses stack. pulse() never replaces the spring state — it adds a velocity impulse to whatever Velocity the spring already has:

self.Spring.Velocity += Vector3.new(math.cos(Angle) * Kick, Kick * 0.6, math.sin(Angle) * Kick) * 12

Kick = (Options.Shake or 1.2) * Intensity, and the kick direction comes from os.clock() folded into an angle (not the Rng module — this isn’t seeded or deterministic). Horizontal motion (X/Z) gets the full kick; vertical (Y) is damped to 0.6× of it. Because concurrent pulses add velocity onto one shared spring rather than each owning independent state, two impacts a frame apart compose into one bigger, messier shake rather than the second silently overwriting the first.

Every pulse computes a duck scale from its own intensity and hands it to AudioKit:

local Scale = 1 - (1 - (Options.Duck or 0.4)) * Intensity
Hold.Release = self.AudioKit:duck(Options.DuckBus or "SFX", Scale, 0.05)

At full intensity (1) the bus drops straight to Duck (0.4 by default — 40% volume); at lower intensity it interpolates back toward 1 (no dip). This is a side-chain duck in the mixing sense: the impact is the “key” signal, and the target bus’s volume is temporarily pulled down in response, then auto-restored — the caller never has to hold onto the release function or time anything themselves. AudioKit’s duck(bus, scale, fadeSeconds) fades the bus down over the given fadeSeconds (0.05 here) and — because AudioKit reuses that same fadeSeconds closure on release — fades it back up over the same 0.05 seconds when the hold’s Release() fires. That auto-release is Impact’s own Hold bookkeeping: each pulse records Until = os.clock() + Duration, and step() calls Hold.Release() and drops the entry once Now >= Until.

Impact.attach measures the device once, at attach time:

local Quality = 0
local BenchOk, DeviceBench = pcall(function() return require(script.Parent.DeviceBench) :: any end)
if BenchOk then
local QualityOk, Measured = pcall(function() return DeviceBench.quality(Options.Bench) end)
if QualityOk then Quality = Measured end
end
Instance2.PostEnabled = Quality >= PostQuality

DeviceBench.quality() is the device’s overall clamped quality multiplier (the geometric mean across all three bench axes, not a single axis) — see DeviceBench. PostQuality (default 1, the mid-range reference) is the threshold it has to clear. Only when PostEnabled is true does attach create a BlurEffect and ColorCorrectionEffect in Lighting at all; below the threshold, or if DeviceBench fails to require or fails to measure, PostEnabled stays false and post-processing never engages — it fails closed to “no post fx,” not “full post fx.” Shake and ducking have no such gate: they’re driven purely by the spring and AudioKit, both cheap enough to run everywhere, so every device gets the hit registering as camera motion and a mix dip; only devices that cleared PostQuality also get the blur/saturation dip on top.

pulse() doesn’t touch Blur/Saturation/the spring’s rest state directly — it drops a Hold record (Until, Blur, Saturation, Release) into self.Holds. step(dt) advances the spring every frame regardless, then walks every live hold: expired ones fire their Release (lifting the duck) and get removed; surviving ones contribute Blur = math.max(Blur, Hold.Blur) and Saturation = math.min(Saturation, Hold.Saturation) for the frame — so with several pulses overlapping, the strongest blur and the deepest (most negative) saturation dip win, rather than averaging or stacking further. Those two aggregated numbers get written to the BlurEffect/ColorCorrectionEffect once per frame; the spring’s Offset (if PostEnabled created no effects, the spring still runs) gets applied to workspace.CurrentCamera.CFrame the same way.

-- CLIENT
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Kernel = require(ReplicatedStorage.ChloeKernel).boot()
local Impact = require(ReplicatedStorage.ChloeKernel.Impact)
local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)
-- AudioKit's own default buses already include "SFX" — Impact ducks it
-- by default, no extra Buses setup needed
local Audio = AudioKit.attach(Kernel)
local Feedback = Impact.attach(Kernel, {
AudioKit = Audio,
PostQuality = 1, -- default; explicit here for clarity
})
local function explode(origin: Vector3)
Feedback:pulse(origin, {
Intensity = 1.4, -- a big bang can outrun the 0..1 baseline
Radius = 120,
Duration = 0.6,
Shake = 2,
Duck = 0.25, -- SFX bus dips hard while the shockwave passes
Blur = 18,
Saturation = -0.5,
})
end
Kernel.Bus:subscribe("Explosion.Detonated", function(position: Vector3)
explode(position)
end)

Distance falloff means players near the blast get the full 1.4 intensity’s worth of shake, duck, and blur, and players at the edge of the 120-stud radius get almost nothing — no per-caller distance math required.

-- Register once alongside your VfxSuite sequences
Handlers.Impact = function(event, context)
Feedback:pulse(context.CFrame.Position, event)
end

Impact.attach(kernel, options: AttachOptions?): Impact

Section titled “Impact.attach(kernel, options: AttachOptions?): Impact”
Option Type Default Meaning
AudioKit AudioKit? nil Enables side-chain ducking. Omit to skip audio entirely
PostQuality number? 1 Minimum DeviceBench.quality() to enable blur/color-correction. Below it, only shake and ducking fire
Bench DeviceBench.Result? nil Injected benchmark result (specs; otherwise reads the cached/live device bench)
SkipLoop boolean? false Skip the RenderStepped connection; call :step(dt) yourself

Feedback:pulse(origin: Vector3, options: PulseOptions?)

Section titled “Feedback:pulse(origin: Vector3, options: PulseOptions?)”
Option Type Default Meaning
Intensity number? 1 Base strength at the origin, before distance falloff. Values above 1 are valid (bigger than baseline)
Radius number? 80 Studs to zero falloff. 0 (or negative) disables falloff entirely
Duration number? 0.5 Seconds the duck hold and post-processing dip last before auto-releasing
Shake number? 1.2 Spring velocity impulse per unit of localized intensity
Duck number? 0.4 Bus volume scale at full intensity (1 = no duck)
DuckBus string? "SFX" AudioKit bus to duck — the default is one of AudioKit’s own default buses, so it exists out of the box
Blur number? 12 BlurEffect.Size at full intensity
Saturation number? -0.3 ColorCorrectionEffect.Saturation delta at full intensity
Member Description
Feedback:step(dt: number) Advances the spring, ages holds, writes Blur/Saturation/camera offset. Called for you unless SkipLoop
Feedback:destroy() Disconnects the stepper, releases every live duck hold, destroys the BlurEffect/ColorCorrectionEffect
Impact.intensityAt(base, distance, radius): number Pure. The distance-falloff function, exposed standalone — no kernel or camera needed
Impact.springStep(state, dt, stiffness, damping): state Pure. One integration step of the damped spring, exposed standalone. state = { Offset: Vector3, Velocity: Vector3 }, mutated and returned

Impact fires no bus topics of its own — kernel is stored on the instance but the module has no Bus:publish calls to date.

  • The spring is shared, not per-pulse. All pulses on one Impact instance kick the same Spring state. That’s what “pulses stack” means: velocity impulses add, they don’t queue or replace each other.
  • Ducking needs an AudioKit. Without one, pulse() still shakes and (if gated in) posts — it just skips the Hold.Release assignment and never calls duck.
  • Kick direction is not seeded. It comes from os.clock(), not Rng — don’t expect reproducible shake direction across runs.