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

Stats

Stats gives an entity numbers that items, upgrades and buffs change: a speed, a jump, a launch. You declare each stat’s base, add modifiers, and read the result. The engine does the maths the same way every time.

Its props are in StatsProps. Each prop is one stat, named by the prop: <Stats speed={{ base: 6, max: 12 }} />.

clamp((base + Σflat) × (1 + Σpercent) × Π(1 + more), min, max)

A StatModifier sets one or more of three kinds:

  • flat adds to the base.
  • percent sums with the stat’s other percents. 0.2 is +20%.
  • more multiplies with the stat’s other mores. Two mores of 0.5 make ×2.25, where two percents of 0.5 make ×2.

On a base of 1000, two percent: 1 and one more: 1 give 1000 × 3 × 2 = 6000. With all three as percent, they give 4000.

The base never changes under a modifier. min and max clamp the value when it is read, never the base, so a modifier that ends restores the value it hid.

All take the entity and come from @spawnite/engine/core, so a system and a component call them alike:

  • addStatModifier(entity, name, modifier) adds a modifier to a stat.
  • removeStatModifiers(entity, source) removes every modifier that source added, on every stat: an item taken off, a buff cancelled.
  • readStat(entity, name) returns the resolved value, or undefined where the entity has no stat by that name.
  • setStatBase(entity, name, base) changes the base and keeps the bounds and the modifiers.
  • resolveStat(stat) applies the formula to a stat you already hold, such as one from useTrait(entity, StatsTrait).

A modifier with seconds lasts that many simulated seconds. The step counts it down and drops it the step it runs out. A modifier without seconds stays until its source is removed.

Bases and modifiers are plain data, and a source is a string, so a save or a room can carry them as they are.

A buff that lasts one round, such as a speed boost bought at a shop for the next run, is a modifier with a source and no seconds: a system adds it as the round’s state reaches RoundState.Playing and removes it with removeStatModifiers(character, source) as the round is won or lost. A timed modifier would end at a time of its own rather than with the round. The character holds a trait of the game’s own while the boost waits, which the game’s save keeps, so a boost bought in the lobby reaches the run. The modifier itself waits for the round, so she walks the lobby at her own speed. The engine’s tests play a round with it:

packages/engine/test/outside/roundBoost.ts
import { createQuery, type World } from "koota";
import {
addStatModifier,
definePlugin,
defineTrait,
moveSpeedStat,
readEach,
removeStatModifiers,
stats,
} from "@spawnite/engine/core";
import { RoundMachine, RoundTrait, rounds } from "@spawnite/engine/rounds";
// A speed boost bought before a run, such as at the lobby's shop, which
// lasts the next round she plays: added as the round plays, under a
// source of its own, and taken off with that source as the round is won
// or lost, which uses the boost up.
/** On a character who holds a boost; `active` while her round plays. The
* game's own save keeps it, so a boost bought in the lobby waits for the
* run. */
export const BoostTrait = defineTrait("outsideBoost", { active: false });
/** The source the boost's modifier is added under, so the round's end
* takes it off and leaves her other modifiers. */
export const boostSource = "round-boost";
const boosted = createQuery(BoostTrait);
export const roundBoost = definePlugin({
name: "round-boost",
requires: [rounds, stats],
// No runsOn: the room alone owns the stats, and a game played alone
// is its own room. In the physics phase, after every rule, so a round
// any rule starts or wins moves the boost in that same step, before a
// round screen can pause the next one.
systems: {
physics: {
boost: boostForTheRound,
},
},
});
function boostForTheRound(world: World) {
const round = world.queryFirst(RoundTrait);
if (!round) return;
const isPlaying = round.has(RoundMachine.is.playing);
const isOver =
round.has(RoundMachine.is.won) || round.has(RoundMachine.is.lost);
readEach(world, boosted, (_traits, character) => {
const isActive = character.get(BoostTrait)?.active === true;
if (isPlaying && !isActive) {
addStatModifier(character, moveSpeedStat, {
source: boostSource,
percent: 0.5,
});
character.set(BoostTrait, { active: true });
} else if (isOver && isActive) {
removeStatModifiers(character, boostSource);
character.remove(BoostTrait);
}
});
}

The engine reads the following stats itself, so a buff, an item or an upgrade on one of them changes the game with no system of the game’s own:

Stat Name in code What it sets Declared by
moveSpeed moveSpeedStat A character’s walking speed, MovementTrait.speed, each step declareMoveSpeed(character), from its own speed, or an ability’s slow
maxHealth maxHealthStat The cap of the entity’s health <Health> and <CharacterSpawn>
healthRegen "healthRegen" Health the entity regains each second, in place of registerHealth’s regen The game, with <Stats> or declareStats
max<Name>, such as maxMana nameMaxResourceStat(mana) The cap of a resource Giving it the resource
<name>Regen, such as manaRegen "manaRegen" What a resource regains each second, in place of its regen The game
critChance, critDamage "critChance", "critDamage" An ability’s chance to crit, 0.05 unset, and the crit’s multiplier, 1.5 unset The game
The stat an ability’s damage.stat names, such as spellPower The name the ability gives The ability’s damage, as a percentage The game
The stats a weapon’s stats names, such as damage The name the weapon gives The weapon’s numbers The game, from a base of zero

