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

Abilities

An ability is something a character does from a key or an action-bar slot, such as a spell. Its settings are data that the room and the page share, registered by name. The room judges each cast, so a page cannot cast what its player does not hold, cannot cast past a cooldown, and cannot cast at a target out of range or out of sight.

abilities() needs weapons(), cooldowns(), resources() and characters() in the same plugins list, since a cast flies as a projectile, waits out a cooldown, spends a resource and is made by a character. A world made without one of them throws as it is made, naming the plugin to add, such as abilities() needs weapons(): add weapons() to plugins.

registerAbility(world, name, settings) registers an ability on a world. Call it in a plugin’s setup, which the world runs as it is made, on the room and on every page, so each holds the same settings. List the plugin in the game’s plugins beside abilities(), which it requires, and the four plugins abilities() requires. The engine’s tests make a world from this file and read both abilities back:

packages/engine/test/outside/mage.ts
import type { World } from "koota";
import {
abilities,
AbilityKind,
behaviours,
characters,
cooldowns,
definePlugin,
defineResource,
registerAbility,
resources,
stats,
weapons,
} from "@spawnite/engine/core";
export const mana = defineResource("mana", { maximum: 100, regen: 5 });
function registerMageAbilities(world: World) {
registerAbility(world, "arcane-bolt", {
kind: AbilityKind.Projectile,
label: "Arcane Bolt",
castSeconds: 0.4,
cooldownSeconds: 0,
range: 25,
damage: { min: 20, max: 26, stat: "spellPower" },
projectile: { speed: 14, turnDegrees: 540 },
});
registerAbility(world, "frost-nova", {
kind: AbilityKind.Area,
label: "Frost Nova",
castSeconds: 0,
cooldownSeconds: 12,
cost: { [mana]: 25 },
radius: 6,
damage: { min: 25, max: 30, stat: "spellPower" },
slow: { percent: 50, seconds: 3 },
});
}
// The world calls setup as it is made, on the room and on every page, so
// each holds the same settings.
export const mageKit = definePlugin({
name: "mage-kit",
requires: [abilities],
resources: [mana],
setup: registerMageAbilities,
});
/** The plugins a game lists in src/game.ts: abilities() and the plugins
* it requires, and the kit. */
export const magePlugins = [
characters(),
stats(),
cooldowns(),
resources(),
behaviours(),
weapons(),
abilities(),
mageKit,
];

An ability that plays a clip names it in clip in place of castSeconds, and the clip’s release mark is its cast time, as Casting says. Register the clip with registerClip at module scope, before the plugin’s setup runs.

cost names what the cast spends, by the keys defineResource returns for each resource: { [mana]: 25 }, or { [mana]: 20, [rage]: 30 } for two. Left out, the ability is free. The cast holds the cost back under castReservation, so a game’s own action on the same character, holding a cost under its own key, never spends the cast’s or has its own spent by it.

approach and faceTarget set how the caster moves for a cast at a target. Each is true when left out:

  • approach: true walks her toward a target past range and casts once she is in range. approach: false refuses the cast with “Out of range”.
  • faceTarget: true turns her to face the target as the cast starts. faceTarget: false casts facing wherever she stands.

kind says what the release does:

  • AbilityKind.Projectile launches a projectile at the target, which turns toward the target at turnDegrees a second. It passes through a friendly and through a hostile that is down, to what stands behind.
  • AbilityKind.Area strikes every hostile target within radius of the caster that she has line of sight to. It needs no target.
  • AbilityKind.Chain strikes the target, then jumps to the nearest hostile target within jumpRadius that it has not struck, jumps times. Each jump deals falloff times the damage of the one before it. It never strikes one target twice.

A character casts only the abilities its AbilitiesTrait trait holds. Each grant has a source:

  • <CharacterSpawn>’s abilities grants abilities for as long as she is in the world, under the source "player": <CharacterSpawn abilities={["frost-nova"]} resources={[mana]} />. In a room it holds for every player’s character.
  • grantAbilities(entity, source, names) grants abilities under a source, and revokeAbilities(entity, source) takes off that source’s abilities and no other.
  • An item’s equip.abilities grants abilities while the item is worn: equip: { slot: EquipmentSlot.MainHand, abilities: ["arcane-bolt"] }. Taking the item off takes those abilities off, and cancels a cast of one of them.

