tecs.physics

Rigid-body simulation as part of the world.

Install physics, spawn an entity with a Transform2D, and attach a body:

tecs.physics.install(world, {gravity = {0.0, 980.0}})

local ground = world:spawn(tecs.ecs.Transform2D(320, 460))
tecs.physics.attach(world, ground, {kind = "static", halfWidth = 320, halfHeight = 20})

local crate = world:spawn(tecs.ecs.Transform2D(320, 100))
tecs.physics.attach(world, crate, {halfWidth = 16, halfHeight = 16, friction = 0.4})

tecs.physics.applyImpulse(world, crate, 400.0, 0.0)

The solver writes each body's Transform2D back, so nothing else has to move the entity. detach takes a body out of the solve without despawning its entity. On successful body creation, physics seeds PreviousTransform2D from the creation pose before publishing the solver's first result. The builtin snapshot system refreshes that history before subsequent fixed steps. Rendering interpolates position and the shortest rotation arc without changing simulation state.

Public positions, extents, linear velocities, impulses, forces and gravity use pixels. Angles and angular velocities use radians. The solver works in meters through the pixelsPerMeter conversion.

Body and Collider declare what the game requested. Motion preserves simulation state across pauses. Engine-owned RigidBody keeps the attachment visible to tools, and ordinary game code should ignore it. A secondary collider lives on its own entity and relates back through ColliderOf; a joint lives on its own entity and relates to its two bodies through JointBodyA and JointBodyB.

The fixed phases own the simulation, and one fixed step is one crossing into the solver. Every mutation a game requests is queued and applied at the start of the step that follows it, and every read reports the state after the last completed step. That is what keeps the crossing batched: a world of ten thousand bodies costs one call per step rather than one call per body.

A body declared during a frame becomes live during that frame's next fixed step, and its RigidBody row appears at the end of that step's FixedUpdate, which is where the solver hands back the slot it assigned.

Submodules

ModuleDescription
tecs.physics.contractThe batched crossing between Tecs entities and a native rigid-body solver.
tecs.physics.rapierThe Rapier implementation of the batched physics crossing.

Module contents

Types

TypeKindDescription
BodytypeThe value a Body column holds.
BodyOptionstypeThe initial body and collider settings attach reads.
CollidertypeThe value a Collider column holds.
ContactBeginrecord
ContactEndrecord
JointtypeThe value a Joint column holds.
JointOptionstypeThe joint settings attachJoint reads.
MotiontypeThe value a Motion column holds.
OptionstypeThe settings install reads before it creates a simulation.
QueryOptionstypeThe collision filters a raycast reads.
RaycastHitrecordReports where a ray first met a collider.
SensorBeginrecord
SensorEndrecord
SolverHandlestructIdentifies one live slot in a simulation.

Functions

FunctionKindDescription
angularVelocityfunctionReads angular velocity in radians per second.
applyForcefunctionApplies a continuous force at the body's center.
applyForceAtfunctionApplies a continuous force at a world-space point.
applyImpulsefunctionPushes a body once, at its center of mass.
applyImpulseAtfunctionPushes a body once at a world-space point, which spins it as well.
applyTorquefunctionApplies torque, cleared at the end of every step like the two forces.
attachfunctionDeclares a body on an entity.
attachColliderfunctionAdds another collider to a declared body.
attachJointfunctionDeclares a joint between two bodies.
detachfunctionRemoves a body's declaration.
hasBodyfunctionReports whether the solver is still simulating a body for an entity.
installfunctionInstalls a simulation and its fixed-step systems in a world.
isAwakefunctionReports whether the solver currently considers a body awake.
offunctionReturns the simulation installed in a world.
raycastfunctionCasts a segment and returns its nearest collider, in pixels.
setAngularVelocityfunctionSets angular velocity in radians per second and wakes the body.
setAwakefunctionWakes or sleeps a body at the next fixed step.
setVelocityfunctionSets a body's velocity, in pixels per second, and wakes it.
teleportfunctionTeleports a body and immediately updates its Transform2D.
velocityfunctionReads a body's velocity after the last completed fixed step, in pixels per second.

Values

ValueKindDescription
Bodyvariable
Collidervariable
ColliderOfvariable
ColliderShapevariable
Jointvariable
JointBodyAvariable
JointBodyBvariable
JointLinkvariable
Motionvariable
PhysicsDisabledvariable
PhysicsHeldvariable
pixelsPerMetervariableReports the fixed conversion between public pixels and solver meters.
RigidBodyvariable

Types#

Bodytype#

type Body = BodyValue

The value a Body column holds.

BodyOptionstype#

