Abilities
Opaque verbs with server-tracked cooldowns and an IAbilityRules SPI.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayAbilities(); and it wins.
IAbilityGate game SPI
Actor seam: may this entity use abilities right now? Runs with the universal gates — BEFORE IAbilityRules and before any cost is inspected — so one game rule ("the dead don't shoot", silences, cutscenes…) refuses every verb at the door, whatever a client sends. Abilities never learns WHY an actor is gagged; the game's gate owns that.
public sealed class EliminationActionGate : IAbilityGate
{
public bool CanUse(long actorEntityId) => !_matchPolicy.IsEliminated(actorEntityId);
}
// services.AddSingleton<IAbilityGate, EliminationActionGate>(); // BEFORE AddCrossplayAbilities() -
bool CanUse(long actorEntityId)Whether this actor may use abilities right now. The default always allows.
IAbilityRules game SPI
SPI the game implements to give abilities their meaning — hit detection, damage, combos, ammo. The framework runs the universal gates (in-world, payload size, server-tracked cooldowns) and the replication; the rules own EVERYTHING else, usually via the World queries + Stats API. Default: accept-all relay (a pure "entity performed X" event bus, like Emotes with cooldowns). Spatial-optional (two overloads, implement EITHER). A spatial game implements the Decide overload (the actor's WorldEntity in hand — position/zone for target queries), exactly as before. A card / non-spatial game composed WITHOUT the World piece implements the World-free Decide overload (only the actor's session and entity id; the game supplies targets its own way). Both are default-implemented so existing rules keep compiling and a single World-free implementation also answers spatial calls.
sealed class CombatRules : IAbilityRules
{
public AbilityDecision Decide(ISession actor, WorldEntity self, ushort abilityId, byte[] targetData)
{
if (!_stats.TryGet(self.Id, out float mana, out _) || mana < CostOf(abilityId))
return AbilityDecision.Reject(AbilityNotificationCodes.Rejected); // or your own code
foreach (var hit in _queries.QueryCone(self, RangeOf(abilityId)))
_stats.Modify(hit.Id, HealthStatId, -DamageOf(abilityId));
return AbilityDecision.Accept(cooldownSeconds: 3f, broadcastData: targetData);
}
}
// services.AddSingleton<IAbilityRules, CombatRules>(); // BEFORE AddCrossplayAbilities() -
AbilityDecision Decide(ISession actor, WorldEntity actorEntity, ushort abilityId, byte[] targetData)Decide one use with the actor's WorldEntity available (the spatial overload — position/zone for World queries). Runs AFTER the universal gates passed. The default delegates to the World-free overload (passing Id), so a game may implement EITHER overload and still be called on the spatial path.
-
AbilityDecision Decide(ISession actor, long actorEntityId, ushort abilityId, ReadOnlySpan<byte> targetData)Decide one use WITHOUT the World piece — only the actor's session and entity id are known (a non-spatial game supplies targets its own way through the opaque payload). Runs AFTER the universal gates passed. The default is the accept-all relay (the framework default), so an existing spatial rules implementation that only overrides the WorldEntity overload keeps compiling unchanged.
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IAbilityBookPolicy provider
The game's learning rule (donor-gap E4): may THIS player learn (or rank up) THIS ability now, and at what cost? Trainers, level-up picks, scrolls, skill points — all the game's vocabulary; charge costs inside your implementation. The DEFAULT REFUSES everything, so the learn verb is inert until the game supplies a policy. Register yours BEFORE AddCrossplayAbilities.
-
bool AllowLearn(ISession session, long ownerId, ushort abilityId, byte currentRank, out ushort reasonCode)True when the learn/rank-up may commit. On refusal set reasonCode (0 falls back to LearnRefused).
IAbilityBookStore provider
Storage seam for the per-player book. The default is in-memory (books reset with the server process); the Hosting composition bridges PlayerData for durability, and a game can register any store of its own.
-
IReadOnlyDictionary<ushort, byte> Of(long ownerId)The player's known abilities (empty when none).
-
void Set(long ownerId, ushort abilityId, byte rank)Stores one rank (overwrites).
Services you call 2
Crossplay implements these. Resolve them from DI and call them from your own systems.
IAbilityBookService
Server-side book API — what the wire handlers call, and what game code calls directly (a trainer dialog action, a level-up grant, a quest reward teaching a spell).
-
void Grant(long ownerId, ushort abilityId, byte rank)Grants a rank server-side (no policy — trainers/quests/scripts are authoritative).
-
event Action<long, ushort, byte> LearnedRaised after the book changed with (owner id, ability id, new rank).
-
byte RankOf(long ownerId, ushort abilityId)The rank held (0 = unknown) — rules read this to scale damage/radius per rank.
-
bool TryLearn(ISession session, ushort abilityId, out ushort reasonCode, out byte rank)Learns/ranks-up through the policy gate. False with a code on refusal.
IAbilityService
Server-side API for the game to fire abilities on behalf of entities (NPC attacks, scripted events…). Unlike a client's AbilityRequest, this bypasses the gates and rules — the server is already authoritative — and simply broadcasts the visible act to observers.
-
bool InterruptCast(long entityId, CastInterrupt kind = Forced)Breaks an entity's in-flight cast when kind matches the cast's interrupt flags (Forced always breaks). The composition hook damage/movement glue calls — the piece itself never decides WHAT interrupts.
-
void Perform(long entityId, ushort abilityId, byte[] data = null)Broadcasts an ability act for an entity (server-originated — no gates, no rules, no cooldown).
-
bool TryGetActiveCast(long entityId, out ushort abilityId, out float remainingSeconds)The entity's in-flight cast, if one exists (for gates, UI, and interrupt glue).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
AbilityOptions
Configurable ability-pipeline parameters (defaults here; never inlined in logic).
-
Dictionary<ushort, AbilityCastDefinition> Casts { get; set; }Optional CAST-TIME table (donor-gap G5): ability id → wind-up definition. An ability listed here telegraphs first (EntityCastStarted to observers + the actor) and executes its rules only when the cast completes; one cast in flight per entity, interruptible via InterruptCast. Abilities NOT listed (the default: an empty table) stay byte-identical to the instant pipeline — casting is pure opt-in per ability. MMO casts, MOBA channels, charge-up shots, crafting timers: same wind-up, the game's render.
-
Dictionary<ushort, AbilityDefinition> Catalog { get; set; }Optional ability catalog: ability id → cost/cooldown/projectile as pure data (see AbilityDefinition). The piece never interprets it — the composition layer's catalog bridge implements IAbilityRules from these entries. Empty = fully inert; a hand-written rules class keeps working exactly as before.
-
bool EchoToSelf { get; set; }Echo the EntityAbility broadcast to the actor too (their own client usually predicted the act; some games want the authoritative echo).
-
float GlobalCooldownSeconds { get; set; }Global cooldown applied after ANY accepted ability (0 = none) — the universal spam floor; per-ability cooldowns come from the rules' verdicts.
-
int MaxDataBytes { get; set; }Largest targeting/act payload accepted.
-
bool RequireKnown { get; set; }Require abilities to be KNOWN (donor-gap E4): when true, a client request for an ability absent from the actor's book refuses with NotKnown — pair it with the learn verb and a book policy. False (the default) keeps today's behavior: every ability id is usable and the book is ignored.
Wire messages 10
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
AbilityRequest Client → server: "I use ability X". The ability id and the targeting payload (a direction, a target entity, a screen position, a combo token…) are the GAME's vocabulary — a punch, a rifle shot, a fireball and a card play all travel exactly like this.
AbilityResponse Server → actor: the verdict, plus an opaque result and the cooldown the server started.
EntityAbility Server → observers: the visible act. One game renders a muzzle flash, another a combo hit, another a spell — the piece cannot tell the difference.
EntityCastStarted Server → observers + the actor: a cast began. One game renders a cast bar, another a wind-up animation, another a glowing telegraph — the piece cannot tell the difference. The act itself (EntityAbility) follows only if the cast completes.
EntityCastEnded Server → observers + the actor: the cast ended, and how.
CastCancelRequest Client → server: "cancel my cast" (only the actor's own in-flight cast).
LearnAbilityRequest Client → server: "learn ability X" (rank-up when already known). Skill trainers, level-up picks, scroll consumption — the game's policy decides if and at what cost.
LearnAbilityResponse Server → actor: the learn verdict and the resulting rank.
AbilityBookRequest Client → server: "send me my book".
AbilityBook Server → actor: the known-abilities snapshot.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IAbilityClient
The Abilities piece, client side: fire opaque verbs at the server, receive the private verdict (with the server-tracked cooldown), and observe every visible entity's acts — the hook your game maps onto animations, VFX and audio. A punch, a rifle shot and a card play all look the same to this piece. Prediction: TryPredictUse lets the local player fire responsively — it starts a LOCAL cooldown and plays your effect immediately, then reconciles when the server's verdict lands: a REJECT rolls the predicted cooldown back (and raises PredictionRejected) so a misfire costs nothing; an ACCEPT snaps the local cooldown to the authoritative one. The server stays the single source of truth — prediction only hides the round-trip.
-
void Use(ushort abilityId, byte[] targetData = null)Fire-and-forget: send the use, no local prediction (the server verdict drives everything).
-
bool TryPredictUse(ushort abilityId, float predictedCooldownSeconds, byte[] targetData = null)PREDICTED use: if the ability isn't locally cooling down, send it, start a predicted cooldown of predictedCooldownSeconds, and return true so the caller plays the effect now. Returns false (no send) when the local cooldown says it isn't ready — instant feedback, no wasted round trip. Reconciled by the server's AbilityResponse.
-
float CooldownRemaining(ushort abilityId)Local cooldown remaining for an ability (seconds) — predicted, then server-corrected.
-
bool TryQueueUse(ushort abilityId, float predictedCooldownSeconds, float bufferWindowSeconds, byte[] targetData = null)The INPUT QUEUE: like TryPredictUse, but a press landing inside the last bufferWindowSeconds of the cooldown is REMEMBERED and fires the moment the cooldown ends — the "press slightly early, it still comes out" feel every action game has. One queued use at a time (a newer press replaces it). Pump Tick once per frame to release queued uses. Returns true when used or queued.
-
void Tick()Releases a queued use whose cooldown has ended. Call once per frame.
-
event Action<ushort> QueuedUseFiredA queued use fired (ability id) — play your wind-up now.
-
event Action<ushort, ushort> PredictionRejectedThe server rejected a predicted use — roll back your local effect (ability id + reason).
-
event Action<AbilityResponse> ResultReceivedThe server's private verdict on one of your uses.
-
event Action<EntityAbility> AbilitySeenA visible entity performed an ability (including yourself when the server echoes).
-
void CancelCast()Cancel your own in-flight cast (cast-time abilities only).
-
void Learn(ushort abilityId)Learn ability X — or rank it up when already known (the server's book policy gates it).
-
void RequestBook()Ask for your known-abilities snapshot (answered via BookReceived).
-
event Action<EntityCastStarted> CastStartedAn entity STARTED casting (telegraph/cast bar — includes your own casts).
-
event Action<EntityCastEnded> CastEndedAn entity's cast ENDED (outcome: completed / interrupted / rejected).
-
event Action<LearnAbilityResponse> LearnResultReceivedThe server's verdict on one of your learns (with the resulting rank).
-
event Action<AbilityBook> BookReceivedYour known-abilities snapshot arrived.
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayAbilitiesView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.