Fixed‑timestep update loop with independent draw rate and quantized interpolation.
pnpm add snaprollimport { Snaproll, SnaprollActionType } from 'snaproll'
// Create with custom rates and context
const snaproll = new Snaproll({
updateRate: 60, // Fixed timestep updates at 60 Hz
drawRate: 60, // Draw calls at 60 Hz
context: { score: 0 },
})
// Subscribe to the animation loop
const subscription = snaproll.subscribe((context) => {
switch (context.action) {
case SnaprollActionType.Begin:
// Frame initialization - check if ready to proceed
const skipFrame = !document.hasFocus()
// Return true to skip this entire frame
return skipFrame
case SnaprollActionType.Update:
// Fixed timestep animation logic
const coalesce = context.updateStep >= 100
const totalTime = coalesce ? context.updateStep * context.timestep : context.timestep
// Update animation state
context.score += coalesce ? context.updateStep : 1
// Return true to skip remaining updates this frame
return coalesce
case SnaprollActionType.Draw:
// Interpolated drawing with quantized alpha
draw(context.alpha)
break
}
})
// Start the animation loop
snaproll.resume()
// Dynamic rate adjustment
snaproll.updateRate = 30 // Reduce to 30 Hz updates
// Manual control
subscription.pause() // Pause this subscription
subscription.resume() // Resume after
// Reset with new configuration
snaproll.reset({
updateRate: 120,
drawRate: 60,
context: { score: 0 },
keepSubscriptions: true,
})- updateRate — how often animation logic runs (defaults to 60 Hz).
- drawRate — how often frames draw to screen (defaults to 60 Hz).
Snaproll uses a bang-bang digital PLL (phase-locked loop) that captures requestAnimationFrame edges nearest to the target cadence. The PLL uses performance.now() for high-precision timing and computes phase error in target-frame units with a symmetric dead-zone (epsilon = 3e-3 ≈ 50µs at 60Hz) to determine when to advance the target timestamp. This keeps timing error bounded to within ±0.5 target frame periods while decimating 120→60 Hz, 144→48 Hz, 60→30 Hz cleanly without drift.
During the Draw phase, snaproll provides an alpha value [0, 1) representing fractional progress toward the next update. The alpha value is quantized to a power-of-two grid based on the draw rate. The quantization grid is calculated as 2^⌈log₂(drawRate * 2)⌉. For example, a 60 Hz draw rate uses a 128-step quantization grid. The alpha is calculated as ((alpha * grid + 0.5) | 0) / grid clamped to (grid-1)/grid, which rounds to the nearest grid step. This controlled quantization improves visual consistency at the cost of temporal precision.
Both rates accept any positive number and can be changed while running:
const snaproll = new Snaproll({
updateRate: 60, // animation logic frequency
drawRate: 30, // drawing frequency
})
// Change rates dynamically
snaproll.updateRate = 120 // higher precision
snaproll.drawRate = 60 // smoother drawingThe subscription function receives the same context object during each phase of the animation loop. Each phase populates different fields within this shared context object.
Each frame follows this order:
- Begin — Frame initialization with current timestamp
- Update — Animation calculations (may repeat)
- Draw — Render using interpolated values
Multiple Update phases may run before Draw. The updateStep field counts down the remaining updates in the current frame, starting from the total number needed and decrementing to 1 on the final update. For example, if 5 updates are needed in a frame, updateStep will be 5, then 4, then 3, then 2, then 1 across the five Update calls. This allows subscription functions to make optimization decisions based on update backlog.
case SnaprollActionType.Update:
console.log(`${context.updateStep} updates remaining this frame`)
updateAnimation(context.timestep)
breakconst snaproll = new Snaproll({
updateRate: 120, // Higher precision updates
drawRate: 60, // Standard display refresh
context: { score: 0 }, // Initial application state
})Update rates while the animation runs. The accumulator is preserved during rate changes:
// Rates take effect immediately, accumulator unchanged
snaproll.updateRate = 30 // Slower animation calculations
snaproll.drawRate = 120 // Higher refresh rateUse reset() to clear accumulator state and optionally reconfigure:
// Reset with new configuration
snaproll.reset({
updateRate: 60,
drawRate: 30,
keepSubscriptions: true, // Keep existing subscriptions (default)
keepContext: true, // Preserve context object (default)
})
// Reset accumulator only
snaproll.reset()Reset behavior:
- Clears internal accumulator state
- Preserves subscriptions and context by default
- Resumes automatically if the loop was running
- Keeps current rates unless new ones provided
Control what happens to the context when calling reset():
// Use provided context object, merge existing context into it
snaproll.reset({
context: { newField: 'value' },
keepContext: true,
})
// Result: { newField: 'value', ...existingContextProperties }
// Note: existing properties overwrite conflicting new ones
// Use provided context object
snaproll.reset({
context: { onlyField: 'value' },
keepContext: false,
})
// Result: { onlyField: 'value' }
// Keep existing context unchanged
snaproll.reset({ keepContext: true })
// Start with empty context
snaproll.reset({ keepContext: false })Return true from a subscription to control when expensive operations run.
Return true from Begin to skip the entire frame. The accumulator remains untouched when skipping frames:
case SnaprollActionType.Begin:
if (!assetsLoaded) {
return true // Skip this frame entirely, accumulator unchanged
}
breakUse updateStep to detect multiple queued updates and optimize accordingly. Zeroing the accumulator prevents spiral of death scenarios where update costs exceed frame budget:
case SnaprollActionType.Update: {
const coalesce = context.updateStep >= 100
// Physics substep merge: combine multiple fixed timesteps into one larger step
const totalTime = coalesce ? context.updateStep * context.timestep : context.timestep
updateAnimation(totalTime)
// Returning true zeros the accumulator and skips remaining updates and Draw phase for all subscribers
return coalesce
}- Subscriptions run in the order they were added
- All subscriptions process each phase before moving to the next
- Returning
truefrom Update affects the current frame for all subscribers
const subscription = snaproll.subscribe((context) => {
// Handle animation phases
})
// Control individual subscriptions
subscription.pause()
subscription.resume()
subscription.unsubscribe()
snaproll.pause() // Stop animation, keep subscriptions
snaproll.resume() // Resume animationLoop states:
- active — Running animation frames (has active subscriptions)
- idle — Not running (no active subscriptions)
- paused — Stopped by
snaproll.pause()(subscriptions remain)
Unless paused, snaproll starts automatically when subscriptions are added and becomes idle when all subscriptions are paused or removed.
Use TypeScript declaration merging to add type safety for shared state:
declare module 'snaproll' {
interface SnaprollUserContext {
score?: number
}
}
// Now available in all context handlers
snaproll.subscribe((context) => {
if (context.action === SnaprollActionType.Update) {
context.score += 10
}
})Measures frame timing to detect display's refresh rate and recommend compatible draw rates.
import { SnaprollDrawRateAdvisor } from 'snaproll'
// Create advisor with optional configuration
const advisor = new SnaprollDrawRateAdvisor({
samples: 90, // Collect 90 frame intervals
warmup: 10, // Skip first 10 frames
minDraw: 10, // Minimum recommended rate
})
// Subscribe to recommendations
const unsubscribe = advisor.subscribe((response) => {
console.log(`Quality score: ${response.score.toFixed(3)}`)
console.log(`Recommended rates: ${response.values.join(', ')} Hz`)
})
// Start analysis
advisor.trigger()The advisor provides quality scores [0-1] using RF (Robustness × Fit) formula and recommended draw rates sorted descending. Scores ≥0.85 indicate healthy timing with mild jitter; scores <0.60 show strong evidence of blocking/jitter or regime split and recommend re-running.
View examples at https://escapace.github.io/snaproll/ or see the examples/ directory:
- Bouncing Balls (
canvas-2d-bouncing-balls.vue) — Canvas animation with interpolated movement - Moving Rectangles (
css-transform-rectangles.vue) — CSS transform animation with smooth transitions
Each example demonstrates different aspects of snaproll:
- Frame rate independence
- Smooth interpolation using alpha values
- Performance optimization techniques
- Multiple subscription management
class Snaproll ↗
Fixed-timestep animation loop with independent draw and update rates.
export declare class SnaprollCreates animation loop instance with specified configuration.
constructor(options?: Partial<SnaprollOptions>);| Parameter | Type | Description |
|---|---|---|
options |
Partial<SnaprollOptions> |
Options applied during initialization. |
Uses the default updateRate of 60 Hz, drawRate of 60 Hz, and a new shared context object when those entries are not provided.
Stops animation while keeping subscriptions.
pause(): void;Resets the animation loop with optional configuration updates.
reset(options?: SnaprollResetOptions): void;| Parameter | Type | Description |
|---|---|---|
options |
SnaprollResetOptions |
Reset options controlling configuration overrides and retention behavior. |
Retains the current updateRate and drawRate when those fields are omitted.
keepSubscriptionsdefaults totrue, preserving existing subscriptions unless explicitly disabled.keepContextdefaults totrue, reusing the current context. Providing context copies existing entries whenkeepContextstaystrue, or replaces the context whenkeepContextisfalse.
Resumes animation.
resume(): void;Registers subscription callback for animation frame processing.
subscribe(value: SnaprollSubscription, options?: {
immediate?: boolean;
}): SnaprollSubscriptionControls;| Parameter | Type | Description |
|---|---|---|
value |
SnaprollSubscription |
Callback function to execute during frame processing. |
options |
{ |
Subscription configuration. The immediate flag defaults to true and activates the subscription on registration. |
Control object for pausing, resuming, and removing the subscription.
Subscriptions run in insertion order. New subscriptions start immediately unless options.immediate is set to false.
Getter and setter for the current draw rate in Hz.
get drawRate(): number;
set drawRate(value: number);Current animation loop state.
Returns 'active' when running animation frames, 'idle' when no active subscriptions, or 'paused' when stopped by pause().
get state(): "active" | "idle" | "paused";Getter and setter for the current update rate in Hz.
get updateRate(): number;
set updateRate(value: number);class SnaprollDrawRateAdvisor ↗
Provides draw rates inferred from observed frame periods.
The recommendation logic is stateless and deterministic: identical period inputs produce identical outputs. Concurrent class instances do not interfere with each other.
export declare class SnaprollDrawRateAdvisorCreates a new draw rate advisor instance.
constructor(options?: SnaprollDrawRateAdvisorOptions);| Parameter | Type | Description |
|---|---|---|
options |
SnaprollDrawRateAdvisorOptions |
Configuration options for the advisor |
Assertion error if any option values are invalid
Cancels ongoing operations and releases resources.
dispose(): void;Registers callback for recommendation results.
Callbacks receive SnaprollDrawRateAdvisorResponse when recommendation completes successfully. Callbacks are not invoked if the operation is canceled or produces no recommendations.
subscribe(subscription: SnaprollDrawRateAdvisorSubscription): () => void;| Parameter | Type | Description |
|---|---|---|
subscription |
SnaprollDrawRateAdvisorSubscription |
Callback function to invoke with results |
Function that unregisters the subscription when called
Cancels any previous ongoing operation before starting a new one. Results are delivered to registered subscriptions when the operation completes. If the operation is canceled or produces no recommendations, subscriptions are not invoked.
trigger(): void;enum SnaprollActionType ↗
Animation frame phases.
export declare enum SnaprollActionType| Member | Value |
|---|---|
Begin |
0 |
Update |
1 |
Draw |
2 |
interface SnaprollActionBegin ↗
Frame initialization action.
export interface SnaprollActionBeginCurrent frame time
timestamp: numberinterface SnaprollActionDraw ↗
Interpolated drawing action.
export interface SnaprollActionDraw extends Omit<SnaprollActionUpdate, 'action'>Interpolation factor [0, 1)
alpha: numberinterface SnaprollActionUpdate ↗
Fixed timestep animation logic action. The updateStep counts down remaining updates in the current frame.
export interface SnaprollActionUpdate extends Omit<SnaprollActionBegin, 'action'>Time to advance per update
timestep: numberRemaining updates this frame, counts down to 1
updateStep: numberinterface SnaprollDrawRateAdvisorOptions ↗
Array of canonical refresh rates to consider for snapping. Must be non-empty array of positive integers.
canonicalBases?: readonly number[];Maximum count of divisors to consider for candidate generation and output. Must be positive integer ≥ 1.
maxDivisor?: number;Minimum draw rate to include in results. Must be positive integer ≥ 5.
minDraw?: number;Number of frame intervals to collect after warmup. Must be positive integer ≥ 30.
samples?: number;Number of frames to ignore before sampling. Must be non-negative integer ≥ 0.
warmup?: number;interface SnaprollDrawRateAdvisorResponse ↗
Quality score in range [0,1] using RF (Robustness × Fit) formula. Higher scores indicate better data consistency and more reliable recommendations.
Score interpretation:
0.95–1.00: Rock-solid. Extremely stable capture, fits a canonical/divisor cleanly0.85–0.95: Healthy. Mild jitter only; values are trustworthy0.75–0.85: Borderline steady. Noticeable instability or light regime mixing; fine for most uses, re-run if chasing perfection0.60–0.75: Shaky. Significant jitter or likely mid-phase change; consider re-running< 0.60: Unstable. Strong evidence of blocking/jitter or regime split; re-run recommended
score: numberArray of recommended draw rates (Hz), sorted descending and de-duplicated. All values are integers and ≥ minDraw.
values: number[];interface SnaprollOptions ↗
Configuration interface for animation loop.
export interface SnaprollOptionsdrawRate and updateRate operate independently, allowing different frequencies for draw and update rates.
Optional shared state object passed to all subscription callbacks.
context?: SnaprollUserContext;Draw rate in Hz, controls visual frame timing.
drawRate: numberUpdate rate in Hz, determines fixed timestep size.
updateRate: numberinterface SnaprollResetOptions ↗
Options accepted by Snaproll.reset.
export interface SnaprollResetOptions extends Partial<SnaprollOptions>Extends SnaprollOptions and controls how subscriptions and context objects are preserved.
Determines the context to use after reset completes.
context?: SnaprollUserContext;The context resolution follows these rules:
- If
keepContext=trueandcontextis provided: applies the provided object and copies existing context into it. - If
keepContext=falseandcontextis provided: uses the provided object as-is. - If
keepContext=trueandcontextis omitted: reuses the existing context object. - If
keepContext=falseandcontextis omitted: creates a new empty context object.
Preserve the existing context object during reset.
keepContext?: boolean;Preserve existing subscription callbacks during reset.
keepSubscriptions?: boolean;interface SnaprollSubscriptionControls ↗
Control interface for managing individual animation subscriptions.
export interface SnaprollSubscriptionControlsEach subscription operates independently. Pausing one subscription does not affect others. The animation loop continues running as long as any subscription remains active.
Pauses this subscription
pause: () => void;Resumes this subscription
resume: () => void;Removes this subscription
unsubscribe: () => void;interface SnaprollUserContext ↗
Extension point for application-specific state that the animation loop shares across frames.
export interface SnaprollUserContextAugment this interface via declaration merging so custom properties flow into SnaprollContext. Snaproll maintains a single context instance per controller; store long-lived data on user fields and rely on action-specific payloads for phase details.
declare module 'snaproll' {
interface SnaprollUserContext {
score: number
}
}type SnaprollContext ↗
Context object passed to subscription callbacks during animation frames.
export type SnaprollContext = (SnaprollActionBegin | SnaprollActionDraw | SnaprollActionUpdate) &
SnaprollUserContextIntersects the action-specific payload with SnaprollUserContext, so that custom state persists across loop phases. Inspect the action discriminant to determine which SnaprollAction* view is valid while treating application-specific fields as shared state.
type SnaprollDrawRateAdvisorSubscription ↗
Callback function invoked when draw rate estimation completes.
export type SnaprollDrawRateAdvisorSubscription = (
response: SnaprollDrawRateAdvisorResponse,
) => voidtype SnaprollSubscription ↗
Subscription callback function.
export type SnaprollSubscription = (context: SnaprollContext) => boolean | undefinedReturn value controls frame execution flow:
truefrom Begin phase skips the entire frametruefrom Update phase skips remaining updates and draw for current frameundefinedorfalsecontinues normal execution