Skip to content

Tutorial: Writing a Recorder Program

Glossary

What Is a Recorder Program?

The Recorder Program is a script used to simulate your coaster and create a Train Animation.

Think of the plugin as a creative framework for simulating roller coasters.

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

If you created a new project using Initialize Project, you should have been given a template script that will allow you to get up and running quickly. It should already have everything from API import, start, simulation, and export. The following should explain what each of those do, especially if you are creating one from scratch.

Before we get into the specifics, first a note on how the API is designed.

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

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

Finish

Will build, returning the object, and destroy the builder. This is useful for single use builders.

Clone

Clones the builder. A select few implement this method.

Destroy

The deconstructor. Destroys the builder.

Imports (API)

As much as I would have liked to bake it into the script or have it as global variables, everything you need is given to you in one big table. I did the due to the fact that Roblox Studio does not offer an option to turn off lints for certain keywords and you would have seen annoying errors in Script Analysis.

Program API

--- API
-- name : type

api {
    Compiler [Deprecated] : TrainAnimationCompiler;
    Recorder : TrainAnimationRecorder;

    CFrameTrain : CFrameTrain;
    CFrameTrack : {
        CFrameTrack : CFrameTrack,
        PointToPointCFrameTrack : PointToPointCFrameTrack,
        PointToPointCFrameTrack2 : PointToPointCFrameTrack2,
        TurntableCFrameTrack : TurntableCFrameTrack,
        MovingSectionCFrameTrack : MovingSectionCFrameTrack,
        TweenedPointsCFrameTrack : TweenedPointsCFrameTrack,

        CreateFromInstanceData : function (
            trackData : Instance
        ) -> CFrameTrack;
    };

    CreateCFrameTrack : function (
        trackData : Instance
    ) -> CFrameTrack;
    TrackSectionsData : TrackSectionsData;
    TrackType : TrackType;
    TypeSection : TypeSection;

    DoCompile [Deprecated] : function (
        compiler : TrainAnimationCompiler,
        trackSectionsData : TrackSectionData,
        stopPosition : number,
        ?stopExactly : boolean = compiler.StopExactlyAtStopPosition,
        ?numPasses : number = 0
    ) -> nil;
    ExportAnimation [Deprecated] : function (
        compiler : TrainAnimationCompiler,
        parent : Instance,
        name : string
    ) -> nil;
}

Compiler is Deprecated, but still accessible. I found it to be too simple for what I wanted to do and difficult to update.

Recorder API

Recorder is the newest way of creating TrainAnimationPlayers and thus the currently supported one.

Many of the classes in the Recorder API have constructors that use the Builder pattern.

--- Recorder
-- name : type

Recorder {
    KeyframeData : KeyframeData;
    KeyframeDataBuilder : KeyframeDataBuilder;

    PhysicsState : PhysicsState;

    KeyframeRecorder : KeyframeRecorder;
    TriggerRecorder : TriggerRecorder;

    GetPassedTriggers : function (
        recorder : KeyframeRecorder,
        tracksData : Dictionary<CFrameTrack>,
        triggerData : TriggerInfo []
    ) -> TriggerRecorder;

    PhysicsSimulator : PhysicsSimulator;
    EasingSimulator : EasingSimulator;

    Exporter : Exporter;

    KEYFRAME_TRANSITION_TYPE : KEYFRAME_TRANSITION_TYPE;
}

To get setup quickly, copy and paste this into your script:

local Recorder = api.Recorder

local PhysicsState = TrainAnimationRecorder.PhysicsState

local KeyframeRecorder = TrainAnimationRecorder.KeyframeRecorder
local TriggerRecorder = TrainAnimationRecorder.TriggerRecorder

local PhysicsSimulator = TrainAnimationRecorder.PhysicsSimulator
local EasingSimulator = TrainAnimationRecorder.EasingSimulator

local GetPassedTriggersBuilder = TrainAnimationRecorder.GetPassedTriggersBuilder

local Exporter = TrainAnimationRecorder.Exporter

