projectiles

Projectiles

Kinematic projectile entities with deterministic client rendering + click-frame prediction: one launch event per volley, no interpolation buffer.

Category Combat Seams 2 Services 1 Options 1 Wire ids 5 Unity types 1
Server
services.AddCrossplayProjectiles();
Unity package
com.crossplay.projectiles
Depends on
movementabilities

Seams you implement 2

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayProjectiles(); and it wins.

IProjectileRules game SPI

SPI the game implements to give projectiles their meaning. OnHit answers one question — "did this contact COUNT?" — and the piece decides the consequence: a counted contact spends one unit of the projectile's hit budget (MaxHits, 1 by default), and the projectile despawns only once that budget is empty. Returning false means the contact did not count at all: the projectile flies on, spends nothing, and may still hit that entity later. Default: every contact counts, against a budget of 1 — i.e. consume on first hit, no effect. Register your own BEFORE AddCrossplayProjectiles (a TryAdd seam). Both callbacks fire on the server tick; apply damage / effects through the Stats / StatusEffects APIs from here.

Example
sealed class HarmRules : IProjectileRules
{
    public bool OnHit(Projectile p, WorldEntity hit)
    {
        if (!IsValidTarget(hit)) return false;       // doesn't count: fly on, budget untouched
        _stats.Modify(hit.Id, HealthStatId, -HarmOf(p.Data) * ScaleFor(p.HitsConsumed));
        return true;                                 // counts: spends one unit of the budget
    }
    public void OnExpire(Projectile p) { } // p.HitsConsumed is the final tally
}
// services.AddSingleton<IProjectileRules, HarmRules>(); // BEFORE AddCrossplayProjectiles()
  • void OnExpire(Projectile projectile)

    Lifetime ran out or a wall (shared world bounds) stopped it before its hit budget was spent. HitsConsumed is the final tally of contacts that counted.

  • bool OnHit(Projectile projectile, WorldEntity hit)

    The projectile touched an entity (never its owner, another projectile, or one it has already counted). Return true if the contact COUNTS — it spends one unit of MaxHits and despawns the projectile once the budget is empty — or false if it does not count at all, leaving the projectile flying with its budget intact. Read HitsConsumed to tell a first contact from a later one.

IProjectileTargetResolver game SPI

Acquisition seam for seeking kinds (Homing): which entity should this projectile steer toward, and is the one it already holds still worth steering toward? The piece owns the MECHANISM — acquisition radius, turn rate, retarget cadence, the per-tick query budget — and never learns what a faction, a team or a spent target is; that meaning lives behind this seam. Register your own BEFORE AddCrossplayProjectiles (a TryAdd seam); the default acquires the nearest entity in range and nothing more. The piece rejects the firer, the projectile itself, every other in-flight projectile, and any entity this projectile has ALREADY consumed a hit on, regardless of what a resolver returns, so no resolver implementation can make targeting unsafe or leave a chaining projectile orbiting a target it is done with. That is a safety net, not the selection rule — an implementation should exclude candidates it does not want WHILE choosing (see HasAlreadyHit), because a rejected answer simply means nothing was acquired that tick.

  • bool IsStillValid(Projectile projectile, WorldEntity target)

    Asks whether an already-acquired target is still worth steering toward — the hook a game uses to drop a target that has become irrelevant by its own rules.

  • bool TryAcquire(Projectile projectile, float radius, out long targetEntityId)

    Picks an entity for this projectile to steer toward.

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IProjectileService