A modifier on a stat the entity has not declared starts it at a base of 0. Declare moveSpeed before a speed buff, or the buff stops the character: declareMoveSpeed(character), then addStatModifier(character, moveSpeedStat, { source: "boots", percent: 0.2 }) makes her 20% faster until removeStatModifiers(character, "boots").

dumpState lists each entity’s stats under stats: each stat’s base and bounds, and its modifiers, a timed one with its seconds left. The devtools inspector shows the same in one Stats card, with the value each stat resolves to, whichever component or system declared the stats. Unreal’s showdebug abilitysystem and Roblox’s Attributes panel show attributes live in the same way.

The card has a slider on each base, which sets it through setStatBase, so the modifiers still apply on top. A trait’s field that a stat should set, as sled’s rider sets its track mover’s sideSpeed from side, takes one call beside the trait. From then on each step, before the move, sets that field to the stat’s resolved value on every entity that has both, and the inspector hides the field’s own slider, since the next step would overwrite an edit. The game writes no system for the copy. The field must hold a number, since a stat resolves to one: naming a text, boolean or object field is a type error. Health’s maximum goes through the same call:

registerStatField("side", TrackMoverTrait, "sideSpeed");

The devtools page says how the card reads and writes them.

A weapon reads a player’s stat differently from other code: it lays the stat on its own number rather than taking the stat’s value. A card that adds { percent: 0.25 } to her damage stat raises a 10-damage rifle to 12.5 and a 40-damage rail to 50, when both name damage in their stats. Her stat’s base and flat modifiers add to the weapon’s number, and its percent and more modifiers scale it; its bounds are not read. So a game’s gun stats start at a base of zero, or are not declared at all. A stat can raise a weapon’s damage, range, shots a second, pellets, spread and pierce, and a projectile’s speed. Weapon has the rules for each number.

A system that lays several lists of modifiers on a number of its own, as a weapon does, sums them with the formula’s totals: clearModifierTotals(totals) starts a sum, addModifierTotals(totals, modifiers) adds each list, and applyModifierTotals(base, totals) returns (base + flat) × (1 + percent) × more. One ModifierTotals record, kept at module scope, serves every step with no garbage.

Server. A stat has a consequence: it decides how fast, how high, how hard.

The step drops spent modifiers before the behaviours run, so every behaviour in a step reads the same value.

A save and a room carry each stat’s base and its modifiers, never the value they resolve to. Whoever reads them resolves the value with the same formula.

Where the game’s save includes stats, the save keeps the player’s stats under engine.stats:

  • The engine reads the character’s stats there. It leaves out every modifier with seconds, so a reload never makes a buff permanent.
  • The maxHealth stat’s base is the character’s health.maximum, which the character save keeps.
  • The engine lays the saved stats on her once her spawn has set her up, before her worn items put their modifiers back.
  • A game whose player has no character keeps readStatsSave(entity) in its own save’s state, and calls restoreStats(entity, stats) in its own restore.

In a room, the room owns the stats:

  • The stream carries each entity’s stats under the name stats: the bases, the bounds, and the modifiers with their seconds left. Every client receives them, and each one resolves the value locally.
  • A client never counts a modifier down. The room drops it when its seconds run out, and the next delta tells every client.
  • A <Stats> on an entity the room streams writes nothing, so a client’s scene never replaces the room’s bases.

Unreal’s Gameplay Ability System replicates each attribute’s resolved value, and sends the active effects to the owning client alone in its mixed mode. Its save is the game’s own code, which by convention keeps the base values and applies lasting effects again. Roblox Attributes replicate plain values, and a game writes its own DataStore save. Unity and Godot games replicate a value through a network variable or a synchronizer and save what they choose. Stats sends the bases and modifiers instead, so every client can show a buff’s source and its seconds left, and resolves the same value the room does.

Engine or game Kinds Formula
Unreal 5 GAS AddBase, MultiplyAdditive, MultiplyCompound, AddFinal, Override ((Base + AddBase) × MulAdd × MulCompound) + AddFinal
Unity, the common Kryzarel pattern Flat, PercentAdd, PercentMult (base + Σflat) × (1 + Σpct) × Π(1 + mult)
Path of Exile, League of Legends flat, increased, more the same three buckets
Godot, the godot-attributes addon add, multiply ordered calculators
Roblox, PlayCanvas none built in none

Stats keeps the three buckets that Unreal 5, Unity’s common pattern, Path of Exile and League share, and leaves out Unreal’s Override and AddFinal. It keeps Unreal’s split between a permanent base and temporary modifiers. Unlike Unreal, which clamps in two places, it clamps once, when the value is read. The name more is Path of Exile’s.

An item adds its modifiers when it is equipped and removes them by its source when it comes off:

import { useEffect } from "react";
import {
Entity,
Stats,
addStatModifier,
removeStatModifiers,
useEntity,
} from "@spawnite/engine";
function Slingshot() {
const sled = useEntity();
useEffect(() => {
addStatModifier(sled, "launch", { source: "slingshot", more: 0.04 });
return () => removeStatModifiers(sled, "slingshot");
}, [sled]);
return null;
}
export function Sled() {
return (
<Entity>
<Stats launch={{ base: 20, min: 0 }} />
<Slingshot />
</Entity>
);
}