local CFrameTrain = api.CFrameTrain
local CFrameTrack = api.CFrameTrack
local CreateCFrameTrack = api.CreateCFrameTrack
local TrackSectionsData = api.TrackSectionsData
local TrackType = api.TrackType
local TypeSection = api.TypeSection;

Setup

Setup the Track

CFrameTrack contains the positions for a Track for every position from 0 to it's track length. It can also return the vertical angle for a track position which is used for physics.

There are many different types of CFrameTracks. Each have different properties and uses, but they all inheirit the base class CFrameTrack.

Track Classes

PointToPoint

The oldest type of track and used for most kinds of normal coaster track. Each point must be the same distance from one another and is defined in the value DistanceBetweenPoints.

Discouraged in favor of PointToPoint2.

PointToPoint2

Recommended for most kinds of normal coaster track.

Compared to the first version, each point can be any distance away from another. Uses a hash to accomplish this with a minimal increase in computation from version 1.

Turntable

Used to define turntables or places where the train is rotated along one axis. You can also use this for tilt tracks. Must define 1 point which is the position it rotates around.

MovingSection

Useful for vertical lifts, drop tracks, and transfer tracks. Any track where the train is on a section of track that moves.

TweenedPoints

Useful for trackless dark ride type rides when you want to move the train in a particular way.

SpacekBezier (Not Implemented)

Instead of compiling the points, you can use the bezier spline itself. With this method, the track would most likely be smoother.

CreateCFrameTrack

CreateCFrameTrack is a function that takes the Instance data and, using the TrackClass value in it, creates and returns a CFrameTrack using the specified Track class.

local cframeTrack = CreateCFrameTrack(instanceData)

Tracks Data Table

To export, you need a table (a dictionary) containing all of the CFrameTracks used in the TrainAnimation. The indexes must match the name of the track used when simulating.

local tracksData = {
    Main = cframeTrack;
    ["Track Name 2"] = cframeTrack2;
}

Setup the TrackSectionsData

TrackSectionsData defines where track physics such as acceleration and deceleration are applied along a track. One is required to use the PhysicsSimulator.

There are two ways to create a TrackSectionsData:

Using a table

local trackSectionsDataTable = {
    DefaultTrackType = {
        Speed = 10;

        Type = 0;
        Acceleration = 1;
        Deceleration = 1;
        UsePhysics = true;
        ClampToTargetSpeed = false;
    },
    TypeSections = {
        {
            Start = 0;
            End = 1000;

            Speed = 10;

            Type = 1;
            Acceleration = 1;
            Deceleration = 1;
            UsePhysics = true;
            ClampToTargetSpeed = true;
        },
        -- ...
    }
}

local trackSectionsData = TrackSectionsData.new(trackSectionsDataTable)

Using an Instance

local trackSectionsData = TrackSectionsData.FromInstanceData(instanceData)

The Instance Data is laid out similarly to the table version.

TrackSectionsData : Instance
    DefaultTrackType : Instance
        Type : IntValue
        Acceleration : NumberValue
        Deceleration : NumberValue
        UsePhysics : BoolValue
        ClampToTargetSpeed : BoolValue
    TypeSections : Instance
        [ANY NAME] : Instance
            Start : NumberValue
            End : NumberValue
            Type : IntValue
            Acceleration : NumberValue
            Deceleration : NumberValue
            UsePhysics : BoolValue
            ClampToTargetSpeed : BoolValue
        ...
        []

Setup the Train PhysicsState

CFrameTrain contains the CFrame position data for a train.

PhysicsState contains the current physics state for the train. This includes the current speed and move direction. Additionally, with the CFrameTrain, it contains the WheelSet offsets using to calculate physics.

You can utilize your TrainModel in the TrainAnimationPlayer RideData to easily create a CFrameTrain.

The folllowing code should be included in your template.

local rideData = ReplicatedStorage:FindFirstChild(RIDE_NAME .. "RideData")
assert(rideData, "RideData not found! Name = " .. RIDE_NAME .. "RideData")

local trainAnimationPlayersData = rideData:FindFirstChild("TrainAnimationPlayers")


local cframeTrainPlayerData = trainAnimationPlayersData:FindFirstChild("Train1")
local cframeTrainModel = cframeTrainPlayerData:FindFirstChild("TrainModel")

