Skip to main content

CameraController

This item only works when running on the client. Client

A small object-oriented wrapper around a custom camera loop. A CameraController holds an update function that runs once per frame while the controller is active (bound with RunService:BindToRenderStep at Enum.RenderPriority.Input priority) and an optional reset function that puts the camera back the way you want it when the controller is cleaned up. Only one controller can be active at a time; the module tracks it so other scripts can find it with GetCurrent or tear it down with DestroyCurrent.

Everything is tracked with a Trove that is attached to workspace.CurrentCamera, so a controller is destroyed automatically if the camera it drives is destroyed.

The module requires the client and errors when required on the server. It returns a table with new, GetCurrent and DestroyCurrent; methods are called on the object returned by new.

local Players = game:GetService("Players")
local CameraController = require(path.to.CameraController)

local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local root = character:WaitForChild("HumanoidRootPart") :: BasePart

-- Top-down camera that follows the character
local controller = CameraController.new(function(camera, dt)
	camera.CameraType = Enum.CameraType.Scriptable
	local target = root.Position
	camera.CFrame = CFrame.lookAt(target + Vector3.new(0, 40, 20), target)
end, function(camera)
	-- Runs when the controller is cleaned up: hand control back to Roblox
	camera.CameraType = Enum.CameraType.Custom
end)

controller:Start()

-- Later, from anywhere on the client:
CameraController.DestroyCurrent()
Lifecycle

Start calls Stop first, and Stop cleans the whole Trove. The bookkeeping hook that new registers (which calls ResetFunc, resets Active and clears the current controller) is part of that Trove, so it runs during Start, before the update loop begins, and is not re-added. After a Start, a later Stop or Destroy only unbinds the update function: ResetFunc is not called again and Active stays true. When you are finished with a controller, prefer CameraController.DestroyCurrent() (which also clears the tracked controller so a new one can be started) and create a fresh controller with new rather than restarting the same object.

