Log inGet started

skeleton

Updated October 20, 2026

The skeleton pose-data pipeline: sample a clip into a pose buffer, and bind/apply a pose buffer onto a Skeleton + Model entity.

require("@builtin::modules.api.engine.skeleton") -- skeleton (also available as global 'skeleton')

The skeleton namespace: 42 functions.

globals/skeleton/applyPose

skeleton.applyPose(sinkHandle: number, poseBuffer: Substrate.TypedBuffer) -> boolean

Snapshot the buffer's first layout.total_floats values and queue a pending apply for the next ECS drain. Returns false on unknown sink/buffer or buffer too small for the layout. The Buffer is unchanged.

Parameters

  • sinkHandle number — Sink handle from bindPose.
  • poseBuffer Substrate.TypedBuffer — The pose buffer to apply.

Returns boolean — True on success.

globals/skeleton/bindClip

skeleton.bindClip(zanimBytes: buffer | string, boneOrder: { string }) -> ClipBindInfo?

Decode a zanim payload and bind it to boneOrder, precomputing which of the clip's channels feed each bone so per-frame sampleClip is allocation-free. Returns { handle, matched, total, duration }, or nil on a malformed payload / empty bone order. Check matched: 0 means the clip drives none of these bones.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
  • boneOrder { string } — Output bone names — one stride-10 record per bone.

Returns ClipBindInfo? — { handle, matched, total, duration }, or nil.

globals/skeleton/bindPose

skeleton.bindPose(entityId: (string | entityRef)?, opts: SkeletonLayout) -> number?

Register a pose sink targeting entityId. The opts table carries the layout: boneOrder is the bone-name array ({"hip", "spine", ...}), stride defaults to 10 (translation.xyz + rotation.xyzw + scale.xyz). Pass entityId as nil to use the current component's owning entity.

Parameters

  • entityId (string | entityRef) (optional) — Engine entity id or proxy, or nil for the current entity.
  • opts SkeletonLayout — { boneOrder, stride }.

Returns number? — Sink handle, or nil.

local h = skeleton.bindPose(nil, { boneOrder = bones, stride = 10 })

globals/skeleton/clipBones

skeleton.clipBones(zanimBytes: buffer | string) -> { string }?

Decode a zanim payload and return its bone-name array. Pure: build a bind order or a retarget map from a clip without binding a sampler. Returns nil on bytes that aren't a valid zanim payload.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns { string }? — Bone names referenced by the clip, or nil.

local names = skeleton.clipBones(assert(vfs.read(path .. "/data.zanim")))

globals/skeleton/clipDecode

skeleton.clipDecode(zanimBytes: buffer | string) -> string?

Decode a zanim payload to its readable JSON form ({ name, duration, channels, bone_names }), the clip as text to read, store or diff. The binary parse is the engine's. Code that transforms a clip's channels in Luau (e.g. the retarget bake) reads the same clip as a table with clipToTable, which skips the text. Returns nil on bytes that aren't a valid zanim payload. Inverse of clipEncode.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns string? — The clip as a JSON string, or nil.

local json = require("@builtin::modules.json")
local clipJson = skeleton.clipDecode(bytes)
local clip = if clipJson then json.decode(clipJson) else nil

globals/skeleton/clipEncode

skeleton.clipEncode(jsonString: string) -> string?

Encode a clip's JSON form (the shape clipDecode returns) back to a zanim payload — the bytes a .animation stores and bindClip/sampleClip consume. Inverse of clipDecode. A clip held as a table encodes with clipFromTable, which skips the text. Returns nil on invalid JSON.

Parameters

  • jsonString string — A clip JSON document.

Returns string? — The clip's zanim payload bytes, or nil.

local json = require("@builtin::modules.json")
local bytes = skeleton.clipEncode(json.encode(clip))

globals/skeleton/clipFromTable

skeleton.clipFromTable(clip: { [string]: any }) -> (string?, string?)

Encode a clip table (the shape clipToTable returns) to a zanim payload, the bytes a .animation stores and bindClip/sampleClip consume. Every field is required and every number finite. Inverse of clipToTable. Returns nil and the reason, naming the field, when the table is not a clip.

Parameters

  • clip { [string]: any } — A clip table: { name, duration, channels, bone_names }.

Returns (string?, string?) — The clip's zanim payload bytes, or nil and the reason.

local bytes = skeleton.clipFromTable(clip)

globals/skeleton/clipPack

skeleton.clipPack(packed: PackedClip) -> (string?, string?)