local cframeTrain = CFrameTrain.FromInstanceData(cframeTrainModel)
local physicsState = PhysicsState.new(cframeTrain)

Globals

local TIME_INTERVAL = 1/24  -- to be used in the future
local RECORD_INTERVAL = 1/24

local GRAVITY = 7.5 ^ 2
local FRICTION = 0.125

RECORD_INTERVAL

This is passed into everything that returns a KeyframeRecorder. At the moment, the TrainAnimation only supports a fixed timestep, so varying the record interval is discouraged.

GRAVITY

Used for the PhysicsSimulator

FRICTION

Also used for the PhysicsSimulator

KeyframeRecorder

KeyframeRecorder holds all of the keyframes, KeyframeData, for an animation.

local rideRecorder = KeyframeRecorder.new()

KeyframeData

KeyframeData {
    Position : number,
    TrackSpeed : number,
    Track : string,

    IsTrainBackwards : boolean,
    HasPassedTrackEnd : boolean,
    HasPassedTrackEndDirection : boolean,

    TransitionType : KEYFRAME_TRANSITION_TYPE

    Length : number
}

KEYFRAME_TRANSITION_TYPE

An Enum that determines how to tween between two keyframes. The first keyframe's TransitionType is used.

Useful Methods

Append

Adds the keyframes from one KeyframeRecorder to the end of itself.

keyframeRecorder:Append(
    otherKeyframeRecorder : KeyframeRecorder
) -> nil

Example:

keyframeRecorder:Append(otherKeyframeRecorder)

GetLastTrackPosition

Gets track position value of the last keyframe. Useful for setting up the simulation state's start position.

keyframeRecorder:GetLastTrackPosition() -> number

Example:

local position = keyframeRecorder:GetLastTrackPosition()

AddTimeAmountOfKeyframe

Because varying keyframe lengths is not currently supported, to add a section where the train stays in one spot, you must add the necessary amount of keyframes. This can be a bit tedious. Luckily I added a method to assist with this.

keyframeRecorder:AddTimeAmountOfKeyframe(
    keyframe : Keyframe,
    timeAmount : number,
    timeInterval : number
) -> nil

Example:

keyframeRecorder:AddTimeAmountOfKeyframe(startKeyframe, 30, RECORD_INTERVAL)

TriggerRecorder

TriggerRecorder records all of the triggers for an animation.

A trigger is used to notify when the animation player has passed a certain time stamp, usually the the train has passed a certain positioin on the track. This is useful for scripting events on the ride.

local triggerRecorder = TriggerRecorder.new()

Recording Triggers

Using Recorder Length

This is useful if you have already determined where in your animation you want to add a trigger.

triggerRecorder:Add(
    name : string,
    timePosition : number
)
triggerRecorder:Add("TRIGGER_NAME_HERE", rideRecorder:GetLength())

Using Get Passed Triggers

Instead of stopping a simulation to add a trigger at the time position, you can do it after you have done your simulation.

The GetPassedTriggers function looks through all the keyframes in your KeyframeRecorder and finds the time positions where it passes any trigger in the given list.

GetPassedTriggers (
    recorder: KeyframeRecorder,
    triggerData: TriggerInfo [],
    cframeTrack: CFrameTrack,
    ?trackName: string = cframeTrack.Name,
    ?rangeStart: number = 1,
    ?rangeEnd: number = #recorder.Keyframes
) -> TriggerRecorder

TriggerInfo {
    Name : string,
    Position : number,
}

There is also a Builder pattern implementation that aids in creating your trigger list.

local passedTriggerRecorder = GetPassedTriggersBuilder.new()
    :WithRideRecorder(rideRecorder)
    :WithCFrameTrack(mainCFrameTrack)
    :WithTrigger("TRIGGER_NAME", 100)
    :Finish()

rideRecorder:Append(passedTriggerRecorder)

Start Keyframe

Our animation needs a start keyframe

We can use the KeyframeDataBuilder in order to build a new keyframe to add to our system.

local START_POSITION = 10

