Combat

Projectiles

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

Server
services.AddCrossplayProjectiles();
Unity package
com.crossplay.projectiles
Depends on
movementabilities

Seams you implement 1

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 returns true to consume the projectile (rockets) or false to pierce (railguns). Default: 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.

sealed class BulletRules : IProjectileRules
{
    public bool OnHit(Projectile p, WorldEntity hit)
    {
        _stats.Modify(hit.Id, HealthStatId, -DamageOf(p.Data));
        return true; // consume on first hit (false = pierce and keep flying)
    }
    public void OnExpire(Projectile p) { } // e.g. spawn an explosion effect
}
// services.AddSingleton<IProjectileRules, BulletRules>(); // BEFORE AddCrossplayProjectiles()
  • void OnExpire(Projectile projectile)

    Lifetime ran out or a wall (shared world bounds) stopped it without a hit.

  • bool OnHit(Projectile projectile, WorldEntity hit)

    The projectile touched an entity (never its owner or another projectile). Return true to despawn it, false to pierce.

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

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 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 MaxProjectiles { get; set; }

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

Wire messages 4

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.

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.

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