how to make roblox cutscenes

How to make Roblox Cutscenes

Learning how to make cutscenes in Roblox gives games that extra cinematic polish. Instead of a flat and static screen, we can introduce the game by sweeping across our maps or share our story as if it were a movie. 

In this post, I’ll help demystify how the camera uses CFrame and demonstrate the right design patterns you need to create your scenes.

Get your character’s ready for their close up!

What you’ll learn

  • Design pattern to setup scenes
  • Playing sounds and music in a scene
  • Controlling the camera
  • How to animate the camera

How Roblox camera works

The Roblox camera is modelled after a real life physical camera that you would buy in the store. It’s able to set its focus to make close up objects sharp and clear while far away objects become blurry. The camera can also control its field of view to control how much it can see.

Like in a movie or TV show, the camera can also pan itself and follow its main subject. We can use different types of cameras through the CameraType property.

Different Roblox camera types

  • Fixed – a stationary camera that does not move
  • Follow – moves with the current subject and rotates to keep subject in view
  • Track – moves with the current subject but does not rotate
  • Watch – a stationary camera that follows its current subject (similar to a security camera)
  • Scriptable – uses a custom script to define its behave

Roblox camera scripts should go under the StartPlayerScripts folder in the workspace.

If models or parts are blocking a camera’s view, we can use the GetPartsObscuringTarget function to get all of these parts.

We can use the FirstPersonTransition event to react to when the player moves from first person perspective to third person and vice versa.
For VR camera controls, we can use GetPanSpeed and GetTiltSpeed to know how fast a player’s headset is moving on the x, y, and z axises.

How to animate and control the camera

For cutscenes we’ll use a Scriptable camera type. To animate the camera’s position we’ll use the TweenService

Tweening comes from the word between since it calculates values from a start value to a desired end value. We can use tweening to manipulate all kinds of numeric properties such as position, color, opacity and so on.

The TweenService also gives a script the ability to define how quickly it should transition from a start value. For example, we can transition a part’s color from bright red to dark blue while at the same time moving the part from point A to point B.

Using different easing styles also gives the animation a more natural feel. Think about how a moving vehicle slowly stops as you apply the brakes. If you hit the brakes hard the vehicle lurches to a stop but if you apply the brakes normally it’s a lot smoother.

Code Example

local Camera = workspace.CurrentCamera
local Player = game.Players.LocalPlayer
local Character = Player.Character
local Destination = workspace.DestinationPart
	
local TweenService = game:GetService("TweenService")	
local TweenInfo = TweenInfo.new(5, Enum.EasingStyle.Linear, Enum.EasingDirection.Out, 0, false, 0)
	
local Tween = TweenService:Create(Camera, TweenInf, {CFrame = Pos})	
Tween:Play()

In this example, we have a part named DestinationPart in our workspace that the camera will move towards. The tween info set the animation duration to 5 seconds and use a smooth  linear easing style. We can use other EasingStyle enums to have different effects.

How to setup cutscenes

To first set up a cutscene in Roblox we should determine the right data structures our scripts will use. A cutscene is made up of its camera and a video script (not a lua script) to define what happens during the scene.

A film script contains a sequence and directions for the camera, the actors, and background action in the scenery. In regular cinema, they use beats as a guideline for the pacing of a scene. 

Just like any movie or video, the pacing helps set the mood. Since we’re working with code and not actors, we’ll use a time duration for each beat in our scene. 

Now to get into the technical bits, we’ll use a Lua table keyed by the starting point of each beat. The start point is how many seconds have passed since the start of the scene.

Cut scene data structure example

local cutscene = {}
local firstBeat = { start = 0, next = nil, actions = {} }
local action = {
  Type = nil,
  StartPosition = nil -- CFrame
  EndPosition = nil -- CFrame
}

Since there’s a lot happening as each second passes, actions must be able to run at or near the same time. This includes moving the camera, animating characters, and playing music and sounds.

local RunService = game:GetService(“RunService)

-- CutScene Beat class
CutSceneBeat = {}
function CutSceneBeat:new()
  local self = setmetatable({}, CutSceneBeat)
  self.start = 0
  self.next = nil
  Self.Actions = {}
  return self  
end

function CutSceneBeat:AddAction(action)
  table.insert(self.Actions, action)
end

function CutSceneBeat:play()
  for i, action in ipairs(self.Actions) do
    -- execute action
  end