local startKeyframe = TrainAnimationRecorder.KeyframeDataBuilder.new()
    :WithPosition(START_POSITION)
    :WithTrack(mainCFrameTrack.Name)
    :WithTrackSpeed(0)
    :WithTrainDirection(true)
    :WithLength(RECORD_INTERVAL)
    :WithTransitionType(TrainAnimationRecorder.KEYFRAME_TRANSITION_TYPE.LINEAR)
    :Finish()

-- set start keyframe
rideRecorder:AddKeyframe(startKeyframe)

Additionally, you can use the BuildKeyframe method of KeyframeRecorder to build and add in one.

rideRecorder:BuildKeyframe()
    :WithPosition(START_POSITION)
    :WithTrack(mainCFrameTrack.Name)
    :WithTrackSpeed(0)
    :WithTrainDirection(true)
    :WithLength(RECORD_INTERVAL)
    :WithTransitionType(TrainAnimationRecorder.KEYFRAME_TRANSITION_TYPE.LINEAR)
    :Finish()

-- no AddKeyframe necessary

Physics Simulator

Simulates physics for a singular track and train. This is how you make your trains move realistically (kinda).

PhysicsSimulator.Simulate(
    simulationState : PhysicsSimulationState,
    physicsState : PhysicsState
) -> KeyframeRecorder, SimulationStopData

In the SimulationState, you can determine where to stop the simulation in the following ways: - Adding a StopPosition: The simulation will stop when it passes the given position. - Stopping When the Direction Changes: Will stop when it's move direction has changed. Useful to prevent valleying.

There are also certain times where it will stop automatically: - MaxValleyTime: When the train valleys, or slows to under a given speed for a certain amount of time, it will be stopped early. This usually won't be fired most of the time as it will reach the MaxSimulationTime before this happens. - MaxSimulationTime: If all else fails, it will stop when the MaxSimulationTime is reached. The default is 600 seconds, or 5 minutes. Please do not set this to math.huge O_O - StoppedByTrackType: If you have a Brake section with it's speed set to zero, when the train's speed reaches zero on this section, the simulation will stop and this will be returned.

Along with a KeyframeRecorder, the Simulate function returns data on how it stopped. The data should be treated like an Enum in the Rust programming language where data can also be attached.

SimulationStopReason {
    Type : string
}

-- the names of these are SimulationStopReason's Type value
PassedStopPosition {
    Name : string,
    Position : number,
    TimeDifference : number,
}

DirectionChanged {
    Direction : boolean
}

StoppedByTrackType {

}

HasValleyed {

}

ReachedMaxCompileTime {

}

Use:

local simulationState = PhysicsSimulator.SimulationStateBuilder.new()
    :WithCFrameTrack(mainCFrameTrack) -- Sets CFrameTrack
    :WithStartPosition(rideRecorder:GetLastTrackPosition()) -- Sets the start position; You can use KeyframeRecorder:GetLastTrackPosition() to get the position of the last Keyframe
    :WithTrackSectionsData(mainTrackSectionsData) -- Sets TrackSectionsData
    :WithTrainDirection(true) -- Sets whether the TrainModel faces forwards or backwards
    :WithPositionClampedToTrackLength(true) -- If true, the position keeps in the range [0, CFrameTrack.Length)
    :WithGravity(GRAVITY) -- Sets Gravity
    :WithFriction(FRICTION) -- Sets Friction
    :WithTimeInterval(TIME_INTERVAL) -- Sets interval for updating physics (not used)
    :WithRecordInterval(RECORD_INTERVAL) -- Sets interval for recording
    :UntilStopPosition(100, "Stop") -- Adds a StopPosition
    :UntilStopPosition(200, "Stop2") -- Adds another StopPosition. You can add as many as you want
    :UntilDirectionChanges() -- Sets StopWhenDirectionChanges to true
    :Finish()

local physicsRecorder, simulationStopReason = PhysicsSimulator.Simulate(
    simulationState,
    physicsState
)

-- checking stop reason
if (simulationStopReason.Type == "PassedStopPosition") then
    if (simulationStopReason.Name == "Stop") then
        print(simulationStopReason.Position)
    elseif (simulationStopReason.Name == "Stop2") then
        print(simulationStopReason.TimeDifference)
    end
