physics2d

package
v0.18.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: LGPL-3.0 Imports: 8 Imported by: 0

README

physics2d

Box2D v3–backed 2D physics plugin for Cardinal, powered by the pure-Go Box2D port in pkg/box2d. All simulation state lives in the plugin instance's engine world; Cardinal drives it through three ECS components attached to the entities you want simulated.

Regenerating the wire code

The wire.gen.go files are generated by world sdk generate. From the world-engine repo root:

world sdk generate ./pkg/plugin/physics2d \
  --go-out ./pkg/plugin/physics2d/gen

Requires Docker (the generator runs protoc/buf in a container). Rerun after changing any command/component/event struct in this plugin.

--proto-out ./pkg/plugin/physics2d/proto is optional. The generator compiles the schema in a temp dir, so nothing in the build reads a persisted copy. Add the flag when you want to read the wire contract directly — what a field became, or whether a type fell back to opaque JSON.

Registration:

w, err := cardinal.NewWorld(cardinal.WorldOptions{TickRate: 60})
if err != nil {
    panic(err)
}
physics := physics2d.NewPlugin(physics2d.Config{
    Gravity:      physics2d.Vec2{X: 0, Y: -9.8},
    TickRate:     60, // match WorldOptions.TickRate
    SubStepCount: 4,
})
w.RegisterPlugin(physics)
w.StartGame()

RegisterPlugin registers the physics components and the plugin's Init and PreUpdate systems. Systems in one hook run in registration order, so a game system that must run before the plugin's (for example an Init spawner whose bodies the first rebuild should see) is registered before RegisterPlugin. That order needs no extra step for components: a system resolves its archetypes on its first Contains, Exact, or Create call inside Run, not when it is registered, and every Register* call panics once the world has started.

w.RegisterSystem(&spawnBodiesSystem{}, cardinal.WithHook(cardinal.Init))
w.RegisterPlugin(physics)

Only code that creates physics entities before RegisterPlugin runs (a test harness seeding a scene) needs the components earlier. Call physics2d.RegisterComponents(w) first; RegisterPlugin registering them again is a no-op.

Keep the *physics2d.Plugin value: queries, Engine, and Reset are methods on it. All simulation state belongs to that instance — the package holds no globals, and multiple plugin instances in one process simulate fully independently.

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 in the engine.

Component Purpose
Transform2D World-space position + rotation (authoritative pose)
Velocity2D Linear + angular velocity
PhysicsBody2D 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
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)
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 an immutable.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.

Read it with Len, At, and All; change it with Append, With, Without and friends, then Set the component. Those derivations edit the array the component already holds, so a Set after each one is required rather than tidy — see immutable.Slice.

pb := entity.Get[physics2d.PhysicsBody2D]()
sh := pb.Shapes.At(0)
sh.Friction = 0.9
pb.Shapes = pb.Shapes.With(0, sh)
entity.Set(pb)
Polygon vertices

A convex polygon's vertices live in a fixed [MaxPolygonVertices]Vec2 array with VertexCount saying how many slots are live — the same shape box2d.Polygon uses, and the same bound (8) Box2D compiles in. Build one with WithVertices, which sets the count and zeroes the unused tail:

tri := physics2d.ColliderShape{
    ShapeType:    physics2d.ShapeTypeConvexPolygon,
    Density:      1,
    Friction:     0.4,
    CategoryBits: 0xFFFF,
    MaskBits:     0xFFFF,
}.WithVertices(
    physics2d.Vec2{X: 0, Y: 0},
    physics2d.Vec2{X: 2, Y: 0},
    physics2d.Vec2{X: 1, Y: 1.5},
)

Past MaxPolygonVertices, WithVertices returns a shape that fails Validate (vertex_count: must be between 0 and 8) rather than truncating or panicking — an over-long polygon is a bad asset, and the reconciler refuses the fixture the same way it refuses any other bad geometry. PolygonVertices() reads back the live prefix. A chain has no such bound, so ChainPoints is an immutable.Slice[Vec2]; build one with immutable.SliceOf(pts...).

Built-in queries

Use these first; they cover most needs:

  • (*physics2d.Plugin).Raycast(RaycastRequest) RaycastResult
  • (*physics2d.Plugin).OverlapAABB(AABBOverlapRequest) AABBOverlapResult
  • (*physics2d.Plugin).CircleSweep(CircleSweepRequest) CircleSweepResult

