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

Health

Health gives an entity a pool of hit points, starting full. A player’s character 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 HealthProps.

dealDamage(step, entity, { amount, source, weapon, data }) takes an entity’s health down, never below zero, and returns why it applied nothing, or null: an entity with InvulnerableTrait takes no damage, DamageRefusal.Invulnerable, and neither does an ally of whoever is behind the hit while friendly fire is off, DamageRefusal.FriendlyFire, so with no teams() one player’s hit on another player’s character applies nothing. The step downs the entity at zero and removes it the step after. Damage is an outcome, so its first argument is the room’s step, or requireAuthority(world) outside a step, as Where a system runs describes. An entity with <Respawn> is not reaped: it goes down and stands again. A player’s character is not reaped either: she goes down, with the DownedTrait trait, stays in the world, and respawns a few seconds later unless the game turns that off. source is who dealt it, such as the shooter’s character; the entity’s DamageSourceTrait keeps the last one, and DiedEvent names the one whose hit took it to zero, so a game credits the kill. weapon names the weapon it came from, and data is the game’s own record for the hit; both are optional. Each call emits one DamagedEvent on the entity, which the weapon page says how to read. An entity with the InvulnerableTrait trait takes none, and a game that keeps its characters’ health in a trait of its own spares an InvulnerableTrait character too, as Holdfast’s wardens do. A room started with ROOM_INVULNERABLE=1 gives every player’s character it, for spawnite play profile --invulnerable, the way Unreal’s bCanBeDamaged and Roblox’s ForceField stop damage.

dealHealing(step, entity, { amount, source }) gives health back, never past the maximum. It is an outcome too, so it takes the room’s step, or requireAuthority(world) outside a step, as dealDamage does. Each call emits one HealedEvent, { amount, source }, on the entity, so a view draws a rising number and a game credits the healer. Its amount is the heal as asked, which can be more than it restored where the entity stood near its maximum, as a hit’s amount can be more than the health it had left. A view reads HealedEvent as it reads DamagedEvent, with useEvent, as the weapon page says, and useFloatingText draws the number over the entity. A downed character is healed and stays down until the game removes DownedTrait, as a revive does. An amount below 0, or no finite number, throws, naming dealDamage as the way to take health off, and an entity with no health throws too. A potion’s heal is one of these, by the character who drank it. changeResource does not change health: healthResource is a type error there, and a key that holds it throws, so a page in a room cannot change an outcome. Unreal’s ability system applies a heal as a gameplay effect beside damage, and Roblox leaves it to a script that sets Humanoid.Health; here a heal is its own outcome with its own event, as damage is.

useHealth(entity) reads an entity’s { current, maximum } for a bar or a label. It returns undefined while the entity has no Health, so the caller chooses whether to hide the bar or draw it full.

The reap, behaviours.reapDead, downs an entity whose health has reached zero, with DownedTrait, and emits DiedEvent on it: in the same step for a hit dealt before the reap, and in the next step for one dealt after it, such as a projectile or an ability in a later system. A system that reads DiedEvent after the reap reads it in the step it is emitted, and one before it reads it the next step, as every event is read. The step after the reap, it removes the entity, unless it is a player’s character or has a respawn. Between the two, the entity keeps its traits but does nothing: no ability targets it, a projectile passes through it, a chaser stops, and it fires no shot. So every game system reads its death and its traits, such as a boss’s mark or a monster’s loot table, before the entity goes. Roblox fires Humanoid.Died the same way, and Unity’s Destroy and Godot’s queue_free hold an object until the frame ends for the same reason.

DiedEvent carries source: the entity whose hit first took the health to zero. A later hit in the same step does not take the credit, and a death no hit dealt, such as a game setting the health to 0 itself, names null. source is null too where the killer is already gone. A player’s character and an entity with a respawn get DiedEvent once per life: a hit while she lies down fires nothing, and the next death after she stands again fires it again. An entity the game downed itself, such as a hostile it stunned at full health, dies as any other does when its health reaches zero. Healing an entity above zero before the step reaches it, or a game setting its health above zero, means it never dies, and a later death names only a hit dealt after that. Healing it after it died does not bring it back: the step after still removes it, unless the game takes DownedTrait off first, as a revive does.

A game reads DiedEvent in a system with readEvents, and a page hears it with useEvent(DiedEvent, …). In a room the room emits it, and every page that holds the entity hears it a send later, with the dead entity and its killer still standing in the page’s world: a page keeps an entity the stream removed until its next step has read the events that name it, as the systems page says. So a kill feed, a death animation or a drop on a page reads what died, where it fell and who killed it. A game that counts deaths counts them in a system on the room and streams the count, as the following kill count does. It runs on the room, and the engine’s tests run it against a real room:

packages/room/test/outside/kills.ts
import type { World } from "koota";
import {
definePlugin,
defineTrait,
DiedEvent,
readEvents,
type AuthoritativeStep,
} from "@spawnite/engine/core";
// A kill count, built as a creator's plugin is, from the engine's public
// entry alone: the room adds one to a player's character's count for each
// death she dealt the lethal hit of, and the stream carries the count to
// every page.
/** The kills a player's character has made; the stream carries it. */
export const KillsTrait = defineTrait("outsideKills", { count: 0 });
export const kills = definePlugin({
name: "kills",
onCharacterSpawn: (_step, character) => {
character.add(KillsTrait);
},
// No runsOn: the room alone counts, and a game played alone is its
// own room.
systems: { rules: { count: { system: countKills } } },
});
function countKills(_world: World, step: AuthoritativeStep) {
for (const { record } of readEvents(step, DiedEvent)) {
const killer = record.source;
const tally = killer?.get(KillsTrait);
if (killer && tally) killer.set(KillsTrait, { count: tally.count + 1 });
}
}

A round won on a boss’s death reads DiedEvent the same way, as the Round page shows.

A player’s character at zero health goes down, waits 5 seconds, and comes back at the point she spawned at with full health, whether <CharacterSpawn>, a room or spawnCharacter spawned her. In a room her connection stays open throughout, and her page shows the seconds left. A game needs no code for this. Roblox does the same with its Players.CharacterAutoLoads and Players.RespawnTime, 5 seconds by default. Unreal’s game mode restarts a player’s pawn when the game calls RestartPlayer, and Unity, Godot and PlayCanvas leave it to the game.

To change it, set respawn on the scene’s <CharacterSpawn>:

import { CharacterSpawn, RespawnPlace } from "@spawnite/engine";
export function Character() {
return <CharacterSpawn respawn={{ seconds: 3, at: RespawnPlace.Fallen }} />;
}
  • seconds is how long she stays down first: 5 by default, and 0 brings her straight back.
  • at is where she comes back: RespawnPlace.Spawn, the point she spawned at, by default; RespawnPlace.Fallen, where she fell; or a point the game names, such as [0, 2, 10].

In a room <CharacterSpawn>’s respawn holds for every player’s character. Each character carries it as her RespawnTrait trait, which the dump of a room or of a game played alone shows as respawn, and which no page’s stream carries: seconds, point, and inPlace where she comes back where she fell. While she waits, her character keeps DownedTrait and carries RespawningTrait, whose seconds is the whole wait and whose secondsLeft counts down; a dump shows it as respawning. She walks, jumps and fires nothing while she is down.

To own the death flow, such as a revive by a teammate, turn respawn off with respawn={false}. Her character then stays down, her connection stays open, and the game gets her up with respawnCharacter(step, character, { position }), the call the engine’s own respawn makes: the same entity at full health, off the ground, at position at rest or where she fell, then each plugin’s onCharacterRespawn, as Unreal’s RestartPlayer does. A game can also give her health above zero and remove DownedTrait itself; removed at zero, the next step downs her again. A game that gets her up while she waits to respawn ends the wait. Depthfield turns respawn off for its death screen. Holdfast keeps its wardens’ health on a trait of its own, so the engine never downs a warden, and turns respawn off as well.

While her character waits, Game draws the seconds left over the game. A game draws its own screen, or none, with Game’s respawnScreen.

Any other entity at zero health goes down for a step, then the step removes it, as When it dies says. To keep one in the world, such as a monster that comes back, give it <Respawn> beside its <Health>:

import { Entity, Health, Respawn, type EntityProps } from "@spawnite/engine";
export function Wolf({ position }: Pick<EntityProps, "position">) {
return (
<Entity model="wolf" animation="idle" position={position}>
<Health maximum={60} />
<Respawn seconds={20} clip="death" />
</Entity>
);
}

Its props are in RespawnProps:

  • seconds is how long it stays down: 5 by default, as a player’s character’s wait is.
  • clip names the clip its model plays once as it goes down and holds until it stands, as playClip holds one. With none, its model keeps playing the animation it loops.

At zero health the entity goes down on the next step, as a player’s character does:

  • It carries DownedTrait and RespawningTrait, and its health stays at 0.
  • No ability targets it, and a projectile passes it to what stands behind.
  • A chaser stops where it fell, and the other chasers walk over it. A game’s own system that moves an entity leaves out one with DownedTrait, as it would a downed character.
  • After seconds it stands where it fell, at full health, and its held clip ends.

