Projectiles
Kinematic projectile entities with deterministic client rendering + click-frame prediction: one launch event per volley, no interpolation buffer.
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.
ProjectileCatalogRequest Client → server: send me the projectile catalog slice (sent once after connecting; the reply is ProjectileCatalogSync).
ProjectileCatalogSync Server → client: every catalog kind's client-relevant slice (the complete set).
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).
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 CatalogReceivedTrue once the server's catalog slice has arrived (requested automatically).
-
int ActiveCountDeterministic projectiles currently flying under this client's control (diagnostics).
-
int GhostCountUnadopted 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> DespawnedRaised 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.