Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prototools
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
go = "1.25.5"
209 changes: 209 additions & 0 deletions pkg/plugin/physics2d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# physics2d

Box2D v3–backed 2D physics plugin for Cardinal. All simulation state lives
on the C side; Cardinal drives it through three ECS components attached to
the entities you want simulated.

Registration:

```go
world := cardinal.NewWorld(cardinal.WorldOptions{TickRate: 60})
cardinal.RegisterPlugin(world, physics2d.NewPlugin(physics2d.Config{
Gravity: physics2d.Vec2{X: 0, Y: -9.8},
TickRate: 60, // match WorldOptions.TickRate
SubStepCount: 4,
}))
world.StartGame()
```

## Enrolling an entity into physics

An entity participates in the simulation when it carries **all three** of
these components. Missing any one → the reconciler skips the entity and no
body is created on the C side.

| Component | Purpose |
|---|---|
| [`Transform2D`](component/spatial.go) | World-space position + rotation (authoritative pose) |
| [`Velocity2D`](component/spatial.go) | Linear + angular velocity |
| [`PhysicsBody2D`](component/physics_body.go) | Body kind, damping, flags, and the compound collider (`Shapes`) |

Use the `NewPhysicsBody2D` constructor — bare struct literals leave
`Active`, `Awake`, `SleepingAllowed` at `false` and `GravityScale` at `0`,
which produces an inactive, sleeping, gravity-less body.

### Example: a dynamic circle

```go
import (
"github.com/argus-labs/world-engine/pkg/cardinal"
"github.com/argus-labs/world-engine/pkg/plugin/physics2d"
)

func SpawnBallSystem(ctx cardinal.WorldContext) error {
id, err := cardinal.Create(ctx,
physics2d.Transform2D{
Position: physics2d.Vec2{X: 0, Y: 10},
Rotation: 0,
},
physics2d.Velocity2D{
Linear: physics2d.Vec2{X: 0, Y: 0},
Angular: 0,
},
physics2d.NewPhysicsBody2D(
physics2d.BodyTypeDynamic,
physics2d.ColliderShape{
ShapeType: physics2d.ShapeTypeCircle,
Radius: 0.5,
Density: 1.0,
Friction: 0.3,
Restitution: 0.2,
CategoryBits: 0x0001,
MaskBits: 0xFFFF,
},
),
)
_ = id
return err
}
```

### Example: a static box (world geometry)

```go
cardinal.Create(ctx,
physics2d.Transform2D{Position: physics2d.Vec2{X: 0, Y: 0}},
physics2d.Velocity2D{},
physics2d.NewPhysicsBody2D(
physics2d.BodyTypeStatic,
physics2d.ColliderShape{
ShapeType: physics2d.ShapeTypeBox,
HalfExtents: physics2d.Vec2{X: 25, Y: 1},
Friction: 0.5,
CategoryBits: 0x0002,
MaskBits: 0xFFFF,
},
),
)
```

### Body-type cheat sheet

- **`BodyTypeStatic`** — immovable world geometry. No writeback.
- **`BodyTypeDynamic`** — full simulation: forces, gravity, collisions. Writeback updates `Transform2D`/`Velocity2D` each tick.
- **`BodyTypeKinematic`** — velocity-driven; Box2D integrates position. Writeback on.
- **`BodyTypeManual`** — gameplay owns position/velocity; Box2D is used only for contact detection. No writeback; the reconciler pushes ECS → Box2D each tick. Use for characters/enemies driven by input or AI.

### Compound colliders

`PhysicsBody2D.Shapes` is a slice — each entry is a child fixture with its
own `LocalOffset`, `LocalRotation`, material, and filter. Shape identity is
by index (slot `i` in `Shapes` ↔ fixture slot `i`), so don't reorder shapes
after creation if you care about per-shape references in contact events.

## Built-in queries

Use these first; they cover most needs and don't require CGO:

- `physics2d.Raycast(RaycastRequest) RaycastResult`
- `physics2d.OverlapAABB(AABBOverlapRequest) AABBOverlapResult`
- `physics2d.CircleSweep(CircleSweepRequest) CircleSweepResult`

All three return zero results when no C-side world exists yet (e.g. before
the first reconcile, or right after `ResetRuntime`).

## Custom queries via CGO

If you need a Box2D feature the plugin doesn't expose (joints, shape casts,
custom query filters, sensor-only overlap, etc.), call Box2D directly from
your own CGO package. The plugin exposes the raw world handle via
[`physics2d.WorldID()`](plugin.go), which returns the Box2D v3 `b2WorldId`
packed as a `uint32`. Reconstruct it in C with `b2LoadWorldId`.

### Userdata encoding

The bridge stuffs identity into Box2D userdata pointers (see [bridge.c:214-217](internal/cbridge/bridge.c#L214-L217)):

- **Body userdata** = entity ID, packed as `(void*)(uintptr_t)entity_id` — unpack with `(uint32_t)(uintptr_t)b2Body_GetUserData(bodyId)`.
- **Shape userdata** = shape slot index (the index into `PhysicsBody2D.Shapes`), packed the same way but as `int32_t`.

So inside any Box2D callback you can recover the ECS entity with one line.

### Example: custom AABB overlap that returns every hit, including sensors

```go
package myphysics

/*
#cgo CFLAGS: -I${SRCDIR}/../../vendor/world-engine/pkg/plugin/physics2d/third_party/box2d/include
#include "box2d/box2d.h"
#include <stdint.h>

static bool overlap_cb(b2ShapeId shapeId, void* ctx) {
// Recover the ECS entity ID from body userdata.
b2BodyId body = b2Shape_GetBody(shapeId);
uint32_t* out = (uint32_t*)ctx;
// ... append unpack_uint32(b2Body_GetUserData(body)) to your buffer ...
(void)out;
return true; // keep going
}

static int my_overlap_all(
uint32_t world_id_packed,
float minX, float minY, float maxX, float maxY,
uint32_t* out_entities, int32_t cap
) {
b2WorldId world = b2LoadWorldId(world_id_packed);
if (!b2World_IsValid(world)) return 0;

b2AABB aabb = { {minX, minY}, {maxX, maxY} };
b2QueryFilter filter = b2DefaultQueryFilter(); // matches everything
b2World_OverlapAABB(world, aabb, filter, overlap_cb, out_entities);
// ... return fill count ...
return 0;
}
*/
import "C"

import "github.com/argus-labs/world-engine/pkg/plugin/physics2d"

func OverlapAll(minX, minY, maxX, maxY float64) []uint32 {
worldID := physics2d.WorldID()
if worldID == 0 {
return nil // no world yet: before init or after ResetRuntime
}
buf := make([]uint32, 256)
n := C.my_overlap_all(
C.uint32_t(worldID),
C.float(minX), C.float(minY), C.float(maxX), C.float(maxY),
(*C.uint32_t)(&buf[0]), C.int32_t(len(buf)),
)
return buf[:int(n)]
}
```

### Rules of engagement

- **Always null-check `WorldID()`** — it returns `0` before the first
`PreUpdate` reconcile and after `ResetRuntime`. Treat `0` as "no world,
skip the query."
- **Never mutate world state from a system.** `WorldID()` gives you a raw
Box2D handle; calling `b2Body_SetTransform` / `b2DestroyBody` / etc.
directly will desync the bridge's entity→body map and the reconciler
will fight you next tick. For mutations, go through ECS components — the
reconciler pushes changes to Box2D before each step.
- **Queries are fine.** Raycasts, overlaps, shape casts, sensor iteration,
contact walks — read-only Box2D calls are safe to make any time after
you've confirmed `WorldID() != 0`.
- **Don't cache the handle across ticks.** `WorldID()` is cheap; call it
at the top of each query. After `ResetRuntime` (e.g. snapshot restore)
the old id is invalid.

## Contact events

Contacts and triggers flow through Cardinal's system-event bus. Register an
emitter with `physics2d.SetStepContactEmitter` and the plugin flushes
`ContactBeginEvent` / `ContactEndEvent` / `TriggerBeginEvent` /
`TriggerEndEvent` each tick. The events carry both entity IDs and both
shape indices, so you can look up the exact `ColliderShape` that produced
the contact.
33 changes: 33 additions & 0 deletions pkg/plugin/physics2d/component/active_contacts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package component

// PhysicsSingletonTag marks the single entity that holds physics plugin state (ActiveContacts).
type PhysicsSingletonTag struct{}

func (PhysicsSingletonTag) Name() string { return "physics_singleton_tag" }

// ContactPairEntry is one active contact pair tracked by the physics engine. Entries are
// normalized: EntityA < EntityB (or if equal, ShapeIndexA <= ShapeIndexB).
type ContactPairEntry struct {
EntityA uint64 `json:"a"`
ShapeIndexA int `json:"sa"`
EntityB uint64 `json:"b"`
ShapeIndexB int `json:"sb"`
IsSensor bool `json:"sensor"`
// Fixture filters for normalized EntityA/B (recovery End / trigger vs contact routing).
// Omitempty keeps older snapshots valid.
FilterACategoryBits uint64 `json:"fa_cat,omitempty"`
FilterAMaskBits uint64 `json:"fa_mask,omitempty"`
FilterAGroupIndex int32 `json:"fa_grp,omitempty"`
FilterBCategoryBits uint64 `json:"fb_cat,omitempty"`
FilterBMaskBits uint64 `json:"fb_mask,omitempty"`
FilterBGroupIndex int32 `json:"fb_grp,omitempty"`
}

// ActiveContacts persists which contact pairs have had Begin emitted (and not yet End).
// After a rebuild, the physics step diffs this against Box2D's live contact list to emit
// correct Begin/End events without duplicates or missed ends.
type ActiveContacts struct {
Pairs []ContactPairEntry `json:"pairs"`
}

func (ActiveContacts) Name() string { return "active_contacts" }
126 changes: 126 additions & 0 deletions pkg/plugin/physics2d/component/collider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package component

import (
"errors"
"fmt"
)

// ShapeType selects which geometry fields in ColliderShape are valid.
//
// Callers must set ShapeType consistently with the populated geometry fields.
// Box2D validates geometry internally (convexity, vertex count, welding) and will panic
// on invalid input. This is caught during development.
type ShapeType uint8

const (
// ShapeTypeCircle uses Radius; fixture is a circle in the shape's local frame.
ShapeTypeCircle ShapeType = iota + 1
// ShapeTypeBox uses HalfExtents (half-width, half-height) for an axis-aligned box in the
// shape's local frame before applying LocalOffset/LocalRotation.
ShapeTypeBox
// ShapeTypeConvexPolygon uses Vertices as a convex polygon in the shape's local frame.
ShapeTypeConvexPolygon
// ShapeTypeStaticChain uses ChainPoints for open chain segments (static or kinematic
// bodies only; not for dynamic bodies which require mass).
ShapeTypeStaticChain
// ShapeTypeStaticChainLoop uses ChainPoints for closed chain loops (static or kinematic
// bodies only; not for dynamic bodies). Unlike ShapeTypeStaticChain, the last vertex
// automatically connects back to the first, creating a sealed boundary.
ShapeTypeStaticChainLoop
// ShapeTypeEdge uses EdgeVertices (exactly 2 points) for a single line segment
// (static or kinematic bodies only). Lighter than a 2-point chain for isolated barriers
// or triggers.
ShapeTypeEdge
// ShapeTypeCapsule uses CapsuleCenter1, CapsuleCenter2, and Radius; fixture is a capsule
// (two semicircles connected by a rectangle) in the shape's local frame.
ShapeTypeCapsule
)

// ColliderShape is one child shape inside a compound PhysicsBody2D.
//
// Each entry has its own local transform, sensor flag, material, and collision filter (category, mask, group).
// Geometry fields are a tagged-union style: only the fields that match ShapeType are used.
// - ShapeTypeCircle → Radius
// - ShapeTypeBox → HalfExtents (half-width on X, half-height on Y, axis-aligned before LocalOffset/LocalRotation)
// - ShapeTypeConvexPolygon → Vertices (convex polygon, respect backend limits)
// - ShapeTypeStaticChain → ChainPoints (open polyline in local space)
// - ShapeTypeStaticChainLoop → ChainPoints (closed loop in local space)
// - ShapeTypeEdge → EdgeVertices (exactly 2 points in local space)
// - ShapeTypeCapsule → CapsuleCenter1, CapsuleCenter2, Radius (two semicircles connected by a rectangle)
type ColliderShape struct {
ShapeType ShapeType `json:"shape_type"`
LocalOffset Vec2 `json:"local_offset"`
LocalRotation float64 `json:"local_rotation"`
IsSensor bool `json:"is_sensor"`

// Geometry (use fields matching ShapeType).
Radius float64 `json:"radius,omitempty"`
HalfExtents Vec2 `json:"half_extents,omitempty"`
Vertices []Vec2 `json:"vertices,omitempty"`
ChainPoints []Vec2 `json:"chain_points,omitempty"`
EdgeVertices [2]Vec2 `json:"edge_vertices,omitempty"`
CapsuleCenter1 Vec2 `json:"capsule_center1,omitempty"`
CapsuleCenter2 Vec2 `json:"capsule_center2,omitempty"`

// Material and per-shape collision filtering (fixture-level in Box2D).
Friction float64 `json:"friction"`
Restitution float64 `json:"restitution"`
Density float64 `json:"density"`
CategoryBits uint64 `json:"category_bits"`
MaskBits uint64 `json:"mask_bits"`
GroupIndex int32 `json:"group_index,omitempty"`
}

// Validate checks for NaN/Inf in all float fields and a valid ShapeType tag.
func (s ColliderShape) Validate() error {
if err := validateVec2("local_offset", s.LocalOffset); err != nil {
return err
}
if !isFinite(s.LocalRotation) {
return errors.New("local_rotation: must be finite")
}
if !isFinite(s.Friction) {
return fmt.Errorf("friction: must be finite, got %v", s.Friction)
}
if !isFinite(s.Restitution) {
return fmt.Errorf("restitution: must be finite, got %v", s.Restitution)
}
if !isFinite(s.Density) {
return fmt.Errorf("density: must be finite, got %v", s.Density)
}
if !isFinite(s.Radius) {
return fmt.Errorf("radius: must be finite, got %v", s.Radius)
}
if err := validateVec2("half_extents", s.HalfExtents); err != nil {
return err
}
for i, v := range s.Vertices {
if err := validateVec2(fmt.Sprintf("vertices[%d]", i), v); err != nil {
return err
}
}
for i, v := range s.ChainPoints {
if err := validateVec2(fmt.Sprintf("chain_points[%d]", i), v); err != nil {
return err
}
}
for i, v := range s.EdgeVertices {
if err := validateVec2(fmt.Sprintf("edge_vertices[%d]", i), v); err != nil {
return err
}
}
if err := validateVec2("capsule_center1", s.CapsuleCenter1); err != nil {
return err
}
if err := validateVec2("capsule_center2", s.CapsuleCenter2); err != nil {
return err
}

switch s.ShapeType {
case ShapeTypeCircle, ShapeTypeBox, ShapeTypeConvexPolygon, ShapeTypeStaticChain,
ShapeTypeStaticChainLoop, ShapeTypeEdge, ShapeTypeCapsule:
default:
return fmt.Errorf("shape_type: unknown value %d", s.ShapeType)
}
return nil
}
Loading
Loading