Encode a clip in packed form (the shape clipUnpack returns) into zanim bytes. Channels may share one run of times by naming the same keyOffset; interpolation defaults to Linear; with no boneNames the clip names its bones in the order its channels first drive them. times and values take a compute readback's resultBytes() directly. Returns nil and the reason, naming the channel, when a channel's keys fall outside the runs or its times run backwards.

Parameters

  • packed PackedClip — The packed clip.

Returns (string?, string?) — The clip's zanim payload bytes, or nil and the reason.

local bytes = skeleton.clipPack(packed)

globals/skeleton/clipToTable

skeleton.clipToTable(zanimBytes: buffer | string) -> { [string]: any }?

Decode a zanim payload to a table: { name, duration, channels = { { target_bone, path, keyframes_in, keyframes_out, interpolation } }, bone_names }, the shape clipDecode writes as JSON, built by the engine in place. path is Translation, Rotation or Scale; interpolation is Linear, Step or CubicSpline. Inverse of clipFromTable. Returns nil on bytes that aren't a valid zanim payload.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns { [string]: any }? — The clip as a table, or nil.

local clip = skeleton.clipToTable(bytes)

globals/skeleton/clipUnpack

skeleton.clipUnpack(zanimBytes: buffer | string) -> PackedClip?

Decode a zanim payload into its packed form: a channel table ({ bone, path, interpolation, keyOffset, keyCount, valueOffset }, offsets 0-based element indices) and two buffers of little-endian f32s, times and values, holding every channel's keys channel after channel (3 floats a key for Translation and Scale, 4 for Rotation, three times that for CubicSpline). The runs go straight into a typed buffer (buf:writeBytes(packed.values)) for a compute pass. Returns nil on bytes that aren't a valid zanim payload. Inverse of clipPack.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns PackedClip? — The packed clip, or nil.

local packed = skeleton.clipUnpack(bytes)

globals/skeleton/jointTransforms

skeleton.jointTransforms(entityId: string | entityRef) -> table

Read a skinned entity's per-joint world transforms for the current animated pose.

Parameters

  • entityId string | entityRef — Engine entity id or proxy of a skinned entity.

Returns table — Array of joint transforms: { position, matrix, parent, name }.

local joints = skeleton.jointTransforms(meshId)

globals/skeleton/sampleClip

skeleton.sampleClip(handle: number, time: number, poseBuffer: Substrate.TypedBuffer) -> boolean

Sample the bound clip at time (clamped to [0, duration]) and write one stride-10 pose record per bound bone into the Buffer, starting at index 0. Bones the clip does not drive are written as identity. Returns false on unknown handle/buffer or a buffer too small for the bone count.

Parameters

  • handle number — Sampler handle from bindClip.
  • time number — Sample time in seconds.
  • poseBuffer Substrate.TypedBuffer — The stride-10 pose buffer written into.

Returns boolean — True on success.

globals/skeleton/unbindClip

skeleton.unbindClip(handle: number) -> boolean

Drop the bound clip sampler from the registry.

Parameters

  • handle number — Sampler handle to remove.

Returns boolean — True if the sampler existed.

globals/skeleton/unbindPose

skeleton.unbindPose(sinkHandle: number) -> boolean

Remove the sink from the registry.

Parameters

  • sinkHandle number — Sink handle to remove.

Returns boolean — True if the sink was present.

modules/skeleton/applyPose

applyPose(sinkHandle: number, poseBuffer: Substrate.TypedBuffer): boolean

Snapshot the buffer's first layout.total_floats values and queue a pending apply for the next ECS drain. Returns false on unknown sink/buffer or buffer too small for the layout. The Buffer is unchanged.

Parameters

  • sinkHandle number — Sink handle from bindPose.
  • poseBuffer Substrate.TypedBuffer — The pose buffer to apply.

modules/skeleton/bindClip

bindClip(zanimBytes: buffer | string, boneOrder: { string }): ClipBindInfo?

Decode a zanim payload and bind it to boneOrder, precomputing which of the clip's channels feed each bone so per-frame sampleClip is allocation-free. Returns { handle, matched, total, duration }, or nil on a malformed payload / empty bone order. Check matched: 0 means the clip drives none of these bones.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
  • boneOrder { string } — Output bone names — one stride-10 record per bone.

modules/skeleton/bindPose

bindPose(entityId: (string | entityRef)?, opts: SkeletonLayout): number?

Register a pose sink targeting entityId. The opts table carries the layout: boneOrder is the bone-name array ({"hip", "spine", ...}), stride defaults to 10 (translation.xyz + rotation.xyzw + scale.xyz). Pass entityId as nil to use the current component's owning entity.

