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
| Module | Description |
|---|---|
tecs.physics.contract | The batched crossing between Tecs entities and a native rigid-body solver. |
tecs.physics.rapier | The Rapier implementation of the batched physics crossing. |
Module contents
Types
| Type | Kind | Description |
|---|---|---|
Body | type | The value a Body column holds. |
BodyOptions | type | The initial body and collider settings attach reads. |
Collider | type | The value a Collider column holds. |
ContactBegin | record | |
ContactEnd | record | |
Joint | type | The value a Joint column holds. |
JointOptions | type | The joint settings attachJoint reads. |
Motion | type | The value a Motion column holds. |
Options | type | The settings install reads before it creates a simulation. |
QueryOptions | type | The collision filters a raycast reads. |
RaycastHit | record | Reports where a ray first met a collider. |
SensorBegin | record | |
SensorEnd | record | |
SolverHandle | struct | Identifies one live slot in a simulation. |
Functions
| Function | Kind | Description |
|---|---|---|
angularVelocity | function | Reads angular velocity in radians per second. |
applyForce | function | Applies a continuous force at the body's center. |
applyForceAt | function | Applies a continuous force at a world-space point. |
applyImpulse | function | Pushes a body once, at its center of mass. |
applyImpulseAt | function | Pushes a body once at a world-space point, which spins it as well. |
applyTorque | function | Applies torque, cleared at the end of every step like the two forces. |
attach | function | Declares a body on an entity. |
attachCollider | function | Adds another collider to a declared body. |
attachJoint | function | Declares a joint between two bodies. |
detach | function | Removes a body's declaration. |
hasBody | function | Reports whether the solver is still simulating a body for an entity. |
install | function | Installs a simulation and its fixed-step systems in a world. |
isAwake | function | Reports whether the solver currently considers a body awake. |
of | function | Returns the simulation installed in a world. |
raycast | function | Casts a segment and returns its nearest collider, in pixels. |
setAngularVelocity | function | Sets angular velocity in radians per second and wakes the body. |
setAwake | function | Wakes or sleeps a body at the next fixed step. |
setVelocity | function | Sets a body's velocity, in pixels per second, and wakes it. |
teleport | function | Teleports a body and immediately updates its Transform2D. |
velocity | function | Reads a body's velocity after the last completed fixed step, in pixels per second. |
Values
| Value | Kind | Description |
|---|---|---|
Body | variable | |
Collider | variable | |
ColliderOf | variable | |
ColliderShape | variable | |
Joint | variable | |
JointBodyA | variable | |
JointBodyB | variable | |
JointLink | variable | |
Motion | variable | |
PhysicsDisabled | variable | |
PhysicsHeld | variable | |
pixelsPerMeter | variable | Reports the fixed conversion between public pixels and solver meters. |
RigidBody | variable |
Types#
Bodytype#
type Body = BodyValueThe 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 = ColliderValueThe value a Collider column holds.
ContactBeginrecord#
Fields
ContactEndrecord#
Fields
Jointtype#
type Joint = JointValueThe 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 = MotionValueThe 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
endReports where a ray first met a collider.
Fields
entity#
entity: integerRead-only. Names the shape owner. A secondary collider reports its own entity rather than its body's.
fraction#
fraction: numberRead-only. Holds the position along the segment, zero at its start and one at its end.
SensorBeginrecord#
Fields
SensorEndrecord#
Fields
SolverHandlestruct#
struct SolverHandle
index1: integer
world0: integer
generation: uint32
endIdentifies 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: integerEngine-owned. Identifies the slot, one-based, and zero when the entity has no live counterpart. Ordinary game code should ignore it.
world0#
world0: integerEngine-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: uint32Engine-owned. Rejects a stale slot. Ordinary game code should ignore it.
Functions#
angularVelocityfunction#
function angularVelocity(borrows world: ecs.World, entity: integer): numberReads 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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity |
Returns
| Type | Description |
|---|---|
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): nilApplies 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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
x | number | the pixel-scaled force along x |
y | number | the pixel-scaled force along y, positive downward |
Returns
| Type | Description |
|---|---|
nil |
applyForceAtfunction#
function applyForceAt(borrows world: ecs.World, entity: integer, x: number, y: number, pointX: number, pointY: number): nilApplies a continuous force at a world-space point.
Cleared at the end of every step, like applyForce.
Arguments
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
x | number | the pixel-scaled force along x |
y | number | the pixel-scaled force along y, positive downward |
pointX | number | the world-pixel application point along x |
pointY | number | the world-pixel application point along y |
Returns
| Type | Description |
|---|---|
nil |
applyImpulsefunction#
function applyImpulse(borrows world: ecs.World, entity: integer, x: number, y: number): nilPushes 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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
x | number | the pixel-scaled impulse along x |
y | number | the pixel-scaled impulse along y, positive downward |
Returns
| Type | Description |
|---|---|
nil |
applyImpulseAtfunction#
function applyImpulseAt(borrows world: ecs.World, entity: integer, x: number, y: number, pointX: number, pointY: number): nilPushes a body once at a world-space point, which spins it as well.
Arguments
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
x | number | the pixel-scaled impulse along x |
y | number | the pixel-scaled impulse along y, positive downward |
pointX | number | the world-pixel impact position along x |
pointY | number | the world-pixel impact position along y |
Returns
| Type | Description |
|---|---|
nil |
applyTorquefunction#
function applyTorque(borrows world: ecs.World, entity: integer, torque: number): nilApplies torque, cleared at the end of every step like the two forces.
Arguments
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
torque | number | the solver's native torque units, where a positive value raises angular velocity |
Returns
| Type | Description |
|---|---|
nil |
attachfunction#
function attach(exclusive world: ecs.World, entity: integer, options: BodyOptions?): nilDeclares a body on an entity.
Physics creates the body during the next fixed update, at which point the entity gains its RigidBody row.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | a world with physics installed |
entity | integer | the entity that gains |
options | BodyOptions? | the initial body and collider settings, or nil for a dynamic eight-pixel box |
Returns
| Type | Description |
|---|---|
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?): nilAdds 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
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | the world that owns both entities |
entity | integer | the secondary collider entity, which gains |
body | integer | an entity carrying |
options | BodyOptions? | the shape, material, filter and offset settings; body-level fields have no effect |
Returns
| Type | Description |
|---|---|
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?): nilDeclares 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
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | the world that owns all three entities |
entity | integer | the joint entity, which gains |
bodyA | integer | the first constrained entity |
bodyB | integer | the second constrained entity |
options | JointOptions? | the joint kind, anchors, limits and motor settings |
Returns
| Type | Description |
|---|---|
nil |
Raises
when the joint kind is unknown
detachfunction#
function detach(exclusive world: ecs.World, entity: integer): nilRemoves a body's declaration.
Physics destroys the body during the next fixed update. The entity's other physics components remain.
Arguments
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | the world holding the entity |
entity | integer | the entity that loses |
Returns
| Type | Description |
|---|---|
nil |
hasBodyfunction#
function hasBody(borrows world: ecs.World, entity: integer): booleanReports 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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | any entity |
Returns
| Type | Description |
|---|---|
boolean | true only while the simulation holds a valid live body |
installfunction#
function install(exclusive world: ecs.World, options: Options?): contract.SimulationInstalls 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
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | a world with no simulation yet |
options | Options? | the gravity, substep count, solver width, and an explicit simulation to drive instead of the default native one |
Returns
| Type | Description |
|---|---|
contract.Simulation | the installed simulation, also available through |
Raises
when the world already has a simulation
isAwakefunction#
function isAwake(borrows world: ecs.World, entity: integer): booleanReports whether the solver currently considers a body awake.
Arguments
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | any entity |
Returns
| Type | Description |
|---|---|
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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world to inspect |
Returns
| Type | Description |
|---|---|
contract.Simulation? | the world's live simulation, or nil before |
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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | a world; one without physics returns nil |
x1 | number | the segment start in world pixels along x |
y1 | number | the segment start in world pixels along y |
x2 | number | the segment end in world pixels along x |
y2 | number | the segment end in world pixels along y |
options | QueryOptions? | the collision filters, or nil to test every shape including sensors |
Returns
| Type | Description |
|---|---|
RaycastHit? | a fresh caller-owned nearest hit, or nil on a miss |
setAngularVelocityfunction#
function setAngularVelocity(borrows world: ecs.World, entity: integer, omega: number): nilSets angular velocity in radians per second and wakes the body.
Arguments
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
omega | number | angular velocity in radians per second |
Returns
| Type | Description |
|---|---|
nil |
setAwakefunction#
function setAwake(borrows world: ecs.World, entity: integer, awake: boolean): nilWakes or sleeps a body at the next fixed step.
Arguments
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op |
awake | boolean | true to wake the body, false to sleep it |
Returns
| Type | Description |
|---|---|
nil |
setVelocityfunction#
function setVelocity(borrows world: ecs.World, entity: integer, vx: number, vy: number): nilSets 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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity |
vx | number | horizontal velocity in pixels per second |
vy | number | vertical velocity in pixels per second, positive downward |
Returns
| Type | Description |
|---|---|
nil |
teleportfunction#
function teleport(exclusive world: ecs.World, entity: integer, x: number, y: number, angle: number?): nilTeleports 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
| Name | Type | Description |
|---|---|---|
exclusive world | ecs.World | the world holding the entity |
entity | integer | the target entity; a missing live body makes this a no-op, including its |
x | number | the body's origin in world pixels along x |
y | number | the body's origin in world pixels along y, positive downward |
angle | number? | radians, or nil to keep the current |
Returns
| Type | Description |
|---|---|
nil |
velocityfunction#
function velocity(borrows world: ecs.World, entity: integer): number, numberReads 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
| Name | Type | Description |
|---|---|---|
borrows world | ecs.World | the world holding the entity |
entity | integer | the target entity |
Returns
| Type | Description |
|---|---|
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#
const ColliderOf: ecs.RelationshipColliderShapevariable#
const ColliderShape: ecs.ComponentDefinition<SolverHandle>Jointvariable#
const Joint: ecs.ComponentDefinition<JointValue>JointBodyAvariable#
const JointBodyA: ecs.RelationshipJointBodyBvariable#
const JointBodyB: ecs.RelationshipJointLinkvariable#
const JointLink: ecs.ComponentDefinition<SolverHandle>Motionvariable#
const Motion: ecs.ComponentDefinition<MotionValue>PhysicsDisabledvariable#
const PhysicsDisabled: ecs.ComponentPhysicsHeldvariable#
const PhysicsHeld: ecs.ComponentpixelsPerMetervariable#
const pixelsPerMeter: numberReports the fixed conversion between public pixels and solver meters.
The conversion keeps game coordinates in pixels and solver coordinates in meter-sized values.
RigidBodyvariable#
const RigidBody: ecs.ComponentDefinition<SolverHandle>