Arcade physics
Pure-C# arcade physics for masses: a hash-grid broadphase plus exact narrowphase tests, contact buffers, and a damage→destroy pipeline. No PhysicsBody per projectile, no torque/inertia/iterative solver — for real rigid-body behavior (tumbling bricks, stacking), couple ECS projectiles with Godot bodies instead (see PhysicsArena3D in Examples).
Pipeline order (FixedStep)
integrate positions (your system, chunk spans: Pos += Vel · dt, gravity, damping)
▼
BroadphaseSystem ── clears CollisionEvent buffers, dyn-vs-staticRegistry tests
▼
NarrowphaseSystem ── dyn-vs-dyn + dyn-vs-staticBoxEntity tests (symmetric events)
▼
(your response: read buffers, reflect/push-out, apply impulses to Godot bodies…)
▼
DamageSystem ── Health -= event count → DestroyTag at ≤ 0
▼
DestroySystem ── destroys DestroyTag entities (snapshot first)
▼
JoltBridge ── optional, sync point only: chunk-level queries into JoltAll four systems are ISystem structs with OnCreate/OnUpdate/OnDestroy plus a testable Update(EntityManager, World) entry. Lazy query init (works when constructed directly in tests). Reused internal arrays (grow-only): zero allocations at steady state, no LINQ.
Bodies and shapes
Namespace GodotECS.Physics (see API reference).
Entity e = em.Create();
em.AddComponent<LocalTransform>(e);
em.AddComponent<PhysicsVelocity>(e); // Vector3 Value, units/s, world space
em.AddComponent<PhysicsRadius>(e); // sphere, Value > 0
em.AddComponent<PhysicsLayer>(e); // { uint Layer, Mask }, bilateral filter
em.AddComponent<PhysicsDamping>(e); // { float Linear, GravityScale }| Component | Meaning |
|---|---|
PhysicsVelocity { Vector3 Value } | Linear velocity. Integrated by your system (demos do Pos += Value · dt over chunk spans). |
PhysicsRadius { float Value } | Sphere shape. Non-positive clamped to 0 in snapshots. |
PhysicsBox { Vector3 HalfExtents } | Box shape, half extents > 0 per axis. Dynamics are spheres; boxes appear as static entities (below) or world colliders. |
PhysicsCapsule { float Radius, Height } | Vertical capsule, Radius > 0, total Height >= 2 · Radius. Used in StaticRegistry colliders. |
PhysicsLayer { uint Layer, Mask } | Membership + mask. Pair test passes only if both directions pass: (a.Mask & b.Layer) != 0 && (b.Mask & a.Layer) != 0 (dyn-dyn and dyn-box-entity; broadphase static test uses dynMask & static.Layer). Defaults: Layer = 1, Mask = all. |
PhysicsDamping { float Linear, GravityScale } | Linear damping (1/s) + gravity scale (1 = full, 0 = none). Default = { 0, 1 }. |
PhysicsStaticTag | Marker: never integrated, only a collision source. |
PhysicsKinematicTag | Marker: script-moved, not gravity-driven. |
CollisionEvent { Entity Other; Vector3 Normal; float Penetration } | Contact in the entity's buffer for this tick. Other == Entity.Null = world static from StaticRegistry. |
PlayerBodyRef { Entity Value } | Plain component holding the player body (not a singleton). |
PhysicsConfig (singleton) | { Vector3 Gravity; int SolverIterations; float ContactSlop }. Default: (0,−9.81,0), 4 iterations, slop 0.001. No CellSize — grids use a local const (4 m). |
The static world (two ways)
StaticRegistrysingleton (baked at scene start, best for walls/floors):
world.SetSingleton(new StaticRegistry
{
Colliders = new[]
{
new StaticCollider { Kind = StaticShapeKind.Box,
Center = new Vector3(0f, -1f, 0f),
HalfExtents = new Vector3(50f, 1f, 50f), Layer = 1u },
new StaticCollider { Kind = StaticShapeKind.Sphere,
Center = new Vector3(10f, 1f, 0f), Radius = 2f, Layer = 1u },
new StaticCollider { Kind = StaticShapeKind.Capsule,
Center = new Vector3(-10f, 2f, 0f), Radius = 0.5f, Height = 3f, Layer = 1u },
},
Count = 3,
});StaticShapeKind: Sphere = 0, Box = 1, Capsule = 2. Box uses Center ± HalfExtents (never Min/Max directly). Capsules are vertical and tested as segment + sphere. Count is clamped to the array length; a missing registry simply yields no static tests.
- Static box entities (
LocalTransform + PhysicsBox + PhysicsStaticTag, tested byNarrowphaseSystemwith symmetric events carrying the real counterpart entity).
Broadphase
BroadphaseSystem: dynamics are LocalTransform + PhysicsRadius without PhysicsStaticTag. Per tick:
- Snapshot dynamics (pos, clamped radius, mask) into reused arrays.
- Clear every dynamic's
CollisionEventbuffer — the broadphase owns the cleanup, the narrowphase only appends. - Bucket statics into a uniform hash grid (64×16×64, cell 4 m): each static is linked into every cell its AABB touches, so cost follows local density, never
O(dynamics × statics). - Per dynamic, scan the 27 neighboring cells with stamp dedup (each static tested once), layer-filter (
dynMask & static.Layer), exact test viaEcsMath.SphereVsBox/SphereVsSphere(capsule via segment+sphere).
Hits append { Other = Entity.Null, Normal, Penetration }. Grid coordinates wrap for dynamics (Mod) and clamp for static bucketing. Assumption: max radius ≤ 2 m (cell ≥ 2× max radius).
Narrowphase
NarrowphaseSystem: two passes, both appending symmetric events (normal negated on the other side).
- Dyn-vs-dyn through the same 64×16×64 grid: bucket by cell, per dynamic scan 27 neighbors with
j > a+ stamp dedup so each pair is tested exactly once in deterministic order, bilateral layer filter,SphereVsSphere. - Dyn-vs-static-box-entity: brute force over the (few) static boxes, bilateral filter,
SphereVsBoxin world space. Both buffers get the event with the real counterpart entity.
Response is yours (arcade choice)
The pipeline detects contacts; resolution is game code. The demos' documented choice: respond only to static contacts (Other.IsNull), reflect velocity on the contact normal and push out by penetration; ball-ball contacts generate events (damage, sound, signals) but no positional response.
var buf = em.GetBuffer<CollisionEvent>(e);
LocalTransform t = em.GetComponentData<LocalTransform>(e);
PhysicsVelocity v = em.GetComponentData<PhysicsVelocity>(e);
Vector3 p = t.Pos, vv = v.Value;
for (int k = 0; k < buf.Length; k++)
{
CollisionEvent c = buf[k];
if (!c.Other.IsNull) continue; // statics only
Vector3 n = c.Normal;
float d = vv.Dot(n);
if (d < 0f) vv -= 2f * d * n; // reflect
p += n * c.Penetration; // push-out
}
t.Pos = p; v.Value = vv;
em.SetComponentData(e, in t);
em.SetComponentData(e, in v);Damage and destroy
// DamageSystem: snapshot first, structural writes after.
Health h = ...;
h.Value -= em.GetBuffer<CollisionEvent>(e).Length; // 1 hp per contact
em.SetComponentData(e, h);
if (h.Value <= 0) em.AddComponent<DestroyTag>(e); // idempotent
// DestroySystem: snapshot entities with DestroyTag, then Destroy (IsAlive-checked).Both snapshot before mutating (adds/destroys are structural moves, never inside iteration).
Jolt bridge (optional, sync point only)
JoltBridge issues a few chunk-level shape queries into Godot/Jolt instead of one per entity — and never inside a job:
var bridge = new JoltBridge();
// One swept query per chunk AABB (sphere approx of the box), radius 0 = 0 hits.
int hits = bridge.CastChunk(space, chunkBox, mask, outHitSpan);
// Rest overlap at a point: returns result count (capped at 1 query).
int n = bridge.QueryRest(space, pos, radius, mask);Both methods have instance and static overloads (same behavior). space == null or empty/non-positive shapes return 0. CastChunk caps at 32 results and outHit length. Call only on the physics thread at a sync point.
Full tick example
// 1. integrate (job over chunk spans: gravity + damping + move)
var integrate = new IntegrateJob { Dt = fixedDelta };
state.ScheduleParallel<IntegrateJob, LocalTransform, PhysicsVelocity>(ref integrate, query).Complete();
// 2. detect
_broad.Update(em, world);
_narrow.Update(em, world);
// 3. respond (game code, see above)
// 4. damage → destroy
_damage.Update(em, world);
_destroy.Update(em, world);Limits and next steps
- No rotation dynamics, friction, restitution solver, or sleeping — arcade only. Couple with
RigidBody3D+ApplyImpulseat the contact point (free torque via offset) when you need tumbling, asPhysicsArena3Ddoes. - Current systems are single-threaded; parallel broad/narrow with per-thread buffers + ordered merge, SIMD kernels, and
StaticBakefromStaticBody/TileMap/GridMapare tracked TODOs. - Benchmarks and honest scaling analysis: Examples & benchmarks.