Parameters

  • entityId (string | entityRef)? (optional) — Engine entity id or proxy, or nil for the current entity.
  • opts SkeletonLayout — { boneOrder, stride }.
local h = skeleton.bindPose(nil, { boneOrder = bones, stride = 10 })

modules/skeleton/clipBones

clipBones(zanimBytes: buffer | string): { string }?

Decode a zanim payload and return its bone-name array. Pure: build a bind order or a retarget map from a clip without binding a sampler. Returns nil on bytes that aren't a valid zanim payload.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
local names = skeleton.clipBones(assert(vfs.read(path .. "/data.zanim")))

modules/skeleton/clipDecode

clipDecode(zanimBytes: buffer | string): string?

Decode a zanim payload to its readable JSON form ({ name, duration, channels, bone_names }), the clip as text to read, store or diff. The binary parse is the engine's. Code that transforms a clip's channels in Luau (e.g. the retarget bake) reads the same clip as a table with clipToTable, which skips the text. Returns nil on bytes that aren't a valid zanim payload. Inverse of clipEncode.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
local json = require("@builtin::modules.json")
local clipJson = skeleton.clipDecode(bytes)
local clip = if clipJson then json.decode(clipJson) else nil

modules/skeleton/clipEncode

clipEncode(jsonString: string): string?

Encode a clip's JSON form (the shape clipDecode returns) back to a zanim payload — the bytes a .animation stores and bindClip/sampleClip consume. Inverse of clipDecode. A clip held as a table encodes with clipFromTable, which skips the text. Returns nil on invalid JSON.

Parameters

  • jsonString string — A clip JSON document.
local json = require("@builtin::modules.json")
local bytes = skeleton.clipEncode(json.encode(clip))

modules/skeleton/clipFromTable

clipFromTable(clip: { [string]: any }): (string?, string?)

Encode a clip table (the shape clipToTable returns) to a zanim payload, the bytes a .animation stores and bindClip/sampleClip consume. Every field is required and every number finite. Inverse of clipToTable. Returns nil and the reason, naming the field, when the table is not a clip.

Parameters

  • clip { [string]: any } — A clip table: { name, duration, channels, bone_names }.
local bytes = skeleton.clipFromTable(clip)

modules/skeleton/clipPack

clipPack(packed: PackedClip): (string?, string?)

Encode a clip in packed form (the shape clipUnpack returns) into zanim bytes. Channels may share one run of times by naming the same keyOffset; interpolation defaults to Linear; with no boneNames the clip names its bones in the order its channels first drive them. times and values take a compute readback's resultBytes() directly. Returns nil and the reason, naming the channel, when a channel's keys fall outside the runs or its times run backwards.

Parameters

  • packed PackedClip — The packed clip.
local bytes = skeleton.clipPack(packed)

modules/skeleton/clipToTable

clipToTable(zanimBytes: buffer | string): { [string]: any }?

Decode a zanim payload to a table: { name, duration, channels = { { target_bone, path, keyframes_in, keyframes_out, interpolation } }, bone_names }, the shape clipDecode writes as JSON, built by the engine in place. path is Translation, Rotation or Scale; interpolation is Linear, Step or CubicSpline. Inverse of clipFromTable. Returns nil on bytes that aren't a valid zanim payload.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
local clip = skeleton.clipToTable(bytes)

modules/skeleton/clipUnpack

clipUnpack(zanimBytes: buffer | string): PackedClip?

Decode a zanim payload into its packed form: a channel table ({ bone, path, interpolation, keyOffset, keyCount, valueOffset }, offsets 0-based element indices) and two buffers of little-endian f32s, times and values, holding every channel's keys channel after channel (3 floats a key for Translation and Scale, 4 for Rotation, three times that for CubicSpline). The runs go straight into a typed buffer (buf:writeBytes(packed.values)) for a compute pass. Returns nil on bytes that aren't a valid zanim payload. Inverse of clipPack.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
local packed = skeleton.clipUnpack(bytes)

modules/skeleton/jointTransforms

jointTransforms(entityId: string | entityRef): table

Read a skinned entity's per-joint world transforms for the current animated pose.

Parameters

  • entityId string | entityRef — Engine entity id or proxy of a skinned entity.
local joints = skeleton.jointTransforms(meshId)

modules/skeleton/sampleClip

sampleClip(handle: number, time: number, poseBuffer: Substrate.TypedBuffer): boolean