Server-side API for spawning projectiles — the game calls this when an ability fires (e.g. from inside its IAbilityRules). The projectile is a real server entity that flies, collides, and replicates through the normal spawn/interest pipeline; IProjectileRules decides what its hits mean.

  • bool IsProjectile(long entityId)

    True when entityId is a live in-flight projectile — the non-combatant check a targeting/harm filter uses so auto-fire never targets a bolt (the sibling of ILootService.IsLoot).

  • long Spawn(long ownerEntityId, float x, float y, float z, float dirX, float dirZ, float speed, float hitRadius, byte[] appearance, byte[] data = null, float ttlSeconds = 0)

    Spawns a kinematic projectile (a real server entity — all clients render it via the normal spawn/interest pipeline). Raw spawns are always Streamed (clients have no catalog spec to evaluate them from); catalog kinds fly deterministically.

  • long SpawnAtPoint(long ownerEntityId, byte kind, float targetX, float targetY, float targetZ)

    Spawns a CATALOG projectile aimed at a POINT rather than a direction — the shape a PointArc kind needs, since it ARRIVES on the point after the kind's flight time whatever the distance. The aim direction is derived from the firer to the point, so spread, muzzle offset and burst behave exactly as for a directional launch. The point is CLAMPED (never rejected) to the kind's MaxTargetRange — or ProjectileOptions.DefaultMaxTargetRange when that is 0 — and the CLAMPED point is what the launch event carries, so authority and every observer evaluate the same arc. A kind whose motion is not PointArc ignores the point and flies the derived direction unchanged.

  • long SpawnAtPoint(long ownerEntityId, byte kind, float targetX, float targetY, float targetZ, uint launchNonce)

    SpawnAtPoint with the shooter's prediction nonce (see SpawnFromCatalog for how the nonce rides the launch event).

  • long SpawnFromCatalog(long ownerEntityId, byte kind, float dirX, float dirZ)

    Spawns a CATALOG projectile (ProjectileOptions.Catalog): flight parameters come from the kind's spec, the origin is the firer's server-side position offset by the spec's muzzle distance along the aim, and the kind byte rides as both the payload (rules resolve it) and the appearance blob (clients render it). A spec with Count > 1 fans that many pellets from a deterministic seeded spread. Deterministic kinds (Linear motion) additionally broadcast one ProjectileLaunched event for the whole volley and send NO per-tick movement stream — clients fly them from the shared evaluator.

  • long SpawnFromCatalog(long ownerEntityId, byte kind, float dirX, float dirZ, uint launchNonce)

    SpawnFromCatalog with the shooter's prediction nonce: the nonce seeds the volley's spread expansion AND is echoed on the launch event — but only on the copy sent to the owner, so only the shooter's ghost manager adopts the launch (everyone else receives the spread seed with a zero nonce). Pass 0 for a non-predicted launch (the server picks a seed).

  • long SpawnFromCatalog(long ownerEntityId, byte kind, float dirX, float dirZ, uint launchNonce, long targetEntityId)

    SpawnFromCatalog with a seek target PRE-ASSIGNED at fire time — the "lock what is ahead of ME, then fire" shape that post-launch acquisition around the PROJECTILE cannot express. Only a Homing kind reads it; every other motion IGNORES it entirely and flies exactly as it would without it. The target is validated at spawn — it must exist, must not be the firer, the projectile or another in-flight projectile, and must pass the registered IsStillValid — and then on every seek step exactly like an acquired target; the moment it dies or stops being valid, the resolver's normal TryAcquire re-acquisition takes over (ProjectileSpec.SeekRetargetSeconds semantics unchanged). A pre-assigned target also lets a kind authored with SeekRadius: 0 steer — a pure lock-on round that never picks targets on its own. Burst follow-ups keep the lock made at fire time (each follow-up volley re-validates it at spawn). Pass 0 for no pre-assignment — identical to the shorter overloads.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

ProjectileOptions

