Builtin Library
README.md
fps_controller
First-person camera controller with realistic procedural camera shake. Move and look with walk/run/crouch, biomechanical head bob, landing impact, idle breathing, and trauma shake. Integrates with the character-controller system for physics-based movement, or runs standalone for camera-only control. All shake is additive — it never fights player input.
Exposed as the FPSController Luau global via --!global. Input comes
from a control map: attach activates the map named by config.inputMap
and subscribes to the controls it drives, detach releases it. It reads
move and look (both required) plus jump, sprint and crouch when
the map declares them, so the same controller answers from a keyboard, a
pad and the on-screen controls. Point inputMap at another map to rebind
the whole controller without touching this module.
Exports
FPS.attach(entityId, userConfig?)— attach a controller to a camera entity. Activates its input map, subscribes to the controls, and sets up shake state.FPS.detach(entityId)— stop and remove the controller from an entity.FPS.update(entityId, dt)— advance the controller one frame. Call from your component'supdate(dt).FPS.setProfile(entityId, name)— switch shake preset with a ~0.33s blend. Built-ins:"realistic","cinematic","intense","minimal","drunk".FPS.setIntensity(entityId, intensity)— global shake multiplier (0 = off, 1 = default).FPS.setAxis(entityId, axis, enabled)— toggle per-axis shake ("vertical","lateral","roll").FPS.addProfile(name, params)— register a custom shake profile.FPS.addTrauma(entityId, amount)— additively add trauma (decays over time).FPS.getState(entityId) -> FPSState— read movement and shake state.FPS.setConfig(entityId, key, value)— live-tune a single config field.FPS.isAttached(entityId) -> boolean— predicate.
Types:
FPSConfig = { inputMap?, walkSpeed?, runSpeed?, crouchSpeed?, sensitivity?, minPitch?, maxPitch?, eyeHeight?, crouchEyeHeight?, profile?, intensity?, axisVertical?, axisLateral?, axisRoll?, bodyEntity?, useCharacterController? }FPSState = { isWalking, isRunning, isCrouching, speed, currentProfile, yaw, pitch, trauma }ShakeProfile = { walkFrequency?, walkVerticalAmplitude?, ... }— seemodules/presetsfor the full field list.
Usage
-- Standalone (camera-only — no physics body):
FPSController.attach("camera", { walkSpeed = 5, runSpeed = 8 })
-- With a character-controller body:
FPSController.attach("camera", {
bodyEntity = "player_body",
useCharacterController = true,
sensitivity = 0.005,
})
-- inside a component's update:
function update(dt)
FPSController.update(self.entity.id, dt)
end
-- Switch shake feel
FPSController.setProfile("camera", "cinematic")
-- Big explosion
FPSController.addTrauma("camera", 0.7)
Notes
attachstartsZin.autoTickso the controls keep dispatching each frame. Calling it multiple times is safe —autoTick.startis idempotent.- Each attachment holds its own activation of its map and its own
subscriptions, so controllers on different camera entities do not share
a reading.
detachreleases them; the last holder to release takes the controls out of the live set. - A map missing
moveorlookrefuses to attach, naming what it lacks, so nothing is left half-attached. A map missingjump,sprintorcrouchattaches without the capability that control carries. dtis clamped to a maximum of 0.1s insideupdate— frame-spikes won't teleport the camera.- Head bob uses layered sine waves; walk is ~2Hz vertical / 1Hz lateral, run is ~3Hz vertical / 1.5Hz lateral. Landing impact decays exponentially. Trauma uses a 3-layer multi-sine pseudo-noise so it reads as organic rather than periodic.
- Shake state (trauma, landing) is per-attachment — detaching and
re-attaching resets it. Live-tuning via
setConfigdoes not. useCharacterController = truerequires a separatebodyEntitywith a CharacterController attached. Without one the controller falls back to standalone (direct position writes + physics-velocity read for landing).