Player ability hook design¶
Historical working record: The details below describe decisions and source state at the time they were written, including superseded plans and verification status. For current scope use the playable-game roadmap; for implemented hook usage use the hook reference; for observed checks use the CLI report and playable-path report.
Design and partial implementation, 2026-10-29. The player-local hub now connects the migrated gameplay and automatic-tick stages through ability-owned subscriptions. Some context and lifetime details below remain proposals; consult the working notes for actual behavior and unresolved policies. See working authoring notes for what is actually implemented and its limits. It uses the findings in the logic inventory and the owner's decisions in the event plan. It does not require the old and new systems to operate together. Protect existing ScriptableObject values, Unity asset references and metadata while moving behavior.
What belongs where¶
- An
AB_script creates its per-player behavior, subscribes it to the stages it needs, and contains its effects and decisions. Short comments explain unusual conditions or interactions. - The gameplay owner decides when a stage happens: movement for dash, weapon for fire, attack for impact, damage handling for player damage, and so on. It publishes a hook at that point. A new ability using an existing stage does not require a new caller.
- A player-local
PlayerHookshub stores subscriptions. It is shared by that player's gameplay owners and ability runtime. A stage is a typed key declared next to its gameplay owner, not an ability name and not a new method on the runtime. For example,MovementHooks.BeforeDashis declared with movement;AttackHooks.EnemyHitwith combat. - The runtime owns acquired ability behaviors, per-ability subscription scopes and timed work. It calls the same
Connectoperation for every ability; it does not contain a list of ability names or a registration check/forwarding method per hook. A shared hub and scope handle registration and disposal; no hook factory or reflective discovery is required. - A gameplay owner can define and publish a genuinely new stage. A new ability can subscribe to existing stages from its own script; a stage cannot happen until its gameplay owner publishes it.
Conceptual API (names can change):
// Each player gets its own hub; stage keys themselves are shared, immutable descriptions.
readonly HookKey<DashContext> BeforeDash;
readonly ModifierKey<DashCostContext, float> DashCost;
readonly DecisionKey<FireContext> CanFire;
// Ability-owned subscriptions; each scope supplies the ability's stable identity.
scope.Listen(MovementHooks.BeforeDash, OnBeforeDash, order: 0, id: "bomb");
scope.Modify(MovementHooks.DashCost, ModifyCost, order: 1, id: "cost");
scope.Decide(WeaponHooks.CanFire, CanFire, order: 0, id: "fire");
// The owner calls the hub for its stage; these are generic operations, not runtime doorways.
hooks.Publish(MovementHooks.BeforeDash, dashContext);
cost = hooks.Modify(MovementHooks.DashCost, costContext, cost);
bool mayFire = hooks.Allowed(WeaponHooks.CanFire, fireContext);
A typed key identifies the channel, its context and operation. Subscribers only get registration access through the scope; publishers get the same player's hub. The hub creates a channel on first use or setup; an empty stage is harmless and needs no special ability checks. Stage keys are ordinary typed definitions placed with their owner. The runtime does not have to name each new key. Where gameplay APIs would otherwise expose a writable hub broadly, provide a small owner-facing reference; do not add one runtime forwarding method per stage.
Three core operations¶
| Operation | Meaning | Examples and result |
|---|---|---|
| Notification | Run all participants in order, each receiving context. | Before dash; weapon prepared; damage reaction. No result. |
| Modifier | Pass a value through participants in order. | Dash cost or hit damage; each receives current value and returns the next value. No participants returns the original value. |
| Decision | Ask in order until the first participant blocks or handles the operation. | Can fire: first denial stops; default allows. Shield or death prevention: first successful handler stops; default unhandled. Use distinct typed keys and names for allow and handle semantics so boolean meanings are not ambiguous. |
Attack preparation may supply an explicitly mutable attack builder or context. Context mutability is declared per stage; it must not hide value changes inside an ordinary notification. Stages that need both a result and effects can define a specific typed context/operation rather than forcing all logic into a boolean or one general event. Do not invent hooks for unused stages.
Priority groups and dispatch¶
Each subscription declares group 0 through 99 in its AB_ script; 0 runs first and 99 is the last group. Negative groups and values above 99 are rejected when connecting. For example, three group-0, two group-1, two group-2 and three group-99 subscriptions run in positions 1–3, 4–5, 6–7 and 8–10. Actual position is computed per dispatch; it never changes serialized data or declared priority. Default group is 0 when omitted.
Within each group, sort by a stable key consisting of the ability definition's persistent identity and an ability-authored subscription id unique for that ability and stage. The identity must survive asset moves and renames and distinguish separate definitions sharing AbilityType; choose an explicit serialized stable identifier or a generated code constant, with a migration for existing assets. Unity asset GUIDs are editor metadata and cannot simply be assumed available in a player build. For repeated copies of the same definition, define a stable instance discriminator at acquisition; do not let tie ordering depend on temporary list indices or pickup order. Duplicate full keys on a channel are an error. Shared identities and repeated same-definition behavior require an explicit migration policy before implementation; if the existing system intentionally shares one behavior, it has one registration, not duplicate registrations.
Take one ordered snapshot at the start of each stage. Connections or disconnections during dispatch affect the next dispatch, including a reentrant call; a handler already in the current snapshot can still run. Exceptions stop that dispatch and propagate; cleanup of failed connection remains separate. Decision channels stop at the first decisive result; modifiers pass each result to the next subscriber. A separate stage is called at a separate gameplay moment, not represented by numeric ranges. If removal must immediately prevent an already snapshotted handler, that is a different explicitly designed policy.
Lifetime and initialization¶
- Player setup constructs one hub and gives the same instance to the ability owner and movement, weapon, combat and other participating owners before they publish or connect.
- On acquisition, the ability creates its own per-player behavior. The runtime creates its scope and invokes
Connect. The scope retains the registrations even if the ability has many hooks; failed setup disposes already-created subscriptions. - Removing an ability disposes its scope so it receives no future player-level events. Other abilities' scopes and subscriptions remain. Player destruction disposes all scopes and player-owned work. Duplicate acquisition, shared identity and evolution require a defined migration rather than assuming one behavior per asset.
- Ongoing timed work and per-attack captured effects are separate from subscriptions. An ability's own lifecycle policy must specify whether its active work finishes or cancels on removal, suspension, death and player destruction. A projectile already created is handled below; do not silently retain all subscriptions as a fallback.
- The scene and movement-driver integrations must each receive the same player-local hub while their callers are migrated; eliminate obsolete routes rather than maintaining parallel fallback execution. No player uses a static global subscriber list.
Attack effects: captured on each attack¶
The inventory shows that a projectile can be marked at creation and hit after the ability is removed. A player-level EnemyHit subscription that is destroyed on removal cannot execute that projectile's effect. The proposed solution is attack-owned effect entries:
- During weapon or companion attack preparation, an ability adds an immutable effect entry to that attack. It captures only what must travel with the attack (for example the effect handler, parameters and any needed source data); the
AB_script implements the effect. The preparation hook applies weapon-specific conditions such asignoreExplodingBullet. - At impact,
PlayerAttackcalls a generic attack-effect dispatcher with the attack and hit context. It does not check ability names or execute ability-specific branches. The attack-owned entries run in their declared priority groups. If both player-level and attack-owned effects participate at the same stage, the dispatcher takes a single combined, ordered snapshot using the same priority and stable key rules, rather than publishing two separate lists in an arbitrary order. - Removal of the ability stops adding entries to new attacks. Existing attacks keep their captured entries until they return to the pool, so they can finish even after ability removal. Capture immutable data or a safe service handle, not a mutable behavior instance that may be disposed. Player destruction, missing owner/service, and enemy removal must have an explicit safe policy when implemented; do not silently use another player.
- When an attack is reused, clear all effect entries and captured data before preparation. A child projectile gets only the entries its spawning ability explicitly copies or constructs. Avoid accidental recursive explosions and stale effect flags. Prefab references such as the explosion remain authored in their existing asset or prefab; pass a reference into the entry without modifying its serialized value.
Illustrative ability-side sketch (API names, actual payload and groups are not final):
// AB_ExplodingEnchantment.cs: per-player behavior
void Connect(SubscriptionScope scope)
{
scope.Listen(WeaponHooks.AttackPrepared, PrepareWeapon, order: 0, id: "weapon");
scope.Listen(CompanionHooks.AttackPrepared, PrepareCompanion, order: 0, id: "companion");
}
void PrepareWeapon(WeaponAttackContext context)
{
if (!context.Weapon.fixedModifier.ignoreExplodingBullet)
AttachExplosion(context.Attack);
}
void PrepareCompanion(CompanionAttackContext context) => AttachExplosion(context.Attack);
void AttachExplosion(AttackBuilder attack)
{
// The attack captures the ability-owned hit effect and its prefab/parameters.
attack.AddHitEffect(ExplodeOnHit, order: 2, id: "explosion");
}
static void ExplodeOnHit(AttackHitContext hit, ExplosionPayload payload)
{
// Spawn at the hit position; set magic damage to 10 + SpellAmp / 4.
// Transfer the intended poison, freeze and concussive payload.
}
ExplodeOnHit and effect data remain in the ability script; the dispatcher is generic. This sketch does not decide whether every field is captured at preparation or read at impact: trace the existing live/captured behavior and child-payload interactions first. Some attacks currently do not set a player owner consistently; resolve their actual source/prefab initialization rather than assuming every attack has one. PlayerAttack continues general enemy validation, damage, hit limits and pooling. Move the specific explosion branch and other effects to their respective abilities as their attack stages are migrated.
Initial stage map from the inventory¶
| Owner and moment | Form | Key context or outcome |
|---|---|---|
| Movement: calculate dash cost | Modifier | Current cost, player/character context; result checked against stamina. |
| Movement: before dash launch | Notification | Dash context and feedback; only after admission succeeds. |
| Weapon: decide firing | Decision | Weapon and player context; allow or block. Charge behavior needs its own documented semantics. |
| Weapon / companion: prepare attack | Notification with explicit mutable builder | Attack, weapon or companion, and payload construction stage. Separate pre/post critical stages where intervening calculations require them. |
| Attack: calculate damage against enemy | Modifier | Attack snapshot, enemy and current damage; Proto Barrier is a candidate. |
| Attack: enemy hit after appropriate core steps | Ordered effects | Enemy, position, attack snapshot and owner; combine eligible attack-owned and live handlers at the same stage. |
| Player damage: reflection before immunity | Notification | Incoming hit; can reenter damage handling. |
| Player damage: absorb shield | Decision/handler | Incoming hit; first successful absorption stops further handling. |
| Player damage: adjust health damage | Modifier | Hit and current health damage. |
| Player damage: reactions and lethal prevention | Notification and separate decision | Reactions run at their own moment; death prevention stops on first handler. |
| Health recovery: eligibility and contribution | Decision and modifier | Current player state and calculated recovery; trace existing combined checks. |
| Collectibles: count, collection and application | Modifier and distinct notifications | Count adjustment; potion collected and potion applied are separate moments. |
| Stats | Ordered contributions, design further | Preserve base calculation, additive/multiplicative phases and live versus sampled state; do not treat one generic notification as a complete stat model. |
This is an initial stage map, not a claim that all existing paths or effects are fully traced. Each actual publisher must define timing, context and results when implemented. Enemy AI, input and GameManager redesign are separate workloads.
Implementation sequence¶
- Confirm stable ability and subscription identity, repeated-acquisition behavior, and attack-entry ownership/cleanup. Inventory serialized asset references and keep authored values intact.
- Build typed keys, a player-local hub, priority ordering and scopes; integrate hub creation into the player without a second legacy event execution path.
- Replace one connected stage across publishers and subscribers, remove its old code, and record any unresolved dependencies. Start with dash notification and dash-cost modification, then attack preparation and hit effects. Translate existing old order relationships into nonnegative priority groups without blindly copying old numbers.
- Migrate weapon, damage, pickup, stats and companion stages in connected groups, then consolidate dash and retire holder responsibilities as callers and serialized bindings move. Remaining unmigrated abilities are a separate later objective.
- Write the practical ability-authoring guide after the API settles. Source review cannot establish Unity import, compilation or gameplay; those checks remain deferred as instructed.