Physics & Collision

#include <safi/physics/physics.h>

SafiEngine's physics is powered by Jolt Physics — a AAA-proven C++ rigid-body engine (Horizon Forbidden West). The engine wraps Jolt behind a C API so user code stays pure C. A single .cpp bridge file in the engine handles all Jolt access.

Physics runs on the SafiFixedUpdate phase at a stable timestep (default 1/60s). After each step, dynamic body transforms are written back to SafiTransform. The transform propagation system (on EcsPreStore) then updates SafiGlobalTransform before the render stage sees them.

Components

SafiRigidBody

typedef enum SafiBodyType {
    SAFI_BODY_STATIC,     /* never moves; other bodies collide with it     */
    SAFI_BODY_DYNAMIC,    /* driven by forces and gravity                  */
    SAFI_BODY_KINEMATIC,  /* moved by user code; pushes other bodies aside */
} SafiBodyType;

typedef struct SafiRigidBody {
    SafiBodyType type;
    float mass;            /* kg; 0 for static                           */
    float friction;        /* [0, 1]; default 0.5                         */
    float restitution;     /* [0, 1]; default 0.3                         */
    uint32_t _body_id;     /* Jolt body ID — set by the physics system    */
    bool _registered;      /* internal — true once added to Jolt          */
} SafiRigidBody;

Fields prefixed with _ are managed by the physics system — don't write to them.

SafiCollider

typedef enum SafiColliderShape {
    SAFI_COLLIDER_BOX,
    SAFI_COLLIDER_SPHERE,
} SafiColliderShape;

typedef struct SafiCollider {
    SafiColliderShape shape;
    union {
        struct { float half_extents[3]; } box;
        struct { float radius; }          sphere;
    };
} SafiCollider;

Adding a physics entity

Attach SafiTransform, SafiRigidBody, and SafiCollider to an entity. The physics system automatically registers it with Jolt on the next fixed step.

/* Dynamic box that falls under gravity */
ecs_entity_t crate = ecs_new(world);
ecs_set(world, crate, SafiTransform, {
    .position = {0, 5, 0},
    .rotation = {0, 0, 0, 1},
    .scale    = {1, 1, 1},
});
ecs_set(world, crate, SafiGlobalTransform, {0});
ecs_set(world, crate, SafiRigidBody, {
    .type        = SAFI_BODY_DYNAMIC,
    .mass        = 1.0f,
    .friction    = 0.5f,
    .restitution = 0.3f,
});
ecs_set(world, crate, SafiCollider, {
    .shape = SAFI_COLLIDER_BOX,
    .box.half_extents = {0.5f, 0.5f, 0.5f},
});

/* Static ground plane (thin box) */
ecs_entity_t ground = ecs_new(world);
ecs_set(world, ground, SafiTransform, {
    .position = {0, -1, 0},
    .rotation = {0, 0, 0, 1},
    .scale    = {1, 1, 1},
});
ecs_set(world, ground, SafiGlobalTransform, {0});
ecs_set(world, ground, SafiRigidBody, {
    .type     = SAFI_BODY_STATIC,
    .mass     = 0,
    .friction = 0.5f,
});
ecs_set(world, ground, SafiCollider, {
    .shape = SAFI_COLLIDER_BOX,
    .box.half_extents = {10.0f, 0.1f, 10.0f},
});

Per-tick flow

The physics system runs on SafiFixedUpdate with four phases per tick:

  1. Register — entities with _registered == false get a Jolt body created from their collider shape + transform.
  2. Push kinematic — kinematic bodies push their SafiTransform into Jolt (user gameplay may have moved them in OnUpdate).
  3. Stepsafi_jolt_step(fixed_dt) advances the simulation by one fixed timestep.
  4. Pull dynamic — dynamic bodies pull Jolt's computed position/rotation back into SafiTransform.

Frame ordering

OnUpdate       (user controls mutate SafiTransform)
FixedUpdate ×N (physics register → push → step → pull)
PreStore       (transform propagation: SafiTransform → SafiGlobalTransform)
OnStore        (render reads SafiGlobalTransform.matrix)

Propagation runs on EcsPreStore (render pipeline) so it captures both user-gameplay changes and physics changes before the renderer sees them.

Lifecycle

Physics is initialized and shut down automatically by safi_app_init / safi_app_shutdown. Gravity defaults to (0, -9.81, 0). The simulation is single-threaded (JobSystemSingleThreaded), adequate for hundreds of bodies.

Collision queries

Two query families are exposed: a closest-hit raycast and box / sphere overlap checks. Both return the owning ecs_entity_t of each hit body directly — no lookup table needed (the engine stores the entity as Jolt BodyUserData at creation time).

Raycast

typedef struct SafiRayHit {
    ecs_entity_t entity;
    uint32_t     body_id;
    float        point[3];
    float        normal[3];
    float        fraction;   /* [0, 1] along the ray */
} SafiRayHit;

bool safi_physics_raycast(ecs_world_t *world,
                          const float origin[3],
                          const float direction[3],
                          float max_distance,
                          ecs_entity_t ignore,   /* 0 to disable */
                          SafiRayHit *out_hit);

direction must be normalized. Pass ignore = 0 to hit everything, or the entity id of a body you want to skip (useful when casting from a character to avoid self-hits). Returns true and fills out_hit when something was hit.

SafiRayHit hit;
float origin[3]    = {0, 1, 0};
float direction[3] = {0, -1, 0};
if (safi_physics_raycast(world, origin, direction, 10.0f, 0, &hit)) {
    SAFI_LOG_INFO("grounded on entity %llu at y=%.2f",
                  (unsigned long long)hit.entity, hit.point[1]);
}

Overlap

int safi_physics_overlap_box(ecs_world_t *world,
                             const float center[3],
                             const float half_extents[3],
                             const float rotation[4],   /* NULL = identity */
                             ecs_entity_t *out_entities, int cap);

int safi_physics_overlap_sphere(ecs_world_t *world,
                                const float center[3],
                                float radius,
                                ecs_entity_t *out_entities, int cap);

Returns the total number of overlapping bodies found; fills up to cap entries into out_entities. If the return value exceeds cap, grow the buffer and call again.

ecs_entity_t buf[16];
float center[3] = {0, 0, 0};
int n = safi_physics_overlap_sphere(world, center, 2.0f, buf, 16);
int written = n < 16 ? n : 16;
for (int i = 0; i < written; i++)
    SAFI_LOG_INFO("in range: entity %llu", (unsigned long long)buf[i]);

:::warning WIP — Not yet implemented

  • Capsule collider — only box and sphere are supported
  • Layer masks / filtering — raycasts support a single ignore entity; no group masks
  • All-hits raycast — only closest hit
  • Constraints / joints — not exposed
  • Contact callbacks — no collision event system
  • Debug wireframe — no physics debug rendering
  • Parented physics bodies — all physics bodies must be root entities (no EcsChildOf hierarchy for physics) :::

See also