Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Trigger

Trigger fires a callback the frame the character comes within a radius of an entity, and another the frame she leaves. The character here is an entity that carries the ControlledTrait trait, whether or not a player controls it now: every character <CharacterSpawn> spawns, and any other entity control linked to a player, a kart she has left among them. A view reads her own character with useCharacter().

Its props are in TriggerProps.

The step marks inside while the character stands within radius, emits the TriggerEnteredEvent event as she comes in and TriggerExitedEvent as she leaves, each with her as other and her key as otherKey. In a game played alone, Trigger fires onEnter and onExit as its view hears each event, so a character who passes straight through between two rendered frames still fires onEnter and then onExit. A room works differently, as Where it runs says. A system queries the same events; a view hears them with useEvent. A callback is allowed here because Trigger is client behaviour: it changes what the page shows, such as a banner or a sound, and never decides an outcome. A gate that wins the round, a door that takes a key or a trap that deals damage is a system, as the following section shows. A door the player opens with a key they carry is pressed rather than walked into: Locks and keys builds one with Interact.

onEnter runs on a page. In a room it runs on the page of the player who crossed, and the room never hears it, so a callback that wins the round wins it on that page alone. An outcome belongs in a system, which runs on the room and in a game played alone.

The step emits TriggerEnteredEvent only as the trigger goes from empty to occupied, with the nearest character as other. A second player who walks in while the first still stands there emits no event. A system that cares who stands in the trigger asks each step instead: findNearestControlled(world, position, { accepts }) returns the nearest character that accepts lets through, and the system compares her distance with the trigger’s radius.

The following exit gate wins the round when a player walks in holding three keys. countItem(character, item) counts what her bag holds, as Inventory says, and finishRound(world) wins the round, as Round says. The gate’s own trait carries the keys it wants, through a behaviour of the game’s own, as Behaviours describes. The room’s tests mount the gate on a room two players join, stand one player with two keys in it, then walk in a second with three:

packages/room/test/outside/ExitGate.tsx
import { createQuery, type World } from "koota";
import {
countItem,
defineBehaviour,
definePlugin,
defineTrait,
Entity,
findNearestControlled,
inventory,
readEach,
RunContext,
TransformTrait,
Trigger,
TriggerTrait,
useBehaviour,
type EntityProps,
type NearestControlledSearch,
} from "@spawnite/engine";
import { finishRound, Round, rounds } from "@spawnite/engine/rounds";
/** The keys a player must hold as she walks into the gate. */
export const GateTrait = defineTrait("exitGate", { keys: 3, item: "key" });
const GateBehaviour = defineBehaviour({
name: "exitGate",
trait: GateTrait,
runsOn: RunContext.Server,
description: "Wins the round when a player walks in holding its keys.",
});
const gates = createQuery(GateTrait, TriggerTrait, TransformTrait);
// The keys the gate being checked wants, written in place per gate, so
// every gate and every step reuses one set of search options.
const checked = { keys: 0, item: "" };
const keyHolders: NearestControlledSearch = {
accepts: (character) => countItem(character, checked.item) >= checked.keys,
};
// On the room, which alone decides the round, from the bags it keeps. It
// asks who holding the keys stands nearest, rather than reading the
// trigger's enter event, which fires only as the first player comes in:
// a player with the keys who joins one without them still wins.
function finishRoundsAtGates(world: World) {
readEach(world, gates, ([gate, trigger, { position }]) => {
checked.keys = gate.keys;
checked.item = gate.item;
const nearest = findNearestControlled(world, position, keyHolders);
if (nearest && nearest.distance <= trigger.radius) finishRound(world);
});
}
export const exitGates = definePlugin({
name: "exit-gates",
requires: [rounds, inventory],
systems: {
rules: {
finish: finishRoundsAtGates,
},
},
});
interface ExitGateProps extends Pick<EntityProps, "position"> {
/** The keys she must hold. */
keys: number;
}
function GateLock({ keys }: Pick<ExitGateProps, "keys">) {
useBehaviour(GateBehaviour, { keys, item: "key" });
return null;
}
export function ExitGate({ position, keys }: ExitGateProps) {
return (
<Entity position={position}>
<Trigger radius={1.5} />
<GateLock keys={keys} />
<mesh>
<torusGeometry args={[1.2, 0.2, 16, 32]} />
<meshStandardMaterial color="#8a7cff" />
</mesh>
</Entity>
);
}
/** The gate and the round it wins. The round has no target and no clock,
* so only the gate ends it. */
export function ExitRound() {
return (
<>
<Round />
<ExitGate position={[0, 0, -6]} keys={3} />
</>
);
}

ExitRound mounts the gate beside a bare <Round />, with no target and no seconds, so the gate is the one way to win it. List exitGates in the game’s plugins after rounds() and inventory(). Its system, finishRoundsAtGates, runs on the room alone, since it names no runsOn, and in a game played alone. <Trigger> gives the gate its radius, and its onEnter can still show a banner on the page, as Show something on the page does.

Client. The callbacks only change what this client shows.

In a room, “the player’s character” is each page’s own. The room steps the entity and streams its trait, whose inside marks any player’s character, and its TriggerEnteredEvent and TriggerExitedEvent, which carry whichever character crossed. Each page’s Trigger ignores them: it measures its own character against the streamed radius each frame, and fires the onEnter and onExit its own scene passed as she crosses it. Another player walking through fires nothing on this page. A character who passes straight through between two frames on a page in a room fires neither callback. A Server behaviour that needs to know which character is inside reads findNearestControlled in its system, as Pickup and Chase do.

A callback changes what this page shows and nothing else. The following place name appears over the old mill while this page’s character stands within 4 metres of it, and goes as she leaves. No other page sees it, the room never hears it, and the round goes on. The engine’s tests step a character in and out of the mill and read the name off the page:

packages/engine/test/outside/PlaceName.tsx
import { useState } from "react";
import { Anchor, Entity, Panel, Text, Trigger } from "@spawnite/engine";
export function OldMill() {
const [isNear, setIsNear] = useState(false);
return (
<Entity position={[0, 0, -10]}>
<Trigger
radius={4}
onEnter={() => setIsNear(true)}
onExit={() => setIsNear(false)}
/>
{isNear && (
<Panel anchor={Anchor.Above}>
<Text>The old mill</Text>
</Panel>
)}
<mesh>
<boxGeometry args={[3, 4, 3]} />
<meshStandardMaterial color="#b08860" />
</mesh>
</Entity>
);
}