else -- it did not stop how we wanted
    warn(("Stop Reason: %s"):format(simulationStopReason.Type))
end

rideRecorder:Append(physicsRecorder)

Easing Simulator

Simulates an easing for a given change in position. Simular styles as in TweenService.

I like using this to smoothly stop a train in the station.

https://github.com/EmmanuelOga/easing

EasingSimulator.Simulate(
    simulationState : EasingSimulationState,
    physicsState : PhysicsState
) -> KeyframeRecorder

Easing Types (Converted to PascalCase):

https://easings.net/

EASING {
    Linear,
    InQuad,
    OutQuad,
    InOutQuad,
    OutInQuad,
    InCubic,
    OutCubic,
    InOutCubic,
    OutInCubic,
    InQuart,
    OutQuart,
    InOutQuart,
    OutInQuart,
    InQuint,
    OutQuint,
    InOutQuint,
    OutInQuint,
    InSine,
    OutSine,
    InOutSine,
    OutInSine,
    InExpo,
    OutExpo,
    InOutExpo,
    OutInExpo,
    InCirc,
    OutCirc,
    InOutCirc,
    OutInCirc,
    InElastic,
    OutElastic,
    InOutElastic,
    OutInElastic,
    InBack,
    OutBack,
    InOutBack,
    OutInBack,
    InBounce,
    OutBounce,
    InOutBounce,
    OutInBounce,
}

Use:

local lastTrackPosition = rideRecorder:GetLastTrackPosition()

local EasingSimulate = EasingSimulator.Simulate
local EasingSimulationStateBuilder = EasingSimulator.SimulationStateBuilder

local tweenRecorder = EasingSimulate(
    EasingSimulationStateBuilder.new()
        :WithCFrameTrack(mainCFrameTrack)
        :WithTweenType(EasingSimulator.EASING.OutSine)
        :WithTimeInterval(RECORD_INTERVAL)
        :WithStartPosition(lastTrackPosition)
        :WithPositionChange(5)
        :WithDuration(3)
        :WithPositionClampedToTrackLength(true)
        :Finish(),
    physicsState
)

Exporter

The Exporter API contains methods to export your animation as data that can be read at your game's runtime.

To use it, you need the following: - The KeyframeRecorder containing all of the keyframes for this animation - The TriggerRecorder containing all of the triggers for this animation. Make sure they positioned appropriately, otherwise they may fire in the wrong spot. - A Dictionary of the Tracks used in the Animation. It will error if any are missing. - You can also add each manually and overwrite the Track name

Like the other classes, it contains a builder class it aid in creating.

Additionally, you can specify the start and end keyframe indexes you want to export.

local exporter = Exporter.ExporterBuilder.new()
    :WithKeyframeRecorder(rideRecorder)     -- KeyframeRecorder
    :WithTriggerRecorder(triggerRecorder)   -- TriggerRecorder
    :WithCFrameTracks(tracksData)           -- Dictionary<CFrameTracks>
    :Finish()

Once an Exporter object is created, you can choose which method you want to use to export.

AsModuleScript

Exporter:AsModuleScript() -> ModuleScript

AsModuleScript exports it as mostly human readable Roblox lua.

AsBitBufferModule

Exporter:AsBitBufferModule() -> ModuleScript

AsBitBufferModule exports it as BitBuffered data. It's not readable, but it's more compact than AsModuleScript

Finally, once you created your animation data instance, just add it to your RideData.

local trainAnimationsData = rideData:FindFirstChild("TrainAnimations")

local module = exporter:AsModuleScript()
module.Name = "RideAnimation"
module.Parent = trainAnimationsData

Gotchas and Bugs

  • The length between keyframes must be the same meaning that the whole animation must use the same timestep. Currently, it is set to 1/24. In the future, it will be determined by the first keyframe. Later, if I figure it out, I'll allow varying time intervals.

  • If a recorded animation is too long, the Exporter will error with String too long. This also happens if it valleys on track end because the times it passes the end of the track is recorded.