Skip to content

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 Jolt

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

csharp
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 }
ComponentMeaning
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 }.
PhysicsStaticTagMarker: never integrated, only a collision source.
PhysicsKinematicTagMarker: 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)

  1. StaticRegistry singleton (baked at scene start, best for walls/floors):
csharp
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.

  1. Static box entities (LocalTransform + PhysicsBox + PhysicsStaticTag, tested by NarrowphaseSystem with symmetric events carrying the real counterpart entity).

Broadphase

BroadphaseSystem: dynamics are LocalTransform + PhysicsRadius without PhysicsStaticTag. Per tick:

  1. Snapshot dynamics (pos, clamped radius, mask) into reused arrays.
  2. Clear every dynamic's CollisionEvent buffer — the broadphase owns the cleanup, the narrowphase only appends.
  3. 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).
  4. Per dynamic, scan the 27 neighboring cells with stamp dedup (each static tested once), layer-filter (dynMask & static.Layer), exact test via EcsMath.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).

  1. 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.
  2. Dyn-vs-static-box-entity: brute force over the (few) static boxes, bilateral filter, SphereVsBox in 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.

csharp
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

csharp
// 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:

csharp
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

csharp
// 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 + ApplyImpulse at the contact point (free torque via offset) when you need tumbling, as PhysicsArena3D does.
  • Current systems are single-threaded; parallel broad/narrow with per-thread buffers + ordered merge, SIMD kernels, and StaticBake from StaticBody/TileMap/GridMap are tracked TODOs.
  • Benchmarks and honest scaling analysis: Examples & benchmarks.

GodotECS docs — version 0.1.0. All rights reserved until a license is added.