Configurable projectile parameters (defaults here; never inlined in logic).

  • Dictionary<byte, ProjectileSpec> Catalog { get; set; }

    Optional projectile catalog: kind byte → flight + hit-intent spec, so a game's projectiles are DATA (see ProjectileSpec). Empty = fully inert; the classic Spawn overload and a hand-written IProjectileRules keep working exactly as before. Treated as startup data: the client-relevant slice is synced from it, so runtime mutations would desync clients.

  • float DefaultMaxTargetRange { get; set; }

    Fallback cap on how far from the firer a launch POINT may be when a kind sets no MaxTargetRange. Points beyond it are shortened toward the firer, never rejected. Only PointArc launches read a point at all, so this is inert for every catalog that has none.

  • float DefaultTtlSeconds { get; set; }

    Default lifetime when the spawner passes none.

  • int FrameBufferInitialBytes { get; set; }

    Initial size of the reused broadcast frame buffer (grows on demand).

  • int HitTrackerPoolMaxRetained { get; set; }

    Idle already-hit trackers kept for reuse. Only multi-hit kinds ever rent one; size this for the concurrent multi-hit projectiles a busy tick expects, so steady state stays allocation-free.

  • int MaxHitsPerProjectileCap { get; set; }

    Hard ceiling on how many entities ONE projectile may hit, and the exact size of each pooled already-hit tracker — so a mis-authored catalog can never make a single projectile scan an unbounded list. Clamped to 1..255 at construction. Kinds left at the default ProjectileSpec.MaxHits of 1 never rent a tracker at all.

  • int MaxProjectiles { get; set; }

    Hard cap on live projectiles (a runaway spawner degrades, never OOMs).

  • int MaxRetargetsPerTick { get; set; }

    Most target ACQUISITIONS (spatial queries) seeking projectiles may perform in one tick — the budget that stops a swarm of them from turning the tick into an O(projectiles × entities) scan. Projectiles over budget keep their heading and retry next tick.

  • float SeekReacquireIntervalSeconds { get; set; }

    Seconds a seeking projectile that FOUND NOTHING waits before searching again. The per-tick acquisition budget above is shared by every seeking projectile in flight, so a bolt with nothing in range must not spend a slot every single tick — it would starve the ones that do have something to find. A successful acquisition resets to the kind's own ProjectileSpec.SeekRetargetSeconds instead, so this only ever paces failures. 0 = retry every tick (the historical behaviour).

  • int VolleyBufferPoolMaxRetained { get; set; }

    Idle entity-id buffers kept per distinct pellet count. Every launch needs one array of exactly its pellet count (the launch event carries the volley's ids), so reusing them is what keeps a rapid-fire kind from producing per-shot garbage. Size it for the concurrent launches one tick can produce; beyond it buffers are simply not retained.

Wire messages 5

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

5501 ProjectileCatalogRequest

Client → server: send me the projectile catalog slice (sent once after connecting; the reply is ProjectileCatalogSync).

5502 ProjectileCatalogSync

Server → client: every catalog kind's client-relevant slice (the complete set).

5503 ProjectileLaunched

Server → observers: a deterministic launch. One event carries a whole volley — observers expand the fan themselves from SpreadSeed via ProjectileSpread (pellet i flies EntityIds[i]) and fly every pellet with TrajectoryEvaluator from ServerTimeMs. The matching World EntitySpawns precede this on the reliable channel (entities still exist for interest/culling/despawn — they just don't stream).

5504 ProjectileDespawned

Server → observers: a projectile despawned and why — sent just before the World EntityDespawn on the same reliable channel, so a game can play the right impact/fizzle effect while the entity view still exists. Games that ignore it lose nothing.

5505 ProjectileHit

Server → observers: a projectile consumed one hit of its budget and KEPT FLYING. The hit that ENDS a projectile is deliberately not reported here — it already arrives as ProjectileDespawned with reason Hit at the same instant — so a single-hit kind (the default) emits none of these at all and costs not one extra byte. Render one impact per ProjectileHit plus one for a Hit despawn and a pass-through projectile looks right; ignore the message entirely and nothing breaks.

Unity seams you implement 1

The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.

IProjectilesClient unity SPI

The projectile client: renders deterministic catalog kinds from the shared trajectory evaluator at server-now (no interpolation buffer — their entities stop streaming movement) and predicts the local player's launches with click-frame ghosts that adopt their authoritative twins by nonce. Presentation stays 100% the game's: kinds are rendered by the game's ICharacterFactory exactly like any entity, and Despawned is an optional hook for impact/fizzle effects. A game that ignores this interface entirely still gets the buffer-free rendering for every deterministic projectile it observes.

  • bool CatalogReceived

    True once the server's catalog slice has arrived (requested automatically).

  • int ActiveCount

    Deterministic projectiles currently flying under this client's control (diagnostics).

  • int GhostCount

    Unadopted predicted ghost volleys currently flying (diagnostics).

  • bool TryGetCatalogEntry(byte kind, out ProjectileCatalogEntry entry)

    The synced client-relevant spec for a kind, once the catalog has arrived.

  • event Action<long, ProjectileDespawnReason> Despawned

    Raised when the server despawns a projectile, with the reason — BEFORE the entity view is destroyed (the despawn-reason event precedes the world despawn on the same reliable channel), so an impact effect can read the view's final position. Purely optional.

  • event Action<long, long, Vector3, int> Hit

    Raised when a projectile hit something and KEPT FLYING (a pass-through kind, i.e. one whose catalog budget is above 1): the projectile's entity id, what it hit, the authoritative impact point, and the 1-based index of the hit. The FINAL hit arrives as Despawned with Hit instead — subscribe to both and play one impact per event to render a pass-through correctly. Single-hit kinds never raise this. Purely optional; the presentation is entirely the game's.

  • bool TryPredictShot(ushort abilityId, byte kind, Vector3 aimDirection, float predictedCooldownSeconds)

    Predicts a projectile ability use on the click frame: fires the ability through the Abilities piece's prediction (cooldown + reject handling) with the aim and a fresh nonce in the opaque payload, and spawns local ghost pellet(s) flying the same catalog trajectory the server will launch — the authoritative launch echoes the nonce back and the ghosts hand over seamlessly (rebase + blend). A rejection (no ammo, wrong phase) fizzles them.