Skip to content

Entity

<Entity model position> spawns a koota entity on mount and destroys it on unmount, except on a page in a room, where it binds to the room’s, as In a room says. It publishes itself through a context, so useEntity() inside reads that entity and nothing else. UI inside an Entity lives as long as the entity exists.

The children mount once the entity exists and unmount on the commit after it dies, whether the Entity unmounted or a system destroyed the entity mid-frame. Entity draws one group at position and binds it to the entity, so a system that moves the entity’s transform moves what the children draw, the model included.

A game in a room has one scene, which the room mounts headless and every page mounts to draw it. An Entity takes its identity from its place in the scene: for each element from the scene’s root down to it, the key the game gave it, or its index among its siblings. The room spawns the entity and streams it under that place. On a page, the Entity spawns nothing: it binds to the entity the room streams at its place, draws its model and its children on it, and leaves its position and its behaviours’ traits to the stream. A game with a list of entities gives each a key, so an entity keeps its identity when the list changes. Replicas draws what no Entity of the scene draws, the other players’ heroes among it.

A model is a GLTF file. registerModel(name, url) keeps the file’s url under the name, and <Entity model> draws that file’s scene inside the entity’s group, stood on its lowest point at the entity’s feet, each entity with its own copy on the one loaded geometry. A file centred on its origin, as the kit’s come, is lifted by half its height, and one whose origin is its feet does not move. Player and InstancedModel stand the same file the same way, so a model placed at y = 0 stands on flat ground whichever draws it. The children stand while it loads. readModel(name) throws on a name nobody registered, which an <Entity model> with that name does as well, into the game’s boundary, as World does for a map. The engine registers no model of its own.

A walker takes its size from its model. A Body whose radius or height the game leaves out, on an entity whose model is a kit model, gets the capsule that fits inside that model: the model’s height, and a radius of half its smallest extent. A kit model is one from the shared assets, named by its asset id as game add asset registers it, such as rock-boulder. pnpm nx shapes @game/assets measures every kit model into a table the engine ships, so a room, which loads no model file, cuts the same capsule a page does. A name the kit does not hold, and a walker with no model, gets the hero’s 0.35 m by 1.4 m. A size the game sets wins, field by field. A radius over half the height would give the capsule a negative length, so the engine cuts the radius to half the height, writes that into the Body, and warns once in a dev world. Unity fits a collider to a mesh’s bounds in the editor and stores it with the prefab, and Roblox measures a mesh’s collision at upload; the table is that stored measurement, made where the models live.

A model may be Draco-compressed: every GLB the engine loads, a VRM included, decodes Draco with three’s glTF decoder. A texture may be KTX2: useKtx2(url) loads one inside the Canvas, transcoded by Basis into a format the device’s GPU reads, for a material’s map. It suspends while it loads, and the texture is shared by URL, so its wrapping and repeat change for every material that uses it. The decoder and the transcoder come from the three package, and the game’s build copies them beside its bundle, so a player’s device fetches them from the game’s own site, and only once a file needs them. Compress assets before they are committed; the engine decodes and does not compress.

import { Entity, registerModel, useKtx2 } from "@daniel-zarinski/engine";
import rock from "./rock-draco.glb?url";
import snow from "./snow.ktx2?url";
registerModel("rock", rock);
export function Slope() {
const map = useKtx2(snow);
return (
<>
<mesh rotation-x={-Math.PI / 2}>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial map={map} />
</mesh>
<Entity model="rock" position={[0, 0, -4]} />
</>
);
}

Every entity says who may change it. The server has the final say over anything with value or anything that changes another player’s outcome. A client owns only what affects how things look and feel for that client. The reason is latency: a character whose every move waits for the server feels slow on a phone, so the client runs its own character’s movement and the server filters what it reports. Everything with a consequence stays on the server.

An Entity carries an Authority trait, RunContext.Server unless the authority prop says RunContext.Client. A physics prop may move between the two at runtime, but the flag is explicit per entity, never inferred. Server means the simulation has the say, not a machine: a single-player game runs the whole simulation locally, writes no authority prop, and the default costs it nothing. The one entity that says Client is the local player’s character, and the Player component sets that itself. The defaults:

Entity kind Authority Note
A player’s own character That client Server sanity filter every step
Other players’ characters Their client Displayed with smoothing
Pickups, doors, switches, spawners Server Client may predict, server confirms or reverts
Enemies, NPCs, AI Server Clients only render
Physics props Server May be flagged client-owned near whoever touches it
Score, wallet, inventory, progress Server Client reads, never writes
Timers, rounds, match state Server
Cosmetic effects, local UI Client Never replicated

