Chase
Chase moves an entity toward the nearest controllable entity, one 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 ChaseProps.
Chase moves toward the nearest player’s character, level with the ground, and stops within reach. It moves the entity; it does not turn it, so a model that should face the way it moves needs its own facing. The physics cuts the chaser’s capsule to the body fields, and a field changed later reaches it from the next step. Its controller prop sets the character controller’s settings it walks by, a partial CharacterControllerTrait, such as controller={{ stepHeight: 0.3 }}. A Chase marks its entity a character, which the characters’ movement moves with moveCharacter by the velocity the chase sets, in the physics phase of the same tick.
How it moves
Section titled “How it moves”A chaser moves along the world’s navmesh, the floor a walker can reach with the rocks, the trees and the crates cut out of it. Each step moves it from where it stood toward where the chase puts it, round the edge of anything the navmesh leaves out, and stands its feet on the ground there, or on a rock or a fixed collider lower than its step, whose top the navmesh keeps as floor. It runs no walk: no sweep of its capsule and no floor probe, so a crowd of monsters costs the room a fraction of what the same number of players cost. Its capsule stays in the physics world, where the players’ walks meet it as the collision layers below say. Unity’s NavMeshAgent and Unreal’s NavWalking mode move a character the same way; Godot’s navigation agent only plans, and the game moves the body. It plans no route of its own: it heads straight for her, so a wall square across that line holds it, and a game that wants a chaser to walk round one replaces the chase, as Change an engine system shows.
The navmesh holds the ground, the map’s scatter, every fixed collider an Entity in the World gives, and every kinematic one at rest. Every World bakes it: on a page, in a room and in the CLI’s headless play alike. A chaser on it follows these rules:
- It stops at a crate, a platform or any other body a fixed collider gives, as it stops at a rock, and slides along its side. A dynamic collider moves, so the navmesh leaves it out, and a chaser passes through it, as it does a fixed one whose
collisionMaskleaves outcharacters.collisionLayers.npcs. A kinematic collider, such as a drawbridge, is left out while it moves and baked in once it has stood still for half a second, as Unity’s “carve only stationary” does; its next move leaves it out again. - It never climbs onto anything taller than its step, falls, or jumps.
- It reads its controller’s
stepHeightonly to find a rock’s top under it, and nomaxSlopeRadians: the navmesh was baked for a player’s character, and it stands where she can stand.
The World bakes the navmesh again when a fixed collider joins or leaves it, moves, turns, or changes its shape, size, kind or layers, and when a kinematic one comes to rest or starts moving, so a crate that mounts late, or that a page learns of from the room, is cut out too. A bake of the meadow takes about 85 ms, which a room spends standing still. A page bakes a crate where the room streamed it, to the centimetre, and the room bakes it at that same precision, so both find the same paths. A room that steps a spawn function rather than a scene bakes once, as it starts, and leaves out a fixed collider spawned after that.
walks asks for the walk a player’s walker runs instead. A chaser that walks climbs and slides by its controller’s stepHeight and maxSlopeRadians, falls, and meets a moving collider as a player does, at the cost of a player’s walk each step it moves:
import { Chase, Entity, type EntityProps } from "@spawnite/engine";
export function Climber({ position }: Pick<EntityProps, "position">) { return ( <Entity position={position}> <Chase speed={4} walks /> <mesh position={[0, 0.9, 0]}> <capsuleGeometry args={[0.5, 0.8]} /> <meshStandardMaterial color="white" /> </mesh> </Entity> );}A chaser also walks where the world has no navmesh, and on a step where no part of the navmesh lies within a metre of its feet, such as on top of a rock taller than its step.
A chaser walks through the players by default: its collisionMask, chaserCollisionMask, leaves out characters.collisionLayers.players, so a player walks through a chaser and a chaser through a player, in the room and on every client alike. A crowd of monsters never pins a player where she stands. A game that wants its monsters to block the players gives them a mask that leaves out the other characters alone, and a chaser on the navmesh then keeps out of the players as well:
import { Chase, characters, Entity, type EntityProps } from "@spawnite/engine";
const blockingChaserMask = { except: [characters.collisionLayers.npcs] };
export function Guard({ position }: Pick<EntityProps, "position">) { return ( <Entity position={position}> <Chase speed={4} collisionMask={blockingChaserMask} /> <mesh position={[0, 0.9, 0]}> <capsuleGeometry args={[0.5, 0.8]} /> <meshStandardMaterial color="white" /> </mesh> </Entity> );}A chaser ignores other chasers by default too: its mask leaves out characters.collisionLayers.npcs, so a chaser that walks never tests another walker’s capsule and a crowd costs the room no more than the same chasers spread apart. Chase keeps the crowd apart itself, moving any two chasers closer than their two radii to that distance each step. Two colliders meet only when each one’s mask holds the other’s layer: a monster that must block a doorway meets npcs again, and it stops the characters whose mask holds npcs too, not a chaser in the default. The player page lists the layers.
Which characters it chases
Section titled “Which characters it chases”A chaser runs at the nearest character, measured along the ground, so a character who jumps or stands on a rock is no farther away. It runs at every character unless the scene gives a test. filterChaseTargets(world, accepts) sets one test for every chaser in the world: a chaser then runs at the nearest character that accepts passes, and skips the rest. A chaser with no character that passes stands still, as it does on a world with no character. The function returns a disposer that takes the test away. The world holds one test at a time, and a disposer takes away only the test that it set.
A game whose monsters leave a downed player alone gives the test from a component in its scene. DownedTrait is the engine’s, which a character carries while she is down, as Health says. The engine’s tests mount this component and run a chaser past a downed character:
import { useWorld } from "koota/react";import { useEffect } from "react";import { DownedTrait, filterChaseTargets } from "@spawnite/engine";
export function IgnoreDowned() { const world = useWorld(); useEffect( () => filterChaseTargets( world, (character) => !character.has(DownedTrait), ), [world], ); return null;}When it reaches a character
Section titled “When it reaches a character”The step marks reached with the character a chaser has within reach, or null, and emits an event on the step it changes:
ChaseReachedEvent, on the chaser, on the step it comes withinreachof a character, with her astarget.ChaseExitedEvent, on the chaser, on the step it no longer has her in reach, with her astarget. She moved off, went down or left the room, the world’sfilterChaseTargetstest stopped accepting her, or the chaser went down.
Each is emitted once per crossing: a chaser that stays within reach of her emits no second ChaseReachedEvent. Once she leaves, it reaches the next character within reach, on the same step. A downed character is never reached, and a downed chaser reaches nobody. A system reads either event with readEvents for a one-off outcome, such as a bite, a lost round or a sound, and a view hears it with useEvent. A rule that lasts while she stays caught, such as health drained each second, reads reached on each step.
The engine’s tests run this bite against a real room: a game’s own rule reads ChaseReachedEvent and damages the character it names.
import type { World } from "koota";import { ChaseReachedEvent, dealDamage, definePlugin, readEvents, type AuthoritativeStep,} from "@spawnite/engine/core";
// A monster's bite, built as a creator's plugin is, from the engine's// public entry alone: each time a chaser comes within its reach of a// player's character, the room deals her 10 damage. The chaser must leave// her reach, or she must, before it bites again.
/** Health a bite takes off. */export const biteDamage = 10;export const bite = definePlugin({ name: "outside-bite", systems: { rules: { // No `runsOn`, so it runs on the room alone, which adds the // event, and in a game played alone. bite: (_world: World, step: AuthoritativeStep) => { for (const { record } of readEvents(step, ChaseReachedEvent)) if (record.target) dealDamage(step, record.target, { amount: biteDamage, }); }, }, },});Godot’s NavigationAgent3D.target_reached, Unreal’s OnMoveCompleted and Roblox’s MoveToFinished signal arrival from the mover too. Unity, PlayCanvas and Phaser leave it to a trigger or an overlap callback. For a mover that is not a chaser, a Trigger on it gives the same events in the room, as TriggerEnteredEvent and TriggerExitedEvent.
Where it runs
Section titled “Where it runs”Server. Chase has a consequence: it moves the entity toward a player’s character, and it decides whom the entity reached. In a room, the room emits ChaseReachedEvent and ChaseExitedEvent and streams them with reached to every page, where useEvent hears them. A page never emits its own.
import { Entity, Chase, type EntityProps } from "@spawnite/engine";
export function Yeti({ position }: Pick<EntityProps, "position">) { return ( <Entity position={position}> <Chase speed={4} /> <mesh position={[0, 0.9, 0]}> <capsuleGeometry args={[0.5, 0.8]} /> <meshStandardMaterial color="white" /> </mesh> </Entity> );}