end

function CutSceneBeat:onRenderStep(deltaTime)
  -- trigger actions in the beat
end

-- CutScene class
CutScene = {}
function CutScene:new()
  local self = setmetatable({}, CutScene)
  self.Beats = {}
  self.CurrentBeat = nil
  self.Position = 0
  return self  
end

function CutScene:AddBeat(beat)
  self.Beats[beat.start] = beat
end

function CutScene:Play()
  if not self.CurrentBeat then
    self.CurrentBeat = self.Beats[0]
  End

  if self.CurrentBeat then
    self.CurrentBeat:Play()
  end
end

function CutScene.onRenderStep(deltaTime)
  if self.CurrentBeat then
    self.CurrentBeat:onRenderStep(deltaTime)
  end
end

We’ll use an enum to distinguish between the different types of beat actions.

CutSceneActionType = {
  Animation = 1,
  Emote = 2,
  Dialog = 3,
  Music = 4,
  Sound = 5
}

We’ll use a Scene player object to execute the sequence of each beat. The runner will have functions to start and stop the cut scene.

Cut Scene Player script

Here’s a fully working script for a Cutscene service. It provides a player and objects for the beats and actions. 
We use RunService to tap into the game’s render loop through its RenderStep event. The RenderStep event gives us how many seconds have passed for each render frame. Typically, Roblox will lock the frame rate to 60 frames per second.

-- module script
-- add to ReplicatedStorage
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local module = {}

local CutSceneActionType = {
	Animation = 1,
	Emote = 2,
	Dialog = 3,
	Music = 4,
	Sound = 5
}

local CutSceneState = {
	Waiting = 1,
	Playing = 2,
	Paused = 3,
	Stopped = 4,
	Done = 5
}

local ActionState = {
	Waiting = 1,
	Started = 2,
	Done = 3
}

-- CutScene Beat class
CutSceneBeat = {}
function CutSceneBeat:new()
	local o = {}
	o.Name = "CutSceneBeat"
	o.Start = 0
	o.Next = nil
	o.Actions = {}
	self.__index = self
	return setmetatable(o, self)
end

function CutSceneBeat:AddAction(action)
	table.insert(self.Actions, action)
end

function CutSceneBeat:Play(seconds)
	local waitingActions = {}
	for i, action in ipairs(self.Actions) do
		local waiting = action.State == ActionState.Waiting
		if waiting then
			table.insert(waitingActions, action)			
		end
	end	

	for i, action in ipairs(waitingActions) do	
		-- execute action by type
		action.State = ActionState.Started
		if action.Type == CutSceneActionType.Animation then
			self:TriggerAnimation(action)
		elseif action.Type == CutSceneActionType.Emote then
			self:TriggerEmote(action)
		elseif action.Type == CutSceneActionType.Dialog then
			self:TriggerDialog(action)
		elseif action.Type == CutSceneActionType.Music then
			self:TriggerMusic(action)
		elseif action.Type == CutSceneActionType.Sound then
			self:TriggerSound(action)
		end
	end
end

function CutSceneBeat:TriggerAnimation(action)
	local camera = workspace.CurrentCamera
	camera.CameraType = Enum.CameraType.Scriptable
	camera.CFrame = action.StartPosition
	local cameraMovement = {
		CFrame = action.EndPosition
	}
	local tweenInfo = TweenInfo.new(action.Duration)
	local tween = TweenService:Create(camera, tweenInfo, cameraMovement)
	tween:Play()
end

function CutSceneBeat:TriggerEmote(action)
	print('trigger emote')
end

function CutSceneBeat:TriggerDialog(action)
	print('trigger dialog')
end

function CutSceneBeat:TriggerMusic(action)
	print('trigger music')
end

function CutSceneBeat:TriggerSound(action)
	print('trigger sound')
end

-- CutScene class
CutScene = {}
function CutScene:new(o)
	local o = {}	
	o.Name = "CutScene"
	o.Beats = {}
	o.State = CutSceneState.Waiting
	o.Position = 0
	o._DeltaTotal = 0
	
	self.__index = self
	return setmetatable(o, self)
end

function CutScene:AddBeat(beat)
	self.Beats[beat.Start] = beat
end

function CutScene:Play()
	self.State = CutSceneState.Playing
end

