Skip to main content

Recorder Program

This is a somewhat thorough overview how to use a RecorderProgram in the manipulation of the API to create Animations for our Ride.

We will also go a bit over the coaster Project Template, so please read the Project Template tutorial if you haven't already.

As it is designed primarily for the simulation and recording of roller coasters this will also be coaster focused. Later "intermediate" tutorials will go over how to create other types of "track-based" rides (or flat rides).

What Is a Recorder Program?

A Recorder Program is a script used to simulate your coaster and create an Animation.

note

Think of the plugin as a creative framework for simulating roller coasters and other track-based rides.

The program must be a ModuleScript that returns a function. It takes one argument, which is the api passed in. Your script should something like:

return function(api)
-- program goes here
end
TheEpicTwin says

Why Animations?

You may be thinking, why do I have to record an "animation"? Why can't I have my ride simulate in real time?

With a multiplayer game such as Roblox, one problem you will run into is syncing. That is, in the case of a ride system, making sure every player or client can see the ride at the same position at the same time.

Simulating it server-side is expensive for the server as it will have to replicate the position of the train to all clients every frame. With Models in Roblox making up multiple parts, that's a lot for the server to update. The server also runs at a different interval than a client's frame rate which also makes the ride look choppy.

This leaves the client to update the ride model and the server to just handle syncing, but you still have to have a system to make sure all the clients are on the same page. An easy way of doing so is to have the clients sync when the ride starts. For example, a train being released from the station. However, differing frame rates make client-side updating tricky as, though each player can start the same, they could end up later de-synced. From here, you need to make sure all the clients sync back up again at a certain point which could be stopping back in the station.

Finally, you could have one client be the "main" which syncs all others. This could work, but I honestly never got to this point.

Instead, I sought a system where you need to "pre-cache" the positions so that all you need to know is when a ride starts, stops, and how long it is. It's much better than working out edge cases with real-time simulation.

TL;DR: Server-client syncing

Running a Recorder Program

  • Select ModuleScript

  • Click RunProgram

  • Done

  • Alternatively, click Run Previous Program

  • If there are any errors, look in the output

API Overview

Before going into writing a RecorderProgram, it's helpful if we get familiar with the layout and quirks of the API.

A link can be found above (in Recorder API) or right here.

The api is passed into your Recorder Program's function under the first argument.

info

Why pass the API to this function instead of injecting in the script environment as global variables?

Yes, we could use genv and setenv to add global variables and reduce the boilerplate needed to import all the variables.

However, Roblox Studio is not aware of these new variables and will flood the Script Analysis with warnings.

It's a tradeoff and I don't want to flood the Script Analysis as it's important for debugging.

Additionally, to make using it easier, the following patterns and classes have been implemented:

  • Result
  • Option
  • Builder Pattern

Result

API

The Result class is inspired by the Result enum from the Rust programming language. The Rust API can be found here

An alternative to Lua pcalls, this is used to better handle errors by delegating error handling up the call/return stack in a recoverable manner. It also explicitly defines the function as "errorable", forcing the user to handle the error before getting the returned value.

A Result can either be Ok or an Err or error.

To get the value from an Ok Result.

local result = Result.ok(123)
local value = result:unwrap()

print(value) -- prints '123'
local result = Result.err("this is invalid!")
local value = result:unwrap() -- errors with 'Attempted to unwrap an Err value: this is invalid!'
local result = Result.err("this is invalid!")
if result:isErr() then
warn(("Result was an err: %s"):format(result:unwrapErr()))
end

Option

Also inspired by the rust language, you will mostly see this where a value can either be something or nothing.

It avoids errors where a value can be nil.

API

local option = Option.some(123)
local value = option:unwrap()
local option = Option.none()
local value = option:unwrap() -- errors

Builder Pattern

https://en.wikipedia.org/wiki/Builder_pattern

Many of the classes feature a builder pattern constructor. Anything that has the postfix Builder is a builder pattern.

Most will implement the following methods:

build

Builder:build()

