Physics Integration
Problem
You want to integrate a physics engine (BepuPhysics, Box2D, etc.) with your ECS while keeping concerns separated.
Solution
Bridge Components
// Links an ECS entity to a physics body
[Component]
public partial struct PhysicsBody : IComponent
{
public int BodyHandle; // Physics engine's body ID
public BodyType Type;
}
public enum BodyType
{
Static,
Dynamic,
Kinematic
}
// Physics shape definition
[Component]
public partial struct PhysicsShape : IComponent
{
public ShapeType Type;
public float Width;
public float Height;
public float Radius;
}
public enum ShapeType
{
Box,
Circle,
Capsule
}
// Physics material properties
[Component]
public partial struct PhysicsMaterial : IComponent
{
public float Friction;
public float Restitution; // Bounciness
public float Density;
}
Physics World Singleton
public sealed class PhysicsWorldWrapper
{
// Your physics engine instance (e.g., BepuPhysics.Simulation)
public Simulation Simulation { get; }
private readonly Dictionary<int, Entity> bodyToEntity = new();
private readonly Dictionary<Entity, int> entityToBody = new();
public PhysicsWorldWrapper()
{
Simulation = Simulation.Create(
new BufferPool(),
new NarrowPhaseCallbacks(),
new PoseIntegratorCallbacks(new Vector3(0, -9.81f, 0))
);
}
public void RegisterBody(Entity entity, int bodyHandle)
{
bodyToEntity[bodyHandle] = entity;
entityToBody[entity] = bodyHandle;
}
public Entity? GetEntity(int bodyHandle)
{
return bodyToEntity.TryGetValue(bodyHandle, out var entity) ? entity : null;
}
public int? GetBodyHandle(Entity entity)
{
return entityToBody.TryGetValue(entity, out var handle) ? handle : null;
}
}
Physics Sync System (ECS → Physics)
public class PhysicsSyncToEngineSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.PrePhysics;
public override void Update(float deltaTime)
{
var physics = World.GetSingleton<PhysicsWorldWrapper>();
// Sync kinematic bodies from ECS to physics
foreach (var entity in World.Query<PhysicsBody, Position, Rotation>())
{
ref readonly var body = ref World.Get<PhysicsBody>(entity);
if (body.Type != BodyType.Kinematic)
continue;
ref readonly var pos = ref World.Get<Position>(entity);
ref readonly var rot = ref World.Get<Rotation>(entity);
// Update physics body position
var bodyRef = physics.Simulation.Bodies.GetBodyReference(new BodyHandle(body.BodyHandle));
bodyRef.Pose.Position = new Vector3(pos.X, pos.Y, 0);
bodyRef.Pose.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, rot.Angle);
}
}
}
Physics Step System
public class PhysicsStepSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.Physics;
private const float FixedTimestep = 1f / 60f;
private float accumulator;
public override void Update(float deltaTime)
{
var physics = World.GetSingleton<PhysicsWorldWrapper>();
accumulator += deltaTime;
while (accumulator >= FixedTimestep)
{
physics.Simulation.Timestep(FixedTimestep);
accumulator -= FixedTimestep;
}
}
}
Physics Sync System (Physics → ECS)
public class PhysicsSyncFromEngineSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.PostPhysics;
public override void Update(float deltaTime)
{
var physics = World.GetSingleton<PhysicsWorldWrapper>();
// Sync dynamic bodies from physics to ECS
foreach (var entity in World.Query<PhysicsBody, Position, Rotation>())
{
ref readonly var body = ref World.Get<PhysicsBody>(entity);
if (body.Type != BodyType.Dynamic)
continue;
var bodyRef = physics.Simulation.Bodies.GetBodyReference(new BodyHandle(body.BodyHandle));
// Update ECS components from physics state
ref var pos = ref World.Get<Position>(entity);
pos.X = bodyRef.Pose.Position.X;
pos.Y = bodyRef.Pose.Position.Y;
ref var rot = ref World.Get<Rotation>(entity);
// Extract Z rotation from quaternion
rot.Angle = 2f * MathF.Atan2(bodyRef.Pose.Orientation.Z, bodyRef.Pose.Orientation.W);
// Optionally sync velocity
if (World.Has<Velocity>(entity))
{
ref var vel = ref World.Get<Velocity>(entity);
vel.X = bodyRef.Velocity.Linear.X;
vel.Y = bodyRef.Velocity.Linear.Y;
}
}
}
}
Physics Body Creation
public class PhysicsBodyFactory
{
private readonly World ecsWorld;
private readonly PhysicsWorldWrapper physics;
public PhysicsBodyFactory(World ecsWorld)
{
this.ecsWorld = ecsWorld;
this.physics = ecsWorld.GetSingleton<PhysicsWorldWrapper>();
}
public void CreateDynamicBody(Entity entity)
{
ref readonly var pos = ref ecsWorld.Get<Position>(entity);
ref readonly var shape = ref ecsWorld.Get<PhysicsShape>(entity);
var material = ecsWorld.TryGet<PhysicsMaterial>(entity, out var mat)
? mat
: new PhysicsMaterial { Friction = 0.5f, Restitution = 0.3f, Density = 1f };
// Create physics shape
var physicsShape = shape.Type switch
{
ShapeType.Box => new Box(shape.Width, shape.Height, 1f),
ShapeType.Circle => new Sphere(shape.Radius),
_ => throw new NotSupportedException()
};
var shapeIndex = physics.Simulation.Shapes.Add(physicsShape);
// Create body
var bodyDescription = BodyDescription.CreateDynamic(
new RigidPose(new Vector3(pos.X, pos.Y, 0)),
physicsShape.ComputeInertia(material.Density),
new CollidableDescription(shapeIndex, 0.1f),
new BodyActivityDescription(0.01f)
);
var bodyHandle = physics.Simulation.Bodies.Add(bodyDescription);
// Link to ECS
ecsWorld.Add(entity, new PhysicsBody
{
BodyHandle = bodyHandle.Value,
Type = BodyType.Dynamic
});
physics.RegisterBody(entity, bodyHandle.Value);
}
}
Why This Works
Clear Ownership
- ECS owns gameplay data (health, inventory, AI state)
- Physics engine owns physical state (mass, forces, collisions)
- Bridge components link the two
System Phase Separation
PrePhysics → Physics → PostPhysics → Update
↓ ↓ ↓
ECS→Physics Simulate Physics→ECS
This prevents:
- Reading stale physics data
- Overwriting physics changes
- Order-dependent bugs
Fixed Timestep Physics
Physics engines need consistent time steps:
- Variable
deltaTimecauses instability - Accumulator pattern handles frame rate variation
- ECS can still use variable time for visuals
Entity-Body Mapping
The bidirectional map enables:
- Finding ECS entity from collision callback
- Finding physics body to apply forces
- Cleanup when entities are despawned
Variations
Collision Events
// Collision data as component (added by physics callback)
[Component]
public partial struct CollisionEvent : IComponent
{
public Entity Other;
public Vector2 Normal;
public float Penetration;
}
// Physics callback implementation
public struct NarrowPhaseCallbacks : INarrowPhaseCallbacks
{
private World ecsWorld;
private PhysicsWorldWrapper physics;
public void OnContactAdded(/* collision data */)
{
var entityA = physics.GetEntity(bodyHandleA);
var entityB = physics.GetEntity(bodyHandleB);
if (entityA.HasValue && entityB.HasValue)
{
ecsWorld.Add(entityA.Value, new CollisionEvent
{
Other = entityB.Value,
Normal = normal,
Penetration = depth
});
}
}
}
// System to process collision events
public class CollisionHandlerSystem : SystemBase
{
public override void Update(float deltaTime)
{
var buffer = World.GetCommandBuffer();
foreach (var entity in World.Query<CollisionEvent, Health>())
{
ref readonly var collision = ref World.Get<CollisionEvent>(entity);
// Apply damage on collision
if (World.Has<DamageOnContact>(collision.Other))
{
var damage = World.Get<DamageOnContact>(collision.Other);
buffer.Add(entity, new DamageReceived { Amount = damage.Amount });
}
// Remove processed event
buffer.Remove<CollisionEvent>(entity);
}
buffer.Execute();
}
}
Force Application via Components
[Component]
public partial struct ApplyForce : IComponent
{
public Vector2 Force;
public ForceMode Mode;
}
public enum ForceMode
{
Force, // Continuous force
Impulse, // Instant velocity change
Acceleration // Ignores mass
}
public class ForceApplicationSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.PrePhysics;
public override void Update(float deltaTime)
{
var physics = World.GetSingleton<PhysicsWorldWrapper>();
var buffer = World.GetCommandBuffer();
foreach (var entity in World.Query<PhysicsBody, ApplyForce>())
{
ref readonly var body = ref World.Get<PhysicsBody>(entity);
ref readonly var force = ref World.Get<ApplyForce>(entity);
var bodyRef = physics.Simulation.Bodies.GetBodyReference(new BodyHandle(body.BodyHandle));
switch (force.Mode)
{
case ForceMode.Impulse:
bodyRef.ApplyLinearImpulse(new Vector3(force.Force.X, force.Force.Y, 0));
break;
case ForceMode.Force:
// Force is applied per-frame, so scale by timestep
bodyRef.ApplyLinearImpulse(new Vector3(force.Force.X, force.Force.Y, 0) * deltaTime);
break;
}
// Remove after applying
buffer.Remove<ApplyForce>(entity);
}
buffer.Execute();
}
}
// Usage: Jump
world.Add(player, new ApplyForce
{
Force = new Vector2(0, 500),
Mode = ForceMode.Impulse
});
Physics Cleanup on Despawn
public class PhysicsCleanupSystem : SystemBase
{
public override void Initialize()
{
// Subscribe to entity despawn events
World.OnEntityDespawning += HandleEntityDespawn;
}
private void HandleEntityDespawn(Entity entity)
{
if (!World.TryGet<PhysicsBody>(entity, out var body))
return;
var physics = World.GetSingleton<PhysicsWorldWrapper>();
// Remove physics body
physics.Simulation.Bodies.Remove(new BodyHandle(body.BodyHandle));
// Unregister mapping
// physics.UnregisterBody(entity);
}
}
Debug Visualization
public class PhysicsDebugSystem : SystemBase
{
public override SystemPhase Phase => SystemPhase.Render;
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<PhysicsBody, Position, PhysicsShape>())
{
ref readonly var pos = ref World.Get<Position>(entity);
ref readonly var shape = ref World.Get<PhysicsShape>(entity);
var color = World.Has<PhysicsBody>(entity)
? (World.Get<PhysicsBody>(entity).Type == BodyType.Static ? Color.Gray : Color.Green)
: Color.Red;
switch (shape.Type)
{
case ShapeType.Box:
DebugDraw.Rectangle(pos.X, pos.Y, shape.Width, shape.Height, color);
break;
case ShapeType.Circle:
DebugDraw.Circle(pos.X, pos.Y, shape.Radius, color);
break;
}
}
}
}
See Also
- Physics Sample - Full BepuPhysics integration
- Systems Guide - System phases and ordering
- Events Guide - Entity lifecycle events