Until the server package ships, everything runs on the client and the trait is documentation. It becomes routing the day a server exists, which is why it is written now: every behaviour written after it inherits the shape. Replication, prediction and the sanity filter arrive with the server package. The trust boundary cites this section for the room authority contract.

An Entity without a collider has no body: a coin is walked through, and a ball placed in the air stays there. collider names a shape, a body kind, the shape’s full size in metres and the layers it collides with, and the physics world gives the entity that body on its first step and takes it away when the entity dies.

Its fields are in ColliderProps, and Player lists the layers.

A hull is the convex hull of every vertex of the entity’s model, so a walker stops where the model stands rather than at a box around it. It reads no size, and a hull collider without a model throws into the game’s boundary. Its body waits for the model to load. The hull stands where the model is drawn, lifted as the model is. A headless scene loads no model, so its hull has no points.

A dynamic body writes its position and rotation back onto the entity every step, so what the children draw falls and rolls with it, and a walker who walks into it pushes it. A kinematic body goes where the step’s other systems put the entity, so a coin that bobs carries its body with it. The player’s own character has no collider: her capsule is her own.

<Entity
position={[0, 0.5, -8]}
collider={{
shape: ColliderShape.Sphere,
kind: BodyKind.Dynamic,
size: [1, 1, 1],
}}
>
<mesh>
<sphereGeometry args={[0.5]} />
</mesh>
</Entity>

The prop is read when the entity spawns.

A behaviour is a child component of an Entity, never a prop. Spin, Bob, Pickup, Interact, Trigger, Health, Chase and TrackMover compose on any Entity. Each is a trait, a system and a component; the component adds the trait on mount, sets it on prop change and removes it on unmount.

Each behaviour also declares where it runs, as runsOn on a Behaviour object that pairs the trait with its context:

  • RunContext.Client, the default: the behaviour only changes how a thing looks. Spin and Bob are client behaviours, because the server has no reason to track a coin’s rotation. So is Interact’s prompt, and Trigger’s onEnter and onExit.
  • RunContext.Server: the behaviour has a consequence. Pickup, Health, Chase, and the effect Interact triggers.
  • RunContext.Both: the server owns it, and the client also runs it to predict, so it feels instant. The local character’s movement, a moving platform, a door sliding open.

Every behaviour component is one call to useBehaviour(behaviour, value), which adds, sets and removes the trait on the nearest Entity. A system may destroy the entity mid-frame, a pickup being the usual case. The children unmount on React’s next commit, so the rest of that frame still runs: a frame callback that reads its entity checks isAlive() first, as the hook and Entity’s own cleanup do.

const SpinBehaviour: Behaviour<typeof SpinTrait> = {
trait: SpinTrait,
runsOn: RunContext.Client,
};
export function Spin({ speed }: { speed: number }) {
useBehaviour(SpinBehaviour, { speed });
return null;
}

A game reads or writes an entity’s own movement the same way a behaviour does: useEntity() for the koota entity, Transform and Velocity from @daniel-zarinski/engine for its position and speed, and useFrame from @react-three/fiber for the callback.

import { Transform, Velocity, useEntity } from "@daniel-zarinski/engine";
import { useFrame } from "@react-three/fiber";
function LogSpeed() {
const entity = useEntity();
useFrame(() => {
if (!entity.isAlive()) return;
const position = entity.get(Transform);
const velocity = entity.get(Velocity);
console.log(position, velocity?.length());
});
return null;
}

A consequence is data on a trait handled by a system, never a callback prop. <Pickup reward={1} /> puts the reward on the trait, and the pickup system credits the wallet where it runs. A function prop on a behaviour declared Server or Both is a lint finding once the behaviours exist: a closure lives in the browser’s React tree and the server can never run it. A behaviour declared Client may take a callback for local UI, which is how Interact’s onInteract opens a modal.

A consequence the engine has no behaviour for is written by the game the way the engine writes its own: a trait, a system and a component, with the system declared Server.

stateDiagram-v2
    [*] --> Trait: mount adds
    Trait --> Trait: prop change sets
    Trait --> [*]: unmount removes
    note right of Trait
        the system reads it every frame
    end note

Three game components, each one Entity with behaviours as children:

src/components/Coin.tsx
import {
Entity,
Spin,
Bob,
Pickup,
type EntityProps,
} from "@daniel-zarinski/engine";
export function Coin({ position }: Pick<EntityProps, "position">) {
return (
<Entity model="coin" position={position}>
<Spin speed={1} />
<Bob height={0.2} />
<Pickup reward={1} />
</Entity>
);
}

Each behaviour has its own page: Spin, Bob, Pickup, Interact, Trigger, Health, Chase and TrackMover.