All three return zero results when no engine world exists yet (e.g. before the first reconcile, or right after Plugin.Reset).

Custom read-only queries via Engine()

If you need to read something the built-in queries don't cover (shape casts, custom query filters, sensor-only overlap, contact walks, body/shape inspection, debug draw), call the pure-Go engine directly. (*physics2d.Plugin).Engine() returns the underlying *box2d.World, or nil when no world exists (before the first PreUpdate reconcile and after Plugin.Reset).

Engine() is a read-only escape hatch, not an extension point. The reconciler owns body and shape lifecycle and derives it from the ECS components every tick, so engine-side mutations do not survive: changes to solver-owned state (transform, velocity, shape geometry, filters, body flags) are overwritten on the next reconcile, and bodies/shapes/joints you create directly on the engine are untracked and are destroyed by any rebuild (Plugin.Reset, snapshot restore, or a structural component change). Make changes that must persist through the ECS components instead.

Entity → engine object lookup

Engine() alone gives you a world with no way back to your entities. Two lookups close that gap:

  • (*physics2d.Plugin).BodyID(cardinal.EntityID) (box2d.BodyID, bool)
  • (*physics2d.Plugin).ShapeIDs(cardinal.EntityID) ([]box2d.ShapeID, bool)

Both return ok == false when no world exists or the entity has no body yet. ShapeIDs is indexed by collider slot (slot i ↔ PhysicsBody2D.Shapes[i]) and returns a copy you own. The ids are valid only until the next tick's reconcile, which may destroy and recreate the body — look them up again each tick rather than caching them.

// Read the solver's mass and per-shape contact state for one entity.
func InspectMass(physics *physics2d.Plugin, id cardinal.EntityID) (float64, bool) {
    w := physics.Engine()
    if w == nil {
        return 0, false // no world yet: before init or after Plugin.Reset
    }
    bodyID, ok := physics.BodyID(id)
    if !ok {
        return 0, false // entity has no body this tick
    }
    return w.BodyMass(bodyID), true
}

// Does any of this entity's shapes contain the point? (query, not mutation)
func ContainsPoint(physics *physics2d.Plugin, id cardinal.EntityID, p box2d.Vec2) bool {
    w := physics.Engine()
    if w == nil {
        return false
    }
    shapeIDs, ok := physics.ShapeIDs(id)
    if !ok {
        return false
    }
    for _, shapeID := range shapeIDs {
        if w.ShapeTestPoint(shapeID, p) {
            return true
        }
    }
    return false
}
Userdata encoding

The plugin stuffs identity into Box2D userdata (see internal/create.go):

  • Body userdata = entity ID: cardinal.EntityID(uint32(w.BodyUserData(bodyID))).
  • Shape userdata = shape slot index (the index into PhysicsBody2D.Shapes): int(uint32(w.ShapeUserData(shapeID))).

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

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

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

func OverlapAll(physics *physics2d.Plugin, minX, minY, maxX, maxY float64) []cardinal.EntityID {
    w := physics.Engine()
    if w == nil {
        return nil // no world yet: before init or after Plugin.Reset
    }
    var out []cardinal.EntityID
    aabb := box2d.AABB{
        LowerBound: box2d.Vec2{X: minX, Y: minY},
        UpperBound: box2d.Vec2{X: maxX, Y: maxY},
    }
    w.OverlapAABB(aabb, box2d.DefaultQueryFilter(), func(shapeID box2d.ShapeID, _ any) bool {
        bodyID := w.ShapeBody(shapeID)
        out = append(out, cardinal.EntityID(uint32(w.BodyUserData(bodyID))))
        return true // keep going
    }, nil)
    return out
}
Rules of engagement
  • Always nil-check Engine() — it returns nil before the first PreUpdate reconcile and after Plugin.Reset. Treat nil as "no world, skip the query."
  • Reads and queries only. Raycasts, overlaps, shape casts, sensor iteration, contact walks, body/shape getters — safe any time after you've confirmed Engine() != nil.
  • Mutation is not supported. SetBodyTransform / DestroyBody / shape setters bypass the plugin's ECS shadow copy and are overwritten on the next reconcile. Write the ECS components instead; the reconciler pushes them to Box2D before each step.
  • Engine-created objects are not tracked. Bodies, shapes, or joints you create directly on the engine are invisible to ECS and are destroyed by any rebuild — Plugin.Reset, snapshot restore, or a structural component change.
  • Don't cache the world pointer or ids across ticks. Engine(), BodyID, and ShapeIDs are cheap; call them at the top of each query. After Plugin.Reset (e.g. snapshot restore) the old pointer refers to a destroyed world, and reconcile can recreate a body under a new id.

