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).
-- CLIENTlocal 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 })Mental model
Section titled “Mental model”Distance localizes everything
Section titled “Distance localizes everything”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 * FalloffendFalloff 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.
The damped camera spring
Section titled “The damped camera spring”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 stateendThis 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) * 12Kick = (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.
Side-chain ducking through AudioKit
Section titled “Side-chain ducking through AudioKit”Every pulse computes a duck scale from its own intensity and hands it to AudioKit:
local Scale = 1 - (1 - (Options.Duck or 0.4)) * IntensityHold.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.
DeviceBench gating
Section titled “DeviceBench gating”Impact.attach measures the device once, at attach time:
local Quality = 0local 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 endendInstance2.PostEnabled = Quality >= PostQualityDeviceBench.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.
The hold system
Section titled “The hold system”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.
Wire an explosion
Section titled “Wire an explosion”-- CLIENTlocal 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 neededlocal 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.
As a VfxSuite custom action
Section titled “As a VfxSuite custom action”-- Register once alongside your VfxSuite sequencesHandlers.Impact = function(event, context) Feedback:pulse(context.CFrame.Position, event)endAPI reference
Section titled “API reference”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 |
Methods and pure functions
Section titled “Methods and pure functions”| 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.
Gotchas
Section titled “Gotchas”- The spring is shared, not per-pulse. All pulses on one
Impactinstance kick the sameSpringstate. 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 theHold.Releaseassignment and never callsduck. - Kick direction is not seeded. It comes from
os.clock(), not Rng — don’t expect reproducible shake direction across runs.