Gameplay building blocks
The gameplay library is a set of small, preallocated state machines imported from gameable. They live in the guest with your ECS, so solo, rooms, headless tests, direct mode and wasm mode use the same code.
One lifecycle
Create parts at module level with capacities large enough for the match. Call reset() in init() on every new run: Wizer snapshots module state at build time. Add volumes, start quests and start clocks after resetting them. Time-based parts advance once per fixed step through step(ctx) in a system. Inventory and quests change only when called; easing curves are pure functions.
ts
import { createTimers, defineGame } from 'gameable';
const timers = createTimers({ capacity: 1 });
export default defineGame({
init(ctx) {
timers.reset();
timers.start(ctx, 0, 3);
},
systems: [{ run: (ctx) => timers.step(ctx), on: 'authority' }],
snapshot: () => timers.snapshot(),
restore: (state) => timers.restore(state),
});snapshot() returns JSON-safe data; restore(state) reads that data back and rejects malformed state. Wire every part and any game-owned counters into defineGame({ snapshot, restore }). The engine already saves ECS components, including Health; a damage snapshot saves its death flags and invulnerability, not a second copy of health. Use the same factory definitions and capacities when restoring. Reset is for a new run; do not reset after restore.
Snapshots allocate. Take them at persistence/checkpoint boundaries outside systems, not on every step. Named custom tween lanes are registered at creation; their values are included in tween snapshots. Tween reset stops animations but does not reset lane values, so initialise custom lanes in init() too.
Time is whole steps
ADR 0016 keeps ctx.dt fixed: slow motion buys fewer steps, rather than making each step shorter. secondsToSteps(seconds, ctx.dt) rounds to the nearest whole step, at least one. For example, secondsToSteps(0.5, 1 / 60) is 30. Parts capture the conversion when a clock starts and count integers down; do not subtract elapsed floating-point seconds yourself. A zero-duration timer still takes one step; turnSeconds: 0 explicitly disables turn timeouts.
Choose a stable system order. Stepping abilities before checking new actions lets a charge that returns this tick be used immediately. Starting a timer after its step leaves its full duration for the following ticks. Never step a part twice because two systems both need its result.
Memory and callbacks
Allocate capacities, definitions, typed arrays, query terms, positions, HUD models and callback functions at module level. Runtime methods and queries allocate nothing on valid calls. Your callbacks must follow the same rule: mutate a reused payload or lane, call pooled guest commands, and avoid new arrays, object literals, closures and string formatting in systems. Use ctx.rng for gameplay randomness, never Math.random() or a wall clock.
Callbacks run synchronously in the call that causes the event. Their order is part of the contract:
- Timers advance all slots before
onFire, in ascending slot order; repeating timers rearm before callbacks.fired(slot)lasts until the next step. - Tweens write final values and rearm loops before ascending-slot
onDonecallbacks. Loops do not emitonDone. - Trigger steps emit all exits before all enters, volume index then watch insertion order. Removal/unwatch reports exits only if supplied
ctx. - Damage calls
onDamagebeforeonDeath; healing reports the actual amount restored. Death is emitted once until revival or bookkeeping is cleared. - Inventory notifies after mutation; transfers notify in ascending owner order, clearing in item order. Moves emit nothing; reset/restore emit nothing.
- Abilities report each returned charge in owner then ability index order.
- Quests report progress, objective completion, then quest completion; forced completion visits objectives in ascending index order.
- Waves report wave start, spawn attempts in index order, wave clear, then final all clear. The next wave starts on the following step.
- Turns report round before turn before phase. A timeout calls
onTimeoutbefore passing the turn. Phase changes do not restart the turn clock.
Who owns the state
The parts never inspect networking. In a room game, run damage, inventory, abilities, quests, waves and turns from { run, on: 'authority' } systems. Validate inputs there, then publish values through each player's HUD or checked messages. Part state is not automatically replicated. A client must not spend a charge or award an item just because its local presentation says it can. Tweens and cosmetic timers can run with on: 'both'; they must not award shared state twice. Gate authority-only startup with ctx.net.role !== 'client'.
For a turn action, take the sender's seat from the checked message envelope, range-check it, and require turns.isTurn(seat) on the authority before changing the board or calling endTurn(ctx). Never trust a seat in a payload. turns.elapse(ctx, seconds) consumes a sleeping room's elapsed duration using whole steps and emits the same timeout/turn callbacks. Restore first, elapse once on wake, then resume ordinary step(ctx). It caps catch-up at seats * 1000 timeouts and restarts a full clock if that cap is reached. Elapsed wall time must be supplied by the room host, not read by the guest.
A part snapshot is data for a room document; it is not itself a database write. The room lifecycle must store and reload it. ctx.data.saveGame is shared by all rooms of a game, so it cannot hold one independent board per room.
Geometry and health
Triggers are guest geometry tested against each watched entity's Transform point. Boxes and spheres include their boundaries; they do not test a body's radius, contacts or swept motion. They are not physics sensors. Call watch for each entity, and step after the systems that update positions. Deleted entities are automatically unwatched; explicit unwatch(entity, ctx) can report exits at removal time. inside and count reflect the last trigger step.
Damage writes the built-in Health.current and respects Health.max. Give entities a health prefab field and route hits/heals through the part; step does not detect deaths caused by external health writes. heal affects living entities; use revive for a dead one. Call forget(entity) when despawning so reused entity ids inherit neither a death flag nor invulnerability. When a wave enemy dies or despawns, also call waves.killed(entity) to release its tracking.
Pick a part
| Part | Start or mutate | Read or advance | Recipe |
|---|---|---|---|
createTimers | start(ctx, slot, seconds, repeat) | remaining, fired, step | Countdown |
ease | ease.quadOut(fraction) | Clamped input; back/elastic may overshoot | Tween |
createTweens | start(ctx, lane, index, to, seconds) | active, step; marks Transform movement | Tween |
createTriggers | addBox / addSphere, watch | inside, count, step | Zone |
createDamage | damage, heal, kill, revive | fraction, isDead, step | Health |
createInventory | add, remove, transfer, move | count, has; owners are seat indices | Inventory |
createAbilities | use(ctx, owner, id) | ready, charges, remaining, step | Cooldown |
createQuests | start, progress, set | status, value, done | Quest |
createWaves | start(ctx), killed(entity) | alive, finished, step | Waves |
createTurns | start, endTurn, nextPhase, elapse | isTurn, current, round, step | Turns |