Will create a new class of whatever the builder is building. This is useful if you clone the builder after editing it's properties.

build() will always return a Result, so be prepared to handle it, otherwise you will run into errors.

clone

Builder:clone()

Clones the builder. A select few implement this method.

You use the Builder pattern by chaining multiple methods together.

For example, take the following struct Object:

type Object = {
number: number,
boolean: boolean,
string: string,
table: table,
function: function,
}

If you were to just use the normal dot operator, it would look like this:

local object = Object.new()
object.number = 1.234
object.boolean = true
object.string = "Hello"
object.table = {
first = 1,
second = 2,
}
object.function = function()
print("Hello world!")
end

This is fine and all, but Lua is a duck typed language nor does not force type checking. You can pass anything to any of the values and it will still accept it, nor throw an error for the wrong type.

This could have consequences such as logic errors in our code and could be a pain to debug.

The builder pattern version, looks like this:

local object = ObjectBuilder.new()
:withNumber(1.234)
:withBoolean(true)
:withString("Hello")
:withTable({
first = 1,
second = 2,
})
:withFunction(function()
print("Hello world!")
end)
:build() -- returns a Result
:unwrap() -- unwrap the Result

It works by chaining together method calls until we have setup all of our data.

The upside is that we can type check within our Builder class and throw an error much earlier if needed.

We can also force required fields and error if one is not properly set.

Get the Ride Data

As mentioned in the Project Template tutorial, the game's RideData folder contains data used by each ride in order to be setup and run.

The Game RideData Folder can be found in ReplicatedStorage.

local rideDataFolder = ReplicatedStorage:FindFirstChild("RideData")

After we get the main folder, we need to get subfolder for the ride we are working on.

RecorderProgram templates that are created with the project usually have a StringValue named RIDE_NAME as a child.

The Value is the name of our project and how we find our RideData.

We can add this at the top of our script as it's a constant.

local RIDE_NAME = script.RIDE_NAME.Value

Finally, to get our ride's RideData, we now do:

local rideDataFolder = ReplicatedStorage:FindFirstChild("RideData")
local rideData = rideDataFolder:FindFirstChild(RIDE_NAME)

Create Tracks

Our Tracks Folder can be accessed by:

local tracksFolder = rideData:FindFirstChild("Tracks")

API

local cframeTrack = api.cframeTrack

Creating from an Instance

local mainTrackInstance = tracksFolder:FindFirstChild("Main")
local mainTrack = cframeTrack.createFromInstance(mainTrackInstance):unwrap()
note

Here, we can find our first instance of a function returning a Result, in this case returned by cframeTrack.createFromInstance. This is because there is a possibility that an Instance can not be setup correctly.

For all of our Results, we will just :unwrap() them, even if they are an Err, as we want our program to error immediately in such a case.

Each track has two properties:

  • length: number
  • name: string

Length

The length property is the total length of the Track.

Name

The name property is self-explanatory, it's the name of our Track.

Unless otherwise changed later in the script, when a CFrameTrack is created from an Instance (using the createFromInstance function or fromInstance constructor), the name will be the Name of the Instance used to construct the Track.

For our Main track, since the Instance we used to create it was named Main, our track will also be named Track.

print(mainTrack.name) --> outputs 'Main'

This will be useful later when we export out Animation.

note

It's important to note that each Keyframe has a track property which references the current track it is on.

By default in each simulator, it uses the name property of the track, but you can override this and set a different name usually via a trackName property or a withTrackName() method.

See the Recorder API Docs for more information.

Create Track Sections Data

A member of the PhysicsSimulator API, TrackSectionsData defines how to affect physics for a train for each position on a track.

As in this tutorial, we are setting up creating an animation for a coaster, we will be using physicsSimulator and TrackSectionsData anyway.

local physicsSimulator = api.physicsSimulator

local TrackSectionsData = physicsSimulator.trackSectionsData.TrackSectionsData
local mainTrackSectionsData = TrackSectionsData.fromInstance(mainTrackSectionsDataInstance):unwrap()