Sample the bound clip at time (clamped to [0, duration]) and write one stride-10 pose record per bound bone into the Buffer, starting at index 0. Bones the clip does not drive are written as identity. Returns false on unknown handle/buffer or a buffer too small for the bone count.

Parameters

  • handle number — Sampler handle from bindClip.
  • time number — Sample time in seconds.
  • poseBuffer Substrate.TypedBuffer — The stride-10 pose buffer written into.

modules/skeleton/unbindClip

unbindClip(handle: number): boolean

Drop the bound clip sampler from the registry.

Parameters

  • handle number — Sampler handle to remove.

modules/skeleton/unbindPose

unbindPose(sinkHandle: number): boolean

Remove the sink from the registry.

Parameters

  • sinkHandle number — Sink handle to remove.

typed/builtin//modules/api/engine/skeleton/skeleton/applyPose

skeleton.applyPose(sinkHandle: number, poseBuffer: Substrate.TypedBuffer) -> boolean

Snapshot the buffer's first layout.total_floats values and queue a pending apply for the next ECS drain. Returns false on unknown sink/buffer or buffer too small for the layout. The Buffer is unchanged.

Parameters

  • sinkHandle number — Sink handle from bindPose.
  • poseBuffer Substrate.TypedBuffer — The pose buffer to apply.

Returns boolean — True on success.

typed/builtin//modules/api/engine/skeleton/skeleton/bindClip

skeleton.bindClip(zanimBytes: buffer | string, boneOrder: { string }) -> ClipBindInfo?

Decode a zanim payload and bind it to boneOrder, precomputing which of the clip's channels feed each bone so per-frame sampleClip is allocation-free. Returns { handle, matched, total, duration }, or nil on a malformed payload / empty bone order. Check matched: 0 means the clip drives none of these bones.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).
  • boneOrder { string } — Output bone names — one stride-10 record per bone.

Returns ClipBindInfo? — { handle, matched, total, duration }, or nil.

typed/builtin//modules/api/engine/skeleton/skeleton/bindPose

skeleton.bindPose(entityId: (string | entityRef)?, opts: SkeletonLayout) -> number?

Register a pose sink targeting entityId. The opts table carries the layout: boneOrder is the bone-name array ({"hip", "spine", ...}), stride defaults to 10 (translation.xyz + rotation.xyzw + scale.xyz). Pass entityId as nil to use the current component's owning entity.

Parameters

  • entityId (string | entityRef) (optional) — Engine entity id or proxy, or nil for the current entity.
  • opts SkeletonLayout — { boneOrder, stride }.

Returns number? — Sink handle, or nil.

local h = skeleton.bindPose(nil, { boneOrder = bones, stride = 10 })

typed/builtin//modules/api/engine/skeleton/skeleton/clipBones

skeleton.clipBones(zanimBytes: buffer | string) -> { string }?

Decode a zanim payload and return its bone-name array. Pure: build a bind order or a retarget map from a clip without binding a sampler. Returns nil on bytes that aren't a valid zanim payload.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns { string }? — Bone names referenced by the clip, or nil.

local names = skeleton.clipBones(assert(vfs.read(path .. "/data.zanim")))

typed/builtin//modules/api/engine/skeleton/skeleton/clipDecode

skeleton.clipDecode(zanimBytes: buffer | string) -> string?

Decode a zanim payload to its readable JSON form ({ name, duration, channels, bone_names }), the clip as text to read, store or diff. The binary parse is the engine's. Code that transforms a clip's channels in Luau (e.g. the retarget bake) reads the same clip as a table with clipToTable, which skips the text. Returns nil on bytes that aren't a valid zanim payload. Inverse of clipEncode.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns string? — The clip as a JSON string, or nil.

local json = require("@builtin::modules.json")
local clipJson = skeleton.clipDecode(bytes)
local clip = if clipJson then json.decode(clipJson) else nil

typed/builtin//modules/api/engine/skeleton/skeleton/clipEncode

skeleton.clipEncode(jsonString: string) -> string?

Encode a clip's JSON form (the shape clipDecode returns) back to a zanim payload — the bytes a .animation stores and bindClip/sampleClip consume. Inverse of clipDecode. A clip held as a table encodes with clipFromTable, which skips the text. Returns nil on invalid JSON.

Parameters

  • jsonString string — A clip JSON document.

Returns string? — The clip's zanim payload bytes, or nil.

local json = require("@builtin::modules.json")
local bytes = skeleton.clipEncode(json.encode(clip))

