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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
buf.build/go/protovalidate v1.0.0
connectrpc.com/connect v1.19.1
connectrpc.com/validate v0.6.0
github.com/ByteArena/box2d v1.0.2
github.com/aws/aws-sdk-go-v2 v1.41.3
github.com/aws/aws-sdk-go-v2/config v1.32.11
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4=
connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs=
github.com/ByteArena/box2d v1.0.2 h1:f7f9KEQWhCs1n516DMLzi5w6u0MeeE78Mes4fWMcj9k=
github.com/ByteArena/box2d v1.0.2/go.mod h1:LzEuxY9iCz+tskfWCY3o0ywYBRafDDugdSj+/YGI6sE=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
Expand Down
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 uint16 `json:"fa_cat,omitempty"`
FilterAMaskBits uint16 `json:"fa_mask,omitempty"`
FilterAGroupIndex int16 `json:"fa_grp,omitempty"`
FilterBCategoryBits uint16 `json:"fb_cat,omitempty"`
FilterBMaskBits uint16 `json:"fb_mask,omitempty"`
FilterBGroupIndex int16 `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" }
127 changes: 127 additions & 0 deletions pkg/plugin/physics2d/component/collider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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/terrain-style
// geometry only; not for moving dynamic shapes).
ShapeTypeStaticChain
)

// ColliderShape is one child shape inside a compound Collider2D.
//
// 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)
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"`

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

// Collider2D is the authoritative collider description for an entity.
//
// Cardinal allows one instance per component type per entity, so compound colliders are
// modeled as multiple ColliderShape entries in Shapes.
//
// Shape identity (v1): index i in Shapes identifies fixture slot i. Reordering, inserting,
// or removing entries is a structural change and requires fixture/body recreation during
// reconciliation.
type Collider2D struct {
Shapes []ColliderShape `json:"shapes"`
}

// Name returns the ECS component name.
func (Collider2D) Name() string { return "collider_2d" }

// Validate guards against NaN/Inf in float fields. Geometry correctness (convexity, vertex
// count, winding) is enforced by Box2D at fixture creation time — duplicating those checks
// here adds maintenance cost without value.
func (c Collider2D) Validate() error {
if len(c.Shapes) == 0 {
return errors.New("collider_2d.shapes: at least one ColliderShape is required")
}
for i := range c.Shapes {
if err := c.Shapes[i].Validate(); err != nil {
return fmt.Errorf("collider_2d.shapes[%d]: %w", i, err)
}
}
return nil
}

// 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
}
}

switch s.ShapeType {
case ShapeTypeCircle, ShapeTypeBox, ShapeTypeConvexPolygon, ShapeTypeStaticChain:
default:
return fmt.Errorf("shape_type: unknown value %d", s.ShapeType)
}
return nil
}
169 changes: 169 additions & 0 deletions pkg/plugin/physics2d/component/rigidbody.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//nolint:recvcheck // UnmarshalJSON must be pointer receiver to support json.Unmarshal
package component

import (
"fmt"

"github.com/goccy/go-json"
)

// BodyType selects how the rigid body participates in the simulation.
type BodyType uint8

const (
// BodyTypeStatic is immovable world geometry; zero velocity; does not respond to forces.
BodyTypeStatic BodyType = iota + 1
// BodyTypeDynamic is fully simulated: forces, collisions, and integration apply.
BodyTypeDynamic
// BodyTypeKinematic is moved by setting velocity from gameplay; Box2D integrates velocity
// into position each step. Does not respond to forces but can push dynamic bodies on
// contact. Post-step writeback keeps ECS in sync with Box2D's integrated position.
BodyTypeKinematic
// BodyTypeManual is for gameplay-driven entities that use Box2D only for contact detection.
// Under the hood it creates a kinematic body, but post-step writeback is skipped: ECS owns
// position and velocity, and the reconciler pushes ECS values into Box2D each tick.
// Use this for characters, enemies, and other entities where gameplay code (input handling,
// AI, pathfinding) computes position directly.
//
// Box2D collision rules apply: manual bodies generate contacts with dynamic bodies only,
// not with static or other kinematic/manual bodies.
BodyTypeManual
)

