Combat

Abilities

Opaque verbs with server-tracked cooldowns and an IAbilityRules SPI.

Server
services.AddCrossplayAbilities();
Unity package
com.crossplay.abilities
Depends on
world

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> Learned

    Raised 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.

2401 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.

2402 AbilityResponse

Server → actor: the verdict, plus an opaque result and the cooldown the server started.

2403 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.

2404 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.

2405 EntityCastEnded

Server → observers + the actor: the cast ended, and how.

2406 CastCancelRequest

Client → server: "cancel my cast" (only the actor's own in-flight cast).

2407 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.

2408 LearnAbilityResponse

Server → actor: the learn verdict and the resulting rank.

2409 AbilityBookRequest

Client → server: "send me my book".

2410 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> QueuedUseFired

    A queued use fired (ability id) — play your wind-up now.

  • event Action<ushort, ushort> PredictionRejected

    The server rejected a predicted use — roll back your local effect (ability id + reason).

  • event Action<AbilityResponse> ResultReceived

    The server's private verdict on one of your uses.

  • event Action<EntityAbility> AbilitySeen

    A 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> CastStarted

    An entity STARTED casting (telegraph/cast bar — includes your own casts).

  • event Action<EntityCastEnded> CastEnded

    An entity's cast ENDED (outcome: completed / interrupted / rejected).

  • event Action<LearnAbilityResponse> LearnResultReceived

    The server's verdict on one of your learns (with the resulting rank).

  • event Action<AbilityBook> BookReceived

    Your 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.