Resources
A resource is a value a character fills and spends, such as mana, rage, stamina, energy or a shield. Every resource has one shape: you define it once and get its key, give it to a character, and show it with a bar where the player should see it. An ability’s cost spends any of them but health.
Health is the engine’s own resource, healthResource, so <ResourceBar resource={healthResource}> draws it and a game turns its regeneration on with registerHealth. The health page says what stays Health’s own.
Define a resource
Section titled “Define a resource”defineResource(name, settings) defines a resource and returns its key, a ResourceKey. Call it once, at module scope, in a file the room and every page import, as defineCooldown and defineTrait are called. Every call that names the resource takes the key, never its name as text, so a misspelt name fails to typecheck:
import { defineResource } from "@spawnite/engine";
export const mana = defineResource("mana", { maximum: 100, regen: 5, start: "full", color: "#4aa3ff", label: "Mana",});
export const rage = defineResource("rage", { maximum: 100, regen: -2, start: "empty", color: "#d64545", label: "Rage",});
export const stamina = defineResource("stamina", { maximum: 50, regen: 10, start: "full", regenDelaySeconds: 1, color: "#e0c341", label: "Stamina",});The settings are the following. Only maximum is required:
maximumis what it holds when full.regen, which is 0 by default, is what it gains each second, up tomaximum. A negativeregendecays it toward 0, as rage does.start, which is"full"by default, is where it starts when a character is given it:"full","empty", or a number.regenDelaySeconds, which is optional, stops its regeneration for that many seconds after a spend, as stamina does.saved, which is optional and true by default, keeps what a character has of it in a save. Withsaved: false, the save leaves it out and a load starts it fromstart, as a shield that refills on each load wants.color, which is optional, is the fill every bar draws it in: any CSS colour. Left out, a bar draws in the uiBar’s own fill, the health tone.label, which is optional, is what a player reads: the bar’s name, and the line a refused cast shows, such as “Not enough mana”. Left out, it is the name as words:"Combo"for"combo","Lantern charges"for"lanternCharges".
A resource’s name is camelCase from a lowercase letter, such as "mana" or "holyPower". It is also the name the dump, a room’s stream and a save write it under, so the key reads as its name in every tool. A name another trait already uses, such as "stats", throws, and so does "health", the engine’s. Defining a name again replaces its settings, as a reload of its module does.
A hidden resource
Section titled “A hidden resource”Nothing draws a resource until a game places a <ResourceBar> for it or reads it with useResource, so a resource the player never sees needs only its maximum: a combo counter, a quest item’s charges, or a boss’s phase. It starts full and never regenerates unless its settings say so, and a system fills and spends it with changeResource:
import { defineResource } from "@spawnite/engine";
export const combo = defineResource("combo", { maximum: 5, start: "empty" });export const lanternCharges = defineResource("lanternCharges", { maximum: 3 });An ability’s cost spends a hidden resource as it spends mana, and a refused cast names it by its label, “Not enough lantern charges” here. Give it a label where the name alone reads wrong to a player.
Give a character a resource
Section titled “Give a character a resource”Give every character of the game a resource with a plugin’s resources, the player of one scene with <CharacterSpawn>’s resources, and any other entity with <Resource>:
import { defineResource, definePlugin, CharacterSpawn, Entity, Resource,} from "@spawnite/engine";
const mana = defineResource("mana", { maximum: 100, regen: 5 });
export const mageKit = definePlugin({ name: "mage-kit", resources: [mana] });
export function Mage() { return <CharacterSpawn abilities={["arcane-bolt"]} resources={[mana]} />;}
export function Shaman() { return ( <Entity position={[4, 0, -6]}> <Resource resource={mana} /> </Entity> );}A plugin’s resources gives each to a character as she spawns, before the plugin’s own onCharacterSpawn, so the hook can read it. In a room, <CharacterSpawn>’s resources holds for every player’s character, as its health does. A game’s own system gives one with declareResource(entity, key).
Each resource is its own trait, { current, maximum, reserved }, which the dump shows under the resource’s name. A room streams it to every page, so a page draws another player’s bar and a target’s too. findResourceTrait(key) returns the trait for a system of your own.
Each resource but health is a predicted trait on a player’s character: her page regenerates it on every step as the room does, so her bar fills smoothly between the room’s sends, and the room’s word corrects her page where the two differ, such as on a cost the room took. Her maximum follows her max<Name> stat, the room’s word, so her page sets it again on each step and never corrects her for it alone. Health, and every resource of anything but a player’s character, regenerates on the room alone, and each page draws what the room sends.
Show a resource
Section titled “Show a resource”<ResourceBar entity resource> draws the ui’s Bar for an entity’s resource, filled in the resource’s colour, or the Bar’s own fill where it has none, and named by its label. What its reservations hold back shows as a lighter band after the fill. color draws one bar in another colour, label gives it another name, showValue reads 12 / 40 beside it, size sets how large it draws, as Bar’s does, and className and classNames restyle it:
import { defineResource, ResourceBar, useCharacter } from "@spawnite/engine";
const mana = defineResource("mana", { maximum: 100 });
export function ManaBars() { const entity = useCharacter() ?? null; return ( <> <ResourceBar entity={entity} resource={mana} /> <ResourceBar entity={entity} resource={mana} color="#8b5cf6" className="h-3 w-56" /> </> );}To draw a resource any other way, such as an orb, a ring or a number, read it with useResource(entity, key). It returns { current, maximum, reserved }, or undefined while the entity has none. Health’s has no reserved, since no cast holds health back:
import { defineResource, useCharacter, useResource } from "@spawnite/engine";
const rage = defineResource("rage", { maximum: 100, start: "empty" });
export function RageNumber() { const entity = useCharacter() ?? null; const amount = useResource(entity, rage); if (!amount) return null; return <span>{Math.floor(amount.current)}</span>;}Fill and spend it from a system
Section titled “Fill and spend it from a system”changeResource(entity, { resource, amount }) adds amount, or spends it where amount is negative, between 0 and the maximum: a hit that builds rage, a sprint that spends stamina. A spend pauses regeneration for regenDelaySeconds, as a hit from dealDamage pauses health’s. Call it from a system, where the simulation has the say. Health is not one of these: it is an outcome, which dealDamage and dealHealing change with the room’s authority, so changeResource refuses healthResource as a type error and throws on a key that holds it:
import { changeResource, defineResource, type Entity } from "@spawnite/engine";
const rage = defineResource("rage", { maximum: 100, start: "empty" });
export function buildRage(attacker: Entity) { changeResource(attacker, { resource: rage, amount: 10 });}Spend it while the character moves
Section titled “Spend it while the character moves”A resource a character spends over time, such as stamina while she runs, is a system on her step. Each step it reads her VelocityTrait, the metres a second she moves, which the engine’s movement writes from her input before the rules run. Where she moves faster than 0.1 metres a second, it spends the step’s share of 25 a second; standing, it spends nothing, and the resource’s regen fills it again once regenDelaySeconds have passed since the last spend.
The system is predicted, predicted: true, and walks her with step.updateEachPredicted: her page spends on the tick the room does, as it regenerates, so her bar never waits a round trip, and the room’s word corrects it. It runs before the engine’s resources.regenerate, so the pause a spend starts holds regeneration from that same step. The room’s tests walk her on a page joined to a room, with a correction from the room mid-walk, and check that her page never guessed her stamina wrong:
import { createQuery, type World } from "koota";import { changeResource, definePlugin, defineResource, resources, VelocityTrait, type PredictedStep,} from "@spawnite/engine/core";
// Stamina that drains while she moves and refills while she stands, built// as a creator's plugin is, from the engine's public entry alone. The// resource's own regen refills it; the system only spends.
export const stamina = defineResource("stamina", { maximum: 100, regen: 20, regenDelaySeconds: 0.5, color: "#e0c341", label: "Stamina",});
/** Stamina a second she spends while she moves. */const drainPerSecond = 25;/** Metres a second under which she stands still. */const stillSpeed = 0.1;
const movers = createQuery(VelocityTrait);
function drainStamina(_world: World, step: PredictedStep) { step.updateEachPredicted(movers, ([velocity], character) => { if (Math.hypot(velocity.x, velocity.z) < stillSpeed) return; changeResource(character, { resource: stamina, amount: -drainPerSecond * step.deltaSeconds, }); });}
export const staminaPlugin = definePlugin({ name: "stamina", resources: [stamina], systems: { rules: { drain: { system: drainStamina, // Her page spends it on the same tick the room does, as it // regenerates it, so her bar never waits for the room. predicted: true, before: [resources.systems.regenerate], }, }, },});List staminaPlugin in the game’s plugins, and draw the bar with <ResourceBar resource={stamina}>, as Show a resource says.
A test proves the drain and the refill without a playtest, where a round’s end or a modal can stop the walk before the bar fills. It walks her for two seconds, then stands her still until she is full, in Node, as Headless tests says. List the game’s own plugins in place of the four engine plugins here:
import { Vector3 } from "three";import { expect, it } from "vitest";import { characters, cooldowns, createHeadlessGame, findResourceTrait, holdInput, placeCharacter, resources, stats, stepSeconds,} from "@spawnite/engine/core";import { stamina, staminaPlugin } from "./stamina.ts";
// The Resources page's stamina in a game played alone: no playtest, so no// round or modal can stop the walk before the bar refills.
it("drains stamina while she walks and refills it once she stands", async () => { const game = await createHeadlessGame({ scene: () => undefined, plugins: [ stats(), characters(), cooldowns(), resources(), staminaPlugin, ], }); const character = placeCharacter(game, new Vector3()); const readStamina = () => character.get(findResourceTrait(stamina))?.current;
holdInput(game, characters.inputs.move, { x: 0, y: 1 }); stepSeconds(game, 2); const walked = readStamina() ?? 100; expect(walked).toBeLessThan(60);
holdInput(game, characters.inputs.move, { x: 0, y: 0 }); stepSeconds(game, 0.4); // Still within the pause after her last spend: none back yet. expect(readStamina()).toBeLessThanOrEqual(walked); stepSeconds(game, 3); expect(readStamina()).toBe(100); game.world.destroy();});How a cast spends it
Section titled “How a cast spends it”An ability’s cost names what it spends by key, such as { [mana]: 25 } or { [mana]: 20, [rage]: 30 }: a record, so no resource is named twice. A plain name, { mana: 25 }, fails to typecheck wherever the cost is written, in place or held in a variable, with an error that names it and the fix. A record widened to text keys, such as a Record<string, number>, is the one shape no type can refuse, since a keyed cost held in a variable has the same type. For a name in it that no defineResource made, registerAbility throws as the world is made, a development world throws at the first checkCost, reserveCost or changeResource that names it, and a deployed room logs it once, naming the call and the system, and reads the resource as none: checkCost finds the cost short, and a press refuses with Refusal.NoResource. The room refuses a cast whose cost the caster cannot pay, with Refusal.NoResource, and CastRefusedEvent’s resource names the one that fell short.
A cast holds its cost back at its start: current drops and reserved rises, so a second cast cannot spend the same amount. The release spends what was held back, and a cancel or a fizzle gives it back. While a cast winds up, regeneration stops short of what is held back, so the resource never refills what the release spends.
A game’s own system holds a cost back the same way, under a key of its own, a ReservationKey from defineReservation, so its hold and the cast’s never settle each other:
checkCost(entity, cost)returns the key of the first resource the entity has too little of, orundefinedwhere the entity can pay.reserveCost(entity, reservation, cost)holds the cost back underreservation.commitCost(entity, reservation)spends what that key holds and pauses each resource’s regeneration.refundCost(entity, reservation)gives it back, up to each resource’s maximum less what the other keys still hold.
The strike that Build a predicted mechanic builds, which the engine’s own tests run against a real room, defines its resource, its cost and its key at module scope:
/** The resource a strike costs. */export const focus = defineResource("outsideFocus", { maximum: 100 });const cost: ResourceCost = { [focus]: 15 };const hold = defineReservation("outsideStrike");Its press checks the cost with checkCost(character, cost) and holds it with reserveCost(character, hold, cost), and its release spends it with commitCost(character, hold). A strike and a cast on one character each settle their own hold.
Each resource’s reserved is the total of every key’s hold, and ReservationsTrait holds each key’s own, which the dump shows. Both are predicted on a player’s character, so a correction restores every hold with the resources it holds back. The engine’s cast holds its cost under castReservation, ability:cast, a name no defineReservation takes. Reserving again under a key that already holds a cost is a mechanic that never settled its last one: a development world throws, naming the key, and a deployed room keeps the first hold. The names follow Unreal’s ability system, which checks, applies and commits an ability’s cost.
The maximum is a stat
Section titled “The maximum is a stat”maximum is the base of the character’s max<Name> stat: maxMana for "mana", maxRage for "rage". A modifier on it raises or lowers the cap, as an item or a card would: addStatModifier(entity, "maxMana", { source: "robe", flat: 20 }).
Where a character has a <name>Regen stat, such as manaRegen, it takes the place of the defined regen, so an item can raise it too.
Saving
Section titled “Saving”A save holds what the player has of each resource under resources, by name, counting what her reservations held back as hers, since a reload ends what held them. It holds each max<Name> stat with her other stats. <CharacterSpawn> restores her resources from the save by itself, and her stats from its stats, as the stats page says, so pass the save’s stats there too: <CharacterSpawn resources={[mana]} stats={save.stats} />. She then comes back with the mana she left with, under the cap she left with. A resource defined with saved: false starts from start instead. Health is the exception: the save holds it as the character’s own health, as the health page describes. readResourcesSave and restoreResources do the same for a game’s own save code.
What the other engines do
Section titled “What the other engines do”Unreal’s Gameplay Ability System makes health, mana and stamina attributes that any ability’s cost can spend. World of Warcraft gives each unit one power type, such as mana, rage that starts empty and decays, or energy, each with its own colour, and Diablo calls it the class’s resource. Roblox builds in Humanoid.Health. This engine takes one shape for all of them, so an agent learns one pattern, and a creator makes a new resource and its look in one call. Like Unreal’s attributes and this engine’s own cooldown keys, a resource is a typed key rather than a name in text, so a misspelt resource is a type error rather than a cost that is always short. Unreal’s ability system applies a cost at its commit and ties a predicted cost to a prediction key. A reservation key here gives each mechanic’s hold its own name, as a cooldown key does, so two mechanics on one character never settle each other’s cost.