type BodyOptions = {
    kind: string?,
    halfWidth: number?,
    halfHeight: number?,
    radius: number?,
    capsuleLength: number?,
    segment: {number}?,
    offsetX: number?,
    offsetY: number?,
    density: number?,
    friction: number?,
    restitution: number?,
    categoryBits: integer?,
    maskBits: integer?,
    isSensor: boolean?,
    fixedRotation: boolean?,
    isBullet: boolean?,
    sleepEnabled: boolean?,
    gravityScale: number?,
    linearDamping: number?,
    angularDamping: number?
}

The initial body and collider settings attach reads. segment = {x1, y1, x2, y2} selects a line collider in local pixels, useful for static map boundaries. It takes precedence over box or radius settings. Segment coordinates may be negative; endpoints must differ.

Collidertype#

type Collider = ColliderValue

The value a Collider column holds.

ContactBeginrecord#

record ContactBegin
    entityA: integer
    entityB: integer
end
@derive(events.Event)@event(name="ContactBegin")

Fields

entityA#
entityA: integer

Read-only. Identifies one body of the pair.

entityB#
entityB: integer

Read-only. Identifies the other body of the pair.

ContactEndrecord#

record ContactEnd
    entityA: integer
    entityB: integer
end
@derive(events.Event)@event(name="ContactEnd")

Fields

entityA#
entityA: integer

Read-only. Identifies one body of the pair.

entityB#
entityB: integer

Read-only. Identifies the other body of the pair.

Jointtype#

type Joint = JointValue

The value a Joint column holds.

JointOptionstype#

type JointOptions = {
    kind: string?,
    anchorAX: number?,
    anchorAY: number?,
    anchorBX: number?,
    anchorBY: number?,
    axisX: number?,
    axisY: number?,
    limitMin: number?,
    limitMax: number?,
    motorTargetVelocity: number?,
    motorMaxForce: number?,
    contactsEnabled: boolean?
}

The joint settings attachJoint reads.

Motiontype#

type Motion = MotionValue

The value a Motion column holds.

Optionstype#

type Options = {
    gravity: {number}?,
    subStepCount: integer?,
    workerCount: integer?,
    simulation: contract.Simulation?
}

The settings install reads before it creates a simulation.

QueryOptionstype#

type QueryOptions = {
    categoryBits: integer?,
    maskBits: integer?
}

The collision filters a raycast reads.

RaycastHitrecord#

record RaycastHit
    entity: integer
    x: number
    y: number
    normalX: number
    normalY: number
    fraction: number
end

Reports where a ray first met a collider.

Fields

entity#
entity: integer

Read-only. Names the shape owner. A secondary collider reports its own entity rather than its body's.

x#
x: number

Read-only. Holds the contact point along x, in world pixels.

y#
y: number

Read-only. Holds the contact point along y, in world pixels, positive downward.

normalX#
normalX: number

Read-only. Holds the outward unit normal along x, unscaled by pixels.

normalY#
normalY: number

Read-only. Holds the outward unit normal along y.

fraction#
fraction: number

Read-only. Holds the position along the segment, zero at its start and one at its end.

SensorBeginrecord#

record SensorBegin
    sensor: integer
    visitor: integer
end
@derive(events.Event)@event(name="SensorBegin")

Fields

sensor#
sensor: integer

Read-only. Identifies the sensor.

visitor#
visitor: integer

Read-only. Identifies the body that entered it.

SensorEndrecord#

record SensorEnd
    sensor: integer
    visitor: integer
end
@derive(events.Event)@event(name="SensorEnd")

Fields

sensor#
sensor: integer

Read-only. Identifies the sensor.

visitor#
visitor: integer

Read-only. Identifies the body that left it.

SolverHandlestruct#

struct SolverHandle
    index1: integer
    world0: integer
    generation: uint32
end
@derive(nupp.derive.Debug, nupp.derive.Serde)

Identifies one live slot in a simulation.

Engine-owned and transient. RigidBody, ColliderShape and JointLink all use this shape so debug tools can read an attachment without knowing which arena it names. Ordinary game code should use Body, Motion and the functions on this module.

Fields

index1#
index1: integer

Engine-owned. Identifies the slot, one-based, and zero when the entity has no live counterpart. Ordinary game code should ignore it.

world0#
world0: integer

Engine-owned. Identifies the simulation, so a row left over from another world is rejected rather than resolved against whatever took its place. Ordinary game code should ignore it.

generation#
generation: uint32

Engine-owned. Rejects a stale slot. Ordinary game code should ignore it.

Functions#

angularVelocityfunction#

function angularVelocity(borrows world: ecs.World, entity: integer): number

Reads angular velocity in radians per second.

Radians need no conversion, so this is the number the solver holds, unlike the linear velocity beside it.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity

Returns

TypeDescription
number

angular velocity in radians per second, zero without a live body

applyForcefunction#

function applyForce(borrows world: ecs.World, entity: integer, x: number, y: number): nil

Applies a continuous force at the body's center.

The solver clears forces at the end of every step, so holding a body up against gravity means calling this every fixed step rather than once.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