// Rigidbody2D holds simulation parameters for a rigid body.
//
// BodyType selects static vs dynamic vs kinematic behavior. LinearDamping and AngularDamping
// are simulation damping coefficients. GravityScale multiplies the world's gravity vector
// for this body; world gravity itself is runtime configuration, not a component field.
//
// # Body flags
//
// Active controls whether the body participates in the simulation at all. An inactive body
// has no contacts, no collisions, and is effectively removed from Box2D without destroying it.
// Set Active=false to temporarily disable an entity's physics (e.g. a dormant trap).
//
// Awake controls whether the body is currently awake in the simulation. Setting Awake=true
// wakes a sleeping body; Box2D may put it back to sleep on subsequent ticks if nothing
// disturbs it and SleepingAllowed is true. To keep a body permanently awake (e.g. a
// stationary kinematic sensor that must always generate contacts), set SleepingAllowed=false
// instead.
//
// SleepingAllowed controls whether Box2D is permitted to put the body to sleep when it comes
// to rest. When false, the body stays awake indefinitely. Use this for kinematic/manual
// bodies that are stationary but must still generate contacts (the common "sensor" pattern).
//
// Bullet enables continuous collision detection (CCD) for fast-moving dynamic bodies to
// prevent tunneling through thin geometry. Has a performance cost; only enable for
// projectiles or similarly fast objects.
//
// FixedRotation prevents the body from rotating in response to torques or collisions.
// Useful for top-down characters that should not spin.
//
// # Defaults
//
// Box2D defaults Active, Awake, and SleepingAllowed to true and GravityScale to 1. Use
// [NewRigidbody2D] to create a Rigidbody2D with these defaults set correctly. Bare struct
// literals leave bool fields at false and GravityScale at 0, which produces an inactive,
// sleeping body with no gravity — almost never what you want.
//
// When deserializing from JSON (e.g. snapshot recovery), missing fields are defaulted to
// their Box2D values automatically via a custom UnmarshalJSON. Explicitly serialized false
// values are preserved exactly.
//
// Bullet and FixedRotation default to false (off), matching Box2D defaults.
//
// # Post-step writeback
//
// Writeback applies to dynamic and kinematic bodies. Static and manual bodies are
// not written back.
type Rigidbody2D struct {
BodyType BodyType `json:"body_type"`
LinearDamping float64 `json:"linear_damping"`
AngularDamping float64 `json:"angular_damping"`
GravityScale float64 `json:"gravity_scale"`
Active bool `json:"active"`
Awake bool `json:"awake"`
SleepingAllowed bool `json:"sleeping_allowed"`
Bullet bool `json:"bullet"`
FixedRotation bool `json:"fixed_rotation"`
}

// NewRigidbody2D returns a Rigidbody2D with the given body type and Box2D-compatible defaults:
// Active=true, Awake=true, SleepingAllowed=true, GravityScale=1.
func NewRigidbody2D(bodyType BodyType) Rigidbody2D {
return Rigidbody2D{
BodyType: bodyType,
GravityScale: 1,
Active: true,
Awake: true,
SleepingAllowed: true,
}
}

// UnmarshalJSON decodes a Rigidbody2D from JSON, applying Box2D-compatible defaults for
// fields missing from the payload. This handles old snapshots that predate the body flags
// (Active, Awake, SleepingAllowed default to true; GravityScale defaults to 1) while
// preserving explicitly serialized values including false.
func (r *Rigidbody2D) UnmarshalJSON(data []byte) error {
type raw struct {
BodyType BodyType `json:"body_type"`
LinearDamping float64 `json:"linear_damping"`
AngularDamping float64 `json:"angular_damping"`
GravityScale *float64 `json:"gravity_scale"`
Active *bool `json:"active"`
Awake *bool `json:"awake"`
SleepingAllowed *bool `json:"sleeping_allowed"`
Bullet bool `json:"bullet"`
FixedRotation bool `json:"fixed_rotation"`
}
var aux raw
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*r = Rigidbody2D{
BodyType: aux.BodyType,
LinearDamping: aux.LinearDamping,
AngularDamping: aux.AngularDamping,
GravityScale: 1,
Active: true,
Awake: true,
SleepingAllowed: true,
Bullet: aux.Bullet,
FixedRotation: aux.FixedRotation,
}
if aux.GravityScale != nil {
r.GravityScale = *aux.GravityScale
}
if aux.Active != nil {
r.Active = *aux.Active
}
if aux.Awake != nil {
r.Awake = *aux.Awake
}
if aux.SleepingAllowed != nil {
r.SleepingAllowed = *aux.SleepingAllowed
}
return nil
}

// Name returns the ECS component name.
func (Rigidbody2D) Name() string { return "rigidbody_2d" }

// Validate guards against NaN/Inf in float fields and an invalid body type tag.
func (r Rigidbody2D) Validate() error {
switch r.BodyType {
case BodyTypeStatic, BodyTypeDynamic, BodyTypeKinematic, BodyTypeManual:
default:
return fmt.Errorf("rigidbody_2d.body_type: invalid value %d", r.BodyType)
}
if !isFinite(r.LinearDamping) {
return fmt.Errorf("rigidbody_2d.linear_damping: must be finite, got %v", r.LinearDamping)
}
if !isFinite(r.AngularDamping) {
return fmt.Errorf("rigidbody_2d.angular_damping: must be finite, got %v", r.AngularDamping)
}
if !isFinite(r.GravityScale) {
return fmt.Errorf("rigidbody_2d.gravity_scale: must be finite, got %v", r.GravityScale)
}
return nil
}
Loading
Loading