Skip to content

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.

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 * Falloff
end

The 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.

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) * 12

Kick = (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.

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.

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

local Scale = 1 - (1 - (Options.Duck or 0.4)) * Intensity
Hold.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.

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.

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.

src/Client/Game/Controllers/FeedbackController.luau
--!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 = kernel
end
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()
end
end
return FeedbackController

Players 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.

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.

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.
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.
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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.