xnumber

the pixel-scaled force along x

ynumber

the pixel-scaled force along y, positive downward

Returns

TypeDescription
nil

applyForceAtfunction#

function applyForceAt(borrows world: ecs.World, entity: integer, x: number, y: number, pointX: number, pointY: number): nil

Applies a continuous force at a world-space point.

Cleared at the end of every step, like applyForce.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

xnumber

the pixel-scaled force along x

ynumber

the pixel-scaled force along y, positive downward

pointXnumber

the world-pixel application point along x

pointYnumber

the world-pixel application point along y

Returns

TypeDescription
nil

applyImpulsefunction#

function applyImpulse(borrows world: ecs.World, entity: integer, x: number, y: number): nil

Pushes a body once, at its center of mass.

An impulse rather than a force, so the effect does not depend on how long the step happened to be. The solver applies it at the start of the next fixed step and wakes the body, because a resting island would otherwise ignore it.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

xnumber

the pixel-scaled impulse along x

ynumber

the pixel-scaled impulse along y, positive downward

Returns

TypeDescription
nil

applyImpulseAtfunction#

function applyImpulseAt(borrows world: ecs.World, entity: integer, x: number, y: number, pointX: number, pointY: number): nil

Pushes a body once at a world-space point, which spins it as well.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

xnumber

the pixel-scaled impulse along x

ynumber

the pixel-scaled impulse along y, positive downward

pointXnumber

the world-pixel impact position along x

pointYnumber

the world-pixel impact position along y

Returns

TypeDescription
nil

applyTorquefunction#

function applyTorque(borrows world: ecs.World, entity: integer, torque: number): nil

Applies torque, cleared at the end of every step like the two forces.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

torquenumber

the solver's native torque units, where a positive value raises angular velocity

Returns

TypeDescription
nil

attachfunction#

function attach(exclusive world: ecs.World, entity: integer, options: BodyOptions?): nil

Declares a body on an entity.

Physics creates the body during the next fixed update, at which point the entity gains its RigidBody row.

Arguments

NameTypeDescription
exclusive worldecs.World

a world with physics installed

entityinteger

the entity that gains Body and Collider, and gains a default Transform2D and Motion at the next fixed step when it does not already carry them

optionsBodyOptions?

the initial body and collider settings, or nil for a dynamic eight-pixel box

Returns

TypeDescription
nil

Raises

  • when the world has no simulation, the body kind is unknown, or a capsule has no positive radius

attachColliderfunction#

function attachCollider(exclusive world: ecs.World, entity: integer, body: integer, options: BodyOptions?): nil

Adds another collider to a declared body.

The collider is its own entity, which makes each shape independently inspectable and mutable. Physics creates the shape at the first fixed step after its body is live, and an owner that never gains a Body leaves the shape declared and unrealized.

Arguments

NameTypeDescription
exclusive worldecs.World

the world that owns both entities

entityinteger

the secondary collider entity, which gains Collider and ColliderOf

bodyinteger

an entity carrying Body

optionsBodyOptions?

the shape, material, filter and offset settings; body-level fields have no effect

Returns

TypeDescription
nil

Raises

  • when a capsule is requested without a positive radius

attachJointfunction#

function attachJoint(exclusive world: ecs.World, entity: integer, bodyA: integer, bodyB: integer, options: JointOptions?): nil

Declares a joint between two bodies.

Physics creates the joint at the first fixed step after both bodies are live, so declaring the joint in the same frame as the bodies it constrains costs no extra frame. A target that never gains a Body leaves the joint declared and unrealized, which is deliberate: a spawn staged this frame is not published yet, so reading the target here would reject correct code. Destroying either body destroys the joint entity with it.

Arguments

NameTypeDescription
exclusive worldecs.World

the world that owns all three entities

entityinteger

the joint entity, which gains Joint, JointBodyA and JointBodyB

bodyAinteger

the first constrained entity

bodyBinteger

the second constrained entity

optionsJointOptions?

the joint kind, anchors, limits and motor settings

Returns

TypeDescription
nil

Raises

  • when the joint kind is unknown

detachfunction#

function detach(exclusive world: ecs.World, entity: integer): nil

Removes a body's declaration.

Physics destroys the body during the next fixed update. The entity's other physics components remain.

Arguments

NameTypeDescription
exclusive worldecs.World

the world holding the entity

entityinteger

the entity that loses Body

Returns

TypeDescription
nil

hasBodyfunction#

function hasBody(borrows world: ecs.World, entity: integer): boolean

Reports whether the solver is still simulating a body for an entity.

False for an entity whose RigidBody row came out of a snapshot into a simulation that never issued it, which is how a game tells "was simulating and is not any more" from "never had a body".

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

any entity

Returns

TypeDescription
boolean

true only while the simulation holds a valid live body

