{"id":"skatingii/perfect-sequencer","name":"perfect-sequencer","scope":"skatingii","platform":"roblox","description":"Frame-accurate event scheduling for Roblox, on a shared clock or locked to an AnimationTrack's timeline.","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"610a867def0ba2201b09adb86488ea5a7e9007e82adf71ff45a6d9dd250d56cd","likes":0,"downloads":0,"install":"forest install skatingii/perfect-sequencer","url":"https://forest.dev/p/roblox/skatingii/perfect-sequencer","files":"https://api.forest.dev/ai/package/roblox/skatingii/perfect-sequencer/files","readme":"<p align=\"center\">\n  <h1 align=\"center\"><b>PerfectSequencer</b></h1>\n  <p align=\"center\">\n    Frame-accurate event scheduling for Roblox\n    <br />\n    <a href=\"https://github.com/skatingii/PerfectSequencer\"><strong>github →</strong></a>\n  </p>\n</p>\n\n<div align=\"center\">\n\n![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/skatingii/PerfectSequencer/ci.yml?style=for-the-badge&branch=main&logo=github)\n[![GitHub License](https://img.shields.io/github/license/skatingii/PerfectSequencer?style=for-the-badge)](LICENSE)\n\n</div>\n\nPerfectSequencer schedules callbacks by animation frame instead of by seconds. Write `12` and mean frame 12, the same number an animator reads off the timeline, rather than translating it into `task.wait(0.2)` and hoping the two stay in step.\n\n**Two schedulers, for two different problems:**\n\n- Fire events on a [shared clock](#addeventevent) that ticks against real `DeltaTime`, so timings hold under frame drops\n- Fire events on an [animation's own timeline](#fortracktrack), so they follow the track through speed changes and die with it\n\n**Why frames instead of seconds?**\n\nAnimation data is authored in frames. Once a hitbox is written as `0.2` seconds, the link back to the keyframe it came from is gone, and retiming the animation silently desynchronises the gameplay. Keeping the frame number keeps that link.\n\n<details>\n<summary><b>Table of Contents</b></summary>\n\n- [Installation](#installation)\n- [At a Glance](#at-a-glance)\n- [Reference](#reference)\n    - [`AddEvent(Event)`](#addeventevent)\n    - [`Run()`](#run)\n    - [`Stop()`](#stop)\n    - [`Reset()`](#reset)\n    - [`IsRunning()`](#isrunning)\n    - [`GetPendingCount()`](#getpendingcount)\n    - [`Completed`](#completed)\n    - [`ForTrack(Track)`](#fortracktrack)\n    - [`TrackSequencer:At(Frame, Callback, Args?)`](#tracksequenceratframe-callback-args)\n    - [`TrackSequencer:Start()`](#tracksequencerstart)\n    - [`TrackSequencer:Cancel()`](#tracksequencercancel)\n    - [`TrackSequencer.Finished`](#tracksequencerfinished)\n- [Choosing a scheduler](#choosing-a-scheduler)\n- [How it behaves](#how-it-behaves)\n- [Contributing](#contributing)\n\n</details>\n\n## Installation\n\n```toml\n# wally.toml\n[dependencies]\nPerfectSequencer = \"skatingii/perfect-sequencer@0.1.0\"\n```\n\nThen run `wally install`. The package lands at `Packages/PerfectSequencer`.\n\n<details>\n<summary><b>Before it is published</b></summary>\n\nWally 0.3.2 supports neither path nor git dependencies, so an unpublished package cannot be resolved from a manifest at all. To use it anyway, build the tree Wally would have produced:\n\n```bash\nwally install\nbash scripts/build-package-tree.sh\n```\n\nThat writes `dist/Packages`, containing the `_Index` layout and the generated link file. Copy it into your project's `ReplicatedStorage`. When the package is later published, `wally install` writes the same paths, so no require ever changes.\n\n</details>\n\n## At a Glance\n\n```luau\nlocal PerfectSequencer = require(ReplicatedStorage.Packages.PerfectSequencer)\n\nlocal Track = Humanoid.Animator:LoadAnimation(Animation)\n\nPerfectSequencer.ForTrack(Track)\n\t:At(4, function()\n\t\tSoundService:PlayLocalSound(Sounds.Windup)\n\tend)\n\t:At(12, function()\n\t\tApplyHitbox(Character, CFrame.new(0, 0, -3), Vector3.new(4, 4, 6))\n\tend)\n\t:At(20, function()\n\t\tCharacter:SetAttribute(\"Cancellable\", true)\n\tend)\n\t:Start()\n\nTrack:Play()\n```\n\n<details>\n<summary>Explain code</summary>\n\n```luau\nlocal PerfectSequencer = require(ReplicatedStorage.Packages.PerfectSequencer)\n\nlocal Track = Humanoid.Animator:LoadAnimation(Animation)\n\n-- Create a scheduler bound to this specific swing. It reads the track's own\n-- TimePosition, so every frame number below refers to the animation timeline\n-- rather than to wall-clock time.\nPerfectSequencer.ForTrack(Track)\n\t-- Frame 4: the windup is visible, so the sound lands with it\n\t:At(4, function()\n\t\tSoundService:PlayLocalSound(Sounds.Windup)\n\tend)\n\t-- Frame 12: the exact keyframe where the fist is extended\n\t:At(12, function()\n\t\tApplyHitbox(Character, CFrame.new(0, 0, -3), Vector3.new(4, 4, 6))\n\tend)\n\t-- Frame 20: recovery has begun, so the player may cancel out\n\t:At(20, function()\n\t\tCharacter:SetAttribute(\"Cancellable\", true)\n\tend)\n\t-- Nothing runs until Start. Order of :At calls does not matter.\n\t:Start()\n\n-- Start may run a frame before Play, which is handled: the scheduler waits\n-- for the track to actually begin before reading its timeline.\nTrack:Play()\n```\n\n</details>\n\nIf the player cancels the swing and the track stops, the scheduler stops with it. Frame 20 never fires, the connection is disconnected, and `Finished` reports that it did not run to completion.\n\n## Reference\n\n### `AddEvent(Event)`\n\nQueues a callback on the shared scheduler. `Event` is a table of `PresetFrame`, `Callback`, and an optional `Args` array.\n\n```luau\nPerfectSequencer.AddEvent({\n\tPresetFrame = 12,\n\tCallback = function(Target)\n\t\tApplyStun(Target)\n\tend,\n\tArgs = { Target },\n})\n\nPerfectSequencer.Run()\n```\n\nEvents are inserted in sorted order using a binary search, so queueing costs `O(log n)` and the drain loop stops at the first event that is not yet due. Queueing a thousand events does not cost a thousand comparisons per frame.\n\nAn event queued while the scheduler is **already running** counts its delay from the moment it was added, not from when `Run` was called. That is what makes it usable from inside another callback.\n\n> [!NOTE]\n> `PresetFrame` is measured at 60fps but ticked against real `DeltaTime`. Frame 30 means half a second, whether or not the game is holding 60fps.\n\n---\n\n### `Run()`\n\nStarts the shared clock. Calling it while already running is a no-op, so it can never stack two connections.\n\n```luau\nPerfectSequencer.Run()\nprint(PerfectSequencer.IsRunning()) -- true\n```\n\nThe scheduler disconnects itself as soon as the queue empties, then fires [`Completed`](#completed). No idle loop runs in the background between bursts of work.\n\n---\n\n### `Stop()`\n\nStops the clock early and fires `Completed`. Pending events stay queued rather than being discarded.\n\n```luau\nPerfectSequencer.Stop()\n```\n\nNote that `Run` resets the clock to zero, so a stopped and restarted scheduler replays each pending event's full delay rather than continuing from where it left off. To discard the queue instead of keeping it, call [`Reset()`](#reset).\n\n---\n\n### `Reset()`\n\nClears every queued event without stopping the clock.\n\n```luau\nPerfectSequencer.Reset()\n```\n\n---\n\n### `IsRunning()`\n\nReturns whether the shared clock is currently connected.\n\n```luau\nif not PerfectSequencer.IsRunning() then\n\tPerfectSequencer.Run()\nend\n```\n\n---\n\n### `GetPendingCount()`\n\nReturns how many events are still queued.\n\n```luau\nprint(`{PerfectSequencer.GetPendingCount()} events pending`)\n```\n\n---\n\n### `Completed`\n\nA signal fired when the queue drains and the clock stops.\n\n```luau\nPerfectSequencer.Completed:Connect(function()\n\tprint(\"every queued event has fired\")\nend)\n```\n\n---\n\n### `ForTrack(Track)`\n\nReturns a `TrackSequencer` locked to an `AnimationTrack`'s timeline. Unlike the shared scheduler, it reads `Track.TimePosition`, so it follows the animation through speed changes and stops when the track does.\n\n```luau\nlocal Sequence = PerfectSequencer.ForTrack(Track)\n```\n\nEach call returns an independent scheduler. One per swing is the intended usage. They are cheap and self-disposing.\n\n---\n\n### `TrackSequencer:At(Frame, Callback, Args?)`\n\nSchedules a callback at a frame on the track's timeline. Returns the sequencer, so calls chain.\n\n```luau\nSequence\n\t:At(8, PlayWindupSound)\n\t:At(20, ApplyDamage, { Target })\n```\n\nOrder does not matter. Events are matched against the timeline every frame, not consumed in the order they were added.\n\n> [!NOTE]\n> Calling `:At` after the sequencer has finished logs a warning and is ignored, rather than silently doing nothing. A frame that never fires is a bug worth seeing.\n\n---\n\n### `TrackSequencer:Start()`\n\nBegins watching the track. Returns the sequencer, so it chains off `:At`.\n\n```luau\nSequence:Start()\nTrack:Play()\n```\n\n`Start` may legitimately run a frame before `Track:Play()`, so it waits for the track to actually begin rather than assuming it is already playing. If the track never plays within one second, the sequencer cancels itself instead of leaking a connection.\n\n---\n\n### `TrackSequencer:Cancel()`\n\nStops the sequencer, drops pending events, and fires `Finished(false)`.\n\n```luau\nSequence:Cancel()\n```\n\nCancelling is idempotent. Calling it twice does nothing the second time, and it is called automatically when the track stops or when every event has fired.\n\n---\n\n### `TrackSequencer.Finished`\n\nA signal fired exactly once, with `true` if every event ran and `false` if the sequencer was cancelled with events still pending.\n\n```luau\nSequence.Finished:Connect(function(RanToCompletion: boolean)\n\tif not RanToCompletion then\n\t\tCharacter:SetAttribute(\"Attacking\", false)\n\tend\nend)\n```\n\nThis is the hook for cleanup that must happen whether a swing lands or is interrupted.\n\n## Choosing a scheduler\n\n| | `AddEvent` / `Run` | `ForTrack` |\n| --- | --- | --- |\n| Clock source | real `DeltaTime` | `Track.TimePosition` |\n| Follows animation speed | no | yes |\n| Survives the track stopping | yes | no, dies with it |\n| Instances | one shared queue | one per swing |\n| Use for | cooldowns, delayed logic, timed sequences | hitboxes, VFX, sounds tied to keyframes |\n\nRule of thumb: if the timing is written on an animator's timeline, use `ForTrack`. If it is game logic that happens to be delayed, use `AddEvent`.\n\n## How it behaves\n\n**No dependencies.** The package installs with nothing attached. `Signal` and `Cleanup` are internal modules, so nothing else in your project is touched and no version can drift underneath you.\n\n**Callbacks run on pooled threads.** Dispatch goes through an internal signal that caches a runner coroutine, so scheduling an event does not allocate a thread per callback. A handler that yields simply loses the cached thread and the next dispatch creates a new one.\n\n**A failing callback cannot take the scheduler down.** Every callback is wrapped in `pcall`, and errors are logged rather than swallowed.\n\n**Connections are owned by a cleanup object.** Both schedulers hand their connections to an internal `Cleanup`, so there is no separate disconnect path to forget. It swaps its task list out before running, so a task added during cleanup is not cleaned by that pass and re-entrant calls cannot double-clean.\n\n**Tracing is on in Studio, off in a live game.** Gated on `RunService:IsStudio()`, so a track that never plays is reported while you are developing and stays quiet in production.\n\n## Contributing\n\n```bash\naftman install\nwally install\n\nstylua --check src/\nselene src/\nlute run tests/Run.luau\n\nrojo sourcemap default.project.json -o sourcemap.json\nluau-lsp analyze --platform=roblox --definitions=globalTypes.d.luau --sourcemap=sourcemap.json src/\n```\n\nCI runs all of these on every push. `globalTypes.d.luau` is fetched by the workflow and is not committed.\n","readmeTruncated":false}