Character Animation
Generate a reusable animated character, bake a pose function into keyframes, spawn one template copy per player, and keep playback matched to each client's rendered movement. For the animation object model itself, start with the Animation [blocked] guide.
The complete workflow
An animated player character has four separate jobs. Keeping them separate makes the character reusable and keeps runtime logic small:
- Generate the source character once. The AI runs a temporary Server script in the editor. It creates the body, Skeleton, SkJoints, animation objects, and keyframes. The temporary script is removed when it finishes.
- Prepare one server template. A persistent Server script cuts the complete source character into a template when the game starts. The source disappears from the live world.
- Paste one copy per player. The same Server script listens for new
Playerobjects, pastes the template, moves the copy to a spawn object, and assigns the player's body and camera target. - Animate each copy locally. A persistent Client script stored inside the character root reads that body's local velocity and controls that copy's walk clock. Because the script is inside the template, every pasted character receives its own controller on every client.
The important ownership rule is: the server owns which character exists; each client owns how fast the character animation it renders is playing.
Why animation speed can fall out of sync
A client-controlled character's current velocity begins on the controlling client. If a Server script drives the walk clock, the value has to make a round trip:
- the controlling client simulates movement,
- the velocity travels to the server,
- the Server script changes the clock,
- the changed clock travels back to clients.
That delay is unnecessary for visual animation. It also gets worse if the Server script deliberately ignores small speed changes. For example, a guard such as Math.abs(nextSpeed - previousSpeed) > 0.05 means the clock is intentionally allowed to differ from the true velocity by as much as five percent of the authored walk speed.
Instead, put the walk controller in a Client script inside the character template. Every client then reads the velocity already present in its own world and updates its local clock without a network round trip. The controlling player sees immediate animation, while other players animate from the interpolated velocity that their clients are already rendering.
The engine preserves the current animation phase when game.clockService.setSpeed changes a clock's speed, so updating the multiplier does not restart the walk cycle.
Generate the character and bake f(t)
The AI should use run_temporary_script for this authoring step. The script creates one self-contained character root and samples a pose function f(t) at a small timestep. Each sample becomes a SkKeyframe, and each joint returned by f(t) becomes a SkKeyframePose under that keyframe.
This shortened two-part character shows the complete shape of the generator:
const v = (x: number, y: number, z: number): Vec3 => ({ x, y, z });const add = (a: Vec3, b: Vec3): Vec3 => v(a.x + b.x, a.y + b.y, a.z + b.z);const loc = (position: Vec3): Location3D => ({ position, rotation: v(0, 0, 0) });const movement = { velocity: v(0, 0, 0), angularVelocity: v(0, 0, 0) };const mustCreate = (name: string, info: ObjectInfo, parentId: ObjectId) => { const object = game.objectService.createObject(name, info, parentId); if (object === null) throw new Error("Failed to create " + name); return object;};// TODO: AI-PICKED-VALUE: These example proportions, colors, and positions make a small two-part character; replace them for the requested design.const bodyPosition = v(0, 3, 0);const localBindOfJointName: { [jointName: string]: Vec3 } = { Torso: v(0, 0, 0), Head: v(0, 1, 0),};const sizeOfPartName: { [partName: string]: Vec3 } = { Torso: v(0.8, 1.2, 0.5), Head: v(0.6, 0.6, 0.6),};const characterRoot = mustCreate("Player-Character", { type: "group" }, game.world.id);const skeleton = mustCreate("Joints", { type: "skeleton" }, characterRoot.id);for (const jointName of Object.keys(localBindOfJointName)) { mustCreate(jointName, { type: "skJoint", bindPose: loc(localBindOfJointName[jointName]) }, skeleton.id);}const body = mustCreate("Body", { type: "rigidGroup", followSkeletonId: skeleton.id, location: loc(bodyPosition), movement,}, characterRoot.id);for (const partName of Object.keys(sizeOfPartName)) { mustCreate(partName, { type: "brick", material: null, opacity: 1, color: { r: 0.7, g: 0.8, b: 1 }, isAlwaysOnTop: false, size: sizeOfPartName[partName], isWalkthrough: false, isAnchored: true, density: 1, location: loc(add(bodyPosition, localBindOfJointName[partName])), movement, }, body.id);}game.objectService.createSkAttachmentsInRigidGroup(skeleton.id, body.id);// TODO: AI-PICKED-VALUE: This example uses a one-second walk cycle sampled every 0.05 seconds; match both values to the requested motion.const duration = 1;const dt = 0.05;const f = (t: number): { [jointName: string]: Location3D } => { const phase = 2 * Math.PI * t / duration; const bob = 0.08 * Math.sin(2 * phase); return { Torso: loc(add(localBindOfJointName.Torso, v(0, bob, 0))), Head: loc(add(localBindOfJointName.Head, v(0, bob, 0))), };};const animation = mustCreate("Walk-Animation", { type: "skAnimation", duration, interpolationStyle: "Linear",}, characterRoot.id);const numSteps = Math.ceil(duration / dt);for (let frameIndex = 0; frameIndex <= numSteps; frameIndex += 1) { const t = frameIndex < numSteps ? frameIndex * dt : duration; const keyframe = mustCreate("Walk-Keyframe-" + frameIndex, { type: "skKeyframe", time: t, }, animation.id); const poseOfJointName = f(t); for (const jointName of Object.keys(poseOfJointName)) { mustCreate(jointName + "-Walk-Pose-" + frameIndex, { type: "skKeyframePose", jointName, relativePose: poseOfJointName[jointName], }, keyframe.id); }}const clock = mustCreate("Walk-Clock", { type: "clock", clockState: { Playing: { localStartMs: 0, timeLocation: "Server" } }, shouldLoop: true, speed: 1,}, characterRoot.id);mustCreate("Walk-Animator", { type: "skAnimator", skeletonId: skeleton.id, skAnimationId: animation.id, clockId: clock.id, priority: 2, weight: 0, weightTransition: null,}, characterRoot.id);For a full character, f(t) returns poses for every animated SkJoint. A walk cycle usually counter-swings left and right limbs and repeats the first pose at t = duration so the loop has no visible seam.
createSkAttachmentsInRigidGroup must run while the source body and Skeleton are still live. The example does it in the generator. If a manually built character has no attachments yet, call it once in the template-preparation script instead—never in both places.
Put the walk controller inside the template
After the temporary script finishes, create a persistent Client script as a child of Player-Character. Because the Server script later cuts that whole root into a template, every pasted character contains a copy of this controller.
The controller uses script.parent to find its own character. It reacquires the body each tick because a live object returned by getObjectById is a snapshot of its fields; reacquiring it reads the latest velocity.
'use client';const characterRoot = script.parent;const body = characterRoot?.getChild("Body");const walkClock = characterRoot?.getChild("Walk-Clock");const walkAnimator = characterRoot?.getChild("Walk-Animator");if (!characterRoot || !body || !walkClock || !walkAnimator) { console.error("Character walk controller is missing Body, Walk-Clock, or Walk-Animator."); await stopScript();}// TODO: AI-PICKED-VALUE: These values match the example character and the default player speed; tune them with the authored animation.const WALK_START_SPEED = 0.5;const AUTHORED_WALK_SPEED = 9;const WALK_FADE_MS = 150;let isWalking = false;let appliedSpeedMultiplier = 1;while (true) { await tick(); const currentBody = game.objectService.getObjectById(body.id); if (currentBody === null || !("movement" in currentBody)) { await stopScript(); } const horizontalSpeed = Math.hypot( currentBody.movement.velocity.x, currentBody.movement.velocity.z, ); const nextIsWalking = horizontalSpeed > WALK_START_SPEED; if (nextIsWalking !== isWalking) { isWalking = nextIsWalking; game.animationService.smoothlyChangeWeight( walkAnimator.id, isWalking ? 100 : 0, WALK_FADE_MS, "EaseInOut", ); } if (!isWalking) continue; const speedMultiplier = horizontalSpeed / AUTHORED_WALK_SPEED; if (speedMultiplier === appliedSpeedMultiplier) continue; appliedSpeedMultiplier = speedMultiplier; game.clockService.setSpeed(walkClock.id, speedMultiplier);}There is no five-percent dead zone here. The clock changes whenever the horizontal speed changes, and the change is local to this client. The walk weight still changes only when crossing the standing/walking threshold, so its fade is not restarted every tick.
This character has no idle animator, so when the walk weight fades to 0 the leftover weight blends the SkJoints back toward their bindPose — the character smoothly returns to its resting pose. See Blending and transitions [blocked] in the Animation guide for how weights and priorities are distributed.
Cut the template and spawn one copy per player
Create one persistent Server script outside the character root. It runs once when the server starts, cuts the complete source character into a template, and listens for players.
Use named spawn objects in the world instead of hard-coded coordinates. This keeps level layout in the editor and lets the same script work in different games.
const characterRoot = game.objectService.getObjectByName("Player-Character");const templateBody = characterRoot?.getChild("Body");const templateSkeleton = characterRoot?.getChild("Joints");if (!characterRoot || !templateBody || templateBody.type !== "rigidGroup" || !templateSkeleton || templateSkeleton.type !== "skeleton") { throw new Error("Player-Character is missing Body or Joints.");}// The generator already created the attachments. For a manually built source, call// createSkAttachmentsInRigidGroup(templateSkeleton.id, templateBody.id) once here.game.templateService.cutObjectToTemplate(characterRoot.id, "player-character");const spawnObjects = game.objectService.getObjectsByName("Player-Spawn");if (spawnObjects.length === 0) throw new Error("The world needs at least one Player-Spawn object.");let nextSpawnIndex = 0;const characterRootObjectIdOfPlayerObjectId: { [playerObjectId: number]: ObjectId | undefined } = {};game.objectService.addObjectCreatedListener((player) => { if (player.characterObjectId !== null) return; const spawnObject = spawnObjects[nextSpawnIndex % spawnObjects.length]; nextSpawnIndex += 1; if (!("location" in spawnObject)) throw new Error("Player-Spawn must have a location."); const characterCopy = game.templateService.pasteTemplate("player-character", game.world.id); const body = characterCopy.getChild("Body"); const head = body?.getChild("Head"); if (!body || !head) { game.objectService.deleteObject(characterCopy.id); throw new Error("Pasted character is missing Body or Head."); } characterCopy.name = player.username + "-Character"; game.objectService.moveObjectTo(body.id, spawnObject.location.position); game.objectService.setObjectAnchored(body.id, false); player.characterObjectId = body.id; player.headObjectId = head.id; characterRootObjectIdOfPlayerObjectId[player.id] = characterCopy.id;}, "player");game.objectService.addObjectDeletedListener((playerObjectId) => { const characterRootObjectId = characterRootObjectIdOfPlayerObjectId[playerObjectId]; if (characterRootObjectId === undefined) return; delete characterRootObjectIdOfPlayerObjectId[playerObjectId]; game.objectService.deleteObject(characterRootObjectId);}, "player");cutObjectToTemplate stores the entire hierarchy, including script code and internal object pointers. Each pasteTemplate call creates fresh ids and rewires the copied body's Skeleton, attachments, animators, animations, and clocks to that same copy.
The leave listener matters: deleting the pasted root removes the whole character when its player leaves. Deleting only animation bookkeeping would leave an abandoned character in the world.
What to check when it does not work
- The source character root contains the body, Skeleton, animations, clocks, animators, attachments, and Client controller before it is cut.
- The template-preparation function runs once. Attachment creation also runs exactly once.
- Every pasted body's
followSkeletonIdand every animator's object ids point inside the same pasted character. - The Server script assigns both
characterObjectIdandheadObjectId. - The walk controller is a Client script inside the template, not a velocity loop in the Server spawn script.
- The controller reacquires the body before reading
movement.velocity. - Horizontal speed uses X and Z only, so jumping vertically does not speed up the walk cycle.
AUTHORED_WALK_SPEEDis the world speed at which the baked walk cycle should play at multiplier 1.- The controller has no deliberate speed-change dead zone if exact visual matching is required.
Every field and service used here is documented in the Object Types [blocked] API reference.