Impact
Turns one pulse() into a distance-localized camera shake, an AudioKit duck on the ambient bus and a DeviceBench-gated blur, bound to the render step only while something is running.
- Realm
- Client
- Require
ReplicatedStorage.ChloeKernel .Impact - Since
- v0.7.0
- Source
- Impact.luau
Impact coordinates screen-space feedback for hits, explosions and landings, so one call replaces a pile of per-boss
shake scripts. pulse(origin, options) measures the distance from the camera to a world point, turns it into one
intensity and feeds three independent effects: a damped camera spring, a duck on an AudioKit bus,
and, only on devices that can pay for it, a blur and saturation dip. DeviceBench gates the post
effects specifically, so a low-end phone still gets the cheap shake and duck but never the blur pass. Impact composes on
top of CameraKit and can run as a VfxSuite timeline action.
Mental model
Section titled “Mental model”Distance localizes everything
Section titled “Distance localizes everything”Impact.intensityAt(base, distance, radius) is the 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 * FalloffendThe falloff is quadratic: half the radius away is a quarter of the intensity. radius <= 0 turns falloff off, which
suits UI-anchored or guaranteed full-strength pulses. pulse() measures from workspace.CurrentCamera.CFrame.Position
(0 without a camera) and returns at once when the intensity is 0.01 or less, so a pulse outside its radius costs one
distance check.
The camera spring
Section titled “The camera spring”Impact.springStep(state, dt, stiffness, damping) is one semi-implicit Euler step of a mass-1 spring-damper on the
camera offset. The live constants are stiffness 220 and damping 14, a damping ratio near 0.47, so hits ring for a couple
of cycles instead of snapping straight back.
Pulses stack. Each pulse adds a velocity impulse to the one shared spring:
self.Spring.Velocity += Vector3.new(math.cos(Angle) * Kick, Kick * 0.6, math.sin(Angle) * Kick) * 12Kick = (Shake or 1.2) * intensity. The direction comes from os.clock() folded into an angle, so it is not seeded or
reproducible. Vertical motion gets 0.6x the horizontal kick. Two impacts a frame apart compose into one larger shake.
This Euler step diverges at these constants once a step is longer than about 0.085 s. At 10 fps, or on a long hitch, the old spring
threw the camera tens of thousands of studs. Each frame’s dt is now clamped to 0.25 s and the spring substeps at 1/60 s,
so a slow device or a hitch frame integrates the same stable steps as a 60 fps one.
Writing the camera once per frame
Section titled “Writing the camera once per frame”Impact binds with RunService:BindToRenderStep at Enum.RenderPriority.Camera.Value + 2 (202). That is after the
player camera module (Camera, 200) and CameraKit (Camera + 1) write the pose, so the shake
always lands on top of the final camera. Each instance binds under its own name, CKImpact_<n>, readable as
Feedback.RenderName, so one instance settling never unbinds another.
The binding exists only while there is work. The first pulse binds it. Once every hold has expired and the spring is at rest, the frame that settles lands the camera on its unshaken pose and unbinds. An idle Impact has no render-step or frame connection at all.
The offset applies exactly once per frame. The camera module rewrites Camera.CFrame every frame, so Impact normally
offsets a fresh pose. A camera that nothing rewrites (a Scriptable menu or shop camera) still holds Impact’s last write.
Impact remembers the pose it wrote and the pose under it; when the camera still holds its last write, it offsets the pose
underneath instead of stacking another offset. Before 0.9.0 such a camera drifted about 3.5 studs per pulse.
Ducking the ambient bus
Section titled “Ducking the ambient bus”Every pulse computes a duck scale from its intensity and hands it to AudioKit:
local Scale = 1 - (1 - (Options.Duck or 0.4)) * IntensityHold.Release = self.AudioKit:duck(Options.DuckBus or "Ambient", Scale, 0.05)At full intensity the bus drops to Duck (0.4 by default); at lower intensity it stays closer to 1. DuckBus defaults
to "Ambient", one of AudioKit’s default buses. The explosion’s own sound usually plays on SFX, so ducking the ambient
bed lets the blast own the mix instead of ducking itself. AudioKit’s duck tokens nest, so overlapping pulses take the
deepest duck, and each duck releases when its hold expires.
Post effects, gated by the device
Section titled “Post effects, gated by the device”attach reads DeviceBench.quality() once, the overall clamped multiplier. When it is at least PostQuality (default
1, the mid-range reference), attach creates a BlurEffect (CKImpactBlur) and a ColorCorrectionEffect
(CKImpactColor) in Lighting, both disabled. Below the threshold, or if DeviceBench fails to load or measure, no
effects are created and pulses only shake and duck. The gate fails closed.
The effects are Enabled only while a hold lives. The frame the last hold expires writes the size and saturation back to
0 and disables them, and nothing else is written while idle (every write is skipped when the value has not changed).
The engine renders only one BlurEffect at a time, the one with the largest Size. A pause-menu blur larger than the
pulse hides the pulse’s blur, and the reverse.
pulse() records a hold: Until (os.clock() + Duration), a blur size, a saturation dip and the duck release. Each
step advances the spring, releases and drops expired holds, and takes the largest blur and the deepest saturation from
the live ones. Overlapping pulses therefore show the strongest effect instead of adding up.
Reduce Motion
Section titled “Reduce Motion”When the player has GuiService.ReducedMotionEnabled on, the camera kick is multiplied by ReducedMotionScale
(default 0: no shake). Ducking and post effects still apply, because they are not motion. A pulse whose kick is 0 on an
instance with no AudioKit and no post effects returns without binding anything.
Wire explosions
Section titled “Wire explosions”--!strict-- FeedbackController: camera, audio and post feedback for blasts near the camera.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Impact = require(ReplicatedStorage.ChloeKernel.Impact)
local FeedbackController = { Name = "FeedbackController", Dependencies = { "AudioController" },}
function FeedbackController.init(self: any, kernel: any) self.Kernel = kernelend
function FeedbackController.start(self: any) local Audio = self.Kernel:getService("AudioController").Audio self.Feedback = Impact.attach(self.Kernel, { AudioKit = Audio, ReducedMotionScale = 0.25, -- a gentle kick under Reduce Motion instead of none }) self.Connection = self.Kernel.Bus:subscribe("Explosion.Detonated", function(_topic, position: Vector3) self.Feedback:pulse(position, { Intensity = 1.4, -- above 1 is valid for a big blast Radius = 120, Duration = 0.6, Shake = 2, Duck = 0.25, -- the ambient bed dips hard while the shockwave passes Blur = 18, Saturation = -0.5, }) end)end
function FeedbackController.stop(self: any) if self.Connection then self.Connection:disconnect() end if self.Feedback then self.Feedback:destroy() endend
return FeedbackControllerPlayers near the blast get the full shake, duck and blur; players at the edge of the 120-stud radius get almost none,
with no distance math in the caller. Explosion.Detonated is the game’s own topic.
As a VfxSuite action
Section titled “As a VfxSuite action”local Suite = VfxSuite.attach(kernel, { Handlers = { Impact = function(event, context) Feedback:pulse(context.CFrame.Position, event) end, },})A timeline event such as { Time = 0.1, Action = "Impact", Intensity = 1, Radius = 60 } then pulses at the timeline’s
origin, and the event’s fields double as the pulse options.
API reference
Section titled “API reference”Impact.attach(kernel, options?) -> Impact
Section titled “Impact.attach(kernel, options?) -> Impact”| Option | Type | Default | Meaning |
|---|---|---|---|
AudioKit |
AudioKit? |
nil | Enables ducking. Without it pulses never duck. |
PostQuality |
number? |
1 | Minimum DeviceBench.quality() for blur and color correction. |
ReducedMotionScale |
number? |
0 | Camera kick multiplier while Reduce Motion is on. |
Bench |
DeviceBench.Result? |
nil | Injected bench result for specs. |
SkipLoop |
boolean? |
false | Never bind to the render step; call :step(dt) yourself. |
Feedback:pulse(origin, options?) options
Section titled “Feedback:pulse(origin, options?) options”| Option | Type | Default | Meaning |
|---|---|---|---|
Intensity |
number? |
1 | Strength at the origin, before falloff. Above 1 is valid. |
Radius |
number? |
80 | Studs to zero intensity. 0 or less turns falloff off. |
Duration |
number? |
0.5 | Seconds the duck and post dip hold. |
Shake |
number? |
1.2 | Spring impulse per unit of intensity. |
Duck |
number? |
0.4 | Bus volume scale at full intensity (1 is no duck). |
DuckBus |
string? |
"Ambient" |
AudioKit bus to duck. |
Blur |
number? |
12 | BlurEffect.Size at full intensity. |
Saturation |
number? |
-0.3 | ColorCorrectionEffect.Saturation at full intensity. |
Methods and pure functions
Section titled “Methods and pure functions”| Member | Description |
|---|---|
Feedback:pulse(origin, options?) |
Fires feedback from a world point, localized by camera distance. Binds the render step if it was idle. |
Feedback:step(dt) |
Advances the spring (clamped to 0.25 s, 1/60 s substeps), ages holds, writes the post effects and the camera offset, and unbinds once settled. Called for you unless SkipLoop. |
Feedback:destroy() |
Unbinds, returns the camera to its unshaken pose if it still holds Impact’s write, releases every duck and destroys the post effects. |
Feedback.RenderName |
The instance’s render-step name, CKImpact_<n>. |
Impact.intensityAt(base, distance, radius) -> number |
Pure. The quadratic falloff. |
Impact.springStep(state, dt, stiffness, damping) -> state |
Pure. One spring step. state = { Offset: Vector3, Velocity: Vector3 }, changed in place and returned. |
Impact publishes no Bus topics.