Contact events

Contacts and triggers flow through Cardinal's system-event bus. The plugin's own pipeline system sets the emitter each tick and 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.

Documentation

Overview

Package physics2d is a Box2D-backed 2D physics plugin for Cardinal (pure-Go Box2D port in pkg/box2d). ECS components live in component; simulation and reconciliation systems are plugin-internal (internal/system) and are registered for you by Plugin.Register. All derived physics state is owned by the Plugin instance (see Plugin.Reset); the package holds no runtime state.

Usage:

w, err := cardinal.NewWorld(cardinal.WorldOptions{...})
if err != nil {
	panic(err)
}
physics := physics2d.NewPlugin(physics2d.Config{})
w.RegisterPlugin(physics)
w.StartGame()

Keep the *Plugin value: queries (Raycast, OverlapAABB, CircleSweep) and Reset are methods on it.

Standard startup and snapshot recovery reconcile physics automatically. If a custom harness replaces ECS state in an already-running world with FromProto, call Plugin.Reset to discard derived solver and contact state. The next PhysicsPipelineSystem (PreUpdate) rebuilds from ECS without stepping the simulation.

Index

Constants

View Source
const (
	BodyTypeStatic    = component.BodyTypeStatic
	BodyTypeDynamic   = component.BodyTypeDynamic
	BodyTypeKinematic = component.BodyTypeKinematic
	BodyTypeManual    = component.BodyTypeManual
)

Body kinds (PhysicsBody2D).

View Source
const (
	ShapeTypeCircle          = component.ShapeTypeCircle
	ShapeTypeBox             = component.ShapeTypeBox
	ShapeTypeConvexPolygon   = component.ShapeTypeConvexPolygon
	ShapeTypeStaticChain     = component.ShapeTypeStaticChain
	ShapeTypeStaticChainLoop = component.ShapeTypeStaticChainLoop
	ShapeTypeEdge            = component.ShapeTypeEdge
	ShapeTypeCapsule         = component.ShapeTypeCapsule
)

Collider shape kinds (ColliderShape).

View Source
const MaxPolygonVertices = component.MaxPolygonVertices

MaxPolygonVertices is the most vertices a ShapeTypeConvexPolygon collider can hold, matching the bound Box2D compiles in. ColliderShape.Vertices has exactly this many slots.

Variables

This section is empty.

Functions

func RegisterComponents added in v0.17.0

func RegisterComponents(w *cardinal.World)

RegisterComponents registers physics component types without scheduling systems. Plugin.Register also calls it; harnesses that spawn physics entities before registering the plugin can call it first. Registering twice is a no-op.

Types

type AABBOverlapHit

type AABBOverlapHit = physicsquery.AABBOverlapHit

Query API (v1): raycast, AABB overlap, circle sweep.

type AABBOverlapRequest

type AABBOverlapRequest = physicsquery.AABBOverlapRequest

Query API (v1): raycast, AABB overlap, circle sweep.

type AABBOverlapResult

type AABBOverlapResult = physicsquery.AABBOverlapResult

Query API (v1): raycast, AABB overlap, circle sweep.

type ActiveContacts

type ActiveContacts = component.ActiveContacts

Re-export component types for callers that import the plugin root only.

type BodyType

type BodyType = component.BodyType

Re-export component types for callers that import the plugin root only.

type CircleSweepRequest

type CircleSweepRequest = physicsquery.CircleSweepRequest

Query API (v1): raycast, AABB overlap, circle sweep.

type CircleSweepResult

type CircleSweepResult = physicsquery.CircleSweepResult

Query API (v1): raycast, AABB overlap, circle sweep.

type ColliderShape

type ColliderShape = component.ColliderShape

Re-export component types for callers that import the plugin root only.

type Config

