emdomanus/pulse
A small generic sequence playback runtime for Roblox.
Pulse
Pulse is a generic sequence playback runtime for Roblox.
It is intended to be the small timing spine beneath higher-level systems such as VFX playback, skill timelines, cutscene beats, and plugin previews. Pulse owns sequence time, scheduled steps, continuous updates, params, signals, playback speed, time position, and lifecycle handles. Domain systems own their own meaning: VFX emits particles and sounds, skills spawn hitboxes and validate combat, and UI/cutscene code decides what to show.
Pulse uses pesde for deterministic dependencies:
pesde install
The package depends on:
emdomanus/advanced_utilsfor hook-style events and small utilities.emdomanus/tempofor clocks, phases, and scheduler integration.
Initial package shape:
local Pulse = require(ReplicatedStorage.packages.pulse)
local runtime = Pulse.Runtime.new({
tempo = tempo,
phase = tempo.phases.heartbeat,
})
type StepId = "start" | "release"
local builder: Pulse.Builder<StepId> = Pulse.define()
local sequence = builder
:duration(0.5)
:cleanupDelay(1)
:step("start", 0, function(ctx: Pulse.CallbackContext) end)
:event(0, "start", function(ctx: Pulse.CallbackContext) end)
:step("release", 0.25, function(ctx: Pulse.CallbackContext) end)
:event(0.25, "release", function(ctx: Pulse.CallbackContext) end)
:compile()
local handle = runtime:play(sequence, {
params = {
scale = 1,
},
})
steps are generic. VFX sequences can use names such as "release", while
skill adapters can use numeric steps such as 1, 2, and 3.
Builders are the preferred handwritten authoring surface. They compile into the
same flat Sequence runtime data as Pulse.sequence(definition), but avoid most
of Luau's table-literal inference rough edges.
Raw definitions are still supported. Luau can widen string literals or infer an
events array from its first element before the surrounding Definition<StepId>
type is applied. For typed string steps in raw definitions, pin literals and type
heterogeneous event arrays explicitly:
type StepId = "start" | "release" | "finish"
local events: { Pulse.Event<StepId> } = {
{
time = 0,
step = "start" :: "start",
run = function(ctx: Pulse.CallbackContext) end,
},
{
time = 0.25,
step = "release" :: "release",
run = function(ctx: Pulse.CallbackContext) end,
},
}
local definition: Pulse.Definition<StepId> = {
seekMode = "procedural" :: "procedural",
loop = true,
events = events,
updates = {
{
name = "tick",
run = function(ctx: Pulse.CallbackContext, dt: number, timePosition: number)
if math.floor(timePosition * 10) % 5 == 0 then
ctx:signal("marker", dt)
end
end,
},
},
signals = {
continue = function(ctx: Pulse.CallbackContext)
ctx:resume()
end,
},
}
Pulse has generic compiled sequences already: Pulse.sequence(definition) and
builder:compile() both return reusable normalized Sequence objects with
sorted events and default tables. A later VFX compiler can sit above this and
compile asset packs, attributes, or plugin JSON into Pulse builders/definitions.
A sequence can hold until external input by pausing from an event or step and resuming from a signal. This is not coroutine yielding; the playback remains alive and signalable while time is paused:
type StepId = "start" | "hold" | "finish"
local builder: Pulse.Builder<StepId> = Pulse.define()
local sequence = builder
:duration(2)
:step("start", 0)
:step("hold", 1)
:step("finish", 1.5)
:hold(1, "hold", "continue")
:compile()
local handle = runtime:play(sequence)
handle:signal("continue")
cleanupDelay keeps completed playbacks alive in stopping status for a short
real-time linger window after Ended fires. Registered ctx:addCleanup
callbacks run after that delay. Manual stop, cancels, destroys, and failures
clean up immediately.
Seek policy is explicit:
proceduralallows normal time repositioning.forwardonly allows seeks to the current or a later time.nonerejectsseekandsetTimePositioncalls.
Pulse isolates user callback errors. If an event, update, step, signal, or
lifecycle hook throws, the playback transitions to failed and the error is
stored on the completion object instead of escaping through the runtime phase.
Observer hook listener errors are caught and warned without changing playback
state.
Transitions are whitelist-based. Once any transition rule is present, only matching rules may enter the next step. Event-driven step changes use the same gate; if the step is blocked, the event body is skipped.
Looped sequences must have a positive duration. On frame hitches, Pulse replays events for skipped loop cycles up to an internal catch-up cap, then folds the remaining overflow into the current loop time to avoid pathological spins.
Naming convention:
Sequenceis the public compiled/reusable definition.Playbackis the public live playback object returned byruntime:play.Runtime:destroy()releases the runtime binding and destroys active playbacks.SequenceImpl,PlaybackImpl, andRuntimeImplare internal shapes.
The runtime implementation should stay deliberately small: flat sorted events, update callbacks, param mutation, stop/cancel/destroy, generic step transitions, and explicit seek policies. Higher-level VFX, skill, and plugin compilers should emit builders or definitions rather than adding domain rules to Pulse itself.