It keeps its TargetableTrait and its collider while it is down. A game that gets it up first, with health above zero and DownedTrait removed, ends the wait and the held clip. In a room, the room runs the respawn, and every page draws the fall and the return from the stream.

<Respawn> is for an entity a scene places. A player’s character’s respawn is <CharacterSpawn>’s respawn. Outside an <Entity>, <Respawn> throws where it renders and names that prop. Among <CharacterSpawn>’s children that is on a page, once her model is drawn: a room or a headless run draws none of <CharacterSpawn>’s children, so there it does nothing. For a placed entity, a respawn at another spot is not built: a game spawns a new entity there instead, as Spawn one at runtime shows. A player’s character stands again at any spot with respawnCharacter, above.

Roblox respawns a player’s character and leaves a monster to a script that clones its model. Unity, Unreal, Godot, PlayCanvas and Phaser leave a monster’s death and return to the game. The engine ships it, because a creator who asks for wolves that come back should need no code for it.

A system spawns an entity at runtime with spawn(step, prefab, { position }): a wave of monsters, a summoned pet, a drop. definePrefab declares the kind once, its traits, its model and its collider, and a scene places the same prefab with <Entity prefab={wolf} />, so both carry the same traits. A spawn is an outcome, so it takes the room’s step, in a system with no runsOn or inside if (step.authoritative); a step on a page in a room is no authority, so a spawn there does not typecheck. Every trait the stream carries, the engine’s, such as TransformTrait, ModelTrait and HealthTrait, and each a game makes with defineTrait, goes to every page, where the game’s <Replicas> draws the entity: as its model, or, for a walker spawnNpcBody spawned, in the avatar its AvatarTrait names, which each page registers with registerAvatar as the Player page says, or else its placeholder body; a walker takes no ModelTrait. In a game played alone the engine draws the same entity in the same view, with nothing to mount. At zero health the entity is removed, as any other is. The engine’s tests run this spawner against a real room and alone:

packages/room/test/outside/spawner.ts
import { createQuery, type World } from "koota";
import { Vector3 } from "three";
import {
ChaseTrait,
definePlugin,
definePrefab,
defineTrait,
fixedStepSeconds,
HealthTrait,
readEach,
spawn,
TargetableTrait,
type AuthoritativeStep,
} from "@spawnite/engine/core";
// Wolves spawned at runtime, built as a creator's plugin is, from the
// engine's public entry alone: the room spawns a wolf at once and every
// two seconds after, at the next spot of its den, up to three at once.
// Each is one prefab, which a scene places with `<Entity prefab={wolf} />`
// and the spawner spawns the same: a model that chases the nearest
// player's character, can be targeted, and is removed at zero health. Its
// traits stream, so every page draws it, and a game played alone draws it
// the same way.
/** Marks a wolf, so the spawner counts its own; the stream carries it. */
export const WolfTrait = defineTrait("outsideWolf");
/** The most wolves standing at once. */
export const wolvesAtOnce = 3;
/** Seconds between two wolves. */
export const wolfSeconds = 2;
const den = [new Vector3(8, 0, 0), new Vector3(0, 0, 8), new Vector3(-8, 0, 0)];
const wolves = createQuery(WolfTrait);
/** One wolf: what a scene places and what the spawner spawns. */
export const wolf = definePrefab("outsideWolf", {
traits: [
WolfTrait,
HealthTrait({ current: 30, maximum: 30 }),
ChaseTrait({ speed: 3, reach: 1 }),
TargetableTrait,
],
model: "wolf",
});
export const spawner = definePlugin({
name: "spawner",
// No runsOn: the room alone spawns, and a game played alone is its
// own room.
systems: { rules: { wolves: { system: spawnWolves } } },
});
function spawnWolves(world: World, step: AuthoritativeStep) {
if (step.tick % Math.round(wolfSeconds / fixedStepSeconds) !== 0) return;
let standing = 0;
readEach(world, wolves, () => standing++);
if (standing >= wolvesAtOnce) return;
spawn(step, wolf, { position: den[standing] });
}

Unity’s Instantiate, Unreal’s SpawnActor, Godot’s instantiate() and Roblox’s Clone copy a prefab or a template, and definePrefab is the same idea: each spawn copies the prefab’s records, and the spawn’s own traits go over them field by field, so traits: [HealthTrait({ current: 10 })] keeps the prefab’s maximum. An entity with no prefab, such as a match record, is spawn(step, { traits: [...] }), and destroy(step, entity) takes any entity away, nulling every reference a trait holds to it.

maximum is the base of the entity’s maxHealth stat, which <Health> and the <CharacterSpawn> component declare with a min of 1. maxHealthStat names it. A modifier on it raises or lowers the cap, as an upgrade, an item or a curse would:

import { useEffect } from "react";
import {
addStatModifier,
maxHealthStat,
removeStatModifiers,
useEntity,
} from "@spawnite/engine";
export function Amulet() {
const wearer = useEntity();
useEffect(() => {
addStatModifier(wearer, maxHealthStat, { source: "amulet", flat: 20 });
return () => removeStatModifiers(wearer, "amulet");
}, [wearer]);
return null;
}

Each step sets maximum to the stat’s resolved value, then clamps current:

  • When the cap falls below current, current falls to the cap.
  • When the cap rises, current stays where it is. A game that wants the new points filled heals the entity itself.

So the devtools inspector’s Health card has no maximum slider on an entity with the stat, since the next step would overwrite an edit. The inspector’s Stats card slides the maxHealth base instead, and the modifiers still apply over it.

A save keeps the stat’s base as maximum, never a buff over it, and keeps current as it was, which a saved modifier may hold above that base. The first step after a load caps current at the resolved maximum, so a save whose current is past every cap still loads.

Unreal’s Gameplay Ability System pairs Health with a MaxHealth attribute in the same way, and leaves the current value where it is when the cap rises. In Unity, Godot and PlayCanvas, games and add-ons write the same pair.

Health is the resource healthResource, the engine’s own, so its bar and its regeneration work as mana’s and stamina’s do. Damage, death, the downed character, hit zones and the save stay Health’s own.

<ResourceBar resource={healthResource}> draws an entity’s health in the theme’s health tone, var(--color-health-fill), under the label “Health”. label names it otherwise, and showValue reads 30 / 120 beside it:

import type { Entity } from "koota";
import { healthResource, ResourceBar } from "@spawnite/engine";
export function WolfHealth({ wolf }: { wolf: Entity }) {
return (
<ResourceBar
entity={wolf}
resource={healthResource}
label="Wolf health"
showValue
/>
);
}

Health does not regenerate unless a game registers a regen for it, as Unreal’s and World of Warcraft’s games each choose their own. Register it with registerHealth in a plugin’s setup, with regenDelaySeconds for the seconds it waits after each hit, and a color or label where the theme’s do not fit:

import { definePlugin, registerHealth } from "@spawnite/engine";
export const healing = definePlugin({
name: "healing",
setup: (world) => registerHealth(world, { regen: 5, regenDelaySeconds: 4 }),
});
  • registerHealth takes no maximum: <Health maximum> and <CharacterSpawn>’s health give health and set its maximum, so leave healthResource out of <CharacterSpawn>’s resources and of <Resource>.
  • A healthRegen stat on an entity takes the place of regen for it, as an item of regeneration would.
  • An entity at zero never regenerates: a downed character waits for her respawn or the game.
  • An ability’s cost cannot name health, since a cast holds its cost back and health holds no reserve. An ability that spends health deals it to the caster with dealDamage in its effect.

To hang the bar over the entity, as a boss’s or a monster’s is, put it in a Panel anchored to the entity, <Panel anchor={Anchor.Above}>, inside the <Entity> beside its <Health>. The Panel follows the entity and faces the screen, and useEntity() names the entity for the bar. The engine’s tests mount this boss and read his bar before and after a hit:

packages/engine/test/outside/BossHealthBar.tsx
import {
Anchor,
BarSize,
Entity,
Health,
healthResource,
Panel,
ResourceBar,
useEntity,
} from "@spawnite/engine";
function BossHealthBar() {
const boss = useEntity();
return (
<Panel anchor={Anchor.Above} maxDistance={60}>
<ResourceBar
entity={boss}
resource={healthResource}
label="Yeti king health"
size={BarSize.Large}
/>
</Panel>
);
}
export function YetiKing() {
return (
<Entity position={[0, 0, -30]}>
<Health maximum={500} />
<BossHealthBar />
<mesh>
<boxGeometry args={[2, 3, 2]} />
<meshStandardMaterial color="#dfe8f0" />
</mesh>
</Entity>
);
}

A Billboard holds meshes rather than HTML, so a bar built from its meshes draws neither the ResourceBar’s hit nor the theme’s colours.

Server. Health has a consequence: it can end the entity.

import { Entity, Health, type EntityProps } from "@spawnite/engine";
export function Yeti({ position }: Pick<EntityProps, "position">) {
return (
<Entity position={position}>
<Health maximum={30} />
<mesh position={[0, 0.9, 0]}>
<capsuleGeometry args={[0.5, 0.8]} />
<meshStandardMaterial color="white" />
</mesh>
</Entity>
);
}