Create Position Offsets

PositionsOffsets is an array of positions

Used primarily in train physics.

Getting our model.

local modelsFolder = rideData:FindFirstChild("Models")
local trainModel = modelsFolder:FindFirstChild("TrainModel")

Different ways of getting our position offsets.

  • CFrameTrain
  • PositionOffset Util

Additionally, you can just create your own using a table, though the other options are more accurate to the model:

local trainWheelSetOffsets = {0, 1, 2, 3, 5, 8}

CFrameTrain

CFrameTrain is a port of the CFrameTrain class in the Framework, with the same functionality, to get the final, World-Space positions of the TrainModel from a CFrameTrack.

API

local cframeTrain = api.cframeTrain

Setup

local mainCFrameTrain = cframeTrain.fromInstance(trainModel):unwrap()
local trainWheelSetOffsets = mainCFrameTrain.wheelSetOffsets

PositionOffset Util

API

local positionOffsets = api.positionOffsets

Setup

local trainWheelSetOffsets = positionOffsets.getFromTrainModel(trainModel):unwrap()

AdjustMidPoint

If you need to adjust where you want the "middle" of the position is, you can use the adjustMidPoint() function of the positionOffsets utility.

Keep in mind that the MidPoint in the in-game version must also match the one you used to record so that it looks accurate.

-- front of the train
trainWheelSetOffsets = positionOffsets.adjustMidPoint(
trainWheelSetOffsets,
0
)

-- middle of the train
trainWheelSetOffsets = positionOffsets.adjustMidPoint(
trainWheelSetOffsets,
0.5
)

-- end of the train
trainWheelSetOffsets = positionOffsets.adjustMidPoint(
trainWheelSetOffsets,
1
)

Globals

Record Interval

The record interval is our deltaTime that we want to simulate our physics and record our animation at.

For our default value, we will simulate and record at 1/24 seconds or 24 frames per second.

local RECORD_INTERVAL = 1/24

1/24 is considered the lowest interval that a frame can be shown at such that multiple played in a row can display motion while not looking choppy (Citation Needed).

We also choose this value in order to reduce the amount of data.

note

In the future, you will be able to simulate physics at a different interval than you would for your animation then convert it to your animation RecordInterval.

In the meantime, you can mix and match different time intervals and it will be handled correctly.

Gravity

Gravity, in studs per second, is used to affect the constant acceleration that affects the train due to Earth's gravity force.

local GRAVITY = 56.25
A Brief History of the Gravity value

You may notice that our gravity value, 56.25, is, in itself, a very random number.

The workspace's default acceleration is 196.2. However, this was designed with Roblox's toy-like physics in mind. If you were to use this value, it would make the ride zoom around the track too quickly and slow too quickly in a way that resembles a Hot Wheels track.

If you assume the unit conversion of studs to feet is 1 to 1 (1 stud equals 1 foot), this value would be 32.17, which is roughly greater than half of our chosen value.

This would be great if games actually used this scale.

However, a Robloxian body (assuming the blocky R6), while roughly a teenage human height if we use 1:1 studs to feet, is much wider than a normal human. In order to make the scaling look good, coaster trains must be wider to accommodate. To compensate for this, coaster track rails are thicker, usually 1 stud or 1 foot in diameter, which leads to the actual track itself to be much, much bigger.

Overall, the scaling of Roblox coasters is not equal to real life.

Thus, to fit this awkward scaling, gravity has been adjusted (through trial and error and eyeballing) to be roughly 1.5 times normal earth's gravity. (Please don't ask how I actually got this)

If you find that this value does not fit your game's scaling, you can definitely change it.

Friction

Friction is a force that counteracts the movement force of an object.

The value we will use for our train's friction is 0.0225.

local FRICTION = 0.0225
An Even Briefer History of the Friction value

0.0225

This was mainly found through trial and error and feel.

I have no clue how I ended up with this number.

Again, if you find that this value does not fit your game's scaling, you can definitely change it.

KeyframeRecorder and Keyframes

note

