Contact
Generic entity-vs-entity contact: circle / yaw-oriented-box footprints, layer bits + mask, weight-split push-out. Hard corrections ride the swept movement pipeline (never through walls); predicted avatars push via the ISoftContactResolver seam. Zero wire — positions ride the existing movement broadcast. Server-only.
Seams you implement 4
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayContact(); and it wins.
IContactDisplacementResolver game SPI
Where a swept REWIND goes — the seam GAP-19 needs for an entity whose position is owned by a simulation rather than by the world.
-
bool ApplyDisplacement(long entityId, float deltaX, float deltaZ)Move an entity by an exact offset — the rewind from where it was found to where the pair actually touched, plus its share of the separation. Called on the poll thread from the contact pass; keep it allocation-free.
IContactImpulseResolver game SPI
Where an IMPULSE goes — the seam for Impulse, and the counterpart to ISoftContactResolver's positional push.
-
void ApplyImpulse(long entityId, float deltaVelocityX, float deltaVelocityZ, float deltaAngularVelocity)One entity's share of a contact impulse, as the velocity change it produces. Called on the poll thread from the contact pass — keep it allocation-free.
IContactPairFilter game SPI
Optional seam: a veto on which entity PAIRS the piece checks at all — asked once per candidate pair per tick, after the layer/mask filter and before any geometry. Unregistered, every pair the masks admit is checked, exactly as before the seam existed.
-
bool ShouldCheck(long entityA, long entityB)Whether the piece should test entityA against entityB this tick. Called with either order; return the same answer for both.
ISoftContactResolver game SPI
The Soft-correction seam this piece OWNS: how a contact push reaches an entity the server must NOT silently reposition — an input-driven predicted avatar, where a server-side position write would fight the client's own prediction. The piece computes the push (direction + distance) and hands it here; what the push BECOMES — a Movement-piece impulse reconciled through the ordinary prediction/ack stream, a game-specific nudge, a rumble, nothing — is the implementer's. The shipped default is NullSoftContactResolver (a TryAdd seam): with nothing registered, Soft contacts are detected and raise events but displace no one.
sealed class MyPush : ISoftContactResolver
{
public void ApplyPush(long entityId, float dirX, float dirZ, float magnitude)
=> _movement.Impulse(entityId, dirX, dirZ, magnitude, speed: 6f);
}
services.AddSingleton<ISoftContactResolver, MyPush>(); // before AddCrossplayContact -
void ApplyPush(long entityId, float dirX, float dirZ, float magnitude)One contact push for one entity, this tick.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IContactService
Generic entity-vs-entity contact: register a footprint (ContactProfile — circle or yaw-oriented box, weight, layer bits, Hard/Soft resolution) on any world entity and the per-tick pass keeps overlapping pairs apart — push-out split by weight, routed through the standard movement pipeline (Hard) or the ISoftContactResolver seam (Soft). Humans body-blocking, beasts, carts, props — the piece never knows what the entities are, and positions ride the existing movement broadcast (zero wire). Profiles auto-clear when their entity leaves the world; the EntityTemplates table auto-applies profiles by appearance-kind on spawn.
-
bool ClearProfile(long entityId)Removes an entity's contact footprint (its active contacts end, raising ContactEnded). Automatic when the entity leaves the world.
-
event Action<long, long, float> ContactBeganRaised when a filtered pair STARTS overlapping (ids ordered lowest first, plus the overlap depth at first contact). Fires once per pair until the pair separates — with PushOutFraction 0 (detection-only mode) this is a pure sensor.
-
event Action<long, long> ContactEndedRaised when a previously-overlapping pair separates — or when either side's profile is cleared / its entity leaves the world (ids ordered lowest first).
-
int Count { get; }Number of live profiles (diagnostics).
-
bool SetProfile(long entityId, ContactProfile profile)Registers (or replaces) an entity's contact footprint; the values are copied, so the caller may reuse the profile instance. Works for server entities AND player avatars alike.
-
bool TryGetProfile(long entityId, out ContactProfile profile)Reads a copy of an entity's current profile (diagnostics/inspection — allocates a fresh ContactProfile, so keep it off per-tick paths).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
ContactOptions
Contact tuning (defaults here, never inline).
-
bool AngularResponse { get; set; }Whether an Impulse pair also exchanges ANGULAR momentum — the impulse applied at the contact point rather than the centre of mass, so a corner hit spins the body and a square-on hit does not. True (the default) because it is the entire reason to choose impulse resolution; set false for anything that should not rotate, such as a top-down entity whose facing is its aim.
-
float BridgeSoftImpulseSpeed { get; set; }Composition-bridge knob (used by the Hosting layer, never by this piece): the slide speed, world units/second, at which the Movement-piece bridge turns a Soft contact push into a prediction-reconciled IMovementService.Impulse. 0 (the default) leaves the ISoftContactResolver seam on the piece's inert no-op — Soft entities are detected and raise events but are never displaced.
-
float BroadphaseInflation { get; set; }Extra broadphase query slack, world units, added on top of the pair extents so a fast approach is seen a beat early.
-
float ContactDamping { get; set; }Damping applied to corrections of a PERSISTENT contact (a pair already touching last check), 0..1: higher values calm the jitter of entities held in contact (a crowd pressing a door). 0 (default) = off.
-
byte CorrectionLocomotion { get; set; }Locomotion byte broadcast with a Hard correction (game vocabulary — the framework never reads it). A driven entity's own brain/inputs re-assert their locomotion on their next move, so this only colors the correction itself.
-
ContactProfile DefaultTemplate { get; set; }The profile applied to any spawning entity whose appearance-kind byte matches NO EntityTemplates entry (GAP-36). Null (the default) applies nothing — exactly the behaviour before this existed. The hole it closes. The template table keys on an opaque byte the game defines, so a kind the table misses is one typo — or one roster addition — away, and the result is an entity with no contact profile at all: it collides with nothing, and nothing says so. A game whose entities all collide the same way writes this ONE entry instead of one per appearance value; a kind that must differ still gets its own EntityTemplates row, which always wins. Only entities that HAVE a kind byte are considered, with or without this set: an entity spawned with an empty appearance blob is never auto-profiled (it has no kind to match), exactly as before. A kind that falls through with no default set is recorded and warned once per kind — see ContactService.UnmatchedTemplateKinds.
-
Dictionary<byte, ContactProfile> EntityTemplates { get; set; }Per-kind contact profiles keyed by an entity's appearance-kind byte (appearance[0]) — the StatsOptions.EntityTemplates / AgentOptions.BrainTemplates convention, bound straight from configuration. Any spawning entity (server-spawned or a player avatar) whose first appearance byte matches gets the template applied automatically; everything else registers through SetProfile. Empty (the default) = nothing is ever auto-profiled.
-
float Friction { get; set; }Coulomb friction for Impulse pairs: the tangential impulse is clamped to this times the normal impulse, so a glancing hit scrubs speed across the contact instead of sliding along it frictionlessly. 0 (default) = no tangential impulse, which is what keeps a plain bounce a plain bounce. Ignored by the other resolution modes.
-
float MaxAngularImpulse { get; set; }Ceiling on the angular velocity change one contact may impart, radians/second. Bounds the pathological case rather than the ordinary one: a glancing hit near a long body's end has a large lever arm and a small rotational inertia, and without a ceiling a single frame's contact can set a body spinning far faster than anything in the game ever intended.
-
int MaxChecksPerTick { get; set; }Cap on narrowphase shape checks per server tick (rule 11: unbounded work gets a budget). A stampede of overlapping entities degrades contact latency — the round-robin cursor picks up where it stopped next tick — instead of degrading the tick. Cheap layer/mask rejections are not counted; only real geometry tests are.
-
float MaxPenetrationMeters { get; set; }How far two shapes may interpenetrate before SweptNarrowphase rewinds them, world units. This is a TOLERANCE, not a target: a pair overlapping by less than this is left where it is and separated by the ordinary resolution, because rewinding a millimetre would cost a search and move nothing. Set it to roughly the visual tolerance for two bodies touching — 0.15 is a centimetre-scale gap on a car-sized object.
-
int MaxProfiles { get; set; }Most entities that may carry a contact profile at once; SetProfile refuses beyond it.
-
float MaxSeparationPerTick { get; set; }Ceiling on how far one tick's separation may move a pair, world units. 0 (the default) = uncapped, exactly as before. This bounds the pathological case, not the ordinary one. A separating-axis test reports the axis of MINIMUM penetration, so a pair overlapped past half a body can hand back the SIDE axis rather than the one they closed along — and separating fully along it would step a car-sized body most of its own width sideways in a single tick. Capped, the same pair walks apart over several ticks and arrives at a shallow overlap where the reported normal is the right one. Set it to roughly one tick's plausible travel.
-
float MinCorrection { get; set; }Corrections smaller than this (world units) are dropped — keeps micro-overlaps from producing endless tiny moves and broadcast churn.
-
float PenetrationSlop { get; set; }How much overlap a pair is allowed to KEEP, world units — subtracted from the measured depth before PushOutFraction is applied, so the fraction keeps meaning "how much of the excess is corrected per tick" at every depth. Distinct from MinCorrection, which drops a small MOVE: this drops a small OVERLAP. Two bodies resting against each other settle at exactly this depth and stop being corrected, instead of trading a sub-millimetre push back and forth forever — the standard answer to contact churn, and the reason a solver leaves a little slop rather than driving penetration to zero. 0 (the default) is the arithmetic that shipped before it existed.
-
float PushOutFraction { get; set; }Fraction of an overlap corrected per tick (0..1). 1 (default) separates a pair fully in one tick; smaller values ease overlaps apart over several ticks (softer crowds); 0 disables push-out entirely — detection-only mode, where the piece still tracks pairs and raises ContactBegan/ContactEnded but never moves anything.
-
float Restitution { get; set; }Bounce. Its meaning depends on how the pair resolves, which is deliberate rather than sloppy: for Hard/Soft it scales extra SEPARATION derived from the estimated approach speed (a fudge that reads as bounce), while for Impulse it is a real coefficient of restitution — 0 = perfectly inelastic (the pair stops closing), 1 = the approach speed is returned in full. 0 (default) = pure positional push-out in the first two, and a dead thud in the third.
-
int SweptIterations { get; set; }Bisection steps in the swept search, each halving the remaining interval of the pair's relative motion. 8 (the default) resolves the time of impact to 1/256 of one tick's travel — under a centimetre at racing speed, far below MaxPenetrationMeters, so the extra precision of a ninth step is not observable. Each step is one narrowphase test on one pair.
-
bool SweptNarrowphase { get; set; }Whether a pair that is found DEEPLY overlapped is rewound to the moment of contact and resolved there, instead of resolved where it was found. False (the default) = today's behaviour, byte for byte — the swept code is not entered at all. Costs nothing for slow movers: the gate is MaxPenetrationMeters, so a pair that never overlaps by more than the tolerance never takes the path, and at walking speed no pair does. When it does fire, the search is SweptIterations extra narrowphase tests on ONE pair. The rewind is delivered as an ordinary correction, so where it can land depends on how the entity resolves: a Hard body goes back through the swept movement pipeline (still bounds-gated — a rewind cannot put anything through a wall), and an Impulse body goes through the IContactDisplacementResolver seam, which is what lets a simulation that owns its own position (a vehicle) be told about it instead of overwriting the answer on its next substep.
-
int UnmatchedTemplateGraceTicks { get; set; }How many contact ticks a spawn whose kind byte matched no EntityTemplates entry (and no DefaultTemplate) is given before it is recorded and warned about as unprofiled. 1 (the default) is one tick: long enough for a composition bridge that profiles the entity a beat after its spawn — a vehicle on VehicleRegistered, a portable body on its host's registration — to have done so, so the warning names real holes only. An entity profiled by anything within the grace is never reported; 0 judges at the very next tick with no grace at all.
-
float VelocityInflationSeconds { get; set; }Seconds of estimated travel added to the broadphase radius (velocity inflation): the query radius grows by the entity's estimated speed × this, so fast movers do not tunnel past the broadphase between ticks. 0 = position-only queries.
-
bool YawInDegrees { get; set; }The yaw unit convention for Box orientation. Yaw is an opaque game-mapped number to the framework: player movement writes radians, while the built-in agent mover writes degrees — so the GAME says which convention its world uses. False (default) = radians; true = degrees.