Tikki is a game/animation loop orchestrator that allows you to group frame callbacks into phases and dynamically modify their execution order. It's a simple and powerful abstraction that covers many use cases. Tikki is built on top of Eventti
, a highly optimized and battle-tested event emitter.
- 🎯 Simple and intuitive API.
- 🪶 Small footprint (~1kB minified and brotlied).
- ⚙️ Works in Node.js and browser environments out of the box.
- 🍦 Written in TypeScript with strict type definitions.
- 🤖 Extensively tested.
- 💝 Free and open source, MIT Licensed.
npm install tikki eventti
import { Ticker } from 'tikki';
const ticker = new Ticker();
<script type="importmap">
{
"imports": {
"eventti": "https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js",
"tikki": "https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js"
}
}
</script>
<script type="module">
import { Ticker } from 'tikki';
const ticker = new Ticker();
</script>
Tikki comes in two flavors: Ticker
and AutoTicker
.
Ticker
class is basically just a thin wrapper around Eventti's Emitter
with a few tweaks to make it more suitable for our specific use case of orchestrating frame callbacks:
- It replaces the concept of events with phases which are a group of frame callbacks that are executed together. The order of phases can be changed dynamically whenever you want, it's just an array of phase names (
ticker.phases
). This can be useful for e.g. separating game/physics/rendering logic into different phases. You can even provide the same phase multiple times in which case it's callbacks are emitted multiple times on tick. - It replaces the
emit
method with atick
method, which executes all the frame callbacks of all the phases (in the order defined inticker.phases
) with the arguments you provide. You can think of it as a "batched emit" method.
AutoTicker
class extends the Ticker
class and provides extra features to automatically tick the ticker on every animation frame, so it can be used as a drop-in replacement for your basic animation loop. It defaults to requestAnimationFrame
and falls back to setTimeout
in environments where requestAnimationFrame
is not supported. You can also provide your own requestFrame
method if you wish.
import { Ticker, FrameCallback } from 'tikki';
// Define allowed phases. If you don't provide these explicitly then the allowed
// phases are inferred from the phases you provide to the ticker on
// instantiation. If you don't provide any phases then any string, number or
// symbol will be allowed as a valid phase.
type Phases = 'a' | 'b' | 'c';
// Define the frame callback type. This is optional, but it's recommended to
// provide a custom type if you want to enforce the frame callback arguments.
type FrameCallback = (time: number, dt: number) => void;
// Create a ticker instance and define the phases.
const ticker = new Ticker<Phases, FrameCallback>({ phases: ['a', 'b', 'c'] });
// Let's create a game loop that ticks the ticker manually.
let prevTime = 0;
let frameId: number | undefined = undefined;
function gameLoop(time = 0) {
frameId = requestAnimationFrame(gameLoop);
if (prevTime < time) {
const deltaTime = time - prevTime;
ticker.tick(time, deltaTime);
}
prevTime = time;
return () => {
cancelAnimationFrame(frameId);
};
}
// Start the game loop.
let stopGameLoop = gameLoop();
// Stop the game loop when needed.
// stopGameLoop();
// And resume it again when needed
// stopGameLoop = gameLoop(prevTime);
// Add some frame callbacks to the phases.
const idA = ticker.on('a', (time, dt) => console.log('a', time, dt));
const idB = ticker.on('b', (time, dt) => console.log('b', time, dt));
const idC = ticker.on('c', (time, dt) => console.log('c', time, dt));
// Add some frame callbacks to the phases that will be called only once.
ticker.once('a', (time, dt) => console.log('a once', time, dt));
ticker.once('b', (time, dt) => console.log('b once', time, dt));
ticker.once('c', (time, dt) => console.log('c once', time, dt));
// Change the phases dynamically.
ticker.phases = ['c', 'a'];
// Remove a frame callback from a phase by id.
ticker.off('a', idA);
// You can also remove all the callbacks from a specific phase in one go.
ticker.off('b');
// Or just remove all callbacks from the ticker.
ticker.off();
Using AutoTicker
is the same as using Ticker
, but it ticks automatically on every animation frame. You can also pause and unpause the ticker at any time.
// Create ticker. It will start ticking automatically right away.
const ticker = new AutoTicker({ phases: ['a', 'b', 'c'] });
// Add some frame callbacks to the phases. By default the AutoTicker provides
// only the time of the frame to the frame callbacks.
ticker.on('a', (time) => console.log('a', time));
ticker.on('b', (time) => console.log('b', time));
ticker.on('c', (time) => console.log('c', time));
// Pause the ticker any time you want.
ticker.paused = true;
// And unpause it again when needed.
ticker.paused = false;
AutoTicker
also supports on-demand ticking, which means that the ticker will tick only when there are frame callbacks in it. This can be useful if you don't care about the frame time and just want the ticker to tick when there are frame callbacks in it.
// Create ticker with onDemand option set to true.
const ticker = new AutoTicker({ phases: ['a', 'b', 'c'], onDemand: true });
// Once you add a frame callback to the ticker it will start ticking
// automatically, and keeps ticking as long as there are frame callbacks in it.
ticker.on('a', (time) => console.log('a', time));
// If you remove all the frame callbacks from the ticker it will stop ticking.
ticker.off();
// And if you add a frame callback again it will start ticking again.
ticker.on('a', (time) => console.log('a', time));
You can provide your own frame request to the ticker. This can be useful if you want to e.g. track the delta time between frames and provide it to the frame callbacks.
import { AutoTicker } from 'tikki';
// Define the frame callback type.
type FrameCallback = (time: number, deltaTime: number) => void;
// Create a custom frame request that tracks time and delta time.
const createRequestFrame = () => {
let prevTime = 0;
// The frame request method should accept a single argument - a callback which
// receives any arguments you see fit. These arguments are then passed to the
// frame callbacks.
return (callback: FrameCallback) => {
const rafId = requestAnimationFrame((time) => {
const deltaTime = prevTime < time ? time - prevTime : 0;
prevTime = time;
callback(time, deltaTime);
});
// The frame request method should return a function that cancels the
// frame request.
return () => {
cancelAnimationFrame(rafId);
};
};
};
// Provide the custom requestFrame method to the ticker on init.
const ticker = new AutoTicker<'test', FrameCallback>({
phases: ['test'],
requestFrame: createRequestFrame(),
});
// Add a frame callback to the ticker.
ticker.on('test', (time, deltaTime) => {
console.log(time, deltaTime);
});
Tikki also exports a createXrRequestFrame
method, which you can use to request XRSession animation frames.
import { AutoTicker, createXrRequestFrame, XrFrameCallback } from 'tikki';
const xrTicker = await navigator.xr?.requestSession('immersive-vr').then((xrSession) => {
return new AutoTicker<'test', XrFrameCallback>({
phases: ['test'],
requestFrame: createXrRequestFrame(xrSession),
});
});
Sometimes you might need to switch the requestFrame
method on the fly, e.g. when entering/exiting XRSession. Tikki covers this use case and allows you to change the requestFrame
method dynamically at any time. We just need to inform AutoTicker
of all the possible requestFrame
type variations.
import { AutoTicker, createXrRequestFrame, XrFrameCallback } from 'tikki';
// Define the frame callback types as a union of all the possible frame callback
// types that the ticker might encounter. Note that due to limits of TypeScript
// all the variations must have the same number of arguments, but you can use
// `undefined` to mark optional arguments. Alternatively you can just create a
// single custom frame callback type that has all the possible arguments and use
// that.
type FrameCallback = ((time: number, frame?: undefined) => void) | XrFrameCallback;
// Create ticker.
const ticker = new AutoTicker<'test', FrameCallback>({
phases: ['test'],
});
// At any point later on we can switch the requestFrame method.
navigator.xr?.requestSession('immersive-vr').then((xrSession) => {
ticker.requestFrame = createXrRequestFrame(xrSession);
});
// We can then check the arguments with type-safety inside the frame callbacks.
ticker.on('test', (time, frame) => {
if (frame) {
console.log('XR Frame!', time);
} else {
console.log('Normal Frame', time);
}
});
Ticker
class wraps Eventti
's API and replaces the emit
method with a tick
method.
The tick
method loops over the active phases (events) and collects all the frame callbacks (listeners) from them into a queue, and finally processes the queue executing the frame callbacks with the arguments you provide to the tick
method. You can think of it as a "batched emit" method.
Accepts a TickerOptions
object as it's only argument.
Syntax
const ticker = new Ticker( [ options ] );
Options
- phases
- dedupe
- See dedupe docs.
- Accepts:
TickerDedupe
. - Optional. Defaults to
"add"
.
- getId
- See getId docs.
- Accepts:
(frameCallback: FrameCallback) => FrameCallbackId
. - Optional. Defaults to
() => Symbol()
.
Type: Phase[]
.
An array of phase names. You can change this array dynamically at any time to change the order of the phases. If you provide the same phase multiple times then it's callbacks are emitted multiple times on tick.
Type: TickerDedupe
.
Defines how a duplicate frame callback id is handled:
"add"
: the existing callback (of the id) is removed and the new callback is appended to the phase's callback queue."update"
: the existing callback (of the id) is replaced with the new callback without changing the index of the callback in the phase's callback queue."ignore"
: the new callback is silently ignored and not added to the phase."throw"
: as the name suggests an error will be thrown.
Type:
(frameCallback: FrameCallback) => FrameCallbackId;
A function which is used to get the frame callback id. By default Tikki uses Symbol()
to create unique ids, but you can provide your own function if you want to use something else. Receives the frame callback as the first (and only) argument.
Add a frame callback to a phase.
Syntax
ticker.on( phase, frameCallback, [ frameCallbackId ] );
Parameters
- phase
- The name of the phase you want to add the frame callback to.
- Accepts:
Phase
.
- frameCallback
- A frame callback that will be called on tick (if the phase is active).
- Accepts:
FrameCallback
.
- frameCallbackId
- The id for the frame callback. If not provided, the id will be generated by the
ticker.getId
method. - Accepts:
FrameCallbackId
. - Optional.
- The id for the frame callback. If not provided, the id will be generated by the
Returns
A frame callback id, which can be used to remove this specific callback. Unless manually provided via arguments this will be whatever the ticker.getId
method spits out, and by default it spits out symbols which are guaranteed to be always unique.
Add a one-off frame callback to a phase. This works identically to the on
method with the exception that the frame callback is removed immediately after it has been called once. Please refer to the on
method for more information.
Syntax
ticker.once( phase, frameCallback, [ frameCallbackId ] );
Remove a frame callback or multiple frame callbacks. If no frameCallbackId is provided all frame callbacks for the specified phase will be removed. If no phase is provided all frame callbacks from the ticker will be removed.
Syntax
ticker.off( [ phase ], [ frameCallbackId ] );
Parameters
- phase
- The phase you want to remove frame callbacks from.
- Accepts:
Phase
. - optional
- frameCallbackId
- The id of the frame callback you want to remove.
- Accepts:
FrameCallbackId
. - optional
Returns the frame callback count for a phase if phase is provided. Otherwise returns the frame callback count for the whole ticker.
Syntax
ticker.count( [ phase ] )
Parameters
- phase
- The phase you want to get the frame callback count for.
- Accepts:
Phase
. - Optional.
Collects all the frame callbacks (in the currently active phases) into a queue and calls the frame callbacks with the arguments you provide to this method.
Syntax
ticker.tick( [ ...args ] )
Parameters
- ...args
- Any arguments you see fit. Just remember to provide your custom
FrameCallback
type toTicker
when using TypeScript, as demonstrated in the example below. - Accepts:
any
. - Optional.
- Any arguments you see fit. Just remember to provide your custom
AutoTicker
class extends Ticker
class and (as the name says) ticks automatically so you don't have to manually call the tick
method in your own loop. It defaults to requestAnimationFrame
and falls back to setTimeout
in environments where requestAnimationFrame
is not supported.
AutoTicker
has all the same methods and options as Ticker
, and a few extra options/properties to control the auto-ticking. Please refer to the Ticker's API for anything that's not explicitly documented here. We only document the differences and additions here.
Accepts an AutoTickerOptions
object as it's only argument.
Syntax
const ticker = new AutoTicker( [ options ] );
Options
- paused
- See paused docs.
- Accepts:
boolean
. - Optional. Defaults to
false
.
- onDemand
- See onDemand docs.
- Accepts:
boolean
. - Optional. Defaults to
false
.
- requestFrame
- See requestFrame docs.
- Accepts:
FrameCallback
. - Optional. Defaults to
createRequestFrame()
, which usesrequestAnimationFrame
(if available) and falls back tosetTimeout
.
Type: boolean
.
Defines if the ticker is paused. If true
the ticker won't tick automatically until unpaused. You can change this property dynamically at any time to pause/unpause the ticker.
Type: boolean
.
Defines if the ticker should tick only when there are frame callbacks in the ticker. If true
the ticker will tick only when there are frame callbacks in it. If false
the ticker will tick continuously. You can change this property dynamically at any time to switch between on-demand and continuous ticking.
Type: RequestFrame
.
Defines the method which is used to request the next frame. You can change this property dynamically at any time to switch the frame request method.
Here's a list of all the types that you can import from tikki
.
import {
Phase,
FrameCallback,
FrameCallbackId,
TickerDedupe,
TickerOptions,
AutoTickerOptions,
RequestFrame,
CancelFrame,
} from 'tikki';
type Phase = string | number | symbol;
type FrameCallback = (time: number, ...args: any) => void;
type FrameCallbackId = null | string | number | symbol | bigint | Function | Object;
type TickerDedupe = 'add' | 'update' | 'ignore' | 'throw';
interface TickerOptions<P extends Phase> {
phases?: P[];
dedupe?: TickerDedupe;
getId?: (frameCallback: FrameCallback) => FrameCallbackId;
}
interface AutoTickerOptions<P extends Phase, FC extends FrameCallback> extends TickerOptions<P> {
paused?: boolean;
onDemand?: boolean;
requestFrame?: RequestFrame<FC>;
}
type RequestFrame<FC extends FrameCallback = (time: number) => void> = (
callback: FC,
) => CancelFrame;
type CancelFrame = () => void;
Copyright © 2022-2024, Niklas Rämö ([email protected]). Licensed under the MIT license.