If you are following along through the RecorderProgram in order, you will notice that we are holding off on discussing the "what is" and the "use of" AnimationRecorder and TriggerRecorder.

As manipulating Keyframes are an important part of creating an animation, AnimationRecorder and TriggerRecorder will be better explained towards the end after you have good grasp of how to generate Keyframes using the different simulators.

As such, we will also be skimming over the pre-written variables (START_POSITION, END_POSITION, TWEEN_START_OFFSET, TRACK, TRACK_SECTIONS_DATA) until later, but these should be self explanatory.

Just know that AnimationRecorder helps to reduce a majority of the boilerplate with creating KeyframeRecorders and overall recording Animations.

AnimationRecorder also goes over the creation and exporting of Animations to our RideData, so it's better to leave that towards the end.

  • Holds all of our Keyframes for our Animation

  • A Keyframe is the current state of an animation at a given time

  • Methods that will be used:

    • "Add Keyframe" methods
      • push()
      • append()
    • "getLast" methods
      • getLastPosition()
      • getLast()

Adding Keyframes

  • push() is used for adding keyframes
  • append() is used for adding KeyframeRecorders
  • Simulator's simulate functions return KeyframeRecorders as the overall result of the simulation.

getLast

  • Used when you want to get the last recorded state of our animation
    • Last position, last speed, or just last keyframe

KeyframeBuilder

  • Simulators record after an update
    • Our KeyframeRecorder is empty
    • Thus, the first actual keyframe added will not start at position X, but at position X + Y
    • StartKeyframe is needed
local keyframe = KeyframeBuilder.new()
:withPosition(START_POSITION)
:withTrack(TRACK.name)
:withTrackSpeed(0)
:withModelDirection(true)
:withLength(RECORD_INTERVAL)
:withTransitionType(TransitionType.Linear)
:build()
:unwrap()
note

This is also our first instance of the Builder Pattern being used via the creation of the Keyframe.

It's a lot of lines (so a lot of variables), but don't be afraid to look at the documentation.

When we build() our object, it will return a Result that we will unwrap in a similar fashion.

Finally, once we created our Keyframe we will push() it

keyframeRecorder:push(keyframe)

fromTimeAmount

Returns a KeyframeRecorder whose length is given by the length argument. The returned KeyframeRecorder contains the minimum number of Keyframes to have it's length calculated, which is 2.

The first argument is a Keyframe whose length is overwritten to the given length. The original keyframe is then cloned as the second keyframe.

local timeAmountRecorder = KeyframeRecorder.fromTimeAmount(keyframe, 20) -- returns a KeyframeRecorder of length `20`

To add:

keyframeRecorder:append(timeAmountRecorder)

PhysicsState

A PhysicsState is a simple struct that holds the current state of a ride or train's physics.

It is used in many of the Simulators for keeping the current speed and move direction.

type PhysicsState = {
currentSpeed: number,
currentDirection: boolean,
}
local physicsState = api.createPhysicsState()
note

Direction is an alias for the boolean type that defines whether something is moving or facing either Forward or Backward

  • true is Forward
  • false is Backward

You'll see this used in Physics and ModelDirection

Physics Simulator

The PhysicsSimulator API is designed to simulate any track-based physics such as coasters.

We have already setup the API import earlier when creating the TrackSectionsData, but here's how to get it again:

local physicsSimulator = api.physicsSimulator

There is currently one version of the physics simulator with a future one in the planning stages.

local physicsSimulatorV1 = physicsSimulator.v1
TheEpicTwin Says

The algorithm behind the simulation of coaster physics is relatively simple in a way.

It's not "accurate" per say, but it looks good enough to the human eye, and that's good enough for me.

Simulate

The simulate function for PhysicsSimulatorV1 needs two arguments:

  • simulationState: PhysicsV1SimulationState
  • physicsState: PhysicsState

PhysicsState was mentioned earlier, but what's the simulationState?

SimulationState

As always, it is recommended to use the Builder pattern constructor when present.

The SimulationState is a good example of how many properties need to be set.