type Config struct {
	// Gravity is applied to the Box2D world (world gravity vector).
	Gravity Vec2
	// TickRate is simulation steps per second: each Cardinal tick steps the physics world by 1/TickRate.
	// Match cardinal.WorldOptions.TickRate so simulated time advances one tick of wall-clock intent per tick.
	// Zero or negative defaults to 60 (same as historical FixedDT 1/60).
	TickRate float64
	// SubStepCount is the number of sub-steps per physics step. Zero defaults to 4.
	SubStepCount int
	// Workers is the number of workers the physics step may use
	// (box2d.WorldDef.WorkerCount). 0 means 1 (serial); negative values are
	// treated as 0 and values above box2d.MaxWorkers (64) are clamped down,
	// so any value is safe. The engine does not clamp to the core count:
	// whatever you set here is the partition width, so counts beyond the
	// host's cores just oversubscribe the Go scheduler. Simulation results
	// are byte-identical for every value — the engine's worker pool is
	// deterministic by construction — so this is purely a throughput knob
	// and never affects rollback, replay, or cross-machine agreement.
	//
	// Recommend setting Workers only for large scenes (hundreds or more
	// active bodies): small scenes run inline below the engine's per-stage
	// grain thresholds regardless of this value, and worker counts beyond
	// ~8 have diminishing returns.
	Workers int
}

Config holds plugin options for simulation and stepping.

type ContactBeginEvent

type ContactBeginEvent = physicevent.ContactBeginEvent

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type ContactEndEvent

type ContactEndEvent = physicevent.ContactEndEvent

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type ContactEventEmitter

type ContactEventEmitter = physicevent.ContactEventEmitter

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type ContactEventPayload

type ContactEventPayload = physicevent.ContactEventPayload

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type ContactPairEntry

type ContactPairEntry = component.ContactPairEntry

Re-export component types for callers that import the plugin root only.

type Filter

type Filter = physicsquery.Filter

Query API (v1): raycast, AABB overlap, circle sweep.

type FixtureFilterBits

type FixtureFilterBits = physicevent.FixtureFilterBits

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type PhysicsBody2D

type PhysicsBody2D = component.PhysicsBody2D

Components entities require to participate in physics simulation.

type PhysicsSingletonTag

type PhysicsSingletonTag = component.PhysicsSingletonTag

Re-export component types for callers that import the plugin root only.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin implements cardinal.Plugin for the physics2d package. It owns the derived physics state (the runtime, including the pure-Go Box2D world) for the world it is registered with. Multiple Plugin instances in one process simulate fully independently.

func NewPlugin

func NewPlugin(config Config) *Plugin

NewPlugin builds a physics2d plugin instance.

func (*Plugin) BodyID added in v0.16.6

func (p *Plugin) BodyID(entityID cardinal.EntityID) (box2d.BodyID, bool)

BodyID returns the Box2D body id backing entityID, and whether the entity currently has one. It reports false before the first reconcile creates the body, after the entity's body is destroyed, and whenever no world exists (before init or after Reset).

The id is a read-only handle for use with Engine(): it is valid only until the next tick's reconcile, which may destroy and recreate the body. Look it up again each tick instead of caching it.

func (*Plugin) CircleSweep added in v0.16.6

func (p *Plugin) CircleSweep(req CircleSweepRequest) CircleSweepResult

CircleSweep sweeps a circle from req.Start to req.End and returns the earliest TOI hit along that segment.

func (*Plugin) Engine added in v0.16.6

func (p *Plugin) Engine() *box2d.World

Engine returns the underlying pure-Go Box2D world, or nil when no world exists (before init or after Reset).

It is a read-only escape hatch: use it for reads and queries the plugin does not expose directly (shape casts, custom query filters, sensor-only overlap, contact walks, body and shape inspection, debug draw). Pair it with BodyID / ShapeIDs to go from a Cardinal entity to the engine objects to inspect.

Mutating the engine is NOT supported. The reconciler owns body and shape lifecycle and derives it from the ECS components each tick, so:

  • changes to solver-owned state (body transform, velocity, shape geometry, filters, body flags) are overwritten on the next reconcile;
  • objects you create directly on the engine (bodies, shapes, joints) are untracked and are destroyed by any rebuild: Reset, snapshot restore, or a structural component change.

For changes that must persist, write the ECS components (Transform2D, Velocity2D, PhysicsBody2D); the reconciler pushes them into Box2D before each step.