function CutScene:onRenderStep(deltaTime)	
	if self.State == CutSceneState.Playing then	
		self._DeltaTotal = self._DeltaTotal + deltaTime
		self.Position = math.floor(self._DeltaTotal)

		local CurrentBeat = self.Beats[self.Position]	
		if CurrentBeat then
			CurrentBeat:Play(self.Position)			
		end
	end
end

-- CutScene class
CutScenePlayer = {}
function CutScenePlayer:new(o)
	o = o or {}
	setmetatable(o, self)
	self.__index = self

	self.Camera = nil
	self.CutScene = nil
	return o
end

function CutScenePlayer:Play()
	if self.CutScene then
		self.CutScene:Play()
	end
end

function CutScenePlayer:Pause()
	if self.CutScene then
		self.CutScene:Pause()
	end
end

function CutScenePlayer:Resume()
	if self.CutScene then
		self.CutScene:Resume()
	end
end

function CutScenePlayer:onRenderStep(deltaTime)
	if self.CutScene then
		self.CutScene:onRenderStep(deltaTime)
	end
end

function createAction(params)
	local action = {
		Type = params.Type,
		StartTime = params.StartTime,
		Duration = params.Duration,
		StartPosition = params.StartPosition,
		EndPosition = params.EndPosition,
		State = ActionState.Waiting
	}
	return action
end

module.CutScene = CutScene
module.CutSceneBeat = CutSceneBeat
module.createAction = createAction
module.CutSceneActionType = CutSceneActionType
module.CutScenePlayer = CutScenePlayer

return module

Next we’ll set up our cutscene in a LocalScript that moves the camera through several positions using invisible parts. Using these parts, we can reference their CFrame to create the camera track that our cutscene follows.

-- LocalScript Example
-- Add to StarterPlayerScripts
local RunService = game:GetService("RunService")
local localPlayer = game.Players.LocalPlayer
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CutSceneService = require(ReplicatedStorage:WaitForChild("CutsceneService"))

local CutScene = CutSceneService.CutScene
local CutSceneBeat = CutSceneService.CutSceneBeat
local createAction = CutSceneService.createAction
local CutSceneActionType = CutSceneService.CutSceneActionType
local CutScenePlayer = CutSceneService.CutScenePlayer

local function startCutscene()
	-- example
	local cutscene = CutScene:new()

	-- setup test cutscene beats & actions
	local beat = CutSceneBeat:new()
	beat.Start = 0
	local action = createAction({
		Type = CutSceneActionType.Animation,
		StartTime = beat.Start,
		Duration = 3,
		StartPosition = workspace.scene1.CFrame,
		EndPosition = workspace.scene2.CFrame
	})
	beat:AddAction(action)		
	cutscene:AddBeat(beat)	
	
	local beat2 = CutSceneBeat:new()
	beat2.Start = 5
	local action2 = createAction({
		Type = CutSceneActionType.Animation,
		StartTime = beat2.Start,
		Duration = 3,
		StartPosition = workspace.scene2.CFrame,
		EndPosition = workspace.scene3.CFrame
	})
	beat2:AddAction(action2)		
	cutscene:AddBeat(beat2)		
	
	local beat3 = CutSceneBeat:new()
	beat3.Start = 10
	local action3 = createAction({
		Type = CutSceneActionType.Animation,
		StartTime = beat3.Start,
		Duration = 5,
		StartPosition = workspace.scene3.CFrame,
		EndPosition = workspace.scene4.CFrame
	})
	beat3:AddAction(action3)		
	cutscene:AddBeat(beat3)
	
	-- setup cutscene runner
	local runner = CutScenePlayer:new()
	runner.Camera = workspace.CurrentCamera
	runner.CutScene = cutscene

	RunService.RenderStepped:Connect(function (delta)
		runner:onRenderStep(delta)
	end)	

	-- start cutscene
	runner:Play()
end

local function onCharacterAdded(character)
	startCutscene()
end

localPlayer.CharacterAdded:connect(onCharacterAdded)

What’s next

Now you have a working example of how to make Roblox cutscenes. Your game can trigger these cutscenes in all sorts of ways.

Try triggering them by:

  • When the player joins the game
  • Player touches a part
  • Current round is over or just starting

Check out my guide on how Roblox games work if you’re not sure how to create these triggers.

Please join my email newsletter so you don’t miss out on any future guides!

Thank you for reading and stay curious!

Leave a Reply