Credits: cleanup is handled by Trove, by sleitnick (sleitnick's RbxUtil). Wally installs it.

Installation and guide: CameraController package page.

Types​

UpdateFunc​

type UpdateFunc = (
camera: Camera,
dt: number
) → ()

Runs every frame while the controller is active, at Enum.RenderPriority.Input priority (before Roblox's own camera scripts). Receives the controller's Camera and the frame delta time. Set it in new or with SetUpdate.

ResetFunc​

type ResetFunc = (camera: Camera) → ()

Runs when the controller's Trove is cleaned (see the class description for exactly when that happens). Use it to restore CameraType, field of view or anything else the update function changed. Set it in new or with SetReset.

Properties​

Camera​

This item is read only and cannot be modified. Read Only
CameraController.Camera: Camera

The camera this controller drives: workspace.CurrentCamera at the time new was called. It is passed to UpdateFunc and ResetFunc. The Trove is attached to it, so destroying the camera destroys the controller.

Trove​

This item is read only and cannot be modified. Read Only
CameraController.Trove: Trove

The Trove holding the render-step binding and the controller's cleanup hook. Stop cleans it, Destroy destroys it. You may add your own objects (connections, instances) so they are released together with the controller.

Active​

This item is read only and cannot be modified. Read Only
CameraController.Active: boolean

true once Start has bound the update function. Start errors while this is true.

Locked​

This item is read only and cannot be modified. Read Only
CameraController.Locked: boolean

true between Lock and Unlock. While locked, SetUpdate and SetReset error instead of replacing the functions.

UpdateFunc​

CameraController.UpdateFunc: UpdateFunc?

The per-frame function. nil until set by new or SetUpdate; Start errors if it is nil.

ResetFunc​

CameraController.ResetFunc: ResetFunc?

The optional reset function called when the controller's Trove is cleaned.

Functions​

new​

CameraController.new(
updateFunc: UpdateFunc?,--

Function to run every frame while active.

resetFunc: ResetFunc?--

Function to run when the controller is cleaned up.

) → CameraController--

The new, inactive controller.

Creates a controller for workspace.CurrentCamera. Nothing runs until you call Start.

Both functions are optional here and can be supplied later with SetUpdate / SetReset, but Start requires an update function. The constructor also registers a cleanup hook in the controller's Trove that calls resetFunc, resets Active and clears the current controller when the Trove is cleaned, and attaches the Trove to the camera so the controller is destroyed with it.

GetCurrent​

CameraController.GetCurrent() → CameraController?--

The tracked controller, if any.

Returns the controller most recently activated with Start, or nil if none has been started or the current one was cleared (by its cleanup hook or by DestroyCurrent). Lets scripts that did not create the controller inspect or destroy it.

local current = CameraController.GetCurrent()
if current and current.Active then
	print("A custom camera is running")
end

DestroyCurrent​

CameraController.DestroyCurrent() → ()

Destroys the tracked current controller (if there is one) and clears the module's reference to it, so a new controller can be started without hitting "An existing CameraController is already active". Safe to call when nothing is active. This is the recommended way to end a custom camera mode.

Construct​

CameraController:Construct() → CameraController--

A shallow clone of this controller.

Returns a shallow copy of this controller made with table.clone. The copy has the same metatable and starts with the same UpdateFunc, ResetFunc, Active and Locked values, but it shares the original's Trove and Camera: stopping or destroying either object cleans the shared Trove. Treat it as a way to derive a variant with a different update function via SetUpdate, not as an independent controller.

Stop​

CameraController:Stop() → ()

Cleans the controller's Trove. This unbinds the render-step update added by Start, so the update function stops running, and releases anything else you added to the Trove. Start calls this itself before binding, which is when the cleanup hook from new (and therefore ResetFunc) runs; see the class description for the consequences.

SetUpdate​

CameraController:SetUpdate(
updateFunc: UpdateFunc--

The new per-frame function.

) → ()

Replaces the per-frame update function. Takes effect on the next frame if the controller is already active, since the bound render step reads UpdateFunc each frame.

Errors

TypeDescription
"CameraController is locked"`Lock` was called and `Unlock` has not been.

SetReset​

CameraController:SetReset(
resetFunc: ResetFunc--

The new reset function.

) → ()

Replaces the reset function that the Trove cleanup hook calls with the controller's camera.

Errors

TypeDescription
"CameraController is locked"`Lock` was called and `Unlock` has not been.

Start​

CameraController:Start() → ()

Activates the controller. After validating that an update function is set and that neither this nor any other controller is active, it calls Stop (cleaning the Trove), binds the update function with Trove:BindToRenderStep("CameraController", Enum.RenderPriority.Input.Value, ...), sets Active to true and makes this the controller returned by GetCurrent.

Each frame the bound function calls UpdateFunc(Camera, dt); if UpdateFunc has somehow become nil the controller stops itself instead.

Errors

TypeDescription
"There is no update function set in the CameraController"`UpdateFunc` is `nil`.
"This CameraController is already active"`Active` is already `true`.
"An existing CameraController is already active"Another controller is current and active; destroy it (for example with `DestroyCurrent`) first.

Lock​

CameraController:Lock() → ()

Sets Locked to true, making SetUpdate and SetReset error until Unlock is called. Use it to stop other scripts from hijacking a camera mode (a cutscene, for example) while it runs. Locking does not affect Start, Stop or Destroy.

Unlock​

CameraController:Unlock() → ()

Sets Locked back to false so SetUpdate and SetReset work again.

Destroy​

CameraController:Destroy() → ()

Destroys the controller's Trove, unbinding the update function and releasing everything the Trove holds. The object should not be used afterwards. Note that this does not clear the module's tracked current controller by itself; DestroyCurrent does both.

Show raw api
{
    "functions": [
        {
            "name": "new",
            "desc": "Creates a controller for `workspace.CurrentCamera`. Nothing runs until you call `Start`.\n\nBoth functions are optional here and can be supplied later with `SetUpdate` / `SetReset`, but\n`Start` requires an update function. The constructor also registers a cleanup hook in the\ncontroller's Trove that calls `resetFunc`, resets `Active` and clears the current controller\nwhen the Trove is cleaned, and attaches the Trove to the camera so the controller is destroyed\nwith it.",
            "params": [
                {
                    "name": "updateFunc",
                    "desc": "Function to run every frame while active.",
                    "lua_type": "UpdateFunc?"
                },
                {
                    "name": "resetFunc",
                    "desc": "Function to run when the controller is cleaned up.",
                    "lua_type": "ResetFunc?"
                }
            ],
            "returns": [
                {
                    "desc": "The new, inactive controller.",
                    "lua_type": "CameraController"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 165,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Construct",
            "desc": "Returns a shallow copy of this controller made with `table.clone`. The copy has the same\nmetatable and starts with the same `UpdateFunc`, `ResetFunc`, `Active` and `Locked` values, but\nit **shares** the original's `Trove` and `Camera`: stopping or destroying either object cleans\nthe shared Trove. Treat it as a way to derive a variant with a different update function via\n`SetUpdate`, not as an independent controller.",
            "params": [],
            "returns": [
                {
                    "desc": "A shallow clone of this controller.",
                    "lua_type": "CameraController"
                }
            ],
            "function_type": "method",
            "source": {
                "line": 210,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Stop",
            "desc": "Cleans the controller's Trove. This unbinds the render-step update added by `Start`, so the\nupdate function stops running, and releases anything else you added to the Trove. `Start`\ncalls this itself before binding, which is when the cleanup hook from `new` (and therefore\n`ResetFunc`) runs; see the class description for the consequences.",
            "params": [],
            "returns": [],
            "function_type": "method",
            "source": {
                "line": 224,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "SetUpdate",
            "desc": "Replaces the per-frame update function. Takes effect on the next frame if the controller is\nalready active, since the bound render step reads `UpdateFunc` each frame.",
            "params": [
                {
                    "name": "updateFunc",
                    "desc": "The new per-frame function.",
                    "lua_type": "UpdateFunc"
                }
            ],
            "returns": [],
            "function_type": "method",
            "errors": [
                {
                    "lua_type": "\"CameraController is locked\"",
                    "desc": "`Lock` was called and `Unlock` has not been."
                }
            ],
            "source": {
                "line": 238,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "SetReset",
            "desc": "Replaces the reset function that the Trove cleanup hook calls with the controller's camera.",
            "params": [
                {
                    "name": "resetFunc",
                    "desc": "The new reset function.",
                    "lua_type": "ResetFunc"
                }
            ],
            "returns": [],
            "function_type": "method",
            "errors": [
                {
                    "lua_type": "\"CameraController is locked\"",
                    "desc": "`Lock` was called and `Unlock` has not been."
                }
            ],
            "source": {
                "line": 255,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Start",
            "desc": "Activates the controller. After validating that an update function is set and that neither\nthis nor any other controller is active, it calls `Stop` (cleaning the Trove), binds the update\nfunction with `Trove:BindToRenderStep(\"CameraController\", Enum.RenderPriority.Input.Value, ...)`,\nsets `Active` to `true` and makes this the controller returned by `GetCurrent`.\n\nEach frame the bound function calls `UpdateFunc(Camera, dt)`; if `UpdateFunc` has somehow\nbecome `nil` the controller stops itself instead.",
            "params": [],
            "returns": [],
            "function_type": "method",
            "errors": [
                {
                    "lua_type": "\"There is no update function set in the CameraController\"",
                    "desc": "`UpdateFunc` is `nil`."
                },
                {
                    "lua_type": "\"This CameraController is already active\"",
                    "desc": "`Active` is already `true`."
                },
                {
                    "lua_type": "\"An existing CameraController is already active\"",
                    "desc": "Another controller is current and active; destroy it (for example with `DestroyCurrent`) first."
                }
            ],
            "source": {
                "line": 279,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Lock",
            "desc": "Sets `Locked` to `true`, making `SetUpdate` and `SetReset` error until `Unlock` is called. Use\nit to stop other scripts from hijacking a camera mode (a cutscene, for example) while it runs.\nLocking does not affect `Start`, `Stop` or `Destroy`.",
            "params": [],
            "returns": [],
            "function_type": "method",
            "source": {
                "line": 316,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Unlock",
            "desc": "Sets `Locked` back to `false` so `SetUpdate` and `SetReset` work again.",
            "params": [],
            "returns": [],
            "function_type": "method",
            "source": {
                "line": 327,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Destroy",
            "desc": "Destroys the controller's Trove, unbinding the update function and releasing everything the\nTrove holds. The object should not be used afterwards. Note that this does not clear the\nmodule's tracked current controller by itself; `DestroyCurrent` does both.",
            "params": [],
            "returns": [],
            "function_type": "method",
            "source": {
                "line": 340,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "GetCurrent",
            "desc": "Returns the controller most recently activated with `Start`, or `nil` if none has been started\nor the current one was cleared (by its cleanup hook or by `DestroyCurrent`). Lets scripts that\ndid not create the controller inspect or destroy it.\n\n```lua\nlocal current = CameraController.GetCurrent()\nif current and current.Active then\n\tprint(\"A custom camera is running\")\nend\n```",
            "params": [],
            "returns": [
                {
                    "desc": "The tracked controller, if any.",
                    "lua_type": "CameraController?"
                }
            ],
            "function_type": "static",
            "source": {
                "line": 361,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "DestroyCurrent",
            "desc": "Destroys the tracked current controller (if there is one) and clears the module's reference to\nit, so a new controller can be started without hitting \"An existing CameraController is already\nactive\". Safe to call when nothing is active. This is the recommended way to end a custom camera\nmode.",
            "params": [],
            "returns": [],
            "function_type": "static",
            "source": {
                "line": 375,
                "path": "packages/src/CameraController/init.luau"
            }
        }
    ],
    "properties": [
        {
            "name": "Camera",
            "desc": "The camera this controller drives: `workspace.CurrentCamera` at the time `new` was called. It is\npassed to `UpdateFunc` and `ResetFunc`. The Trove is attached to it, so destroying the camera\ndestroys the controller.",
            "lua_type": "Camera",
            "readonly": true,
            "source": {
                "line": 102,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Trove",
            "desc": "The [Trove](https://sleitnick.github.io/RbxUtil/api/Trove/) holding the render-step binding and\nthe controller's cleanup hook. `Stop` cleans it, `Destroy` destroys it. You may add your own\nobjects (connections, instances) so they are released together with the controller.",
            "lua_type": "Trove",
            "readonly": true,
            "source": {
                "line": 111,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Active",
            "desc": "`true` once `Start` has bound the update function. `Start` errors while this is `true`.",
            "lua_type": "boolean",
            "readonly": true,
            "source": {
                "line": 118,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "Locked",
            "desc": "`true` between `Lock` and `Unlock`. While locked, `SetUpdate` and `SetReset` error instead of\nreplacing the functions.",
            "lua_type": "boolean",
            "readonly": true,
            "source": {
                "line": 126,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "UpdateFunc",
            "desc": "The per-frame function. `nil` until set by `new` or `SetUpdate`; `Start` errors if it is `nil`.",
            "lua_type": "UpdateFunc?",
            "source": {
                "line": 132,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "ResetFunc",
            "desc": "The optional reset function called when the controller's Trove is cleaned.",
            "lua_type": "ResetFunc?",
            "source": {
                "line": 138,
                "path": "packages/src/CameraController/init.luau"
            }
        }
    ],
    "types": [
        {
            "name": "UpdateFunc",
            "desc": "Runs every frame while the controller is active, at `Enum.RenderPriority.Input` priority (before\nRoblox's own camera scripts). Receives the controller's `Camera` and the frame delta time. Set\nit in `new` or with `SetUpdate`.",
            "lua_type": "(camera: Camera, dt: number) -> ()",
            "source": {
                "line": 83,
                "path": "packages/src/CameraController/init.luau"
            }
        },
        {
            "name": "ResetFunc",
            "desc": "Runs when the controller's Trove is cleaned (see the class description for exactly when that\nhappens). Use it to restore `CameraType`, field of view or anything else the update function\nchanged. Set it in `new` or with `SetReset`.",
            "lua_type": "(camera: Camera) -> ()",
            "source": {
                "line": 92,
                "path": "packages/src/CameraController/init.luau"
            }
        }
    ],
    "name": "CameraController",
    "desc": "A small object-oriented wrapper around a custom camera loop. A CameraController holds an\nupdate function that runs once per frame while the controller is active (bound with\n`RunService:BindToRenderStep` at `Enum.RenderPriority.Input` priority) and an optional reset\nfunction that puts the camera back the way you want it when the controller is cleaned up.\nOnly one controller can be active at a time; the module tracks it so other scripts can find it\nwith `GetCurrent` or tear it down with `DestroyCurrent`.\n\nEverything is tracked with a [Trove](https://sleitnick.github.io/RbxUtil/api/Trove/) that is\nattached to `workspace.CurrentCamera`, so a controller is destroyed automatically if the camera\nit drives is destroyed.\n\nThe module requires the client and errors when required on the server. It returns a table with\n`new`, `GetCurrent` and `DestroyCurrent`; methods are called on the object returned by `new`.\n\n```lua\nlocal Players = game:GetService(\"Players\")\nlocal CameraController = require(path.to.CameraController)\n\nlocal player = Players.LocalPlayer\nlocal character = player.Character or player.CharacterAdded:Wait()\nlocal root = character:WaitForChild(\"HumanoidRootPart\") :: BasePart\n\n-- Top-down camera that follows the character\nlocal controller = CameraController.new(function(camera, dt)\n\tcamera.CameraType = Enum.CameraType.Scriptable\n\tlocal target = root.Position\n\tcamera.CFrame = CFrame.lookAt(target + Vector3.new(0, 40, 20), target)\nend, function(camera)\n\t-- Runs when the controller is cleaned up: hand control back to Roblox\n\tcamera.CameraType = Enum.CameraType.Custom\nend)\n\ncontroller:Start()\n\n-- Later, from anywhere on the client:\nCameraController.DestroyCurrent()\n```\n\n:::caution Lifecycle\n`Start` calls `Stop` first, and `Stop` cleans the whole Trove. The bookkeeping hook that `new`\nregisters (which calls `ResetFunc`, resets `Active` and clears the current controller) is part\nof that Trove, so it runs during `Start`, before the update loop begins, and is not re-added.\nAfter a `Start`, a later `Stop` or `Destroy` only unbinds the update function: `ResetFunc` is\nnot called again and `Active` stays `true`. When you are finished with a controller, prefer\n`CameraController.DestroyCurrent()` (which also clears the tracked controller so a new one can\nbe started) and create a fresh controller with `new` rather than restarting the same object.\n:::\n\n**Credits:** cleanup is handled by [Trove](https://sleitnick.github.io/RbxUtil/api/Trove/), by [sleitnick](https://github.com/Sleitnick) ([sleitnick's RbxUtil](https://sleitnick.github.io/RbxUtil/)). Wally installs it.\n\nInstallation and guide: [CameraController package page](/docs/packages/camera-controller).",
    "realm": [
        "Client"
    ],
    "source": {
        "line": 73,
        "path": "packages/src/CameraController/init.luau"
    }
}