Do not cache the returned pointer across ticks: after Reset the old world is destroyed.

func (*Plugin) OverlapAABB added in v0.16.6

func (p *Plugin) OverlapAABB(req AABBOverlapRequest) AABBOverlapResult

OverlapAABB returns distinct (entity, shape index) pairs whose shapes overlap the world-space AABB.

func (*Plugin) Raycast added in v0.16.6

func (p *Plugin) Raycast(req RaycastRequest) RaycastResult

Raycast casts a ray along the segment from req.Origin to req.End and returns the closest hit. Requires an initialized physics runtime with a live world (e.g. after FullRebuildFromECS). A zero-length segment returns Hit=false. When Filter is nil, all category/mask pairs match and sensors are skipped (same as Filter{CategoryBits: ^uint64(0), MaskBits: ^uint64(0), IncludeSensors: false}).

func (*Plugin) Register

func (p *Plugin) Register(w *cardinal.World)

Register implements cardinal.Plugin: creates this instance's runtime state and registers systems. Registering the same Plugin instance twice panics.

func (*Plugin) Reset added in v0.16.6

func (p *Plugin) Reset()

Reset drops all derived physics simulation state (no world, no bodies, empty maps). ECS components are unchanged. The next PhysicsPipelineSystem (PreUpdate) runs FullRebuildFromECS from current physics entities without stepping the simulation. It is a no-op on a plugin that has not been registered yet.

func (*Plugin) ShapeIDs added in v0.16.6

func (p *Plugin) ShapeIDs(entityID cardinal.EntityID) ([]box2d.ShapeID, bool)

ShapeIDs returns a copy of the Box2D shape ids backing entityID, indexed by collider slot (slot i is PhysicsBody2D.Shapes[i]), and whether the entity currently has any. Chain slots hold a null shape id because chains are tracked separately. The caller owns the returned slice; mutating it does not affect the plugin.

It reports false under the same conditions as BodyID, and the ids carry the same lifetime: valid only until the next tick's reconcile.

type RaycastRequest

type RaycastRequest = physicsquery.RaycastRequest

Query API (v1): raycast, AABB overlap, circle sweep.

type RaycastResult

type RaycastResult = physicsquery.RaycastResult

Query API (v1): raycast, AABB overlap, circle sweep.

type ShapeType

type ShapeType = component.ShapeType

Re-export component types for callers that import the plugin root only.

type Transform2D

type Transform2D = component.Transform2D

Components entities require to participate in physics simulation.

type TriggerBeginEvent

type TriggerBeginEvent = physicevent.TriggerBeginEvent

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type TriggerEndEvent

type TriggerEndEvent = physicevent.TriggerEndEvent

Contact / trigger system events. Plugin.Register registers them; read them in a system that runs after the pipeline with World.SystemEvents.

type Vec2

type Vec2 = component.Vec2

Re-export component types for callers that import the plugin root only.

type Velocity2D

type Velocity2D = component.Velocity2D

Components entities require to participate in physics simulation.

Directories

Path Synopsis
gen
Code generated by world sdk generate.
Code generated by world sdk generate.
Package query declares the request, result, and filter types for the physics2d query API (raycast, AABB overlap, circle sweep).
Package query declares the request, result, and filter types for the physics2d query API (raycast, AABB overlap, circle sweep).
test
e2e/cmd/physics2d-e2e command
Command game is the physics2d test game: a Cardinal world whose only job is to exercise the physics2d plugin and report anything that does not behave the way Box2D does.
Command game is the physics2d test game: a Cardinal world whose only job is to exercise the physics2d plugin and report anything that does not behave the way Box2D does.
e2e/internal/probe
Package probe holds the ECS components this test game adds on top of the physics2d plugin's own Transform2D / Velocity2D / PhysicsBody2D.
Package probe holds the ECS components this test game adds on top of the physics2d plugin's own Transform2D / Velocity2D / PhysicsBody2D.
e2e/internal/restore
Package restore drives the crash-restore check: snapshot a running world, rebuild it in a fresh one, and compare.
Package restore drives the crash-restore check: snapshot a running world, rebuild it in a fresh one, and compare.
e2e/internal/scenario
Package scenario holds the individual physics2d test scenarios.
Package scenario holds the individual physics2d test scenarios.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL