Tutorial · Game systems

How to make a cutscene in Roblox Studio

A cutscene in Roblox is the camera taking over for a few seconds: it cuts or glides between shots, shows a caption, and gives control back. This guide builds one you set up by placing invisible parts where the camera should be, with no plugin. The server decides when a player sees it (when they first spawn, or when they touch a trigger), and the player's device moves the camera, shows black bars and a Skip button, and hands the camera back.

Updated 15 October 202623 min readBeginnerLuau

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:Interpolate is deprecated. Roblox's reference says to animate the camera with TweenService instead.
On this page
  1. How it works
  2. 1. Place the shots
  3. 2. Server script
  4. 3. LocalScript
  5. 4. Touch trigger
  6. Camera details
  7. Older tutorials
  8. Exploit limits
  9. 5. Test it
  10. Extending it
  11. Common errors
  12. With RoCode
  13. Questions
  14. Sources

How a cutscene works

IllustrationA two-shot intro at real speed: a cut to the first shot, a 1 second hold, a 2.5 second glide to the bridge, a 1 second hold, then the camera goes back to the player.

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:

  1. The server decides a player should see a cutscene: the Intro on their first spawn in the server, or another one when they touch a trigger part.
  2. 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 CutsceneEvent to that player only, with an id and the list of shots.
  3. 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.
  4. When the last shot ends, or the player presses Skip, it hands the camera back to the character and fires CutsceneEvent to the server with the same id.
  5. 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.
The pieces of the cutscene system
PieceWhere it livesWhat it does
CutsceneService ScriptServerScriptServiceDecides who sees which cutscene and when, reads the shots, holds the player still and remembers who has seen what.
CutscenePlayer LocalScriptStarterPlayer, StarterPlayerScriptsPlays a cutscene on the player's screen: bars, captions, Skip and the camera moves. Hands the camera back at the end.
CutsceneEvent RemoteEventReplicatedStorage (CutsceneService creates it)Carries the shots from the server to one player, and the "cutscene ended" message back.
Cutscenes FolderWorkspaceOne folder per cutscene, such as Intro, holding the shot parts named 1, 2, 3.
CutsceneTriggers FolderWorkspace (optional)Parts that start a cutscene when a player touches them.
Explorer when the cutscene system is finished. The Bridge folder and CutsceneTriggers come in Step 4. CutsceneService adds a RemoteEvent named CutsceneEvent to ReplicatedStorage when the game runs.
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.

  1. In the Explorer, hover over Workspace, click the + button and insert a Folder named Cutscenes. Inside it, insert a Folder named Intro. Names are case-sensitive.
  2. Fly Studio's camera to the first view you want, as if you were framing a photo.
  3. Insert a Part into Intro and rename it 1.
  4. 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, CurrentCamera is 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.
  5. 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.
  6. Repeat for the next views, naming the parts 2, 3 and 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.

Optional attributes on each shot part
AttributeTypeDefaultWhat it does
MoveSecondsnumber3Seconds 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.
HoldSecondsnumber1Seconds the camera stays on this shot after it arrives.
CaptionstringnoneText 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
-- 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.

The settings at the top of CutsceneService
SettingValue hereWhat 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_SECONDS3How long a glide takes when a shot has no MoveSeconds attribute.
DEFAULT_HOLD_SECONDS1How long the camera stays on a shot when it has no HoldSeconds attribute.
MAX_SHOTS30The highest shot number CutsceneService looks for in one cutscene.

What the rest of the script does, in order:

  • Reads the shots on the server. readShots collects 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. play does 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.delay unfreezes 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 seen table remembers, for each player, which cutscenes have started, so the intro does not replay on respawn and a trigger cutscene plays once. playOnce returns 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

  1. In the Explorer, expand StarterPlayer, hover over StarterPlayerScripts and insert a LocalScript named CutscenePlayer.
  2. Replace its contents with the code below.
LocalScript in StarterPlayerScripts named CutscenePlayer
-- 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.

What each part of CutscenePlayer does
PartWhat it does
The Cutscene ScreenGuiTwo 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 ScreenGuiHolds 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_GUISHides 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.
TouchControlsEnabledOn 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.
updateCameraRuns 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.
waitOrSkipWaits for a shot's move and hold time, checking the Skip flag every frame.
playMakes 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 buttonActivated 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.

  1. In Workspace, Cutscenes, insert a Folder named Bridge and place its shots as in Step 1, named 1 and 2. In the Command Bar line, use Bridge instead of Intro.
  2. Hover over Workspace and insert a Folder named CutsceneTriggers.
  3. 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.
  4. Add an attribute to it named Cutscene, of type string, and set its value to Bridge: 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 styles that suit a camera move
EASING_STYLEHow the move feels
Enum.EasingStyle.Sine (used here)Speed follows a sine wave, for a gentle start and stop.
Enum.EasingStyle.QuadLike Sine, with a slightly sharper curve.
Enum.EasingStyle.LinearConstant 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.

Common patterns in cutscene tutorials, and what this guide does instead
The tutorialThe problemThis 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 elseFocus 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 backThe 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 LocalScriptNothing 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 clientWith 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.

What a cheater can try, and what happens
A cheater tries toResult
Skip a cutscene, or never play itAllowed: it is their own screen. Never give a reward for "finished the cutscene".
Fire CutsceneEvent with any valueOnly 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 mapPossible: 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 anywayNothing 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 elseNot 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

  1. Place the three Intro shots with the Command Bar line. Give shot 1 the Caption "Welcome to the village", give shot 2 MoveSeconds 2.5 and the Caption "Find the bridge", and leave shot 3 without attributes.
  2. 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.
  3. 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.
  4. Walk into BridgeTrigger: the Bridge cutscene plays. Walk through it again: nothing happens.
  5. 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.
  6. Leave Workspace.StreamingEnabled on and move one shot far from the spawn. The scenery should be there when the camera arrives (likely, not guaranteed: see Streaming).
  7. 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.
  8. 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.

You type

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.

RoCode does
  • 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

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.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.