An ability strikes an entity with the TargetableTrait trait, whose faction is Faction.Hostile, the default. A target frame names it with useDisplayName, as Entity says. The caster’s chosen target is the TargetTrait trait on the caster, which <Targeting> writes, and the cast press carries it to the room.

<Health> alone does not make an entity a target, and neither does an NPC’s faction. A game gives its enemies the trait with a behaviour of its own, as Behaviours describes, and puts it beside <Health>. A system that spawns an enemy adds TargetableTrait among its traits, as the spawner on the Health page does. The room’s tests mount this dummy in a room’s scene, check that a player’s page holds it as a hostile target and that the room lets a cast target it, and check that an entity with Health alone is no target:

packages/room/test/outside/TrainingDummy.tsx
import {
defineBehaviour,
Entity,
Faction,
Health,
RunContext,
TargetableTrait,
useBehaviour,
type EntityProps,
} from "@spawnite/engine";
const TargetableBehaviour = defineBehaviour({
name: "targetable",
trait: TargetableTrait,
runsOn: RunContext.Server,
description: "An entity abilities and Targeting can pick.",
});
interface TargetableProps {
/** Whose side it is on: abilities strike a hostile one. */
faction?: Faction | `${Faction}`;
}
export function Targetable({ faction = Faction.Hostile }: TargetableProps) {
useBehaviour(TargetableBehaviour, { faction: faction as Faction });
return null;
}
export function TrainingDummy({ position }: Pick<EntityProps, "position">) {
return (
<Entity position={position}>
<Health maximum={60} />
<Targetable />
<mesh>
<cylinderGeometry args={[0.4, 0.4, 1.8]} />
<meshStandardMaterial color="#b08850" />
</mesh>
</Entity>
);
}

<AbilityBar /> puts every ability the player holds on a slot and a key, and is the bar most games mount. Under it, useCast() gives a page cast(name), refusal and refusalText. cast sends the cast command with the player’s target. refusal holds why the rules refused her last cast, until she casts again, and refusalText is the line to show for it, such as “Out of range” or “Not enough mana”, or the engine’s own line from commandRefusalLines for a cast refused before any rule read it, such as “Too late” for one that reached the room late:

import { useCast } from "@spawnite/engine";
export function BoltButton() {
const { cast, refusalText } = useCast();
return (
<>
<button onClick={() => cast("arcane-bolt")}>Arcane Bolt</button>
{refusalText && <p>{refusalText}</p>}
</>
);
}

describeCastRefusal(world, refused) writes the same line from a CastRefusedEvent event, and castRefusalText holds the line for each reason. Each reason is one of the engine’s shared Refusal codes, such as Refusal.CoolingDown and Refusal.OutOfRange, which the cast command declares as its refusals. A game’s own command declares the codes it refuses with too, with its own beside them, as Build a predicted mechanic shows.

The cast is a predicted command, abilities.commands.cast: her page runs the cast on the tick she sends it, and in a room the room runs it on the same tick. Her page shows the following on that frame, with no wait for the room:

  • The cost comes off her resource bar, held back as reserved.
  • The cast starts: her CastingTrait, which the bar’s rim, the clip, the charge and the hand’s turn draw from.
  • A refusal shows its line, from the command’s result in useLatestCommandResult(castCommand).
  • At the release, the cost is spent and the global cooldown and the ability’s own sweep on the bar.

The room alone decides what the release does: the projectile, the strike, the damage, the slow and every event. Her page draws them as the room streams them, a round trip after the release. The room’s answer settles the command: where it agrees, nothing changes, and where it refused the cast or a rule of the room changed her state, her page takes the room’s word and replays her steps from it, as it does for her movement. A cast at a target out of range, which walks her there first, starts when the room starts it, as Walk into range and turn says. The clip plays once per cast, as the cast starts on her character, and a refusal of that cast stops it.

A player may send 20 casts a second, past a burst of two, and her page refuses a cast past that too-fast. abilities({ castsPerSecond }) changes it for every ability: any number above 0 and up to 60, such as abilities({ castsPerSecond: 30 }), or 0.5 for one cast every two seconds. The rate is a flood guard, not a game rule: an ability’s cooldown, the global cooldown and its cost are what limit casts in play. Set it to at least twice the fastest a player can honestly cast, and leave it alone otherwise. The room and her page read it from the one plugins list both compose, and the room refuses a page whose list differs.