local physicsSimulationState = physicsSimulatorV1.SimulationStateBuilder.new()
:withCFrameTrack(mainCFrameTrack)
:withTrackSectionsData(trackSectionsData)
:withCurrentPosition(keyframeRecorder:getLastPosition())
:withModelDirection(true)
:withClampPositionToTrackLength(true)
:withGravity(GRAVITY)
:withFriction(FRICTION)
:withTimeInterval(RECORD_INTERVAL)
:withStopPosition(0, "Stop", false)
:withStopWhenDirectionChanges() -- stop immediately when valleying
:withUpdateType(PhysicsUpdateType.WithPositionOffsets(trainWheelSetOffsets))
:build() -- builds the simulation state
:unwrap()

Input our initial state:

  • CFrameTrack
  • TrackSectionsData
  • CurrentPosition
  • ModelDirection
  • ClampPositionToTrackLength

Input our globals:

  • GRAVITY
  • FRICTION
  • RECORD_INTERVAL

Input how we want it to stop:

  • StopPosition
  • StopWhenPositionChanges

Input who we want it to update:

  • PhysicsUpdateType

PhysicsUpdateType

PhysicsUpdateType basically tells the simulator how to update the physics.

More info and API can be found here

  • ConstantAngle
  • WithPositionOffsets
  • WithPositionOffsetsBackwards

PhysicsUpdateType has some weird stylistic choices because it's designed to be initialized similar to Rust Enum in that they are Enums with data.

ConstantAngle

Defines a constant angle which the train be moving will be affected by gravity

A steeper angle equals more acceleration from gravity is applied to our train.

math.sin(angle) * GRAVITY_ACCELERATION

-- defines a ConstantAngle update type with an angle of 90 degrees (or vertical)
local updateType = PhysicsUpdateType.ConstantAngle(math.rad(90))

WithPositionOffsets

Determines the way in which gravity accelerates a train on the track using the PositionOffsets.

Gets the current vertical angle on the track.

Averages current vertical angle for all the PositionOffsets

-- defines a WithPositionOffsets update type with our positionOffsets `trainWheelSetOffsets`
local updateType = PhysicsUpdateType.WithPositionOffsets(trainWheelSetOffsets))

WithPositionOffsetsBackwards

Similar to WithPositionOffsets, but automatically reverses the offsets in a way similar to reversing a train.

-- defines a WithPositionOffsetsBackwards update type with our positionOffsets `trainWheelSetOffsets`
local updateType = PhysicsUpdateType.WithPositionOffsetsBackwards(trainWheelSetOffsets))

Spring Simulator

Simulates a dampened spring as it tries to reach a set position.

In our case, we mostly use this to simulate accurately slowing a complete stop.

To access:

local springSimulator = api.springSimulator

SimulationState

local springSimulationState = springSimulator.SimulationStateBuilder.new()
:withCFrameTrack(TRACK)
:withClampPositionToTrackLength(true)
:withCurrentPosition(keyframeRecorder:getLastPosition())
:withAngularFrequency(1)
:withDampingRatio(1)
:withGoal(END_POSITION)
:withMaxSpeed(keyframeRecorder:getLast().trackSpeed) -- clamp the speed to the current track speed so that it doesn't accelerate
:build()
:unwrap()

Simulate

local springRecorder = springSimulator.simulate(springSimulationState, physicsState)

keyframeRecorder:append(springRecorder)

Easing Simulator

The EasingSimulator simulates different easing functions. Supported functions are determined by TweenService, hence the name.

In previous versions before the SpringSimulator was implemented, this would be also be used to hack-ily simulate a train stopping in a station. Now, it's used to script movement in a way similar to Roblox Animations.

local easingSimulator = api.easingSimulator

SimulationState

