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.
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
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.
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
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 pcall
s, 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
.
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()
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.
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.
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
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
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
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 Keyframe
s 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 Keyframe
s 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 KeyframeRecorder
s 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
Keyframe
s for our AnimationA
Keyframe
is the current state of an animation at a given timeMethods that will be used:
- "Add Keyframe" methods
- push()
- append()
- "getLast" methods
- getLastPosition()
- getLast()
- "Add Keyframe" methods
Adding Keyframes
push()
is used for adding keyframesappend()
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()
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()
Direction
is an alias for the boolean
type that defines whether something is moving or facing either Forward
or Backward
true
isForward
false
isBackward
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
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 Keyframe
s 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)
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()