To make an ability wait for the room, register it with predicted: false. Her page then sends its command and starts nothing of it, the room runs it on the tick she sent it, and her page shows the cast, its cost and its cooldowns as the room’s word arrives, a round trip later. A cast at a target out of range still walks her toward it at once. Unreal’s Gameplay Ability System gives the same choice as an ability’s net execution policy: local predicted, the default here, or server only. A game played alone runs every cast on its next step either way.

cast sends the cast command, abilities.commands.cast, also exported as castCommand, through her character, with the ability’s name and the target, an entity({ optional: true }) field that is null for an area ability. createCastPayload(world, character, name) writes the same payload for a game’s own sender, which sends it with sendCommand(world, castCommand, payload, { entity: character }). useCast does the same, so a game’s own button calls useCast’s cast unless it needs more. The cast starts when all of the following hold:

  • The caster holds the ability and is neither downed nor already casting.
  • The ability’s own cooldown and the global cooldown have run out.
  • The caster has enough of each resource in cost.
  • For a projectile or a chain, the target is alive, hostile, within range, and in line of sight.

Otherwise the command is refused with the reason, and with the resource a cost fell short of as its detail. A cast refused later, after she walked into range, is already accepted, so where the world decides outcomes, on the room and in a game played alone, the refusal emits CastRefusedEvent { reason, resource } on the caster with the cast command as its cause: every page and the devtools hear it, and her page alone reads its cause as her cast’s request id. A target past range is not refused at once: the cast waits while she walks into range, as Walk into range and turn says.

A started cast is the CastingTrait trait on the caster: the ability, the target’s key, the seconds of wind-up left and the cast command’s request id. The cast holds its cost back at its start, in each resource’s reserved. It then runs as follows:

  1. The wind-up. A rooted cast ends the walk she is on as it starts, so nothing steers her and she runs down to a stand at her own deceleration. A cast is rooted unless the ability sets rooted: false.
  2. The cancel. The cast is cancelled when the caster goes down, her health reaches 0, she disconnects, or she loses the ability, and, on a rooted cast, when the player presses a move or jump key or sets off on a walk. The cost comes back and no cooldown starts. Her page cancels on her own key or walk on the same tick as the room; it learns that she went down from the room, and a press after that is refused on her page as on the room.
  3. The release. For a projectile or a chain, the target is checked again. A target that died or went out of sight fizzles the cast, which returns the cost and starts no cooldown. Otherwise the cost is spent, the global cooldown starts for 1 second, the ability’s own starts when cooldownSeconds is above 0, and, on the room, the ability does what kind says.

An ability’s own cooldown runs under the key nameAbilityCooldown(name), ability: and its name, and the global cooldown under globalCooldown. Read either with isCoolingDown(caster, nameAbilityCooldown("fireball")) or readCooldown in a system, and useCooldown(caster, nameAbilityCooldown("fireball")) in a view. defineCooldown("fireball") names a game’s own key, which no ability starts.

The cast time has one source. An ability with a clip releases at the clip’s release mark, which registerClip names in seconds, so the spell leaves on the frame the hands let it go; castSeconds beside a clip throws, as does a clip with no release mark. An ability with no clip gives castSeconds, and castSeconds: 0 is instant: the cast releases in the step it starts, and a move key does not cancel it. spawnite play timeline shows a cast frame by frame with the second under each, which is how a mark is read; the player page says how a look hears the clip’s other marks.

A projectile an ability launches carries AbilityProjectileTrait { ability }, which every page is streamed. When the projectile ends, on its target, on cover or at its range, the room emits SpellLandedEvent { ability, at, hit } on the caster: where an impact effect plays. An area ability emits one on the caster as it releases, at her feet and hit null. Two of her bolts ending in one step are two events, as two hits are two DamagedEvents, and useEvent hears each once, as SpellLook does. The room emits SpellStruckEvent { ability, at, from } on each target an ability strikes: from is the caster for a projectile’s hit, an area’s strikes and a chain’s first, and the link before it for each jump of a chain, so a page draws a beam along the chain.

