Extending a plugin
This page continues Packages and kits. It describes how a game changes what a plugin does without copying it.
Extending an ability
Section titled “Extending an ability”Abilities are the worked example of defaults plus a hook, and the maintainer decided its shape on 2026-09-30: common effects as data, and events for the rest. An ability today names one of three kinds, must deal damage, may slow, may target only a hostile, and shares a global cooldown of one second the engine fixes (traits.ts:78, abilities.ts:141, cast.ts:49). A heal, a shield or a teleport means changing the engine. The following changes open it:
- Effects as data, each optional.
damage,healandslowper ability, so a heal needs no code and a cast with no effect only fires the events. A game that needs more writes a system. - The target rule as data.
targetsper ability: hostile, the default, friendly, self or any. The refusalNotHostilebecomesWrongTarget. - A direct kind.
DirectbesideProjectileTrait,AreaandChain, for a single-target cast that lands at its release: a heal, a curse. - The global cooldown as a setting.
abilities({ globalCooldownSeconds }), one second by default, and a cooldown per ability as today. - Events for everything else. The engine emits
CastReleasedTraiton the caster at the release, with the ability, the target and the request; a hit event on each struck entity, with the ability and the caster, whether or not the strike dealt damage; andSpellLandedEventwhere a projectile ends, with the entity it struck or null on cover. The hit and impact events gather a list per step, asDamagedEventgathers each hit, so two bolts striking one target in one step are two occurrences a reader handles once each, and each occurrence names the cast’s request and the caster by her dump key, with the live caster nullable. The impact moves off the caster onto the world, a list per step asShotJudgedEventis, each entry naming the cast’s request, the caster’s and the projectile’s dump keys, the point and the hit or null: a holder that cannot vanish between two sends, where a projectile destroyed before the next delta would take its event with it and a caster who left would never have carried it. The world as a holder is the world event the events section states, which the doors layer builds before the abilities layer uses it. Today one impact per caster per step overwrites a second bolt’s, and a projectile whose caster has left emits none. A reaction sits after every producer of the event it reads,after: [abilities.systems.cast, weapons.systems.flyProjectiles]for the hit event, since a direct cast strikes at the release and a bolt strikes in flight; the engine’s own data effects apply inside those producers, before the event lands. A game’s system reads the event it needs, underrulesafter the systems that produce it. The system is one the devtools, the dump and the profile list, and the room runs it by the same rule as any game system.
A teleport is then a plugin: an ability with no effect, and a system that reads the release for it and moves the caster. A shield is an ability whose system adds a stat modifier on the hit event. A heal is data alone.
The events answer the three questions a hook has to answer. Where a custom effect runs: at the release, at the impact, or per target, so a chain that strikes three targets fires three hit events. What a cancelled or refused cast refunds: nothing custom has run before the release, and the engine already returns what a cast held back when it is cancelled or refused, so a game refunds only what it chose to spend after the release, through changeResource, as it would for a projectile that ends on cover, which the impact reports with a null hit. Why nothing applies twice: an event lasts one step and the step removes it at the start of the next, a game’s reaction is a system without the character mark, so it runs once per world step and never on a character’s replay, and a plugin’s system runs on the authoritative world, Server, unless it says otherwise: a room’s world, or the page’s when the game is played alone. A page fed from a room receives the event in a delta, where it lasts until the next delta, and does not react to it again; a reaction that says Both runs there too and has to be one that only draws. A reset clears every pending event and lays the event step’s watchers again with the rest, and a checkpoint keeps the events a step has not read with the casts and projectiles in flight, and drops the ones it has, as the room’s checkpoint does today, so a continue neither replays a release the rules already took nor loses the impact that follows it. So each release, hit and impact is read once, by the world that judged it.
Unreal’s Gameplay Ability System runs game code in a subclass: CanActivateAbility, ActivateAbility, CommitAbility, which checks cost and cooldown and then spends them, and EndAbility with a cancelled flag; effects are GameplayEffect data, cues fire visuals by tag, and a failed commit charges nothing while a cancel after the commit keeps the cost. This platform takes GAS’s split of data effects from game code and differs on the shape of the code: a system reading an event rather than a callback in the ability, because a system is what the page’s event model already names for a reaction with a consequence, the tools list it, the room runs it, and the step’s rules make it run once. A callback in the registration, GAS’s shape, is three lines shorter for an agent and was the alternative the maintainer weighed.
The work in flight on abilities continues without waiting for this design. The cast polish and the abilities inspector add to the pipeline the abilities() plugin then carries, and the inspector reads the casting, the grants and the registry through the same public reads the plugin keeps. The mmorpg’s three mage spells are data with damage and a slow, so the mage kit layer moves them into plugins as they are.
The events a game reacts to
Section titled “The events a game reacts to”The engine has no emitter whose listeners run as an event is raised, and it keeps none. An event is an entry in a log each world keeps, which emitEvent writes and Events describes: each system reads every event once with readEvents, from where it last read, and each view hears it once with useEvent. The dump lists it, the room streams it, and a replay replays it. An emitter’s listener would run in the middle of a step, outside the system order, and would not reach the room.
The following table lists the events. The existing ones keep their names; the new ones replace a counter or a flag with the same shape as the rest:
| Event | On | Carries | Today |
|---|---|---|---|
TakenEvent |
The pickup | Nothing | Exists. |
ItemAcceptedEvent |
The target of an item’s use | The item | Exists; the target’s own system reads it on the step it lands, and the step removes it. |
DownedTrait |
The character, or an entity with a respawn | Nothing | A state that lasts while it is down; its add is the event. |
DamagedEvent |
The entity hit | Each hit of the step: its amount, source, weapon and the game’s data | Exists; dealDamage adds each hit, the room’s judged shots and projectiles included, and the room streams it to every page. |
TriggerEnteredEvent, TriggerExitedEvent |
The trigger | The character or the mover | New: replaces Trigger’s crossings count and the track trigger’s entered and exited flags. |
ShotJudgedEvent |
The world | Each judged shot | Becomes the first world event, declared with defineEvent and streamed as every event is, with no entity key; the delta’s own shots field goes. |
A step can run twice between two frames, and the room sends once for several steps. So the engine keeps an event until every system has read it, the room’s send has taken it, and the frame has handed it to the views, and the room sends every event emitted since its last send in the delta, one entry per emit.
A game reacts in one of two places, and the rule is the consequence:
- A reaction with a consequence is a system. A coin for a kill, a door that opens, a score: the game’s system queries the event and sits under
rulesin the game’s plugin, after the systems that produce the event, and the scene exports the game’splugins, as Switching a system on says. The room reads that list and runs it, so the reaction runs in the room with no other wiring. A plugin’s system runs on the authoritative world unless its entry saysClientorBoth, so the devtools and an agent read where it runs. - A reaction that only shows is a hook. A sound, a flash, a number over a head: a view hears the event with
useEvent, which the frame loop calls once for each event. A room draws nothing, so it never runs there.
RunContext stays the one enum that says where code runs, with Server, Client and Both; no second enum names the client or the server.
The hook
Section titled “The hook”One hook wraps koota’s world.onAdd for a view: it subscribes on mount, unsubscribes on unmount and does nothing headless, which Pickup’s sound writes by hand today. It is the surface an agent reaches for when it would write on(hit). The engine adds no hook for systems, because a system reads its query.
| Candidate | Reads as |
|---|---|
useEvent(DamagedEvent, (entity) => …), recommended |
The word an agent searches for. React’s own RFC by this name shipped as useEffectEvent, so React 19 has no useEvent to clash with. |
useGameEvent |
Unambiguous beside React, and longer. |
useTraitAdded |
Says the mechanism, not the intent. |
Roblox connects to a signal with RBXScriptSignal:Connect, Unreal binds a delegate with AddDynamic, Unity adds a listener with UnityEvent.AddListener, and Godot connects with Signal.connect. PlayCanvas calls entity.on, and Phaser emitter.on.
Declaring an event
Section titled “Declaring an event”A game declares its own events the way the engine declares its own, so the step removes them and the room streams them. One call makes the trait and registers its name, as the define* verb above says: defineEvent("damaged", (): HitList => ({ hits: [] }), { entities: { "hits.source": WhenAbsent.Null } }), as the engine declares its own, with entities naming each field that holds an entity and what a page that does not hold it receives. A trait registered in the call that makes it cannot be the unregistered Interacted the audit found. Its name, beside the others weighed:
| Candidate | Reads as |
|---|---|
defineEvent("damaged", { amount: 0 }), recommended |
The define* verb beside defineTrait and definePlugin, and koota’s trait with one difference. |
defineEvent |
The intent, with no hint that the result is a trait to query. |
Roblox declares a BindableEvent or a RemoteEvent, Unreal a delegate with DECLARE_DYNAMIC_MULTICAST_DELEGATE, Unity a UnityEvent<T> field, and Godot a signal. PlayCanvas and Phaser declare nothing: any string names an event.
Raising an event
Section titled “Raising an event”One function raises an event, in a system and between two steps alike, on an entity or on the world: emitEvent(entity, event, value). Between two steps an entity can still hold the event the last step’s systems read, which the next step removes before its systems run, so a plain add of it does nothing and a set changes an event no system reads again. emitEvent takes that event off first. A second call in one step replaces the first with its value, and a function in place of the value gathers the step’s moments in one event, as koota’s set takes one: dealDamage adds each hit to the step’s DamagedEvent that way. Its name, beside the others weighed:
| Candidate | Reads as |
|---|---|
emitEvent(entity, DamagedEvent, value), recommended |
The verb of Godot’s emit, Node’s EventEmitter and socket.io, and the pair of useEvent. |
raiseEvent |
The .NET verb, which Unity’s C# events use; rarer in the libraries an agent reads. |
sendEvent |
Bevy’s EventWriter::send; reads as a message to the room. |
fireEvent |
Roblox’s Fire, and Testing Library’s fireEvent, which dispatches a DOM event in tests. |
Roblox calls BindableEvent:Fire, Unreal Broadcast on a delegate, Unity UnityEvent.Invoke, and Godot emit on a signal: each calls the listeners at once. Bevy’s EventWriter::send queues the event for the systems that read it, as emitEvent does. None of them folds two events into one, because each queues or calls every event on its own; here an event is a trait, one of a kind on an entity, so the function form gathers them.
A world event is one whose holder is the world, emitEvent(world, ShotResults, { results }), for what no entity owns: the shots a step judged, the points where its projectiles ended. The step removes the world’s events as it removes an entity’s, the room files one with no entity key, where an entity’s event carries its dump key, and a page lands it on its own world; an entity whose scene path reads world keeps its key, since the two never share a field. Today the room sends the world’s judged shots in a field of the delta of their own, because the event path files an event by its holder’s dump key and lands it on the entity a page spawned for that key, and the world is no such entity. ShotResults moves onto the event path and the delta’s shots field goes: a list on the world is the door a game’s plugin needs for its own impacts, so the engine’s shots walk through the same door, as the principle asks. Bevy’s Events<T> are world resources with no holder, read by an EventReader; Unity, Unreal, Godot and Roblox raise an event on an object, and a world event there is one on a singleton the game makes. Here the world already holds traits, Physics, Systems, ShotResults, so no singleton is needed.
The new events’ names
Section titled “The new events’ names”For the hit: DamagedEvent, recommended, DamageTaken or Hit. Roblox has Humanoid.HealthChanged and Unreal OnTakeAnyDamage.
For the trigger: TriggerEnteredEvent and TriggerExitedEvent, recommended, Entered and Exited, or Touched and TouchEnded. Roblox has BasePart.Touched and TouchEnded, Unreal OnComponentBeginOverlap and OnComponentEndOverlap, Unity OnTriggerEnter and OnTriggerExit, and Godot body_entered and body_exited.
Shared saves
Section titled “Shared saves”A save keeps one key per owner, each with its own version: the game’s own state under game, its persisted stores under stores, each listed kit’s under kits.<name>, and each engine save the game names under engine.<name>. The Game page shows the record.
A plugin declares what it keeps through save: a version, a schema, a read that returns what to keep and a restore that puts it back, numbered migrations, and a scope of player, the default, or world for state no single player owns. The engine’s own saves use the same field and nothing a kit could not use. The two differ in one way: a kit’s save is on once the game lists the kit, and an engine save is off until the game names it in defineSave’s include. So listing inventory() saves nothing by itself, a game names inventory in include, and the building blocks save nothing for a game that names none of them. The camera’s framing is a view setting and is not saved.
Reading a save checks each key on its own, after the owner’s steps have brought it to the owner’s version. A key its schema refuses, a step that throws, or a key a newer version wrote refuses the whole load: the engine restores nothing and writes nothing over the record, and the player is told. A key no listed plugin owns passes through untouched, so a kit removed from a game and added back finds its data where it left it. Code never reads a save a newer version wrote, so every change to a saved form, an added field included, raises its owner’s version and ships a step, and a schema reads its key plainly.
Roblox keeps a DataStore per name and leaves the shape to the place; Unity and Unreal serialize whatever a game writes; Godot’s ConfigFile keeps sections per name. None checks per section. This platform checks per key, and refuses a load it cannot read whole rather than drop a key and play on, because a save written over after a dropped key loses that progress for good.