Logic and views
A scene holds two kinds of component. Logic spawns entities, sets their traits and runs in the room, in spawnite simulate and on every page. A view reads and draws, and only a page mounts it: a room draws nothing, and refuses a drawn element outside a view. Unity’s presentation systems run only in a client world, and a Roblox LocalScript never runs on the server, in the same way.
A view enters a scene in one of two places:
viewon an entity or a prefab draws that entity. The page renders it inside the entity’s group, handed the entity, so it moves with the entity’s pose. Its view on the Entity page says what the default draws and whatview={false}keeps.<Views>holds what draws the whole scene: the camera, the HUD and what a game draws itself. A placedReplicasadds an overlay and takes over the engine’s room stream draw. Any number stand anywhere in a scene. A room and a headless mount render nothing in their place, so every entity beside them keeps its place in the scene, which the room and each page match entities by.
The engine’s own views hold themselves in <Views>: the cameras, Hud, Panel, Window, Replicas, Particles and Sound. Replicas is optional; the engine draws the room stream where the scene places none. One placed outside it still draws on the page and is skipped in a room, though <Views> is where it reads best. Everything else in the scene is logic, an Entity’s children among it. A view is plain React Three Fiber: it animates with useFrame from @react-three/fiber and refs, and what it draws may differ from page to page, since nothing reads it back.
What differs between two entities of one view, such as a colour, is data on the entity: a trait made with defineTrait, which the room streams to every page, and which the view reads with useTrait(entity, Trait). The prefab sets its default, and a placement sets its own with traits on the <Entity>, field by field over the prefab’s, as a system’s spawn takes traits. Unity’s prefab overrides, Godot’s exported variables and Roblox’s attributes set data per placement the same way. A prefab takes model, collider, body and traits beside its view. Keep the trait in a module apart from the view when they live in separate files, so a prefab and its view never import each other.
An Entity’s own traits follow a change of a field, so an edited colour shows at once, and a field a system wrote meanwhile keeps its value while its prop stays. The list of traits itself is fixed at spawn: a new key spawns the Entity again with another list. A trait a behaviour child sets goes through that behaviour alone, and an Entity refuses one it also lists. On a page in a room, the room’s entity holds the traits, and its stream brings each defineTrait one.
Each engine name on this page comes from @spawnite/engine, except a plugin’s own names, which come from the plugin’s entry: useMatch from @spawnite/engine/matches and pinTarget from @spawnite/engine/abilities. That entry exports the components, the hooks and the simulation’s names together. @spawnite/engine/core exports the simulation’s names alone, with no component and no hook, for a file a room and a test load without React, such as a plugin’s systems. Import entries lists each entry.
Entity from @spawnite/engine names two things. In JSX, <Entity> is the component that places an entity. In a type, Entity is the read-only handle a view receives and a query returns, the same type @spawnite/engine/core exports. One import serves both: import { Entity } from "@spawnite/engine".
The scene the tests mount
Section titled “The scene the tests mount”The engine’s tests mount this scene on a page and headless:
import { useFrame } from "@react-three/fiber";import { useRef } from "react";import type { MeshStandardMaterial } from "three";import { box, Camera, definePrefab, defineTrait, Entity, Game, Hud, Panel, PanelVariant, Scene, Slot, Text, useTrait, useWorldRead, Views, Landscape, type EntityViewProps, defineGame,} from "@spawnite/engine";import { cameras } from "@spawnite/engine/cameras";
// What differs from lantern to lantern. A room streams it to every page,// so each page draws the room's colour.export const LanternTrait = defineTrait("campLantern", { color: "orange" });
// A lantern's look: the page draws it on each lantern, and a room never// mounts it. It reads its colour off its entity and flickers each frame,// which may differ from page to page, since nothing reads it back.function LanternView({ entity }: EntityViewProps) { const color = useTrait(entity, LanternTrait)?.color; const glow = useRef<MeshStandardMaterial>(null); useFrame(({ clock }) => { if (glow.current) glow.current.emissiveIntensity = 1 + 0.3 * Math.sin(clock.elapsedTime * 12); }); if (color === undefined) return null; return ( <mesh name="lantern" position-y={1}> <sphereGeometry args={[0.3]} /> <meshStandardMaterial ref={glow} color={color} emissive={color} /> </mesh> );}
// One kind of lantern: orange unless a placement gives its own colour, and// a scene's Entity, a system's spawn and one the room streams draw alike.export const lantern = definePrefab("campLantern", { view: LanternView, collider: { shape: box({ size: [0.6, 2, 0.6] }) }, traits: [LanternTrait({ color: "orange" })],});
// A HUD line that counts across every lantern, read again after each step,// so a lantern whose colour a system changes counts at once.function OrangeCount() { const orange = useWorldRead( (world) => world .query(LanternTrait) .filter((each) => each.get(LanternTrait)?.color === "orange") .length, ); return <Text>{`Find the lanterns: ${orange} glow orange.`}</Text>;}
export function Camp() { return ( <Landscape map="grid"> {/* Logic: the room runs these, and a page draws them. */} <Entity prefab={lantern} position={[0, 0, -3]} /> {/* This placement's own colour, over the prefab's. */} <Entity prefab={lantern} position={[-3, 0, -3]} traits={[LanternTrait({ color: "#33aaff" })]} /> {/* A lantern walkers meet and nobody sees: false goes over the prefab's view, and the collider stays. */} <Entity prefab={lantern} position={[3, 0, -3]} view={false} /> {/* What draws the whole scene. */} <Views> <Camera /> <Hud> <Panel slot={Slot.Bottom} variant={PanelVariant.Glass}> <OrangeCount /> </Panel> </Hud> </Views> </Landscape> );}
export function App() { return ( <Game game={defineGame({ name: "camp", start: "camp", plugins: [cameras()], })} > <Scene name="camp" component={Camp} /> </Game> );}On a page two lanterns glow, orange and the placement’s own blue, the camera frames the scene, and the HUD counts the orange ones, the hidden lantern among them; the scene places no player, which CharacterSpawn adds. Headless, all three lanterns stand with their colliders, the hidden one among them, and nothing draws. view={false} goes over the prefab’s view as any view of the Entity’s own does.
A room and each page match an Entity by its place in the scene, so never render an <Entity> on one side alone, such as behind a check for a page: put what differs inside a view instead.
Logic that writes as it mounts
Section titled “Logic that writes as it mounts”Before you write logic of your own, look for an engine behaviour that does it. A behaviour is a child of an <Entity> that gives it a rule: Pickup hands its reward to whoever reaches it, Spin turns it, and Bob moves it up and down. Write your own behaviour only when none fits, under a name the engine does not use.
A logic component that writes as it mounts, such as one placing its scene’s crates, uses useAuthorityEffect. It is a useEffect that runs only where outcomes are decided: in the room, and in a game played alone. On a page in a room it does not run, because the room runs it. It hands its callback the world’s authority:
useAuthorityEffect((authority) => { const crate = spawn(authority, cratePrefab, { position: [0, 0, -4] }); return () => destroy(authority, crate); }, [])
spawn takes the prefab and an options object: position, rotation and scale, traits over the prefab’s, body, collider, relevance and owner. Spawn one at runtime shows a system that spawns a wolf every two seconds with them.
Its cleanup undoes what it did, since StrictMode, a hot reload, a change of its dependencies and a restart of the scene each run the cleanup and then the effect again. Inside a view or outside a scene it throws in development, naming the component, and a built game logs once and runs nothing.
Use the authority only inside the callback. Never keep it or hand it out of the effect, such as to a view’s handler, because a view that holds it could write the world.
Logic that repeats or counts in seconds, such as a pickup every ten seconds, is a system with its countdown in a trait, never a timer in an effect. A timer keeps the wall’s time: it runs on while the world is paused, ignores spawnite simulate’s speed, and is lost when a room continues from a checkpoint. Most setup belongs in a plugin’s setup or a system too, which runs on the room alone unless its entry says otherwise.
Logic that needs a random number, such as where a pickup lands, draws it from the world’s stream with random(authority), randomRange(authority, min, max) or randomInt(authority, min, max), never Math.random. A continue of the room then draws the same numbers, as State a continue can carry says.
What every world holds for the whole game, the room’s and each page’s alike, goes in a plugin’s setup, such as the weapons a room judges shots from and a page predicts. It runs on every world the plugins make, a headless one too, and its returned function undoes it. A registration that lasts only while a scene stands, on every side, uses useSetupEffect((writer) => registerWeapon(writer, name, settings), [name]). It is a layout effect on the room and on every page, whose setup writer is open while the effect and its cleanup run, as the engine’s <Weapon> and <SpawnPoints> use it.
The read-only world
Section titled “The read-only world”Every component reads the world and none writes it. useWorld() returns a World with koota’s reads, has, get, query and queryFirst, and none of its writes. A world trait’s record reads as read-only, and useQuery, useQueryFirst, useHas and useTag read through it. So requireAuthority(useWorld()) does not compile: a view cannot decide an outcome. Each entity a view reads is the engine’s Entity, which reads and never writes, and get returns its record read-only. Unity’s RefRO reads a component the same way.
A handle a game keeps in a trait, such as an AnimationMixer or a pathfinder, reads as it is when its field is typed LiveObject, as mixer: (): LiveObject<AnimationMixer> | null => null. It then passes where its class is asked for, and its methods run. Keep it in a Reach.Local or a Reach.Authority trait: a trait the stream carries refuses one by type, since a handle is no data a page can be sent. A change made through its methods tells no watcher. Vue’s Raw marks an object the same way. An entity’s model already has the engine’s mixer: play one of its clips with playClip, as Models says, and keep a mixer of your own for a model the game draws itself.
Three hooks read the world, and each draws again on a different change:
useTrait(entity, Trait)reads one entity’s trait.useQuery(...traits)draws again when an entity arrives or leaves, not when one changes or moves.useWorldRead(read)runsreadover the world again after each step, and draws again when the answer changes. It counts or lists across many entities, such as the players ready, as the Camp’s HUD counts its orange lanterns, and it follows entities as they move, as the Hud page’s tracker does.
useWorldRead compares each answer with the last by value. Return plain values made fresh on each read, such as a count, an entity or a list of plain records, and it draws again only when one of them changes. Never return a record the world holds: the world changes that record in place, so it always compares equal. While the world stands still, it reads once a frame, and again after a write between steps. zustand’s useStore(selector) and Redux’s useSelector read again on each change the same way.
A test that writes the world by hand, outside a step, calls noteWorldChanged(world) from @spawnite/engine/testing before it expects a view to follow, as a page’s frame does once the room’s stream lands. A view that acts on each change, such as a line for each player who drops, listens with onTraitChange(world, ConnectionTrait, listener), which takes the read-only world, since listening writes nothing.
What a view writes
Section titled “What a view writes”A view acts by sending a command, sendCommand(world, handle, payload), in a room and in a game played alone alike. Alone, the command lands on the next step, as it does in a room, so a HUD tested alone runs unchanged in a room. The engine’s own actions do the same: a <Weapon> shot, a <Bag> press, and useMatch()’s start and restart.
A view writes only what its page draws, with the writer useViewWriter() returns, as the writer table says: setTrait(writer, entity, AimTrait, { pitch }) from the view’s useFrame, an input handler or an effect. A trait the room streams is the room’s to write, so the verbs refuse it by type, and a cast past the type throws in development. The writer also makes and takes an entity only this page holds, with spawnLocal(writer, …) and findAuthority(writer, entity). Roblox’s LocalScript and Unreal’s client Tick write what never replicates the same way.
State only one view draws, such as a ring’s fade, belongs in a ref, as a React component keeps any such value. A Reach.Local trait is for page state that other views or a system on the page read.
The camera’s and the targeting’s presentation verbs take the same writer, such as shakeCamera(writer, …) and pinTarget, as Shake and Targeting say. Each refuses a room’s world, which draws nothing: it throws in development and logs once in a built game. A room’s logic that wants a shake emits an event, and a view that hears it shakes the camera. useEntityRef(entity) takes no writer: it holds the view’s object as its entity’s placement, as Replicas says. A view lights its own page’s sky with setLightingHour, as the day clock says.
Logic placed inside a view
Section titled “Logic placed inside a view”An Entity, a behaviour, a Landscape, an Npc and the other components that spawn or write the world refuse a place inside a view. In development they throw, naming the component and the fix; a built game logs once and leaves them out. Logic inside a view would run on its page alone, and vanish in the room.
A view spawns nothing the room streams. It draws with three.js directly, as the lantern’s sphere does, or spawns an entity only its page holds with spawnLocal(writer, …) and the writer useViewWriter() returns, as Prefabs, spawning and destroying says.
A plugin’s component is a view too: a room never mounts it, so a scene’s logic must not read a context it provides.
A kit’s own logic component needs no mark to refuse a place inside a view. It writes through useAuthorityEffect or a behaviour’s useBehaviour, and each throws inside a view in development, naming the calling component, as the engine’s own components do. A component that writes nothing is a view, and may stand in one. Unreal’s HasAuthority() and Godot’s is_multiplayer_authority() guard the write the same way, not the place. A game’s lint refuses an import of koota or koota/react, so koota’s own writable useWorld stays out of a game.
What a room refuses
Section titled “What a room refuses”A room renders its scene on a renderer of its own, which holds no three.js object and runs no frame. spawnite simulate and a test’s headless mount use the same renderer, so they refuse what a room refuses:
- A drawn element outside a view, such as a
<mesh>, a light, a<div>or a bare string, throws on a development world, naming the component:Rock renders <mesh> outside a view, and a room draws nothing. A development room,spawnite simulateand the engine’s test fixtures each run a development world. A deployed room logs it once and skips it, and the component’s logic keeps running. Its log names the nearest component above the element that is not the engine’s own, so a mesh among an<Entity>’s children names the component that placed the Entity. - A React Three Fiber hook outside a view, such as
useFrame,useThreeoruseModel, throws in every room, since the room has no canvas:Spinner calls a React Three Fiber hook outside a view. A deployed room cannot skip a component whose render threw, so the room fails; a development room throws the same error before you deploy.
The fix for both is to move the component into the entity’s view or into <Views>. Logic that must differ in a room, such as a scene that deals itself a round where nobody presses Play, reads useHeadless().
A view file’s useModel.preload at its module scope runs wherever a scene imports the file, a room included. A room, a room’s bot and spawnite simulate draw nothing, so a preload there fetches no model.
What a room loads
Section titled “What a room loads”A room loads every file its scene imports, views included, but none of the packages only a page needs. A room’s process, its bots and spawnite simulate load each such view package as a stub: a module with the same exports, whose every value reads as a value and does nothing. Godot’s dedicated server runs every script the same way, against rendering and audio servers that do nothing.
The view packages are the following:
- Drawing:
@react-three/fiber,@react-three/drei,@react-three/postprocessing,postprocessing,three-stdlib,@pixiv/three-vrm,@pixiv/three-vrm-animation,three.quarks,camera-controls,@three.ez/instanced-mesh,troika-three-textandhls.js. - The page:
react-dom,motion,framer-motion,lucide-react,@dnd-kit/reactand@dnd-kit/dom. - The page’s media:
mediasoup-client,mediabunnyand@sentry/browser.
So a view and its prefab may share a file, as the Camp above does, and you mark nothing. A view file’s module scope runs in a room against the stubs, so extend({ Line2 }), useGLTF.preload(url) or class TrailPass extends Pass {} there does nothing. In development, the first such call from each of your files logs once, naming the file, the export and the package.
Logic takes nothing from a view package, not a constant and not a helper. Once the scene runs, a call into one refuses:
- A hook, such as
useFrame, raises the refusal of a React Three Fiber hook outside a view. - A component, such as drei’s
<Html>, is refused as a drawn element outside a view. - Anything else, such as three-stdlib’s
new SimplexNoise()in a system, throwsnew SimplexNoise from three-stdlib does not run in a room. Take math from three instead, which ships three-stdlib’s helpers underthree/addons, such asthree/addons/math/SimplexNoise.js. The game lint refuses an import of three-stdlib.
A game changes the list in its package.json. A package’s name set to true loads it as a stub, such as a view library the list lacks, and false keeps one of the list real, for logic that needs it:
{ "spawnite": { "viewPackages": { "@react-spring/three": true, "three-stdlib": false } }}The room’s start line lists each package outside the list that imported one of the view packages, under viewImporters. Each is a view package by what it imports, and true stubs it too.
Devtools actions
Section titled “Devtools actions”A devtools action is a tool, not a view. A declared panel’s button((authority) => …) and a playtest step’s run(authority) receive the world’s authority in a game played alone, so they may spawn or set traits. On a page in a room, which decides nothing, they throw before they run.