A cast at a target past range walks the caster into range first, as Diablo’s click to attack does, unless the ability sets approach: false:

  1. cast walks her toward the target along the navmesh, with the same walk that click to walk sends. In a room, her page walks her and predicts the walk, and the room runs the walk her page sends.
  2. The cast waits as the PendingCastTrait trait on the caster, and holds nothing back.
  3. She stops once the target is within 0.9 of range, approachShare, so a target that steps back as she stops is still in range.
  4. The room starts the cast once she is in range. A rooted cast waits for her to stop first. Her page shows the cast, its cost and its cooldowns as the room’s word arrives, a round trip after she stops.

A move or jump key drops the pending cast at no cost, as does a click to walk elsewhere, or the target dying or leaving. A target that moves while she walks is walked to again once she reaches where it stood. When she stands out of range for pendingStandSeconds, 0.5 s, because the navmesh reaches no nearer, the room refuses the cast with “Out of range”.

As a cast at a target starts, the caster turns to face the target over castTurnSeconds, 0.15 s, while the wind-up runs, unless the ability sets faceTarget: false.

As the cast releases, the hand that holds her item turns so the item’s tip points at the target, over whatever the clip did with her wrist: in over the last of the wind-up, 1 at the release and held there as the spell leaves, and back to the clip after. Each ability names its own window: aimHand: { inSeconds: 0.3, holdSeconds: 0.2, outSeconds: 0.2 }, any of the three, the rest filled from the engine’s characterMotion.castAim, 0.15 s each, which aimHand: true or nothing takes whole. The turn starts from the press on her own page, which starts the cast. A projectile or a chain turns the hand unless the ability sets aimHand: false; an area, with no target, never does. The item says which way its tip points with HeldItem’s forward, [0, 0, -1] unless the item says otherwise, as the player page describes. Unreal aims a weapon with an aim offset or a two-bone IK node driven by the target, Unity with an aim constraint on the hand bone, Godot with a look-at modifier on a bone; each layers the turn over the clip and weights it in and out, as this does, because a clip is authored with no target.

Diablo walks the character into range and then attacks. Lost Ark and Genshin Impact turn the character toward the target as the player casts. World of Warcraft refuses a cast unless the player faces the target and is in range. Roblox, Unity, Unreal, Godot, PlayCanvas and Phaser leave both to each game. This engine walks and turns, because a player on a phone presses one button and expects the attack to happen.

The room owns an ability’s numbers and the page owns its look. <SpellLook> draws one ability, for every caster and every projectile of it, the player’s and each other player’s alike. The three effects below come from spawnite add asset, which copies each into the game’s src/particles/:

src/components/MageLooks.tsx
import { Particles, SpellLook } from "@spawnite/engine";
import effectBoltBurst from "../particles/bolt-burst.json";
import effectBoltRunes from "../particles/bolt-runes.json";
import effectBoltTrail from "../particles/bolt-trail.json";
export function MageLooks() {
return (
<>
<Particles
effects={[effectBoltBurst, effectBoltRunes, effectBoltTrail]}
/>
<SpellLook
ability="arcane-bolt"
color="#9ecbff"
charge={effectBoltRunes}
chargePosition={[0, 0.8, 0]}
projectile={[effectBoltTrail, effectBoltRunes]}
impact={effectBoltBurst}
/>
</>
);
}

It plays each effect at one moment of the spell:

  • charge plays in the caster’s right hand while she winds the cast up, on her own page from the cast’s start, and stops at its release or its cancel. chargePosition moves it from the hand in the hand’s axes, as HeldItem’s position does, to a staff’s tip, and chargeRotation turns it as HeldItem’s rotation does, so a swirl turns about the staff.
  • projectile rides each projectile of the ability, and model names a registered model drawn there.
  • impact plays once where each projectile ends, on a hit or a fizzle, and at the caster’s feet as an area ability releases.
  • strike plays once on each target the ability strikes: a projectile’s hit, each within an area, and each link of a chain.
  • area mounts its parts at the caster’s feet as an area ability releases, for areaSeconds, 2 by default: a ring that races out, a rune circle, shards. Each part ages from its mount.
  • beam draws from where each strike came from to the target, for beamSeconds, 0.35 by default. It is a function of the two ends, both at the feet, as Roblox’s Beam takes two attachments: beam={({ from, to }) => <SpellBeam from={from} to={to} color="#c9a7ff" />}. A chain draws one beam per jump, from the link before.