local easingSimulationState = easingSimulator.SimulationStateBuilder.new()
:withCFrameTrack(TRACK)
:withEasingStyle(Enum.EasingStyle.Sine)
:withEasingDirection(Enum.EasingDirection.Out)
:withCurrentPosition(keyframeRecorder:getLastPosition())
:withModelDirection(true)
:withTimeInterval(RECORD_INTERVAL)
:withClampPositionToTrackLength(true)
:withDistance(keyframeRecorder:getLastPosition() - END_POSITION)
:withDuration(3)
:build()
:unwrap()

Simulate

local easingRecorder = easingSimulator.simulate(easingSimulationState, physicsState)

keyframeRecorder:append(easingRecorder)

TriggerRecorder

Triggers are time positions in the Animation which, when passed, are used to activate effects.

The TriggerRecorder holds a list of these for our Animation.

The API can be accessed via:

local TriggerRecorder = api.TriggerRecorder

You can create a new TriggerRecorder via:

local triggerRecorder = TriggerRecorder.new()

To add a trigger, use the add() method:

triggerRecorder:add("Trigger1", 100)

To add a trigger at the KeyframeRecorder's current length:

triggerRecorder:add("Trigger2", keyframeRecorder:getLength())

Get Passed Triggers

The getPassedTriggers utility goes through all the Keyframes in the Animation looking for where it has passed a given position and adding a trigger at that time position.

Instead of stopping a simulation once it reaches the given position, then adding the trigger, you can use getPassedTriggers to look through after everything is done.

It's recommended that you use the GetPassedTriggersBuilder builder pattern.

local GetPassedTriggersBuilder = api.GetPassedTriggersBuilder

Passing a CFrameTrack is required as:

  • The track name is used to narrow the track it looks through.
  • The track length is used when hasPassedTrackEnd is flagged during this Keyframe.
local passedTriggerRecorder = GetPassedTriggersBuilder.new()
:withKeyframeRecorder(keyframeRecorder)
:withCFrameTrack(mainCFrameTrack)
:withTrigger("Trigger1", 100)
:withTrigger("Trigger2", 200)
:build()
:unwrap()

getPassedTriggers and GetPassedTriggersBuilder return a TriggerRecorder containing all of the newly found triggers.

To add it to our original TriggerRecorder, will use the combine function.

triggerRecorder:combine(passedTriggerRecorder)
note

TriggerRecorder uses combine instead of append as the name for adding the triggers from a different TriggerRecorder because order is not preserved between triggers.

Triggers are organized by the time position where they are at.

AnimationData and Exporting

It is recommended that you use the AnimationRecorder for the creation and exporting of Animations, but it's useful to know a bit more about AnimationData.

AnimationData is an intermediate representation of the final Animation.

cframeTracks is a Dictionary or Map of strings to CFrameTracks.

Required argument.

AnimationData will error if a track is not found for a Keyframe's track property.

Track is used for cached length purposes

Example:

local TRACKS = {
["Main"] = mainCFrameTrack,
[secondCFrameTrack.name] = secondCFrameTrack, -- using the track's name
}
local AnimationData = api.AnimationData
local animationData = AnimationData.create(keyframeRecorder, triggerRecorder, cframeTracks)
:unwrap()
local AnimationDataBuilder = api.AnimationDataBuilder
local animationData = AnimationDataBuilder.new()
:withKeyframeRecorder(keyframeRecorder)
:withTriggerRecorder(triggerRecorder)
:withTracks(TRACKS)
:withTrack(otherCFrameTrack) -- you can also individually add tracks
:build()
:unwrap()

KeyframeRecorder and TriggerRecorder are required

local animationModule = animationData:toModuleScript():unwrap()
animationModule.Name = "MainAnimation"
animationModule.Parent = rideData.Animations

toModuleScript will error if it cannot create a ModuleScript from the animation. Roblox has a character limit for strings of 200,000. Setting the Source of a ModuleScript requires creating a string that will then be set to it. If the root module reaches this it will error.

Animation Recorder

The AnimationRecorder is an experimental (though recommended) helper class used for generating animations for a ride project. It is designed to gradually and linearly record an animation in steps.

The functions you will be mostly using are:

  • recordSegment()
  • export()
  • recordAndExportSegment()