typed/builtin//modules/api/engine/skeleton/skeleton/clipFromTable

skeleton.clipFromTable(clip: { [string]: any }) -> (string?, string?)

Encode a clip table (the shape clipToTable returns) to a zanim payload, the bytes a .animation stores and bindClip/sampleClip consume. Every field is required and every number finite. Inverse of clipToTable. Returns nil and the reason, naming the field, when the table is not a clip.

Parameters

  • clip { [string]: any } — A clip table: { name, duration, channels, bone_names }.

Returns (string?, string?) — The clip's zanim payload bytes, or nil and the reason.

local bytes = skeleton.clipFromTable(clip)

typed/builtin//modules/api/engine/skeleton/skeleton/clipPack

skeleton.clipPack(packed: PackedClip) -> (string?, string?)

Encode a clip in packed form (the shape clipUnpack returns) into zanim bytes. Channels may share one run of times by naming the same keyOffset; interpolation defaults to Linear; with no boneNames the clip names its bones in the order its channels first drive them. times and values take a compute readback's resultBytes() directly. Returns nil and the reason, naming the channel, when a channel's keys fall outside the runs or its times run backwards.

Parameters

  • packed PackedClip — The packed clip.

Returns (string?, string?) — The clip's zanim payload bytes, or nil and the reason.

local bytes = skeleton.clipPack(packed)

typed/builtin//modules/api/engine/skeleton/skeleton/clipToTable

skeleton.clipToTable(zanimBytes: buffer | string) -> { [string]: any }?

Decode a zanim payload to a table: { name, duration, channels = { { target_bone, path, keyframes_in, keyframes_out, interpolation } }, bone_names }, the shape clipDecode writes as JSON, built by the engine in place. path is Translation, Rotation or Scale; interpolation is Linear, Step or CubicSpline. Inverse of clipFromTable. Returns nil on bytes that aren't a valid zanim payload.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns { [string]: any }? — The clip as a table, or nil.

local clip = skeleton.clipToTable(bytes)

typed/builtin//modules/api/engine/skeleton/skeleton/clipUnpack

skeleton.clipUnpack(zanimBytes: buffer | string) -> PackedClip?

Decode a zanim payload into its packed form: a channel table ({ bone, path, interpolation, keyOffset, keyCount, valueOffset }, offsets 0-based element indices) and two buffers of little-endian f32s, times and values, holding every channel's keys channel after channel (3 floats a key for Translation and Scale, 4 for Rotation, three times that for CubicSpline). The runs go straight into a typed buffer (buf:writeBytes(packed.values)) for a compute pass. Returns nil on bytes that aren't a valid zanim payload. Inverse of clipPack.

Parameters

  • zanimBytes buffer | string — The clip's data.zanim payload bytes (binary-safe).

Returns PackedClip? — The packed clip, or nil.

local packed = skeleton.clipUnpack(bytes)

typed/builtin//modules/api/engine/skeleton/skeleton/jointTransforms

skeleton.jointTransforms(entityId: string | entityRef) -> table

Read a skinned entity's per-joint world transforms for the current animated pose.

Parameters

  • entityId string | entityRef — Engine entity id or proxy of a skinned entity.

Returns table — Array of joint transforms: { position, matrix, parent, name }.

local joints = skeleton.jointTransforms(meshId)

typed/builtin//modules/api/engine/skeleton/skeleton/sampleClip

skeleton.sampleClip(handle: number, time: number, poseBuffer: Substrate.TypedBuffer) -> boolean

Sample the bound clip at time (clamped to [0, duration]) and write one stride-10 pose record per bound bone into the Buffer, starting at index 0. Bones the clip does not drive are written as identity. Returns false on unknown handle/buffer or a buffer too small for the bone count.

Parameters

  • handle number — Sampler handle from bindClip.
  • time number — Sample time in seconds.
  • poseBuffer Substrate.TypedBuffer — The stride-10 pose buffer written into.

Returns boolean — True on success.

typed/builtin//modules/api/engine/skeleton/skeleton/unbindClip

skeleton.unbindClip(handle: number) -> boolean

Drop the bound clip sampler from the registry.

Parameters

  • handle number — Sampler handle to remove.

Returns boolean — True if the sampler existed.

typed/builtin//modules/api/engine/skeleton/skeleton/unbindPose

skeleton.unbindPose(sinkHandle: number) -> boolean

Remove the sink from the registry.

Parameters

  • sinkHandle number — Sink handle to remove.

Returns boolean — True if the sink was present.

  • api
  • reference