Each takes one effect or a list. charge and projectile also take an element, such as a glowing mesh of the game’s own, drawn in the hand or riding the projectile beside the effects: projectile={[effectBoltTrail, <BoltCore />]}. An element that animates reads where it is in its own useFrame. color tints the effects. The scene’s Particles must hold every effect, from the asset library, the three.quarks editor or code. Past that, a game writes its own view that reads the CastingTrait trait, the entities with AbilityProjectileTrait, and the SpellLandedEvent and SpellStruckEvent events, as SpellLook does.

The engine ships the parts a spell is built from, each a glowing mesh with props for its numbers and its colour, and a game’s own element stands in any part’s place:

  • SpellRing races out along the ground to radius over seconds, for area: a nova’s edge.
  • SpellRunes is a rune circle left glowing on the ground, opening in a blink and fading over seconds, for area.
  • SpellShards stand up out of the ground in a ring and sink back, for area.
  • SpellBeam arcs from from to to, both lifted to a body’s middle, and fades, for beam.
  • SpellSpear flies point first with crystals turning round its shaft, for projectile: it turns to face the way its group has moved.
  • SpellSwirl gathers crystals in from wide to tight over a cast’s seconds, for charge at a staff’s tip.
  • createGlowMaterial(color, intensity, opacity) is the lit material each draws with, emitted above 1 so a scene’s bloom takes it.

The mmorpg’s mage composes its three spells from these under SpellLook, as its kit page, wiki/kit/mage.md in the mmorpg’s folder of spawnite/examples, says. Unreal ships Niagara templates for the same reason: a spell’s look is assembled from a few moving parts far more often than drawn from nothing.

Unreal keeps an ability’s look in its Gameplay Cues, apart from the effect that changes the numbers. Unity, Godot, Roblox, PlayCanvas and Phaser leave it to each game. This engine takes Unreal’s split, as one component with a prop for each moment, so an agent writes a finished spell in one element.

Each strike rolls its damage in these steps:

  1. A whole number from damage.min to damage.max, both included, on the world’s seeded stream, so a replay rolls the same.
  2. Scaled by the caster’s stat that damage.stat names, as a percentage: 20 spellPower makes it 1.2 times.
  3. A crit against the caster’s critChance stat, 0.05 by default, multiplies it by her critDamage stat, 1.5 by default.

The strike calls dealDamage with the ability’s name as the hit’s weapon and { crit } as its data. rollAbilityDamage(step, caster, damage) rolls one strike for a game’s own system, on the stream of the world whose step it takes. Damage is the room’s, so it takes the room’s step, and her page never moves the world’s random stream.

A slow leaves a timed modifier on the victim’s moveSpeed stat, which sets MovementTrait.speed. The first slow declares the stat from the victim’s own speed, so the slow runs out back to it.

A game’s own ability system uses the calls the engine’s cast uses. hasLineOfSight(world, looker, target) says whether cover stands between two characters. strikeTarget(step, { caster, victim, name, ability }) deals one strike: the roll, the damage, the ability’s slow and the SpellStruckEvent event. slowCharacter(step, entity, { ability, percent, seconds }) leaves the slow alone. Each of these outcomes takes the room’s step first, as dealDamage and launchProjectile do, so a predicted system calls them inside if (step.authoritative), and a system on the room alone calls them as they are. A game’s own outcome takes authority: Authority first the same way, and code outside a step passes requireAuthority(world). Where a system runs says which step is the room’s.

A slow works through the moveSpeed stat, which sets MovementTrait.speed each step once a slow has declared it. From then on, a write to MovementTrait.speed is overwritten. To change the character’s own speed, set the stat’s base instead, and the modifiers stay on top of it:

import { setStatBase } from "@spawnite/engine";
setStatBase(entity, "moveSpeed", 7);

moveSpeedStat names the stat.

A cast spends resources, such as mana or rage, as damage spends health. The resources page says how to register one, give it to a character and show it.

The devtools’ Inspector has an Abilities section for a caster: each ability’s range, cost, cooldown left and cast state, the target’s reach and sight line, the last refusal, and a switch that draws the range rings and a line to the target. readAbilityInspection(world, caster) reads the same numbers, and spawnite play eval views reads them under "abilities".

Unreal’s Gameplay Ability System keeps the ability, its cost, its cooldown, its target data and its cues apart, all replicated. This engine takes Unreal’s separation, and writes an ability as settings rather than a subclass, so an agent writes one correctly on its first try.