In short
Place invisible, anchored parts named 1, 2, 3 in a folder in Workspace.Cutscenes, each facing where the camera should look. A Script in ServerScriptService reads their CFrames, holds the player still and sends the shots to that player with a RemoteEvent. A LocalScript in StarterPlayerScripts sets workspace.CurrentCamera.CameraType to Scriptable, moves the camera from shot to shot every frame (easing with TweenService:GetValue), then sets CameraType back to Custom and CameraSubject to the player's Humanoid.
- Only the player's device can move its camera, so the camera code is a LocalScript. The server only decides who sees which cutscene and when.
- When the camera is Scriptable, Roblox stops updating
Camera.Focus. Its reference says to update Focus every frame, or dynamic lighting far from the old focus may not render. Camera:Interpolateis deprecated. Roblox's reference says to animate the camera with TweenService instead.
On this page
How a cutscene works
Each player's device has its own Camera object, at workspace.CurrentCamera, so only a LocalScript can move what that player sees. Deciding who sees which cutscene, and when, is a job for the server. This guide splits the work that way. One cutscene goes like this:
- The server decides a player should see a cutscene: the
Introon their first spawn in the server, or another one when they touch a trigger part. - CutsceneService reads the shot parts' CFrames and settings, asks Roblox to stream the first shot's area to that player, anchors the player's HumanoidRootPart so they stay put, and fires
CutsceneEventto that player only, with an id and the list of shots. - CutscenePlayer, on that player's device, shows the black bars and the Skip button, hides the player list, backpack, health bar and touch controls, makes the camera Scriptable and moves it every frame.
- When the last shot ends, or the player presses Skip, it hands the camera back to the character and fires
CutsceneEventto the server with the same id. - The server unanchors the player. If that message never comes, it unanchors them anyway when the cutscene's length plus 2 seconds has passed. That timer starts when the server sends the cutscene, and on a slow join the device can start the intro a few seconds later, because LocalScripts outside ReplicatedFirst do not run until the game has loaded. In that case the player can move again before the intro ends on their screen.
| Piece | Where it lives | What it does |
|---|---|---|
CutsceneService Script | ServerScriptService | Decides who sees which cutscene and when, reads the shots, holds the player still and remembers who has seen what. |
CutscenePlayer LocalScript | StarterPlayer, StarterPlayerScripts | Plays a cutscene on the player's screen: bars, captions, Skip and the camera moves. Hands the camera back at the end. |
CutsceneEvent RemoteEvent | ReplicatedStorage (CutsceneService creates it) | Carries the shots from the server to one player, and the "cutscene ended" message back. |
Cutscenes Folder | Workspace | One folder per cutscene, such as Intro, holding the shot parts named 1, 2, 3. |
CutsceneTriggers Folder | Workspace (optional) | Parts that start a cutscene when a player touches them. |
Workspace
Cutscenes Folder new
Intro Folder new
1 Part new
2 Part new
3 Part new
Bridge Folder new
1 Part new
2 Part new
CutsceneTriggers Folder new
BridgeTrigger Part new
ServerScriptService
CutsceneService Script new
StarterPlayer
StarterPlayerScripts
CutscenePlayer LocalScript new
Step 1: Place the camera shots
Each shot is an invisible part. The camera takes the part's position and looks the same way as its front face, so placing a shot is placing the camera.
- In the Explorer, hover over Workspace, click the + button and insert a Folder named
Cutscenes. Inside it, insert a Folder namedIntro. Names are case-sensitive. - Fly Studio's camera to the first view you want, as if you were framing a photo.
- Insert a Part into Intro and rename it
1. - Open the Command Bar (on the Script tab, or Window, then Script) and run
workspace.Cutscenes.Intro["1"].CFrame = workspace.CurrentCamera.CFrame. In edit mode,CurrentCamerais the view you are looking through, so the part jumps to exactly that shot. The brackets and quotes are needed because a name that starts with a digit cannot follow a dot. - In the part's Properties, tick Anchored, untick CanCollide, CanTouch and CanQuery, and set Transparency to 1. Anchored stops it falling through the map; the others stop it blocking players, firing touch events and getting in the way of raycasts.
- Repeat for the next views, naming the parts
2,3and so on, and changing the number in the Command Bar line to match. CutsceneService plays the shots in number order and stops at the first missing number, so a gap ends the cutscene early.
To change a shot later, fly the camera to the new view and run the same line with that shot's number. With Transparency 1 the parts are invisible in Studio too, so select them in the Explorer when you need them.
Each shot can also have three optional attributes. To add one, select the part, scroll to the Attributes section of the Properties window, click +, enter the name, pick the type and click Save.
| Attribute | Type | Default | What it does |
|---|---|---|---|
MoveSeconds | number | 3 | Seconds the camera takes to glide from the previous shot to this one. 0 makes it a cut. Shot 1 is always a cut, so it ignores this. |
HoldSeconds | number | 1 | Seconds the camera stays on this shot after it arrives. |
Caption | string | none | Text shown in the bottom bar from the moment the camera starts moving to this shot. A shot without a Caption clears the text. |
A number below 0 or above 60, or an attribute of the wrong type, falls back to the default.
Step 2: Add the server script
Hover over ServerScriptService, insert a Script named CutsceneService and paste the code below. The settings at the top are the lines you are meant to change.
-- Script in ServerScriptService named CutsceneService.
-- Decides who sees which cutscene and when. It reads the camera shots from numbered
-- parts in Workspace.Cutscenes, holds the player still, and tells that player's
-- device to play the shots. The camera itself can only be moved on the device.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Settings
local INTRO_CUTSCENE = "Intro" -- plays once when a player first spawns; "" for none
local DEFAULT_MOVE_SECONDS = 3 -- time to glide to a shot when it has no MoveSeconds
local DEFAULT_HOLD_SECONDS = 1 -- time to stay on a shot when it has no HoldSeconds
local MAX_SHOTS = 30
local cutscenes = workspace:WaitForChild("Cutscenes")
local triggers = workspace:FindFirstChild("CutsceneTriggers")
-- This script makes CutsceneEvent itself, so do not add one by hand
local cutsceneEvent = Instance.new("RemoteEvent")
cutsceneEvent.Name = "CutsceneEvent"
cutsceneEvent.Parent = ReplicatedStorage
type Shot = { cframe: CFrame, move: number, hold: number, caption: string }
local seen: { [Player]: { [string]: boolean } } = {}
local playing: { [Player]: number } = {} -- the id of the cutscene a player is watching
local lastId = 0
local function secondsAttribute(part: Instance, name: string, default: number): number
local value = part:GetAttribute(name)
if typeof(value) == "number" and value >= 0 and value <= 60 then
return value
end
return default
end
-- Shots are parts named 1, 2, 3 ... inside a folder named after the cutscene
local function readShots(name: string): { Shot }
local shots: { Shot } = {}
local folder = cutscenes:FindFirstChild(name)
if not folder then
warn(`CutsceneService: no folder named {name} in Workspace.Cutscenes`)
return shots
end
for index = 1, MAX_SHOTS do
local part = folder:FindFirstChild(tostring(index))
if not (part and part:IsA("BasePart")) then
break
end
local caption = part:GetAttribute("Caption")
table.insert(shots, {
cframe = part.CFrame,
-- the first shot is a cut, so the camera jumps straight to it
move = if index == 1 then 0 else secondsAttribute(part, "MoveSeconds", DEFAULT_MOVE_SECONDS),
hold = secondsAttribute(part, "HoldSeconds", DEFAULT_HOLD_SECONDS),
caption = if typeof(caption) == "string" then caption else "",
})
end
return shots
end
local function setFrozen(player: Player, frozen: boolean)
local character = player.Character
local rootPart = character and character:FindFirstChild("HumanoidRootPart")
if rootPart and rootPart:IsA("BasePart") then
rootPart.Anchored = frozen
end
end
local function finish(player: Player, id: number)
if playing[player] ~= id then
return -- this cutscene already ended
end
playing[player] = nil
setFrozen(player, false)
end
local function play(player: Player, name: string): boolean
if playing[player] then
return false -- one cutscene at a time
end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then
return false
end
local shots = readShots(name)
if #shots == 0 then
return false
end
lastId += 1
local id = lastId
playing[player] = id
-- With instance streaming on, the player's device may not have the area a shot
-- looks at, and a request only loads an area for a while. Load the first shot's
-- area now (waiting up to 2 seconds), and each later shot's area 2 seconds before
-- the camera starts moving to it.
local function request(position: Vector3)
pcall(function()
player:RequestStreamAroundAsync(position, 2)
end)
end
request(shots[1].cframe.Position)
if playing[player] ~= id or player.Parent ~= Players then
return false -- the player left while the area loaded
end
setFrozen(player, true)
cutsceneEvent:FireClient(player, id, shots)
local startsAt = 0 -- seconds from now until the camera starts moving to this shot
for index, shot in shots do
if index > 1 then
task.delay(math.max(startsAt - 2, 0), function()
if playing[player] == id then
request(shot.cframe.Position)
end
end)
end
startsAt += shot.move + shot.hold
end
-- Safety net: unfreeze the player even if their device never reports back
task.delay(startsAt + 2, finish, player, id)
return true
end
-- Returns true only when the cutscene started for this player
local function playOnce(player: Player, name: string): boolean
local playerSeen = seen[player]
if not playerSeen or playerSeen[name] then
return false
end
if play(player, name) then
playerSeen[name] = true
return true
end
return false
end
-- The player's device reports that the cutscene ended or was skipped
cutsceneEvent.OnServerEvent:Connect(function(player: Player, id: unknown)
if typeof(id) == "number" then
finish(player, id)
end
end)
-- Trigger parts: touching one plays the cutscene named in its Cutscene attribute
if triggers then
for _, trigger in triggers:GetChildren() do
if not trigger:IsA("BasePart") then
continue
end
if not trigger.CanTouch then
-- Connecting Touched on a part with CanTouch off throws an error, so warn instead
warn(`CutsceneService: tick CanTouch on {trigger:GetFullName()} so it can start a cutscene`)
continue
end
trigger.Touched:Connect(function(otherPart: BasePart)
local name = trigger:GetAttribute("Cutscene")
local character = otherPart:FindFirstAncestorOfClass("Model")
local player = character and Players:GetPlayerFromCharacter(character)
if player and typeof(name) == "string" then
playOnce(player, name)
end
end)
end
end
local function onCharacterAdded(player: Player, character: Model)
character:WaitForChild("HumanoidRootPart", 10)
if playing[player] then
setFrozen(player, true) -- respawned during a cutscene, so hold the new character still too
elseif INTRO_CUTSCENE ~= "" then
playOnce(player, INTRO_CUTSCENE)
end
end
local function onPlayerAdded(player: Player)
if seen[player] then
return -- already set up
end
seen[player] = {}
player.CharacterAdded:Connect(function(character: Model)
onCharacterAdded(player, character)
end)
local character = player.Character
if character then
task.spawn(onCharacterAdded, player, character)
end
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do -- anyone who joined while this script waited for Cutscenes
onPlayerAdded(player)
end
Players.PlayerRemoving:Connect(function(player: Player)
seen[player] = nil
playing[player] = nil
end)
CutsceneService creates the CutsceneEvent RemoteEvent itself, once Workspace.Cutscenes exists, so there is nothing to insert in ReplicatedStorage by hand. If you already made a RemoteEvent with that name, delete it: the LocalScript could find yours, which nothing on the server listens to.
| Setting | Value here | What it changes |
|---|---|---|
INTRO_CUTSCENE | "Intro" | The folder in Workspace.Cutscenes that plays when a player first spawns in a server. Set it to "" for no intro. |
DEFAULT_MOVE_SECONDS | 3 | How long a glide takes when a shot has no MoveSeconds attribute. |
DEFAULT_HOLD_SECONDS | 1 | How long the camera stays on a shot when it has no HoldSeconds attribute. |
MAX_SHOTS | 30 | The highest shot number CutsceneService looks for in one cutscene. |
What the rest of the script does, in order:
- Reads the shots on the server.
readShotscollects each numbered part's CFrame and attributes into a list. The player's device gets that list, so it never needs the parts themselves. - One cutscene at a time.
playdoes nothing while the player is already watching one, or when their character has no Humanoid or is dead. - An id for every cutscene. The device sends the id back when the cutscene ends or is skipped, and only the matching id unfreezes the player, so a late message from an earlier cutscene cannot end a newer one.
- Holds the player still by anchoring their HumanoidRootPart, and anchors the new character too if the player respawns mid-cutscene. Roblox's reference says an anchored part never changes position from gravity, collisions or any other physics. WalkSpeed is left alone, so this cannot fight a sprint script or a speed pass. At the end it unanchors the root part even if another script anchored it, so do not anchor characters elsewhere while a cutscene can play.
- A safety net.
task.delayunfreezes the player after the cutscene's total length plus 2 seconds, even if their device never reports back. - Plays each cutscene once per server. The
seentable remembers, for each player, which cutscenes have started, so the intro does not replay on respawn and a trigger cutscene plays once.playOncereturns true only when the cutscene actually started. - Sets up every player. The loop over
Players:GetPlayers()at the end catches anyone who joined while the script was waiting for the Cutscenes folder.
The shots travel to the player in one FireClient call, as a list of small tables. Roblox's remote events page says not to send a table that mixes number keys and string keys; this list has only number keys, and each shot inside it has only string keys, which is the shape it asks for.
Step 3: Add the LocalScript that plays it
- In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named
CutscenePlayer. - Replace its contents with the code below.
-- LocalScript in StarterPlayer > StarterPlayerScripts named CutscenePlayer.
-- Plays a cutscene the server sends: black bars, captions, a Skip button, and the
-- camera gliding from shot to shot. Then it hands the camera back to the player.
local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local StarterGui = game:GetService("StarterGui")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local cutsceneEvent = ReplicatedStorage:WaitForChild("CutsceneEvent") :: RemoteEvent
local player = Players.LocalPlayer
local camera = workspace.CurrentCamera
local BAR_HEIGHT = 0.12 -- each black bar, as a share of the screen height
local EASING_STYLE = Enum.EasingStyle.Sine
local FOCUS_DISTANCE = 30 -- studs in front of the camera that Roblox should render in most detail
local HIDDEN_CORE_GUIS = { Enum.CoreGuiType.PlayerList, Enum.CoreGuiType.Backpack, Enum.CoreGuiType.Health }
local REGULAR = Font.fromEnum(Enum.Font.BuilderSans)
local BOLD = Font.fromEnum(Enum.Font.BuilderSansBold)
type Shot = { cframe: CFrame, move: number, hold: number, caption: string }
-- The cutscene screen: two black bars and a caption, edge to edge. Roblox says to use
-- ScreenInsets None only for things nobody presses, so Skip gets its own ScreenGui below.
local gui = Instance.new("ScreenGui")
gui.Name = "Cutscene"
gui.ResetOnSpawn = false
gui.ScreenInsets = Enum.ScreenInsets.None -- cover the whole screen
gui.DisplayOrder = 10
gui.Enabled = false
local topBar = Instance.new("Frame")
topBar.Size = UDim2.fromScale(1, BAR_HEIGHT)
topBar.BackgroundColor3 = Color3.new(0, 0, 0)
topBar.BorderSizePixel = 0
topBar.Parent = gui
local bottomBar = topBar:Clone()
bottomBar.Position = UDim2.fromScale(0, 1 - BAR_HEIGHT)
bottomBar.Parent = gui
local caption = Instance.new("TextLabel")
caption.Size = UDim2.fromScale(0.7, 0.5)
caption.Position = UDim2.fromScale(0.15, 0.25)
caption.BackgroundTransparency = 1
caption.TextColor3 = Color3.new(1, 1, 1)
caption.TextScaled = true
caption.FontFace = REGULAR
caption.Text = ""
caption.Parent = bottomBar
-- The Skip button keeps the default insets, so it stays clear of camera notches
local controls = Instance.new("ScreenGui")
controls.Name = "CutsceneControls"
controls.ResetOnSpawn = false
controls.DisplayOrder = 11
controls.Enabled = false
local skipButton = Instance.new("TextButton")
skipButton.AnchorPoint = Vector2.new(1, 0.5)
skipButton.Size = UDim2.fromScale(0.1, BAR_HEIGHT * 0.5)
skipButton.Position = UDim2.new(1, -16, 1 - BAR_HEIGHT / 2, 0) -- on the bottom bar, at the right
skipButton.BackgroundColor3 = Color3.fromRGB(60, 60, 60)
skipButton.TextColor3 = Color3.new(1, 1, 1)
skipButton.TextScaled = true
skipButton.FontFace = BOLD
skipButton.Text = "Skip"
skipButton.Parent = controls
-- Keep Skip big enough to tap on a small phone screen
local skipMinSize = Instance.new("UISizeConstraint")
skipMinSize.MinSize = Vector2.new(64, 32)
skipMinSize.Parent = skipButton
local playerGui = player:WaitForChild("PlayerGui")
gui.Parent = playerGui
controls.Parent = playerGui
-- The current move: from one camera CFrame to the next over a number of seconds
local moveFrom = CFrame.identity
local moveTo = CFrame.identity
local moveStart = 0
local moveSeconds = 0
local skipRequested = false
local busy = false
-- Runs every frame during a cutscene. A Scriptable camera's Focus does not follow
-- anything on its own, so it is set here too.
local function updateCamera()
if camera.CameraType ~= Enum.CameraType.Scriptable then
camera.CameraType = Enum.CameraType.Scriptable -- something reset it, such as a respawn
end
local alpha = if moveSeconds > 0 then (os.clock() - moveStart) / moveSeconds else 1
local eased = TweenService:GetValue(alpha, EASING_STYLE, Enum.EasingDirection.InOut)
camera.CFrame = moveFrom:Lerp(moveTo, eased)
camera.Focus = camera.CFrame * CFrame.new(0, 0, -FOCUS_DISTANCE)
end
local function waitOrSkip(seconds: number)
local endTime = os.clock() + seconds
while os.clock() < endTime and not skipRequested do
task.wait()
end
end
local function play(id: number, shots: { Shot })
if busy then
-- Already playing one, so let the server unfreeze the player for this one now
cutsceneEvent:FireServer(id)
return
end
busy = true
skipRequested = false
-- Hide the player list, backpack and health bar, remembering what was shown
local wasEnabled: { [Enum.CoreGuiType]: boolean } = {}
for _, coreGui in HIDDEN_CORE_GUIS do
wasEnabled[coreGui] = StarterGui:GetCoreGuiEnabled(coreGui)
StarterGui:SetCoreGuiEnabled(coreGui, false)
end
-- On phones, hide the thumbstick and jump button, which sit in the bottom corners
local touchControlsWere = GuiService.TouchControlsEnabled
GuiService.TouchControlsEnabled = false
gui.Enabled = true
controls.Enabled = true
-- A controller can only press a button that is selected, so select Skip for gamepad players
if UserInputService.PreferredInput == Enum.PreferredInput.Gamepad then
GuiService.SelectedObject = skipButton
end
camera = workspace.CurrentCamera
camera.CameraType = Enum.CameraType.Scriptable
moveTo = camera.CFrame
-- One step after Roblox's own camera update, as Roblox's camera tutorial does
RunService:BindToRenderStep("Cutscene", Enum.RenderPriority.Camera.Value + 1, updateCamera)
for _, shot in shots do
moveFrom = camera.CFrame -- start each move from wherever the camera is now
moveTo = shot.cframe
moveStart = os.clock()
moveSeconds = shot.move
caption.Text = shot.caption
waitOrSkip(shot.move + shot.hold)
if skipRequested then
break
end
end
-- Hand the camera back to the player's character
RunService:UnbindFromRenderStep("Cutscene")
camera.CameraType = Enum.CameraType.Custom
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid then
camera.CameraSubject = humanoid
end
if GuiService.SelectedObject == skipButton then
GuiService.SelectedObject = nil
end
gui.Enabled = false
controls.Enabled = false
caption.Text = ""
-- Turn back on only the ones this cutscene turned off, and leave the rest alone
for coreGui, enabled in wasEnabled do
if enabled then
StarterGui:SetCoreGuiEnabled(coreGui, true)
end
end
if touchControlsWere then
GuiService.TouchControlsEnabled = true
end
cutsceneEvent:FireServer(id) -- lets the server unfreeze the player now
busy = false
end
skipButton.Activated:Connect(function()
skipRequested = true
end)
cutsceneEvent.OnClientEvent:Connect(function(id: number, shots: { Shot })
play(id, shots)
end)
A LocalScript in StarterPlayerScripts is copied to the player once, when they join, and keeps running when their character respawns. That is why both ScreenGuis have ResetOnSpawn off: with it on, Roblox would delete them at the first respawn, and this script would not make them again. Remote messages that arrive before the script connects to OnClientEvent are queued and delivered when it does, so a player who spawns quickly still sees the intro.
| Part | What it does |
|---|---|
The Cutscene ScreenGui | Two black bars, each 12% of the screen height (BAR_HEIGHT), with the caption in the bottom bar. ScreenInsets None lets the bars reach the very edges of the screen, and DisplayOrder 10 draws them over ScreenGuis with a lower DisplayOrder. |
The CutsceneControls ScreenGui | Holds only the Skip button, at the right of the bottom bar, with a UISizeConstraint that keeps it at least 64 by 32 pixels so it stays easy to tap on a phone. It keeps the default ScreenInsets, which keeps its contents clear of the Roblox top bar buttons and camera notches. Roblox says to use None only for content with nothing to press, so Skip cannot share the bars' ScreenGui. |
HIDDEN_CORE_GUIS | Hides the player list, backpack and health bar with SetCoreGuiEnabled, after recording which of them were on with GetCoreGuiEnabled, and at the end turns back on only the ones it turned off. A game that hides the backpack elsewhere keeps it hidden, and a script that turns them on during the cutscene, such as a main menu, is left alone. |
TouchControlsEnabled | On phones and tablets, play sets GuiService.TouchControlsEnabled to false for the cutscene. That hides the thumbstick and jump button, which sit in the bottom corners where Skip is, and they come back at the end if they were on. |
updateCamera | Runs every frame while a cutscene plays. It works out how far through the current move it is, eases that with TweenService:GetValue, places the camera between the two shots with CFrame:Lerp, and sets Focus 30 studs in front of the camera. |
waitOrSkip | Waits for a shot's move and hold time, checking the Skip flag every frame. |
play | Makes the camera Scriptable, runs the shots in order, then hands the camera back (CameraType Custom, CameraSubject the Humanoid), turns the core GUIs and touch controls back on and fires the cutscene's id to the server. If a second cutscene arrives while one is still playing, it sends that id straight back instead, so the server does not hold the player still for a cutscene this device did not show. |
| The Skip button | Activated fires for a mouse click, a tap, or the A button on a controller once the button is selected. For a player whose preferred input is a gamepad, play selects Skip as the cutscene starts and clears the selection at the end. |
Change BAR_HEIGHT to make the bars thinner or thicker, and EASING_STYLE to change how the moves feel (see What the camera code gets right). The caption and Skip button use TextScaled, so their text grows and shrinks with the screen.
Step 4: Play a cutscene when a player touches a part
The intro plays on its own. For a cutscene at a place in your map, such as the first time a player reaches a bridge, add a second cutscene and a trigger part.
- In Workspace, Cutscenes, insert a Folder named
Bridgeand place its shots as in Step 1, named1and2. In the Command Bar line, useBridgeinstead ofIntro. - Hover over Workspace and insert a Folder named
CutsceneTriggers. - Insert a Part into it named
BridgeTrigger. Make it big enough that players cannot walk past without touching it, for example a tall, wide slab across the path. Tick Anchored, untick CanCollide and CanQuery, keep CanTouch ticked, and set Transparency to 1. CanQuery off stops the invisible slab from blocking raycasts, such as the shots in the gun guide. Touch events are controlled by CanTouch, not CanQuery, so the trigger still works. - Add an attribute to it named
Cutscene, of type string, and set its value toBridge: the name of the folder to play.
CutsceneService connects Touched on every part directly inside CutsceneTriggers when the server starts, so add triggers in Studio, not from a script while the game runs. A trigger with CanTouch off is skipped with a warning in Output, because Roblox throws an error when a script connects Touched on such a part.
Touched fires many times in quick succession while a character walks through a part. The seen table and the one-cutscene-at-a-time rule turn that into one cutscene per player per server. A player who touches the trigger while another cutscene is playing is ignored, and the trigger stays ready for them.
Keep triggers off roads. A seated player's HumanoidRootPart is welded to the seat, and when one part of a welded group is anchored, Roblox anchors the rest with it. A player who drives into a trigger therefore holds their whole vehicle still, passengers included, until the cutscene ends.
What the camera code gets right
Scriptable, then back to Custom
Roblox's camera scripts do not move or update the camera while CameraType is Scriptable, which is what lets CutscenePlayer place it. At the end it sets CameraType back to Custom and makes the player's Humanoid the CameraSubject, which is what Roblox's camera scripts follow by default. That runs after a Skip too, so the player is never left looking at the last shot.
Two small guards keep the cutscene in charge while it plays. If anything sets CameraType to something else mid-cutscene, the per-frame function sets it back to Scriptable on the next frame. And the script reads workspace.CurrentCamera again at the start of each cutscene, because Roblox creates a new Camera if the current one is ever destroyed.
Focus, every frame
Camera.Focus tells the engine which area to prioritise for work such as lighting. Roblox's camera scripts keep it on the CameraSubject, but not while the camera is Scriptable, and Roblox's reference says to update it every frame in that case: dynamic lighting from objects such as PointLights may not render far from the focus. CutscenePlayer sets it 30 studs in front of the camera (FOCUS_DISTANCE). Raise that if your shots look at something further away.
The function runs through RunService:BindToRenderStep, every frame before the frame is drawn, one priority step after Roblox's own camera update (Enum.RenderPriority.Camera.Value + 1). That is the priority Roblox's camera tutorial uses, so the cutscene places the camera after Roblox's own camera code has run.
Easing with TweenService:GetValue
A tween on camera.CFrame would move the camera, but the Focus still has to be set every frame, so CutscenePlayer does the moving itself in the same function. For each move it works out the share of the move time that has passed (from 0 to 1), passes it through TweenService:GetValue with an easing style and direction, and places the camera that far between the two shots with CFrame:Lerp, which moves the position in a straight line and turns the rotation smoothly. GetValue clamps its input between 0 and 1, so the camera stops exactly on the shot when the move time runs out.
| EASING_STYLE | How the move feels |
|---|---|
Enum.EasingStyle.Sine (used here) | Speed follows a sine wave, for a gentle start and stop. |
Enum.EasingStyle.Quad | Like Sine, with a slightly sharper curve. |
Enum.EasingStyle.Linear | Constant speed, so each move starts and stops at full speed. |
The direction is InOut: the easing is applied forwards for the first half of the move and in reverse for the second half, so the camera speeds up out of one shot and slows down into the next.
Streaming
New places have instance streaming on: by default a player's device holds the area around their character, so a shot that looks at the far side of the map can show an empty world. CutsceneService asks Roblox to stream each shot's area to that player with Player:RequestStreamAroundAsync: the first shot's area before it fires the event, waiting up to 2 seconds, and each later shot's area 2 seconds before the camera starts moving to it.
Roblox says this makes the area likely, not certain, to be loaded, and that the effect is temporary. Keep important shots within your streaming radius, or use Player:AddReplicationFocus on the server for an area that must stay loaded until you remove the focus. With streaming off, the device has the whole map anyway, and the pcall around each request means a request that fails cannot stop the cutscene.
What older cutscene tutorials get wrong
If you are comparing this guide with a video or a forum post, these are the differences that matter, with the reason each pattern is a problem.
| The tutorial | The problem | This guide |
|---|---|---|
Moves the camera with camera:Interpolate() | Roblox marks Interpolate deprecated and says to animate the camera with TweenService instead. | Eases each move with TweenService:GetValue and places the camera every frame. |
Tweens camera.CFrame and nothing else | Focus stays where the camera scripts left it, near the character, so dynamic lighting far from it may not render (Roblox's Camera.Focus reference). | Sets Focus every frame in the render step. |
| Never gives the camera back | The player is stuck looking at the last shot. | Sets CameraType to Custom and CameraSubject to the Humanoid at the end, after a Skip too. |
Starts the cutscene from Touched in a LocalScript | Nothing stops it replaying on every touch, and the decision sits on a device the player controls. | The server picks the player and remembers who has seen it. |
| Reads the camera parts on the client | With streaming on, parts far from the character may not exist on the device yet. | The server reads the parts, sends their CFrames, and asks for each shot's area shortly before the camera gets there. |
What a player can and cannot change
Assume a cheater controls everything on their own device: they can edit or delete CutscenePlayer and fire CutsceneEvent with any value. For a cutscene, that matters very little, as long as nothing real depends on it.
| A cheater tries to | Result |
|---|---|
| Skip a cutscene, or never play it | Allowed: it is their own screen. Never give a reward for "finished the cutscene". |
Fire CutsceneEvent with any value | Only a number that matches their own current cutscene does anything, and all it does is unfreeze them early. |
| Touch a trigger from anywhere on the map | Possible: each device controls its own character, and Roblox's security docs list manipulating Touched events among the things it can do. They see that cutscene once, and nothing else happens. |
| Move during a cutscene anyway | Nothing to gain. The freeze keeps honest players from wandering off mid-cutscene; it is not a security check. |
| Use the remote to start a cutscene for someone else | Not possible. Only the server fires CutsceneEvent to a player, and the only message a device can send back is "this cutscene ended". |
If a cutscene leads into something real, such as a door that opens after it or a boss that appears, let the server do that on its own timer, not when the device says the cutscene ended.
Step 5: Test it
- Place the three Intro shots with the Command Bar line. Give shot 1 the
Caption"Welcome to the village", give shot 2MoveSeconds2.5 and theCaption"Find the bridge", and leave shot 3 without attributes. - Press Play (F5). Black bars appear and the camera cuts to shot 1 with its caption, glides to shot 2 in 2.5 seconds, then to shot 3 in 3 seconds with no caption. The camera then comes back behind your character and the player list returns. You cannot walk while it plays.
- Stop, press Play again, and press Skip while the camera glides to shot 2. The camera comes back at once, and you can walk straight away.
- Walk into BridgeTrigger: the Bridge cutscene plays. Walk through it again: nothing happens.
- Choose Server & Clients in the test mode dropdown, set 2 clients and press Play. When Client 1 walks into the trigger, the Bridge cutscene plays only on Client 1, and Client 2 can still walk.
- Leave
Workspace.StreamingEnabledon and move one shot far from the spawn. The scenery should be there when the camera arrives (likely, not guaranteed: see Streaming). - For a controller, stop, press Play, open the Controller Emulator (Emulation tab, then Controller) and walk into BridgeTrigger with the emulated gamepad: Skip should be selected, so A skips. If you use Studio's Device Simulator (a beta when we checked on 15 October 2026: turn it on in File, Beta Features, New Device Simulator, then restart Studio), pick a phone, turn it to landscape and check that Skip sits clear of the camera notch and is easy to tap, and that the thumbstick and jump button are hidden while the cutscene plays.
- If you also built the main menu, press Play, then the menu's Play button, and let the intro run to the end: the player list should be back on screen afterwards.
Keep the Output window open (Window menu, then Output) the whole time. These scripts print nothing when they work, so any warning or red error from CutsceneService or CutscenePlayer is a real problem. See Common errors.
Extending it
Show the intro only on a player's first visit
The seen table lasts one server, so a player who rejoins sees the intro again. To show it only on their first visit, save a flag such as SeenIntro with the rest of their data, using the PlayerData module from the DataStore tutorial. Keep INTRO_CUTSCENE as it is and change the intro part of onCharacterAdded in CutsceneService: wait until PlayerData.Get(player) returns the player's data, skip the intro when data.SeenIntro is true, and set data.SeenIntro = true only when playOnce(player, INTRO_CUTSCENE) returns true. Do not start the intro from the code that loads the data: data can finish loading before the character spawns, and play does nothing without a character, so the flag would be saved for an intro nobody saw.
Characters acting in the cutscene
Make the animation in Studio's Animation Editor, or in a third-party animation plugin such as Moon Animator, which Roblox's own docs mention, and play it on an NPC rig in the shot when the camera gets there. Roblox's Animator reference says an NPC's animations replicate only when the server loads and starts them, so play it from a server Script if every player should see the NPC act. Loaded and started in CutscenePlayer instead, the animation is not replicated, which suits a cutscene only one player is watching.
Dialogue
Captions suit short lines that play by themselves. For a conversation where the player answers and picks choices, see the NPC dialogue guide.
An intro after the main menu
The main menu guide turns off CharacterAutoLoads, so nobody spawns until they press Play. CutsceneService plays the intro on a player's first spawn, so with both systems the intro plays on the first spawn after Play. The menu turns the core GUIs back on once the character appears, which can happen while the intro is already playing, so the player list may show during the intro. CutscenePlayer turns back on only the ones it hid itself, so it leaves the menu's change alone and they stay on after the intro. The main menu guide's Command Bar trick for aiming a camera part is the same one Step 1 uses.
A flyover at the start of a round
To start a cutscene from another server script, such as a map flyover when a round in the round system guide begins, add a BindableEvent named PlayCutscene to ServerStorage. At the end of CutsceneService, connect its Event to the play function, and have the round script fire it with each player and the cutscene's name. play skips the once-per-server check, so the flyover plays every round.
Cutscene plugins
Plugins are fine to use alongside this guide. If a cutscene plugin writes its own camera script, check that it hands the camera back (CameraType Custom and a CameraSubject) and sets Focus while the camera is Scriptable, and keep the decision about who sees the cutscene on the server, as in this guide.
Common errors and fixes
Open the Output window (Window menu, then Output) before you press Play. Most cutscene problems either print a warning there or come from a name that does not match.
Infinite yield possible on 'Workspace:WaitForChild("Cutscenes")'
CutsceneService is waiting for a folder named Cutscenes directly in Workspace. Create it as in Step 1 and check the spelling and the capital C. Until it exists, CutsceneService does not create CutsceneEvent, so CutscenePlayer waits too.
Infinite yield possible on 'ReplicatedStorage:WaitForChild("CutsceneEvent")'
CutscenePlayer is waiting for the remote that CutsceneService creates. Check that CutsceneService is a Script, not a LocalScript, directly inside ServerScriptService, that it is not disabled, and that Output shows no error or Cutscenes warning from it.
CutsceneService: no folder named Intro in Workspace.Cutscenes
The name in INTRO_CUTSCENE, or in a trigger's Cutscene attribute, does not match a folder inside Workspace.Cutscenes. Names are case-sensitive.
Nothing plays, and Output shows nothing
The folder has no part named 1, or its parts are not directly inside it (for example, they are inside a Model). CutsceneService reads parts named 1, 2, 3 in order and stops at the first missing number. Also remember that each cutscene plays once per player per server: stop the test and press Play again.
The camera points the wrong way
The camera looks the same way as the shot part's front face. Fly Studio's camera to the view you want and run the Command Bar line from Step 1 again for that shot.
The trigger does nothing
Check that the trigger part is directly inside Workspace.CutsceneTriggers when the game starts, that CanTouch is ticked (Output shows a warning if not), and that its Cutscene attribute is a string naming a folder in Workspace.Cutscenes. Walking through a second time does nothing by design.
The camera stays on the last shot after the cutscene
CutscenePlayer stopped with an error before it could hand the camera back, so look for a red error from it in Output. Another script that keeps setting CameraType to Scriptable, such as a menu camera that is still bound, can also hold on to the camera.
Build it with RoCode
If you would rather not wire this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the feature in its web chat, and its Studio plugin creates the part and the scripts in your Explorer, so you are not copying code out of a chat window.
Add an intro cutscene: a Cutscenes folder with an Intro folder of shots named 1 to 3, captions from a Caption attribute, black bars and a Skip button, played once when a player first spawns, with the server holding the player still and the camera handed back at the end.
- Can check your place first with Search Scripts, for example for a menu or camera script that already sets
CameraType, so the two do not fight - Creates the folders and the trigger part with Create Instance, and sets attributes such as Caption and MoveSeconds on the parts
- Writes CutsceneService and CutscenePlayer with Create Script
- Is told to check each script it writes with Check Script, which compiles it inside Studio without running it, and sets an undo point before and after each batch of changes
Place the shot parts yourself by flying Studio's camera to each view, then ask for the scripts. With plugin 3.9 or newer RoCode can run a short playtest and read the errors, but it does not move your character or take screenshots, so you watch the cutscene yourself. For the order to build a whole game in, see making a Roblox game with AI, system by system. How RoCode connects to Studio.
Questions
How do I make a cutscene play when a player joins?
Name a folder of shots Intro in Workspace.Cutscenes, or change INTRO_CUTSCENE to your folder's name. CutsceneService plays it on each player's first spawn in a server. To show it only on someone's very first visit, save a flag in a data store and set it only once the intro has started, as described in Extending it.
Can I make a cutscene without plugins?
Yes. This guide uses only parts, attributes and two scripts, and the Command Bar line places each shot exactly where Studio's camera is. Plugins such as Moon Animator are still handy for animating characters that act in the cutscene.
Why does my camera not move in my cutscene?
Usually the camera code is in a server Script, but only a LocalScript can move a player's camera, because each device has its own. The other common cause is that CameraType is not Scriptable, so Roblox's default camera scripts keep moving the camera back to the character.
How do I stop the player moving during a cutscene?
Anchor their HumanoidRootPart on the server while it plays and unanchor it afterwards, as CutsceneService does. An anchored part never moves from physics. It leaves WalkSpeed alone, so it does not fight a sprint script or a speed pass.
How do I add a Skip button to a cutscene?
Put a TextButton on the cutscene screen and connect its Activated event, which fires for a click, a tap, or the A button on a controller once the button is selected. CutscenePlayer's Skip sets a flag the shot loop checks every frame, is selected automatically for gamepad players, and sits in its own ScreenGui with the default insets so a phone's camera notch cannot cover it. The thumbstick and jump button are hidden while the cutscene plays, so they do not sit next to it.
Why is the world empty in my cutscene?
With instance streaming on, a player's device only has the area near their character. Ask the server to call player:RequestStreamAroundAsync for each shot shortly before the camera gets there, as CutsceneService does. Roblox says this makes the area likely, not certain, to be loaded, so keep important shots within your streaming radius, or use Player:AddReplicationFocus for an area that must stay loaded.
Sources and further reading
- Roblox Creator Docs: Customize the camera. each client's own Camera, Scriptable, CFrame and Focus
- Roblox Engine API: Camera. CameraType, CameraSubject, Focus every frame, and the deprecated Interpolate
- Roblox Creator Docs: Control the user's camera. BindToRenderStep one priority step after the camera
- Roblox Engine API: TweenService:GetValue. eased alpha, clamped between 0 and 1
- Roblox Engine API: ScreenGui.ScreenInsets. why None is only for content with nothing to press
- Roblox Creator Docs: Instance streaming. on by default for new places, and the replication focus
- Roblox Creator Docs: Streaming techniques. pre-fetching an area before firing an event
- Roblox Engine API: Player:RequestStreamAroundAsync. a temporary request with no guarantee
- Roblox Creator Docs: Remote events and callbacks. queued messages and which tables can be sent
- Roblox Creator Docs: Network ownership, movement validation, and physics. what a client can do with its own character, including manipulating Touched events
- Roblox Engine API: Animator:LoadAnimation. which animations replicate
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 15 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.