Player
<CharacterSpawn> spawns the player’s character inside a World and draws her. WASD and the arrows walk her in the camera’s frame; the on-screen stick steers the same way. While the camera’s lock is on or its eye is in her head, she faces the way the camera looks and the keys walk her sideways and back without turning her, as Roblox’s shift lock does; the camera page says when. She runs at 5 m/s unless her movement says otherwise. Game listens to the keys, not <CharacterSpawn>, so a <CharacterSpawn> that remounts while a key is down, as one inside a <World key={round}> does on a new round, walks on. A walk walkTo starts, as a click on the ground does, shows a mark on the ground at the point asked for until she arrives or the player takes over: the pulse by default, a flash with waves running out and a beam that flares; a pulsing ring, a sigil or motes of light that land with a snap, or nothing, by marker, which takes a GroundMark, the same shapes Targeting draws under a target. In a room the walk goes to the room too, which finds her path on its own navmesh, baked from the same map, and starts her on it at the step her page did.
An avatar is a VrmBody: the URL of a VRM file, its scale, the armSpread that holds its arms off its body, and, for a body with cloth, the armColliders that keep that cloth off its arms. The engine turns a VRM 1.0 body, whose front faces +z as glTF’s does, so that she faces the way she walks, and leaves a VRM 0.x body, which already faces -z, as it is. rotateY, in radians, turns a body further, for a file that faces some other way. avatar takes a body, or the name registerAvatar registered one under. The engine ships no VRM body, so a game imports one by name from @spawnite/assets/avatars, such as import { fris } from "@spawnite/assets/avatars", or its own file with ?url. spawnite model avatar turns a character rigged by Meshy into a VRM body, as Models says. A body from @spawnite/assets takes the rest of its record from @spawnite/assets/avatars.json, keyed by the file’s name, as the mmorpg’s avatars table and the arena’s registration do.
In a room, every page draws every character in her avatar, her own included, with the same clips <CharacterSpawn> plays. Only a name travels: the room dresses each character in the avatar her page’s join names, avatar in the Game’s room options, or else in the one the scene’s <CharacterSpawn> names. A character whose avatar this page never registered, or who has none, is drawn as the default body. Two characters in one avatar are two copies of it: the page parses the file once for each body it draws at once, and downloads it once. Roblox draws every player in the avatar she chose the same way.
A page poses the body of another player’s character every frame while she stands large on its screen, every other frame once she spans under 5 % of its height, and not at all while she is out of its view. Her clips play on while she is out of view, and her springs rest as she comes back, so she walks in mid-stride with her hair at rest. Nothing a game decides reads another character’s pose, since a shot meets her capsule. The character the page steers is posed every frame, on screen or off, because a game may aim her shots from her hands. cullingMode on an Npc or a CharacterView sets it for one body: AnimationCulling.AlwaysAnimate, or AnimationCulling.CullUpdateTransforms, the default for every other character. The names are those of Unity’s Animator.cullingMode. Roblox throttles the animation of a character small on screen by default in the same way, and leaves the player’s own character alone.
A raycast, a pointer event’s included, meets an avatar as an invisible capsule the size of her physics body rather than her skinned model, so it costs no more over her than over any other mesh. A model or a primitive she wears is met on its own meshes.
Its props are in CharacterSpawnProps.
The three body fields, radius, height and collisionMask, are hers at spawn: the physics cuts her capsule to them. What she steps over, climbs and jumps is the character controller’s, controller, a partial CharacterControllerTrait laid over its defaults: stepHeight 0.6 m, maxSlopeRadians 50 degrees, floorSnapDistance 0.6 m and jumpHeight 1.2 m among them. pushes lists the layers of the dynamic bodies she shoves as she walks into them, every layer when left out, and turnWithPlatform, off by default, turns her facing with a turning platform she stands on, though it does not carry her round the platform’s axis. maxSlopeRadians is her one walkable angle: she climbs, stands on and walks down any ground up to it, and slides down anything steeper. Unreal’s walkable floor angle, Unity’s slope limit and Godot’s floor max angle work the same way, each 45 degrees or near it by default. A game whose ground rises steeper than the default sets controller={{ maxSlopeRadians }}, the way Unity’s CharacterController and Godot’s CharacterBody3D carry their slope limit on the character. Past a ledge’s edge she stands on its rim while the ground under her centre is walkable or lies farther below her feet than her height. Over a bank steeper than maxSlopeRadians within that reach, she slides. The controller walks her capsule itself, as Unreal’s CharacterMovementComponent does, behind one call, moveCharacter(step, character, { velocity, jump }), which her movement makes once a tick: a move along the floor it found under her, one slide along a wall, a step onto what stands under stepHeight, and a push back out of any walker the move left her inside, cast like the move, so a wall or a ball in the way stops it. Each cast stops her 1 cm off a box or a rock’s hull, at a corner too, where Rapier’s own cast lets her dome come up to 6 mm nearer a corner. Two walkers that meet head-on stay a body’s width apart, unless a wall pins one inside the other.
Her TransformTrait is where her capsule stands: the dump and the stream carry it, and a room’s correction rebuilds her capsule on it, in the room and on every client alike. Her model’s feet are drawn apart from it, by her DrawnFeetTrait trait, which the view reads. On a slope the capsule rests above the ground under her axis, and her feet are drawn on that ground, no farther than her stepHeight under the capsule and no farther than the slope she rests on lifts a capsule, plus 5 cm. Over a ledge’s rim, where the face she rests on is the ledge’s level top, her feet stay within those 5 cm of the capsule’s bottom, so she stands on the rock rather than in it. The stream carries no drawn feet. Each client casts its own rays against the same ground, so every tab that shows her large draws her feet at the same height. A client casts them for another walker from when she spans 3 % of its screen’s height until she spans under 2.5 %, and for six steps after, while her feet ease from the measured ground back to her TransformTrait, the capsule’s bottom. A smaller walker, or one out of view, is drawn at her TransformTrait and casts nothing once her feet have eased there. Her feet ease onto the measured ground over the same six steps as she grows past 3 %, so none jumps as she crosses a line. Unreal’s CharacterMovementComponent and Unity’s CharacterController work the same way: the capsule is the truth, and a separate pass places the mesh’s feet without moving the capsule.
Another walker’s capsule is ground like any other face: she lands on a monster or on another player that blocks her, walks on it and jumps from it as from the ground. A jump from a walker’s top lifts her feet jumpHeight above it, and her movement drops a press in the air, so she never climbs walkers in the air. A capsule’s top is a dome, and where she holds on it follows her maxSlopeRadians. A default body holds anywhere the dome is within 50 degrees of level, up to about 0.5 m off the walker’s axis for two default capsules, and a landing further out slides her off its side. On a walker her feet are drawn on its top, and every tab draws them at the same height while she moves. She stands on the middle walker of a stack three high with a 1 cm gap. A room and each of its clients stand her at the same height on a walker that stands still, and a walker that walks carries her by its own move in the same tick, since the room moves each walker no player controls before the players’ characters and a base before its rider. Fortnite and Roblox let players stand on heads the same way. Unreal lets a pawn stand on another unless the base refuses it, and Zelda slides the player off an enemy.
Movement
Section titled “Movement”How she runs, stops, turns and jumps lives on her MovementTrait trait: her speed, her acceleration and deceleration, how fast she turns at speed, whether she turns on the spot from a stand, past which angle and at what rate, and how high she jumps. preset picks a whole record. Responsive, the default, brings her to full speed in a tenth of a second and pivots her at speed. Realistic takes a third of a second to reach speed and to stop, and plants her feet to turn on the spot before she walks off. movement lays single settings over the preset: movement={{ speed: 7 }}. The devtools inspector shows her settings on her row, each number as a slider and turnInPlace as a switch, and an edit moves her at once. Once a number feels right, the card’s Copy as prop button copies the changed settings as movement={{ speed: 7 }}, ready to paste into <CharacterSpawn>.
In a room she moves as she does alone. A scene holds one <CharacterSpawn>, and it is the one place a room reads its players’ characters from. The room mounts the scene, and its <CharacterSpawn> spawns nobody there: the room spawns every player’s character with the <CharacterSpawn>’s preset, movement, body fields, facing, health, stats and respawn, and hands each plugin’s character hook the rest of its props, such as bagSlots, weapons, abilities and resources, all as they stood when the room started. respawn says whether she comes back once her health runs out, after how long and where, as the health page says. Where the scene places no spawn points, a character spawns at <CharacterSpawn>‘s position, as she does alone, unless another character stands within a metre of it. Then she takes the first free place of eight on a ring 1.5 m round it, so two players who join together stand apart. Unreal’s GameMode skips an occupied PlayerStart the same way. The room refuses a scene’s file that exports movement, so a game sets its characters’ movement on <CharacterSpawn> alone. Each client reads her settings from the stream. The inspector shows them on her row read-only, greyed with the reason, unless the room takes edits. A room takes edits only when startRoom is given allowEdits: true or the room’s process runs with ROOM_EDITS=1, as the arena’s spawnite.room sets, so no client sets its own speed in a game others play. Where it does, a slider edit on your own character goes to the room as any devtools edit does, and the room clamps it to the slider’s range and streams it to every client; the devtools page says how. On a room’s world her row holds the room’s settings as they stood when she joined, so an edited card reads “Tried, not saved” and Copy as prop copies the fields that differ from the room’s. To keep them, paste them into <CharacterSpawn>’s movement.
A scene that places spawn points has the room stand each joining player’s character at one of them instead, with <CharacterSpawn>’s other fields all the same. A scene calls placeSpawnPoints(world, points) with a list of Vector3s, in an effect after it mounts if it likes, since the room reads the list on each join. Each player holds a seat: the lowest number no other player in the room holds, freed when she leaves or her character is gone. The player in seat n spawns at point n, round the list again past its end. Her first step stands her on the ground under the point, whatever its height.
She walks and jumps on the five inputs characters() declares, which ride every tick’s input to the room, as any input a plugin declares does:
| Input | Kind | Keys | What it does |
|---|---|---|---|
characters.move |
Vector | W, A, S, D and the arrows | Steers her in the camera’s view, at most 1 long: up away from the camera. |
characters.jump |
Button, edge: true |
Space | Jumps her, once for each press. |
characters.heading |
Angle | none | The camera’s heading, which the move is read in; the camera sets it. |
characters.pitch |
Angle | none | How far the camera looks above level, her aim while she strafes. |
characters.strafe |
Button | none | Whether she faces the heading as she walks; the camera’s lock sets it. |
characters({ keys }) moves the move’s and the jump’s keys: characters({ keys: { jump: ["Space", "Enter"] } }) jumps on Enter too, and characters({ keys: { jump: [] } }) frees Space for a dodge. A system reads each with readInput(character, characters.inputs.move), and a touch stick sets the move with setInput, as useInput’s steer does. A game that lists no characters() binds W, A, S, D and Space to its own inputs.
Game binds the engine’s own keys to these actions, each a handle in engineActions that useAction hears:
| Action | Keys | What it does |
|---|---|---|
interact |
E | Uses what she stands near, whose prompt shows. |
cameraLock |
Shift | Turns the camera’s lock on or off. |
menu |
Escape | Opens or closes the play menu. |
pushToTalk |
V | Held, sends her voice; with open mic, turns her mic on or off. |
performanceStats |
F2 | Steps the performance overlay through its levels. |
forward, back, left, right |
W, S, A, D and the arrows | Fly the devtools’ free camera; a game may bind them. |
climbUp, climbDown |
E, Q | Fly the devtools’ free camera up and down; a game may bind Q. |
controls() in the game’s plugins moves or drops an engine action’s keys: controls({ keys: { cameraLock: [] } }) frees Shift for a sprint. As the world is made, every key a game’s plugin binds, for a held input, a press or a page action, is checked against these and against each other, and a clash throws naming both and the fix. The ability bar’s keys are its own prop, outside the check. Unity’s Input System and Godot’s Input Map bind actions to keys the same way; player rebinding and gamepads come later, as more sources for the same actions.
The jump
Section titled “The jump”A press of Space jumps her, and so does a tap on the phone: a game mounts <Tap label="Jump" onTap={jump}> beside its Joystick, with jump read off the useInput store. She leaves the ground at the speed that lifts her feet by jumpHeight on her CharacterControllerTrait, 1.2 m by default, under the scene’s gravity, and the same gravity brings her down. Under the default 9.8 m/s² she leaves at 4.85 m/s and lands 0.99 s later; a game that raises it, with definePhysics({ gravity: [0, -20, 0] }) passed to <Game physics>, keeps the height and shortens the arc. Unity’s Starter Assets state the jump the same way, as a 1.2 m JumpHeight, and Roblox as Humanoid.JumpHeight.
- One press is one jump. A held Space jumps once, as in Unity’s and Godot’s templates. Space on a focused button presses that button and does not jump her.
- In the devtools’ edit mode, Space pauses the world and does not jump her.
- She jumps from the ground, and for a moment after she walks off an edge, which is called coyote time. A press made just before she lands is kept and jumps her as she lands, which is called a jump buffer. Each is a setting a game changes, and
CharacterSpawnPropsnames them. - The stick steers her in the air as on the ground, at the same speed.
- A jump that meets a ceiling stops rising there and falls from it, as in Unreal and Godot.
- The jump is an edge button,
characters.inputs.jump: the step carries the press on the one tick it was made, so a test, the harness and a room jump her as the keys do. The step takes the press off the input it reads, soholdInput(game, characters.inputs.jump, true)jumps her once, on the next step. A press that reaches the room up to 17 ticks late still jumps her, once, on the room’s next tick.
Her CharacterTrait says whether her last move ended with her on the ground, isGrounded, the entity she stands on, standingOn, how long she has been off the ground, airSeconds, the jumps since she left it, jumpsSinceGrounded, and her vertical speed in metres a second, negative on the way down, verticalSpeed. moveCharacter writes it as it moves her, so a game and the dump read it off her entity, as Unity’s isGrounded and Godot’s is_on_floor() do. In a room, a correction puts her body in the room’s state, in the air or on the ground, and the slope under her, so a correction halfway through a jump leaves her the rest of the room’s arc, and one on a hillside walks her on up it as the room does.
On a VRM body she plays three clips over the jump:
jump.vrmawhen she leaves the ground standing, andrun-jump.vrmawhen she leaves it running. The one chosen as she leaves holds until she lands, however the stick moves in the air.land.vrmaafter she lands with the stick idle, held for the clip’s whole length. A stick held on landing, or pushed during the crouch, goes straight to the run.
The land clip plays from the moment her feet touch, 0.25 s in, because the frames before it carry a fall in her hips that the capsule has already made.
Where she comes back
Section titled “Where she comes back”A player’s character at zero health comes back as CharacterSpawn’s respawn says: after a wait, where she spawned, where she fell, or where the game says, such as the last checkpoint she touched. RespawnOptions names the settings, and Health says how a character goes down.
Changing her movement for a while
Section titled “Changing her movement for a while”Three calls change how she moves from a game’s own predicted system, each predicted in a room and restored by a correction, as Change the character’s movement shows with a sprint, a dash and a blink, and a fourth, a launch, is the room’s:
addSpeedModifier(step, character, { key, more, seconds? })multiplies her top speed by1 + moreunder a keydefineSpeedModifiermakes: a sprint, a crouch, a root atmore: -1. Keys multiply with each other and with hermoveSpeedstat, andreadMoveSpeed(character)answers the result.characters.systems.countDownends a timed one throughcountDownSpeedModifiers(step), which a game that replaces the plugin’s countdown calls from its own predicted system.pushCharacter(step, character, { velocity, seconds, endSpeed?, priority? })holds her horizontal velocity for a while, with no turn toward the stick: a dash, a knockback. Avelocity.yabove 0 launches her as a jump does.readPush(character)reads the push she holds, its velocity and seconds left, or null, for a movement of a game’s own that honours it.teleport(step, character, { position, rotation?, keepVelocity? })places her body and her transform at a spot exactly, never short of it, keeping her velocity unlesskeepVelocityis false, and draws her there with no glide. Inside a wall there, it pushes her out by the shortest way, up to her radius; still inside, it answersisClear: falseand marks her stuck, and her next move recovers her. A blink that stops at the first wall casts her capsule first withcastShape, as the blink sample does.addLinearVelocity(step, character, velocity)shoves her, on the room’s step alone: its upward part lifts her off the floor, as Unreal’sLaunchCharacterdoes, and its sideways part decays on the floor by her controller’sgroundFriction.
Each of the first three takes the predicted system’s step. A knockback or a teleport the room starts, such as a trap’s, takes the room’s step inside if (step.authoritative). Unreal’s MaxWalkSpeed, root motion sources and TeleportTo, and Roblox’s WalkSpeed and PivotTo, do the same three jobs.
Collision layers
Section titled “Collision layers”Her capsule stands in characters.collisionLayers.players, and every other character’s in characters.collisionLayers.npcs, two of the collision layers every world numbers; the terrain, its edge walls and its scatter stand in CoreLayers.ground, and a body that names no layer in CoreLayers.default. collisionMask lists the layers her sweeps and contacts meet, as a body’s does, every layer when left out, or { except: [...] } for every layer but those; a layer left out never stops her and never costs her a test. A chaser leaves out both characters’ layers. Two colliders meet only when each one’s mask holds the other’s layer, so a game that wants its monsters to block her gives them { except: [characters.collisionLayers.npcs] }. A ghost that walks through everything but the floor meets [CoreLayers.ground], the way Unity’s layer collision matrix and Godot’s collision mask narrow what a body meets.
Getting unstuck
Section titled “Getting unstuck”A walker whose capsule starts inside a rock, a tree or a prop has no way out on foot: a save restored where the scatter now stands, or a spawn onto a placement, leaves her there. freeCharacter(world) stands the player’s character on the ground at the nearest spot within 10 metres where her capsule overlaps nothing, and she walks on from there. It answers whether she now stands clear. A character already clear stays where she is, and so does one with nowhere clear in reach, for whom it answers false. It moves the walker it is handed in place of the player’s character. While her capsule overlaps a hull or a prop, the step keeps the StuckTrait tag on her character, and takes it off once she stands clear; another walker or a dynamic prop, such as a crate that fell on her, never counts, since she walks off either, and neither does a wall she walks into. A game shows its way out only while she carries the tag, as the mmorpg’s Unstuck button does, the way Unreal’s OnCharacterStuckInGeometry tells a game when its character overlaps geometry it cannot leave. A dump lists it as stuck. It searches the ground alone, so a rock top is never where it stands her. In a room the room’s next correction puts her back; the room frees no one yet.
What she holds
Section titled “What she holds”Each of the following comes from a plugin the game lists, as What a plugin adds to a character describes: her weapons from weapons(), her abilities from abilities(), and her bag from inventory(). Her resources need no plugin.
She starts holding the weapons weapons names, by the names registerWeapon gives them, as the weapons a character holds says: in a room, a shot from any other is refused from the moment she spawns. Left out, she holds every registered weapon.
She holds the abilities abilities names for as long as she is in the world, and has each resource resources names, such as ["mana"], where its settings say it starts, as the abilities page and the resources page say. Left out, she holds no ability of her own and has no resource.
In a game that lists inventory(), she carries a bag of 16 slots, or as many as bagSlots says, and wears the engine’s equipment slots, as the inventory page describes. Roblox gives every character a Backpack and Minecraft every player an inventory; here a game that has no items lists no inventory(), and its characters carry none. The player’s save keeps both where the game’s save includes inventory, as Saving progress says, so a game’s save code never names them. A room gives her the same bag and slots, empty each time she joins.
<HeldItem> draws its children in her right hand, so they move with the hand as her clips play. Put it in her overlay: <CharacterSpawn>’s children, or the Replicas overlay in a room, which draws every character her page draws, her own included. On an avatar it hangs on the hand bone; on the default body it stands at her right side, at 0.6 of her height. position, rotation and scale place the held thing in the hand’s axes, which are hers while she stands at rest: y up, -z the way she faces and x to her right, so a barrel along -z points the way she faces. It draws nothing outside a character’s overlay. Roblox welds a tool’s handle to the right hand the same way, and Unity and Unreal attach it to a hand socket.
While it holds something, an avatar’s right hand closes round it: each frame, after her clip, the engine curls her thumb and fingers to a grip over whatever the clip does with her hand, and lets go when the HeldItem goes. The engine’s grip closes round a handle about 3 cm across that runs along the hand’s z axis, which on a VRoid body sits about [0.055, -0.025, 0] in the hand’s axes: put a sword’s or a wand’s handle there. grip sets how far each finger closes, from 0, straight, to 1, a fist, over the engine’s grip: grip={{ index: 0 }} points her index finger along the handle. An avatar with no finger bones, and the default body, draw no grip. forward names the way the held thing’s tip points in its own axes, [0, 0, -1] unless set, which a cast’s hand turn carries onto the target as the spell releases: a wand whose orb is up its y gives [0, 1, 0]. Unreal lays a hand pose over any animation with a layered blend per bone, and Unity plays a grip clip on a layer masked to the hand. The engine’s grip is one curl per finger instead, as Unity’s humanoid muscles describe a finger, so the same numbers fit every avatar.
import { HeldItem, Replicas } from "@spawnite/engine";
export function Heroines() { return ( <Replicas> {() => ( <HeldItem position={[0, 0, -0.15]}> <mesh> <boxGeometry args={[0.05, 0.08, 0.45]} /> </mesh> </HeldItem> )} </Replicas> );}Aim and side-step
Section titled “Aim and side-step”While the camera’s lock is on, or its eye is in her head, an avatar aims where the camera looks. Her right arm points along the look, so what HeldItem puts in her hand points at the crosshair’s line, and her chest leans half the look’s pitch. The arm follows the look to the camera’s stops: 80 degrees up or down, and 89 in first person. A step to the side turns her hips and legs toward it, as far as 75 degrees, while her chest stays square to the camera, and a step back plays the run clip in reverse with her legs facing the camera. Where the characters’ clips include strafe clips, a step within 30 degrees of square plays strafeLeft or strafeRight with her legs square, and a step back plays backpedal; each one missing falls back to the run. The engine ships none: a game adds one with registerClip(StrafeClip.Left, url), as Your own pose clips says. spawnite model clip writes a VRMA from a Meshy clip, as Models says. The pose eases in and out over about a quarter of a second as the lock turns on and off.
Unity and Unreal build the same thing from clips: an aim offset of upper-body poses and a blend of walk clips in eight directions. The platform ships neither, so the pose is built from the bones over whichever clip plays. Every page draws it for every character from her pose: the step writes whether her input strafes and the pitch it aims at into pose.strafe and pose.pitch, and the room streams them, while her input itself stays on the room and her own page. What she holds points parallel to the camera’s look from her shoulder, so its line runs a few tenths of a metre beside the crosshair’s rather than meeting it.
aimAt(character, point) aims a character at a point in the world, such as the enemy a system just shot at. It works on any character: <CharacterSpawn>’s avatar or model, and an Npc drawn the same way. While she aims, the step turns her to face the point, and her steer walks her without turning her, as the camera’s lock does. Her legs turn toward her step, as far as 75 degrees off the point, and a step back plays her walk in reverse with her legs facing the point. On an avatar her right arm points at the point and her chest leans toward it, as they do for the camera’s look. The point wins over the camera’s lock while both are on. On a model, the whole model faces the point, and the bones under its spine, the hips and the legs, turn toward the step while the spine turns back. aimAt(character, null) lets go, and she turns back to her step. Her legs ease in and out over about a quarter of a second, as for the lock. The point lives on her AimPointTrait, so a room streams it to every page and the dump shows it. A point whose x, y or z is not a finite number throws, saying so.
// In a system, on each shot.aimAt(character, { x: target.x, y: 1, z: target.z });// Once nothing has fired for 0.5 s.aimAt(character, null);Unreal builds this from an aim offset, which poses the upper body toward the aim, and Lyra’s orientation warping, which turns the hips toward the step. Unity aims with a Multi-Aim Constraint from its Animation Rigging package, or with the Animator’s LookAt IK. Godot turns a bone toward a target with LookAtModifier3D. Roblox turns AutoRotate off on the Humanoid, and the game turns the Waist joint itself. Each of those leaves the game to wire the aim to the gameplay and to the network. aimAt is one call that the step, the stream and every page’s body read.
The default body
Section titled “The default body”With no avatar, the player is a blue capsule cut to her height and radius, 1.4 metres tall and 0.35 metres in radius by default, standing on its position. It is drawn from geometry rather than a model file, so a game starts without any other game’s character and downloads no VRM or VRMA file; hand it an avatar to wear a VRM body, and her body and clips start downloading as <CharacterSpawn> first renders.
shape swaps the capsule for another primitive and color paints it. A PlaceholderShape.Box is as tall as her and as wide as her capsule; a PlaceholderShape.Sphere is a ball her radius round, resting on her feet. shape={PlaceholderShape.Sphere} color="red" makes her a red ball.
model names a model from the same registry Entity reads, so a model spawnite add asset registers works for her too. The model stands with its lowest point on her feet, and its front, +z in the file as glTF puts it, faces the way she does. Where the file has clips, she plays the ones her pose asks for: idle, walk and run blended by her ground speed, and jump off the ground, once, holding its last frame until she lands. The jump fades in and out over 0.2 s. A model with no clips stands as its file poses it. Each clip playClip asks of her plays over them from its file, as an Entity’s model does. ModelClip names the four.
The blend places the idle at 0 and the walk and the run each at its stride speed: the ground speed at which the clip, played at its own pace, keeps her feet planted. Between two neighbours the weights move linearly with her speed. The walk and the run share one phase, so a blend of the two never crosses her legs, and the phase turns as fast as the ground passes under her, so her feet hold on the ground at every speed. Below the walk’s stride speed the walk plays at its own pace and takes a larger share of the blend as she speeds up; above the run’s, the run plays faster. A loop that holds its first pose before its first key, as a Meshy walk and run do for a frame, starts at that key, so the loop does not hitch.
The engine measures each stride speed when her model loads: the mean speed at which each toe moves back under the hips while it stays planted, at the scale the model is drawn. It finds the toes, or the feet where the rig has no toes, by the names Meshy, Mixamo and VRoid give them, such as LeftToeBase, mixamorig:LeftFoot and J_Bip_L_Foot. Where it finds neither, or the clip moves neither foot, that clip plays at its own pace, the walk blending in at half her top speed and the run at her top speed, and the model warns once in development, naming the model, the clip and the clipSpeeds to set. A missing clip leaves its place to its neighbour: a model with no walk blends from its idle straight into its run, and one with neither stands in its idle. A run that clips points at the walk’s clip, such as clips={{ run: "walk" }}, is one place in the blend, as with no run: she walks at every speed past the walk’s.
Unity lays out the same blend as a 1D Blend Tree, whose Compute Thresholds → Speed reads each clip’s speed off its root motion, and Unreal and Godot as a BlendSpace1D, with Unreal’s sync groups keeping the walk and the run in step. Roblox’s Animate script plays one clip at a time and scales the walk’s rate by the character’s speed.
clips points a pose at a clip the file names differently, such as clips={{ run: "Sprint" }} for a file whose run is called Sprint. A name the file lacks warns once in development, listing the file’s clips.
clipSpeeds sets a stride speed in place of the one the engine measures, in metres a second at the scale the model is drawn, such as clipSpeeds={{ walk: 0.7, run: 2.15 }}. Set it where the measure reads a clip wrongly, such as a run whose feet slide, or where a clip warns that it cannot be measured. Each speed is a positive number, and the walk’s is below the run’s; anything else throws as <CharacterSpawn> mounts, saying what to change. The walk’s speed stays below the run’s once the engine adds the speeds it measures and the defaults for a clip it cannot measure, so a run set below a measured walk throws as her model loads, naming where each speed came from. ModelClipSpeeds is its type.
material dresses her model: a function handed each mesh’s own material, which returns the one she wears in its place, such as a glowing shader built over the file’s texture. <CharacterSpawn> reads it when her model loads and again whenever the function changes, so define it once outside the component. The material it returns is the game’s: <CharacterSpawn> never disposes it, so one material can dress every mesh and every character. Left out, her model wears the material it registered with, as an Entity’s does.
An avatar wins over a model, and a model over a primitive.
Whatever she wears, her physics body stays the capsule the body fields cut. Her size never comes from her model, as a walker’s does: she is 1.4 m tall and 0.35 m in radius unless <CharacterSpawn> sets height or radius.
Your own pose clips
Section titled “Your own pose clips”An avatar stands, runs, turns and jumps in the engine’s clips unless the game gives its own. registerClip under a pose’s name puts a VRMA file in place of that pose’s clip for every avatar in the game, such as a heavier run for a knight or a creature’s gait: registerClip(PoseState.Run, trot), with trot a ?url import of the file, as the one-shot sample imports its clip. Register it before the first character draws, beside the game’s other clips. The return forgets it: a character drawn after plays the engine’s clip again, and one already drawn keeps her clips until she draws again.
PoseState names the seven poses: idle, run, turnLeft, turnRight, jump, runJump and land. StrafeClip names the three strafe clips the engine ships none of. The engine’s land clip starts where its feet touch the ground, since the capsule has already fallen by then. A game’s land clip starts at its touch mark, registerClip(PoseState.Land, url, { marks: { touch: 0.25 } }), and plays whole without one. A room never loads a clip: every page plays the pose its room streams, with the clip its own game registered. A model character names its own clips with clips instead.
Roblox swaps the clips of its default Animate script by name, and Unity’s Animator Override Controller replaces a controller’s clips by name the same way. Unreal and Godot give each character its own animation blueprint or tree. Here one call per pose replaces the clip and keeps the engine’s blending, aiming and strafing over it.
One-shot clips
Section titled “One-shot clips”playClip(character, name) plays a clip once over whatever her pose plays, then fades back to the pose, as Unreal plays a montage. A cast or a hit plays this way. Every avatar plays her pose clips and each clip registerClip(name, url) names: a VRMA file, a ?url import, registered before the first character draws. A pose’s name, such as idle or strafeLeft, puts the clip in place of that pose’s, as Your own pose clips says.
import cast from "./animations/cast.vrma?url";import { registerClip } from "@spawnite/engine";
registerClip("cast", cast, { marks: { release: 0.4, settle: 0.9 } });marks names where things happen in the clip, in seconds from its start, as Unity’s animation events, Unreal’s notifies and Roblox’s keyframe markers sit on the clip: a VRMA carries no event track, so the marks sit beside its file. release is the frame a cast lets its spell go, and an ability that plays the clip casts at it. A look hears any mark with useClipMark(entity, "settle", heard), called on the page that draws the clip as her one-shot crosses the mark, with the clip, the mark, its seconds and the play: where shards stand up as her hands come down, exact to the frame rather than to the room’s tick. Each mark raises the ClipMarkEvent event on her for the step. spawnite play timeline shows a clip frame by frame with the second under each, which is how a mark is read, and Animations walks the whole path from a clip file to a confirmed mark.
The clip fades in over fadeSeconds, 0.15 s by default, and fades out over its last fadeSeconds. By default it plays over her whole body. On an avatar, a run or a jump ends it early, faded out the same way, so she never slides across the ground in a cast. A model’s one-shot plays on through a run or a jump to its end, so a model that moves while it casts plays the cast on ClipLayer.UpperBody. { hold: true } holds the last frame instead, as a death does, until stopClip(character) fades her back, the next playClip replaces it, or she respawns. The call writes the OneShotClipTrait trait, which a room streams, so every page plays the clip on her. A name her clips lack plays nothing and stops the clip her layer plays, as stopClip does, and a dev world warns once with the names she has. Entity plays a model’s clips the same way.
layer: ClipLayer.UpperBody plays the clip on the bones from her spine up, while her pose’s clip goes on driving her hips and legs, so she shoots or waves while she runs. A run or a jump does not end an upper-body clip. A clip on each layer plays at once, the upper body’s over the whole body’s, and stopClip(character, { layer: ClipLayer.UpperBody }) fades out the upper body’s alone. The engine finds the spine itself: a VRM avatar’s spine bone, and on a model the first bone whose name holds spine, such as a Meshy rig’s Spine02 or a Mixamo rig’s mixamorig:Spine. A model with no such bone plays the clip on its whole body, and a dev world warns once with the bones it has. The upper body’s clip writes the UpperBodyClipTrait trait, which a room streams as it does the whole body’s.
start, end and speed play part of a clip: from start seconds into the file’s clip to end, at speed times its pace. A held clip holds its end frame. Marks keep the seconds of the file’s clip, so a clip played from 0.3 s never raises a mark at 0.1 s. A start below 0, an end not after start, a speed of 0 or less, or any of them infinite throws, with what to pass. An end past the clip’s length plays to the clip’s end, and a dev world warns with that length. A gun that fires one shot of a looping burst clip plays its first shot on the arms alone:
// In a system, on each shot.playClip(character, "shoot", { layer: ClipLayer.UpperBody, start: 2 / 30, end: 10 / 30, speed: 1.5, hold: true,});// When the aim lapses.stopClip(character, { layer: ClipLayer.UpperBody });Unreal plays a montage into a slot that a layered blend per bone limits to the spine’s branch, and sets a montage section’s start and its play rate. Unity plays the clip on an Animator layer with an AvatarMask that holds the upper body, and sets a state’s speed. On Roblox an animation track drives the joints its keyframes name, at a priority over the movement’s track, with AdjustSpeed and TimePosition. The engine names the layer, as Unity and Unreal do, and finds the spine for you, so the same call fits a VRM avatar, a Meshy rig and a Mixamo rig.
Posing over the clips
Section titled “Posing over the clips”useAfterPose(entity, callback) calls callback({ root, bone }, seconds) on the page each frame, right after the engine has posed the entity’s model: after its clips and one-shot clips, an avatar’s aim, grip and humanoid rig, and before an avatar’s springs, which follow what the callback moves. root is the model’s root object, and bone(name) is the bone of its skeleton named name, as the file names it, or undefined. Call it in any component under the World, such as <CharacterSpawn>’s children. It works on <CharacterSpawn>’s model, a VRM avatar and an Entity’s animated model, and it stops when the component unmounts.
import { useAfterPose } from "@spawnite/engine";import type { Entity } from "koota";import { Quaternion, Vector3 } from "three";
const recoil = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), 0.2);
interface LeanProps { character: Entity; /** Radians the soldier leans back. */ lean: number;}
export function Lean({ character, lean }: LeanProps) { useAfterPose(character, ({ root, bone }) => { root.rotation.x = -lean; bone("RightHand")?.quaternion.multiply(recoil); }); return null;}A bone that the callback reaches through bone goes back to its clip’s pose before the next frame, so a turn the callback adds each frame stays one turn, even over a held clip whose pose does not move. root keeps what the callback writes, so set its values rather than add to them. On a VRM avatar, bone reaches the raw bones, by the names her file gives them. Unity runs the same step in LateUpdate or OnAnimatorIK, after the Animator, and Unreal in a Control Rig or an animation blueprint’s post-process graph.
Names over other players
Section titled “Names over other players”In a room, every page draws each other player’s name over their presence, her character in most games, and never the player’s own, and none for a player with no presence or a hidden one. The name is white, 12 px semibold, the same size on the screen at any distance, with a thin dark outline and a soft shadow and no box behind it, as Roblox draws a display name, so it reads on grass, fire and sky. Its bottom stands 0.15 m over the top of the character’s drawn body. While that player speaks in the room’s voice, as useSpeaking reads it, a green microphone stands left of the name: it shows within a tenth of a second of their voice and stays 0.3 s past their last word.
Who sees a name, and how far:
- Anyone else: whole to 20 m, fading out to nothing at 30 m. A wall between the camera and the name hides it; the page checks each name’s line of sight five times a second, with the same ray a shot uses against cover.
- A teammate, a character on the page’s player’s team: the name takes the team’s colour, never fades with distance unless the game sets
fadeordistance, and shows through walls, as Fortnite’s team nameplates and Roblox’sEnemyOcclusiondo.
The plate hangs in the object the character’s view draws, as the interact prompt does, so a game that draws its own characters, as Holdfast does, gets it with no code. A player turns every plate off with Show other players’ names in Settings.
The scene’s <CharacterSpawn> sets how the plates look and when they show, with one prop, nameplate:
nameplate={false}draws no plates, for a game that draws names of its own.nameplate={{ fade: [30, 50], size: 14 }}tunes the engine’s plate: how far it shows and where it starts to fade, where it stands over the head, its size in pixels, the colour of a name with no team, whether walls hide it for everyone, for enemies only or for nobody, and whose plates show.background: trueputs each name on a 50% black pill.NameplateOptionsholds each field and its default.nameplate={MyPlate}draws each plate with a component of the game’s own. It receivesNameplateProps: the character’sentity, hername, whether she isspeaking, thecolorher name draws in, whether she is ateammate, and the setting’ssizeandbackground. The engine still places it, fades it and hides it behind walls. It draws in pixels, with the plate’s bottom middle at its origin.{ component: MyPlate }does the same beside the other options.
The engine’s own plate is Nameplate, so a game’s plate can draw it beside something of its own, such as a health bar:
import { CharacterSpawn, Nameplate, type NameplateProps,} from "@spawnite/engine";
function PlateWithBar(props: NameplateProps) { return ( <> <Nameplate {...props} /> <mesh position={[0, -4, 0]} scale={[60, 4, 1]}> <planeGeometry /> <meshBasicMaterial color="#ff4d5e" transparent /> </mesh> </> );}
export function Run() { return <CharacterSpawn nameplate={PlateWithBar} />;}A plate’s teammates and its colour come from teams: with teams() listed, two players on one team are teammates, and a plate wears its team’s colour, the color its defineTeam gives, unless the setting’s teamColors is TeamColorMode.Off. With no teams(), nobody is a teammate and every plate wears color, as Roblox’s Player.Team and its TeamColor drive its nameplates.
The name is the one the player joined the room as, PlayerTrait’s name on her player, which every page reads through useDisplayName on her presence, where a DisplayNameTrait the game puts on it stands over it, as Entity says. A plate component receives both entity, her presence, and player, her player entity.
What the other engines do: Roblox is the one that draws names by default, over every character, hidden behind walls, out to 100 studs, and a creator changes it with DisplayDistanceType, NameDisplayDistance and NameOcclusion. Fortnite’s island settings choose who sees nameplates, how far, whether obstacles hide them, and a voice indicator. Unity, Unreal, Godot and PlayCanvas give a game a billboard to build one from. This plate takes Roblox’s default and its look, Fortnite’s controls and its team default, and draws on the engine’s Billboard.
A player’s character is a character she controls
Section titled “A player’s character is a character she controls”spawnNpcBody(world, { position, facing }) spawns a walker: the traits the step walks along a navmesh path, turns, jumps and poses, on the engine’s default movement. A player’s character is the same walker with ControlledTrait, which makes it an entity a player may control, and her health, body, bag and weapons, which the characters plugin spawns for her. What walks, poses or draws her reads the character’s traits, so walkTo(world, destination, character) sends a character with no ControlledTrait round the navmesh as it sends her, and Replicas draws a streamed character in its avatar as it draws a player’s character. In a game played alone, the engine draws a character a system spawned in the same view, so it shows alone as it shows in a room. ControlledTrait says a player may control the entity, and which one does: a chase, the Players collision layer and a room’s seats read it, and pass over a character without it. The devtools’ walkTo, which the CLI and the MCP call, is a test’s step on top of this one: it sends the player’s character and steps the world until she arrives or its steps run out. Unreal splits it the same way: one Character walks, and a player’s controller or an AI controller possesses it.
When a walk is refused
Section titled “When a walk is refused”walkTo returns what it did. A walk it started is { corners, partial }: the corners it walks, and partial, true where no route reaches the point and the corners stop at the nearest point the search reached, such as the foot of a rock the walkers cannot climb. She walks a partial route and stops at its end. A walk it refused is { refusal }, a WalkRefusal:
| Refusal | When |
|---|---|
NoNavMesh |
The world has no navmesh: it mounts no <World>, whose ground every navmesh is baked from, or the bake has not finished. |
NoCharacter |
None was named, and the world has no player’s character. |
NotWalker |
The entity named is gone, or walks no path: it was spawned without spawnNpcBody or spawnCharacter, so has no PathTrait. |
StartOffMesh |
The character stands too far off the walkable ground to snap onto the mesh. |
DestinationOffMesh |
The point is too far off the walkable ground to snap onto the mesh, such as a click into the sky. |
NoRoute |
Both ends snapped and the search failed. |
A refused walk changes nothing: the character keeps the path it had. A click into the sky leaves a walk under way alone, and a routine that wants its character stopped on a refusal clears the path itself, character.set(PathTrait, { corners: [] }). A click on an interactable whose walk is refused forgets the interactable, so a walk under way presses nothing it passes. In a room, a walk her page started goes to the room, which finds its own route; a walk the room’s navmesh refuses stands her there, since her page dropped her last walk for it, and the room logs it.
Unity’s SetDestination returns a bool and its pathStatus says complete, partial or invalid; Unreal’s MoveToLocation returns Failed with a separate partial flag; Godot’s agent answers is_target_reachable(); Roblox’s path answers NoPath. None names why, and each walks a partial route to its nearest point, as this one does. A refused walk keeps the old path, as a Roblox walk goes on after a failed compute, where Unreal’s MoveToLocation aborts the old move: a refusal here is an answer with no side effect, so the caller chooses whether to stop.
Control
Section titled “Control”A player controls the entities her input drives: her character in most games, a drone or a kart beside it or in its place, or nothing at all in a card game. ControlledTrait holds the link: the controller, a player entity or none, the way it is simulated, Simulation.Predicted or Simulation.Server, and its control serial, a number each change of the link takes. Control changes on the room alone:
control(step, player, entity, { simulation })makes her control the entity from the next tick. The mode is required. A control of an entity another player holds hands it over in one change. Three options beside it:visibility: ControlVisibility.Controllerhides who controls it from every page but hers,contextnames the input context live on her page while she controls it, andviewTarget: truemakes it her view target, the one her camera follows.tryControl(step, player, entity, { simulation, from: null })returns{ ok: true }, or{ ok: false, refusal }with aControlRefusal:EntityGone,ControllerLeaving,NotControllablefor a player or a room controller,NotAControllerfor an entity that is neither, andControlledByOtherwherefromnames another controller than the one that holds it. Two riders who reach for one seat in one tick get one{ ok: true }.controlthrows the same refusals in development and logs them in a deployed room.releaseControl(step, entity)clears the controller: the entity reads each input at rest, and its systems step it on.
Each call checks and reserves at once, and the change commits as the tick ends, so every system of a tick reads one controller per entity: findController(world, entity) reads the committed one, findPendingController(step, entity) the one after this tick’s changes, readControlled(world, player) lists what she controls, and readPendingControlled(step, player) what she will control once the tick commits. A join hook may control the joining player’s own entities with its step, as the characters plugin controls her character; a hook that reaches for another entity throws, which refuses the join. Destroying a controlled entity drops it from her control at once, and a player who leaves releases everything she controlled.
Her input for each tick rides to the room once, whatever she controls: the room writes it onto her player entity, where readInput(player, handle) reads it, and into each entity she controls. Her page predicts each entity she controls in Simulation.Predicted, as it predicts her character: it steps the entity on her input the moment she presses, and a correction restores and re-runs every one of them. useCharacter() and readLocalCharacter(world) read the character her player controls, a character with its pose before a kart or a drone she controls beside it. The room’s tests run two creators’ plugins against a real room, drone.ts and crank.ts: a drone she flies in her character’s place, and a game whose player has no character and plays through an axis of its own.
A plugin’s onControlRelease(step, entity, { controller, reason }) runs once for each control that ended, at the commit, in plugin order, so a plugin tidies up without a system that polls. The ReleaseReason is Released, HandedOver, EntityDestroyed, ControllerGone for a player who left or a room controller destroyed, or LevelChanged for an entity the room’s scene destroyed as it started again. The entity may be gone already, as the reason says. A control a hook stages commits at the next commit.
A hidden control streams its controller as none to every page but the controller’s: a prop hunt’s disguised player reads, on every other page, as the empty prop beside her. Her own page learns of it in her acknowledgement, so findController there names her player. No page but hers receives a control’s serial, mode or context.
Bot players and room controllers
Section titled “Bot players and room controllers”Room AI drives an entity through the input path a player’s page drives one through, so the entity’s own systems, its prediction and its tuning stay one code:
addController(step, { name })makes a room controller: an entitycontrolaccepts, which is no player, never streams, andreadPlayersleaves out. An NPC racer, an autopilot that lands a dropped pilot’s dragon, or a takeover of a dropped player’s hero each control through one. Destroying it releases what it controls. An entity it controls is simulated on the room alone.addBotPlayer(step, { name })fills a seat with a player of the roleRole.Bot, with every plugin’s join hooks, so thecharactersplugin gives her a character and a scoreboard counts her. She holds no save,kickremoves her, and the room’s close runs her leave with every player’s.writeInput(step, controller, { handle, value })writes one field of a room controller’s or a bot player’s input for the next tick, as a page sends a player’s before the tick it names; a field no system wrote for a tick reads at rest, an angle where it last stood. A player’s own input comes from her page alone.
The autopilot, autopilot.ts in the room’s outside tests, is a creator’s plugin a test runs against a real room: a room controller drives an NPC kart until a player takes the wheel, and a player hides in a prop no other page sees her control.
Input contexts
Section titled “Input contexts”An input context is a named list of a plugin’s inputs with a priority, contexts: { driving: inputContext({ priority: 10, inputs: ["throttle", "handbrake"] }) }, which a control names. While she controls an entity under it, the context is live on her page. Every input fills from its own keys, and a key two live contexts’ inputs share fills the higher one’s alone: W fills a kart’s throttle and not her seated character’s walk, since the characters plugin controls her character under characters.walking, at priority 0. A key a higher context took stays off the lower input until she lets it go, so leaving the kart with W held walks nowhere until she presses it again. An exclusive: true context, a menu’s, rests every input below it. Two live contexts of one priority that share a key throw in development. Two inputs may share a key only where each is listed in a context. As Unreal’s input mapping contexts and their priorities.
The view target and presence
Section titled “The view target and presence”Two references on her player say what draws and listens for her, apart from what she controls:
- The view target is what her camera follows: her character, her kart, a player she watches, a cutscene’s rig.
ViewTargetTraiton her player names it, and her own page alone receives it.setViewTarget(step, player, entity)moves it, andnullhands her camera to the scene’s camera;control(…, { viewTarget: true })moves it as a control does. Her page reads it withuseViewTarget(). - The presence is what stands for her in the world, which her nameplate hangs over: her character in most games, her dragon in a dragon game, none in a card game.
PresenceTraiton her player names it, and every page receives it.setPresence(step, player, entity, { visibility })moves it;visibility: ControlVisibility.Controllerwrites it as none on every page, hers among them since every page shares the stream, while the room keeps the entity, so no nameplate gives a disguised player away.findPlayerByPresence(world, entity)andusePlayerByPresence(entity)go from what stands for her back to her player, which is howuseDisplayNamenames a character.
The characters plugin makes her character both as it spawns her. Watching another moves her view target and never her presence, so no nameplate of hers moves over the player she watches. Each change is checked at once and lands as the tick ends, with the controls: the last of a setViewTarget and a control with viewTarget: true in one tick wins. Destroying the entity either names clears it at once, and her camera falls back to the scene’s camera. A player who drops keeps both through the hold; a player who leaves takes both with her, and one who joins again starts with none until a game sets them. Both take what control takes and refuse as it does: in development a call on an entity that is no player, or on a destroyed entity, throws.
Her page reads what is hers with usePlayer(), her player entity, useCharacter(), her body, useViewTarget(), what her camera follows, and useControlled(trait?), what she controls; readLocalPlayer(world) reads her player outside a component. isControllerHeld(world, entity) says whether the player who drives an entity dropped, which ConnectionTrait on her player holds, so a game leaves a held player’s character out of a vote or a chase.
A plugin that points her camera at the next player’s character on N, and back at her own after the last, from packages/room/test/outside/spectate.ts, which a test runs against a real room:
import type { Entity, World } from "koota";import { buttonInput, definePlugin, PresenceTrait, readInput, readPlayers, readTrait, setViewTarget, ViewTargetTrait, type AuthoritativeStep,} from "@spawnite/engine/core";
// Watching another player, built as a creator's plugin is, from the// engine's public entry alone: N points her camera at the next player's// character, and at her own again after the last. Her presence stays her// own, so no nameplate of hers moves over whom she watches.
export const spectators = definePlugin({ name: "spectators", inputs: { watch: buttonInput({ keys: ["KeyN"], edge: true }) }, systems: { rules: { watchNext } },});
/** What stands for `player` in the world, or null for nothing. */function readPresence(player: Entity) { return readTrait(player, PresenceTrait)?.entity ?? null;}
/** Moves the view target of each player who pressed N to the next * player's presence, and back to her own after the last. */function watchNext(world: World, step: AuthoritativeStep) { const players = readPlayers(world); for (const player of players) { if (!readInput(player, spectators.inputs.watch)) continue; const own = readPresence(player); const watched = readTrait(player, ViewTargetTrait)?.entity ?? own; const others = players .map(readPresence) .filter((presence) => presence !== null && presence !== own); const next = others[others.indexOf(watched as Entity) + 1] ?? own; setViewTarget(step, player, next); }}Unreal splits the same two: a player controller’s view target, which SetViewTarget moves, and the pawn it possesses. Roblox’s camera follows CameraSubject apart from Player.Character, and its nameplate hangs on the character. Unity, Godot and PlayCanvas leave both to the game. Here both are the core’s, so the camera, the nameplates and the voice list read one place.
Unreal possesses one pawn per controller, on the server, and Photon Fusion gives one player input authority over any number of objects, each reading her tick’s input; control here takes Fusion’s shape with Unreal’s rule that the room alone possesses. Unlike Possess, a control is visible at once only through findPendingController, and every system reads the committed link.
The characters plugin
Section titled “The characters plugin”characters() gives each player a character once her save has restored onto her player, in its onPlayerReady: a bot player gets one too. It spawns her at her seat’s spawn point, from the scene’s <SpawnPoint>s, or on the ring round the scene’s <CharacterSpawn>, with the <CharacterSpawn>’s props, runs each plugin’s onCharacterSpawn, registers her character as her saved entity under main, so her save restores over those defaults, and makes it her view target and her presence; in a room she controls it, predicted. In a game played alone her join runs before the scene mounts, so the scene’s <CharacterSpawn> has the plugin spawn hers as it mounts, and destroys it as the scene goes, captured into her save, so the next scene’s character carries her bag. findCharacter(world, player) reads it on the world that decides outcomes, as Roblox’s player.Character, and useCharacter() on her page; findCharacterPlayer(world, character) reads the other way, as Roblox’s Players:GetPlayerFromCharacter, and answers null for an NPC’s. Her character goes with her as she leaves.
onCharacterSpawn(step, character, { player, props })runs once per spawned entity, in plugin order, before her save restores: what a plugin adds to every character, a trait, a bag, an ability.playeris null for a character no player plays, as a story or a test spawns one bare.onCharacterRespawn(step, character, { player })runs as a downed character stands again: the same entity, so a game refills her ammo or clears her buffs there, where a Roblox game puts it inCharacterAdded.respawnCharacter(step, character, { position })stands one again from a game’s own system, such as at a checkpoint it chose, and runs the hook.spawnCharacter(step, player, { position, facing, props, key, restore })spawns her character itself and answers it. A character she has goes first, captured into her save as it goes, so a game that destroys and spawns again, as Roblox’sLoadCharacterdoes, keeps her bag.propsare laid over the scene’s, for a knight, a mage or a rogue from one scene, and the scene’s<CharacterSpawn>draws her by them in a game played alone, while a room’s pages draw each character by her avatar; an explicitpositionandfacingwin over her saved place;keynames another saved-entity key, which is a character switch;restore: falsestarts her fresh, as a roguelike’s new run does.characters({ autoSpawn: false })spawns no character on its own, for a game that spawns them itself: from a choice her save keeps, at a lobby’s round start, or never, as a dragon game whose plugin spawns a dragon does.characters({ poseTolerance, smoothing })sets how her page compares and draws a correction of her character in a room, the optionscontroltakes, carried by the control the plugin makes for every character it spawns, by itself or throughspawnCharacter. Each part left out takes the default: 5 cm, a jump past 2 m or past 0.4 s of her speed where that is farther, and an ease of at most 0.4 s, as Predicting a mechanic says. A game whose characters run at 12 m/s may setcharacters({ poseTolerance: { position: 0.1 } }). A game that callscontrolon her character itself names that call’s own options, and each part it leaves out takes the default, not whatcharacters()names.
Saved entities
Section titled “Saved entities”A saved entity is an entity her save keeps under a saved-entity key: her character under main, each character of a party under its own, a pet under pet. Registering one restores that key’s record over what its spawn set up; losing it, by releaseSavedEntity, a destroy or her leave, captures it first, and a key with no live entity keeps what it last captured or loaded. The engine’s entity, stats, resources and inventory saves and every save of scope: "entity" are kept this way, as Saving progress says, and what outlives a character, her coins and her choices, lives on her player.
setSavedEntity(step, player, entity, { key, restore })registersentityunderkey,mainwhen left out. Registering another under a key replaces the one there, captured first.releaseSavedEntity(step, player, { key })captures the entity underkeyand stops keeping it; it stays in the world.findSavedEntity(world, player, { key })reads the entity underkey, or null.
The following plugin is a party of three a player switches between. Her choice of who is out is hers, kept on her player and restored before her ready hook spawns that one; each character keeps its own health under its own key, and a switch, a command her page sends as sendCommand(world, party.commands.switchTo, { to: "mage" }), captures the one that goes:
import type { World } from "koota";import { z } from "@spawnite/schema";import { characters, defineCommand, definePlugin, defineTrait, findCharacter, oneOf, readCommands, spawnCharacter, TransformTrait, type AuthoritativeStep, type CharacterSpawnProps,} from "@spawnite/engine/core";
// A party of three characters a player switches between. Each keeps its// own bag, stats and health under its own saved-entity key; the choice of// who is out is the player's, kept on her player entity.
/** Each character's props, laid over the scene's `<CharacterSpawn>`. */export const classes = { knight: { health: { current: 80, maximum: 80 }, color: "silver" }, mage: { health: { current: 45, maximum: 45 }, color: "purple" }, rogue: { health: { current: 60, maximum: 60 }, color: "green" },} satisfies Record<string, CharacterSpawnProps>;
type ClassName = keyof typeof classes;
function isClass(name: string): name is ClassName { return Object.hasOwn(classes, name);}
/** Who is out, on her player: her save keeps it. */export const PartyTrait = defineTrait("party", { selected: "knight" });
/** Switches each sender's character to the one she asked for: the old one * is captured into its key as it goes, and the new one restores its key's * record, standing where the old one stood. */function switchCharacters(world: World, step: AuthoritativeStep) { for (const command of readCommands(step, party.commands.switchTo)) { const { player } = command; const { to } = command.payload; const old = findCharacter(world, player); spawnCharacter(step, player, { key: to, props: classes[to], position: old?.get(TransformTrait)?.position.clone(), }); player.set(PartyTrait, { selected: to }); command.accept(); }}
export const party = definePlugin({ name: "party", // The room refuses a class outside the list before the reader runs. commands: { switchTo: defineCommand({ to: oneOf(["knight", "mage", "rogue"]) }), }, save: { version: 1, schema: z.object({ selected: z.string() }), read: ({ player }) => ({ selected: player.get(PartyTrait)?.selected ?? "knight", }), restore: ({ player }, { selected }) => { player.set(PartyTrait, { selected }); }, }, // Before her save restores, which overwrites the default. onPlayerJoin: (_step, player) => { player.add(PartyTrait); }, // Once it has, so her saved choice picks who spawns. onPlayerReady: (step, player) => { const selected = player.get(PartyTrait)?.selected ?? "knight"; const key = isClass(selected) ? selected : "knight"; spawnCharacter(step, player, { key, props: classes[key] }); }, // A command's reader runs on the room alone, or the one world of a // game played alone. systems: { rules: { switchCharacters: { system: switchCharacters, answers: ["switchTo"], }, }, },});
// The party spawns each character itself.export const plugins = [characters({ autoSpawn: false }), party];Roblox’s LoadCharacter makes a new character and leaves what it keeps to the game’s own DataStore code; Unreal’s RestartPlayer spawns a new pawn and keeps a player’s state on the PlayerState. Here the save follows each saved entity by its key, so a new character under the same key carries what the old one had, and one under another key is another character.
Where it runs
Section titled “Where it runs”Her page, and in a room the room too. Her player entity and every entity she controls in Simulation.Predicted say RunContext.Client on her page, so her page runs them ahead of the room; everything else spawns under the server’s authority. In a room, the room also runs each of her moves from her input and has the final say, and her page takes its correction. Multiplayer says how.
Sample
Section titled “Sample”import { CharacterSpawn, World, registerModel, scatterModels,} from "@spawnite/engine";
registerModel("tree-round", scatterModels.treeRound);
export function Run() { return ( <World map="meadow"> <CharacterSpawn model="tree-round" position={[0, 0, 4]} /> </World> );}A rigged model she wears, in a glowing material over its own texture, playing a file’s Sprint as her run:
import { MeshStandardMaterial, type Material } from "three";import { CharacterSpawn, registerModel, World } from "@spawnite/engine";import spirit from "../assets/spirit.glb?url";
registerModel("spirit", spirit);
/** The file's texture, lit from within. */function glow(original: Material) { const map = original instanceof MeshStandardMaterial ? original.map : null; return new MeshStandardMaterial({ map, emissiveMap: map, emissive: "#8fbcff", });}
export function Run() { return ( <World map="meadow"> <CharacterSpawn model="spirit" material={glow} clips={{ run: "Sprint" }} /> </World> );}A VRM body she wears with her clips:
import { CharacterSpawn, World, type VrmBody } from "@spawnite/engine";import model from "../assets/heroine.vrm?url";
const heroine: VrmBody = { model, scale: 1, armSpread: { left: { out: 0, forward: 0 }, right: { out: 0, forward: 0 }, },};
export function Run() { return ( <World map="meadow"> <CharacterSpawn avatar={heroine} position={[0, 0, 4]} /> </World> );}