Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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's update(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?, ... } — see modules/presets for 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

  • attach starts Zin.autoTick so the controls keep dispatching each frame. Calling it multiple times is safe — autoTick.start is 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. detach releases them; the last holder to release takes the controls out of the live set.
  • A map missing move or look refuses to attach, naming what it lacks, so nothing is left half-attached. A map missing jump, sprint or crouch attaches without the capability that control carries.
  • dt is clamped to a maximum of 0.1s inside update — 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 setConfig does not.
  • useCharacterController = true requires a separate bodyEntity with a CharacterController attached. Without one the controller falls back to standalone (direct position writes + physics-velocity read for landing).