CameraKit
CameraKit turns cutscene camera work into data instead of imperative script. A shot is a table describing what the camera should be doing this instant — hold a pose, follow a target, orbit it, or ride a spline — and CameraKit owns the one stepper that turns the active shot into a CFrame every frame. The defining decision: nothing about a shot is baked at the moment you set it. Every field — Target, LookAt, the orbit angle — is re-evaluated live against the current state of the world each frame, so a Follow shot tracks a moving boss and an Orbit shot keeps circling a target that’s walking away, with no per-frame script logic on your side at all.
Mental model
Section titled “Mental model”One stepper, one active shot. CameraKit.attach(kernel, options?) connects a single RunService.PreRender callback (client-only — this runs directly against the engine’s render-camera phase rather than through the kernel’s scheduler, because camera writes have to land inside the same PreRender window the engine reads from, and a phase scheduler’s budget-sharing model has nothing to do with that constraint). Every frame, the stepper re-evaluates whichever shot is active against live target state and writes the result straight to Camera.CFrame / Camera.FieldOfView — there is no cached trajectory, only “what should the camera be doing right now.”
Four shot kinds, four evaluation rules:
-
Static { CFrame, FOV? }— holds a fixed pose. No target, no smoothing. -
Follow { Target, Offset?, Damping?, FOV? }—Offset(defaultCFrame.new(0, 6, 14)) is applied in the target’s local space to get a goal position, then the camera’s smoothed position eases toward that goal every frame:Smoothed = Smoothed:Lerp(Goal, 1 - math.exp(-Damping * dt))Damping(default8) is an exponential-smoothing rate, not a duration — it’s framerate-independent because the1 - math.exp(-Damping * dt)factor is the fraction of the remaining distance closed this frame, at anydt. The camera looks at the target’s raw (unsmoothed) position throughout. -
Orbit { Target, Distance?, Height?, Speed?, Angle?, FOV? }—State.AngleaccumulatesSpeed * dtradians every frame (Speeddefault0.5rad/s,Anglestart default0), and the eye position is:Target.Position + Vector3.new(math.cos(Angle) * Distance, Height, math.sin(Angle) * Distance)That’s a fixed-height ring around the target’s vertical (Y) axis —
Distance(default20) is the ring radius in the XZ plane,Height(default5) is a flat Y offset, not a second radius.Speed = 0holds a fixedAngle(a static orbit position rather than a spin). -
Rail { Points, Duration?, Ease?, LookAt?, FOV? }— a Catmull-Rom spline through thePointsarray. Catmull-Rom passes exactly through every control point (unlike a Bézier curve, where interior points are only tangent handles) by shaping each segment’s curvature from its two neighboring points — so the rail hits everyCFrameyou give it without the “why doesn’t it touch my control point” surprise Bézier chains produce. Progress along the rail is eased, not linear:Age = Clock() - StartedAt,Alpha = ease(clamp(Age / Duration, 0, 1), Ease)(Durationdefault5), so with the defaultSmoothease (smoothstep,3t² - 2t³) the camera accelerates away from the first point and decelerates into the last, rather than moving through the spline at constant time-per-stud. Progress holds at the final point pastDuration— the rail doesn’t loop or vanish. Facing comes from the rail’s own orientation (a straightLerpbetween the two bracketing points’ rotations, not Catmull-Rom — only position is splined) unlessLookAtis given, which overrides facing to point at that target every frame instead.
Targets are live, not snapshots. Follow, Orbit, and Rail’s LookAt all resolve through the same target resolver, and what “live” means depends on the target’s type:
| Target type | Behavior |
|---|---|
Vector3 |
A fixed point. Follow/Orbit around it forever without moving. |
BasePart |
.Position read fresh every frame — a moving part is tracked continuously. |
Model |
:GetPivot().Position read fresh every frame — same live tracking. |
() -> Vector3 |
Called fresh every frame via pcall; return whatever dynamic point you want. |
A BasePart/Model target with a nil .Parent, or a function target that errors or returns a non-Vector3, all resolve to “dead” the same way — which triggers the failsafe below.
The dead-target failsafe. If a shot’s target resolves to nothing (a Model got destroyed mid-shot, a function started throwing), the per-frame evaluator returns no pose, and step() does not freeze on the last valid CFrame — it calls the same finish path release() uses, instantly restoring CameraType and handing the camera back:
function CameraKit.step(self: any, dt: number) local Pose, FOV = self:_evaluate(dt) if not Pose then -- Dead target: hand the camera back rather than freeze on nothing self:_finishRelease() return end ...This is an instant hand-back, not an eased release(seconds) — there’s no “from” pose worth blending from when the target that defined it just vanished.
blend eases from the real current pose; cut snaps. Both take Scriptable ownership of the camera via capture() (recorded once — a second capture() while already captured is a no-op, so chained blend/cut calls don’t clobber the original pre-cutscene pose). The difference is what they start from:
cut(shot)sets the shot and immediately steps once withdt = 0— the camera jumps straight to the shot’s pose this frame.blend(shot, seconds?, ease?)readsCamera.CFrame/Camera.FieldOfViewat the momentblendis called as the “from” pose, then eases toward the new shot’s live-evaluated pose overseconds(default1). Because the “from” pose is the camera’s actual rendered state — not a cached snapshot of where the previous shot’s target used to be — blending away from aFolloworOrbitshot that’s still tracking a moving target starts from wherever the camera really is, so the transition never jumps to catch up with a target that kept moving after the blend began.
release(seconds?) blends back to the player’s camera and restores CameraType. capture() recorded the pre-cutscene CameraType, CFrame, and FieldOfView — once, the first time it’s called, so a chain of cut/blend calls never overwrites that original pose. release re-poses to that recorded CFrame/FOV (as a synthetic Static shot, blended over seconds) and then restores CameraType. This matters because Roblox’s default camera controller only drives the camera while CameraType is Custom — if a cutscene script forgets to call release() (or errors before reaching it), the camera stays Scriptable forever and the player’s mouse/movement stops steering the camera at all, with no way back short of a respawn. release() (and the dead-target failsafe, which calls the same finish path) is the one place that guarantee is enforced.
An instant release (seconds omitted or <= 0) writes all three restored fields — CFrame, CameraType, and FieldOfView — in the same step, so it lands back at the exact pose capture() recorded rather than wherever the last shot happened to be pointing. That CFrame write is real, but its lifespan depends on what CameraType gets restored to:
- If the pre-capture
CameraTypewasCustom(the ordinary player camera, and the common case), Roblox’s own camera controller starts drivingCamera.CFramefrom the character again the very next frame — it runs earlier in the frame than CameraKit’sPreRenderstep, so it recomputes and overwrites whatever CFramerelease()just wrote. The restore is still worth doing: it’s what renders on the framerelease()itself executes (no visible pop to the last shot’s pose before the engine takes over), it’s just cosmetic beyond that one frame, because the engine immediately re-solves from the character regardless. - For any other
CameraType(nothing else is driving the camera), the restoredCFramesticks — there’s no engine controller to overwrite it, so an instant release under a non-Customrestore is a real, persistent re-pose, not a one-frame flash.
FOV is per-shot and optional. Any shot may carry FOV; if a shot omits it, FieldOfView is left exactly as it was — CameraKit never resets it to a default.
CameraKit.ease and CameraKit.rail are pure. Both take plain values (no self, no kernel, no camera) and are exported specifically so tooling and specs can exercise the easing curves and the spline math without spinning up a kernel or a camera at all.
A chase cam that blends into a scripted reveal shot when the player crosses a trigger, then blends back:
local RunService = game:GetService("RunService")local CameraKit = require(game.ReplicatedStorage.ChloeKernel.CameraKit)
local Cam = CameraKit.attach(kernel)
-- Default camera: a Follow shot tracking the player's characterlocal function startChase(character: Model) Cam:cut({ Type = "Follow", Target = character, Offset = CFrame.new(0, 6, 14), -- behind and above, target-local space Damping = 8, FOV = 70, })end
-- On trigger: blend into a Rail shot that sweeps past the gate, looking at it-- throughout, then release back to the chase camlocal function playGateReveal(gate: Model) local Points = { CFrame.new(-40, 15, 0), CFrame.new(0, 20, -20), CFrame.new(40, 15, 0), } Cam:blend({ Type = "Rail", Points = Points, Duration = 4, Ease = "Smooth", LookAt = gate, FOV = 50, }, 1.5) -- 1.5s ease from wherever the chase cam currently is into the rail's start
task.delay(4, function() Cam:release(1.2) -- ease back to the pre-capture camera and restore CameraType end)end
-- example wiringlocal Character = game.Players.LocalPlayer.Characterif Character then startChase(Character)endAPI reference
Section titled “API reference”CameraKit.attach(kernel, options?) -> Instance
Section titled “CameraKit.attach(kernel, options?) -> Instance”AttachOptions field |
Type | Default | Description |
|---|---|---|---|
Camera |
Camera? |
workspace.CurrentCamera |
Injectable for specs. |
Clock |
(() -> number)? |
os.clock |
Injectable for deterministic blend/rail timing in specs. |
SkipLoop |
boolean? |
false |
Skip the PreRender connection; specs drive :step(dt) manually. |
Instance methods
Section titled “Instance methods”| Member | Description |
|---|---|
Instance:cut(shot: Shot) |
Captures the camera (once) and snaps to the shot’s pose immediately (dt = 0). |
Instance:blend(shot: Shot, seconds: number?, ease: string?) |
Captures the camera (once) and eases from the camera’s real current pose to the shot, evaluated live through the transition. seconds default 1. |
Instance:release(seconds: number?) |
Blends back to the pre-capture pose and restores CameraType. seconds omitted/<= 0 restores instantly — CFrame, CameraType, and FieldOfView all land at once (a Custom restore gets overwritten by the engine’s own camera controller the next frame regardless). No-op if the camera was never captured. |
Instance:current() -> Shot? |
The currently active shot, or nil. |
Instance:step(dt: number) |
Evaluates the active shot and writes the camera. Called automatically off PreRender unless SkipLoop. |
Instance:destroy() |
Disconnects the stepper and force-finishes (restores CameraType/FOV if captured). |
| Field | Type | Applies to | Default | Description |
|---|---|---|---|---|
Type |
string |
all | required | "Static" | "Follow" | "Orbit" | "Rail" |
CFrame |
CFrame? |
Static |
current camera CFrame |
The held pose. |
Target |
Vector3 | BasePart | Model | (() -> Vector3) |
Follow, Orbit |
— | Re-resolved live every frame. |
Offset |
CFrame? |
Follow |
CFrame.new(0, 6, 14) |
Target-local offset for the goal position. |
Damping |
number? |
Follow |
8 |
Exponential smoothing rate (per second) toward the goal. |
Distance |
number? |
Orbit |
20 |
Ring radius in the target’s XZ plane. |
Height |
number? |
Orbit |
5 |
Flat Y offset above the target. |
Speed |
number? |
Orbit |
0.5 |
Angular speed, rad/s. 0 holds a fixed Angle. |
Angle |
number? |
Orbit |
0 |
Starting angle, radians. |
Points |
{ CFrame }? |
Rail |
— | Catmull-Rom control points. |
Duration |
number? |
Rail |
5 |
Seconds to traverse the whole rail; holds the last point past this. |
Ease |
string? |
Rail, and the blend/release transition |
"Smooth" |
"Linear" | "Smooth" (smoothstep) | "In" (quadratic ease-in) | "Out" (quadratic ease-out). |
LookAt |
same as Target |
Rail |
— | Overrides the rail’s own orientation; re-resolved live. |
FOV |
number? |
any | unchanged | Field of view for this shot; omit to leave FieldOfView untouched. |
Pure functions
Section titled “Pure functions”| Member | Description |
|---|---|
CameraKit.ease(alpha: number, style: string?) -> number |
Clamps alpha to [0, 1] and applies the named ease curve. No kernel, no camera. |
CameraKit.rail(points: { CFrame }, alpha: number) -> CFrame |
The Catmull-Rom pose at alpha (0 = first point, 1 = last). A single-point list returns that point regardless of alpha. |
Hooks & bus topics
Section titled “Hooks & bus topics”CameraKit fires no bus topics and defines no hooks — it’s a pull-style stepper with nothing to subscribe to. CutsceneKit is the module that wraps cut/blend/release into timeline events and publishes its own bus topics around scene lifecycle.
Design notes
Section titled “Design notes”Cam:blend({ Type = "Orbit", Target = bossModel, Distance = 40, Height = 12, Speed = 0.4,}, 2)One call. Easing is CameraKit.ease, shared and tested everywhere else the kit uses it. If bossModel is destroyed mid-orbit, the camera hands back automatically. If the cutscene forgets to call release(), the dead-target path or a later release() still restores CameraType — the player can never get stuck.
local Angle = 0RunService.RenderStepped:Connect(function(dt) if not bossModel.Parent then return -- now what? camera is frozen on the last frame forever end Angle += 0.4 * dt local Position = bossModel:GetPivot().Position local Eye = Position + Vector3.new(math.cos(Angle) * 40, 12, math.sin(Angle) * 40) camera.CFrame = camera.CFrame:Lerp(CFrame.lookAt(Eye, Position), dt * 2) -- ad hoc easing -- and somewhere else, a DIFFERENT script has its own ad hoc lerp constant, -- and CameraType never gets reset if this connection is torn down mid-cutsceneend)Every hand-rolled camera script re-derives its own easing curve (usually a bare dt * k lerp, which isn’t framerate-independent the way 1 - exp(-k*dt) is), re-invents its own “target died” handling (often: nothing, so the camera just freezes), and has to remember to reset CameraType on every exit path including errors.
What CameraKit buys you over the hand-rolled version: one consistent easing implementation reused by every shot and every blend, target-death safety that’s the same code path as a normal release(), and a restore-on-release guarantee that isn’t re-implemented (or forgotten) per script.