installfunction#

function install(exclusive world: ecs.World, options: Options?): contract.Simulation

Installs a simulation and its fixed-step systems in a world.

Every system this registers runs in the fixed phases, so a simulation advances by whole steps of the world's own timestep and replays the same way on every machine.

Arguments

NameTypeDescription
exclusive worldecs.World

a world with no simulation yet

optionsOptions?

the gravity, substep count, solver width, and an explicit simulation to drive instead of the default native one

Returns

TypeDescription
contract.Simulation

the installed simulation, also available through of

Raises

  • when the world already has a simulation

isAwakefunction#

function isAwake(borrows world: ecs.World, entity: integer): boolean

Reports whether the solver currently considers a body awake.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

any entity

Returns

TypeDescription
boolean

true while the solver actively integrates the body, false when it sleeps or does not exist

offunction#

function of(borrows world: ecs.World): contract.Simulation?

Returns the simulation installed in a world.

Arguments

NameTypeDescription
borrows worldecs.World

the world to inspect

Returns

TypeDescription
contract.Simulation?

the world's live simulation, or nil before install and after shutdown

raycastfunction#

function raycast(borrows world: ecs.World, x1: number, y1: number, x2: number, y2: number, options: QueryOptions?): RaycastHit?

Casts a segment and returns its nearest collider, in pixels.

The cast tests one segment and ignores everything beyond its end, so a miss may mean the segment was too short.

Arguments

NameTypeDescription
borrows worldecs.World

a world; one without physics returns nil

x1number

the segment start in world pixels along x

y1number

the segment start in world pixels along y

x2number

the segment end in world pixels along x

y2number

the segment end in world pixels along y

optionsQueryOptions?

the collision filters, or nil to test every shape including sensors

Returns

TypeDescription
RaycastHit?

a fresh caller-owned nearest hit, or nil on a miss

setAngularVelocityfunction#

function setAngularVelocity(borrows world: ecs.World, entity: integer, omega: number): nil

Sets angular velocity in radians per second and wakes the body.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

omeganumber

angular velocity in radians per second

Returns

TypeDescription
nil

setAwakefunction#

function setAwake(borrows world: ecs.World, entity: integer, awake: boolean): nil

Wakes or sleeps a body at the next fixed step.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op

awakeboolean

true to wake the body, false to sleep it

Returns

TypeDescription
nil

setVelocityfunction#

function setVelocity(borrows world: ecs.World, entity: integer, vx: number, vy: number): nil

Sets a body's velocity, in pixels per second, and wakes it.

Does nothing for an entity with no live body, which matches the zero velocity returns for one.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity

vxnumber

horizontal velocity in pixels per second

vynumber

vertical velocity in pixels per second, positive downward

Returns

TypeDescription
nil

teleportfunction#

function teleport(exclusive world: ecs.World, entity: integer, x: number, y: number, angle: number?): nil

Teleports a body and immediately updates its Transform2D.

A move rather than a push: velocity is left exactly as it was, so a falling body carries on falling from wherever it lands. The solver receives the move at the next fixed step, while the transform changes now so the frame that requested the teleport already draws it there.

Arguments

NameTypeDescription
exclusive worldecs.World

the world holding the entity

entityinteger

the target entity; a missing live body makes this a no-op, including its Transform2D

xnumber

the body's origin in world pixels along x

ynumber

the body's origin in world pixels along y, positive downward

anglenumber?

radians, or nil to keep the current Transform2D.rotation

Returns

TypeDescription
nil

velocityfunction#

function velocity(borrows world: ecs.World, entity: integer): number, number

Reads a body's velocity after the last completed fixed step, in pixels per second.

Pixels match every other linear number this module accepts and returns. A caller that wants meters divides by pixelsPerMeter.

This reports what the solver last computed. Motion stores velocity only at a pause or a save, and does not mirror live motion.

Arguments

NameTypeDescription
borrows worldecs.World

the world holding the entity

entityinteger

the target entity

Returns

TypeDescription
number

horizontal velocity in pixels per second, zero without a live body

number

vertical velocity in pixels per second, positive downward

Values#

Bodyvariable#

const Body: ecs.ComponentDefinition<BodyValue>

Collidervariable#

const Collider: ecs.ComponentDefinition<ColliderValue>

ColliderOfvariable#

ColliderShapevariable#

Jointvariable#

const Joint: ecs.ComponentDefinition<JointValue>

JointBodyAvariable#

JointBodyBvariable#

Motionvariable#

const Motion: ecs.ComponentDefinition<MotionValue>

PhysicsDisabledvariable#

PhysicsHeldvariable#

pixelsPerMetervariable#

const pixelsPerMeter: number

Reports the fixed conversion between public pixels and solver meters.

The conversion keeps game coordinates in pixels and solver coordinates in meter-sized values.

RigidBodyvariable#