Skip to content

Bodies & Forces

Movement is split into simple linear and angular velocity integration and optional rigid-body force integration. An entity can move with Position and Velocity or rotate with Rotation and AngularVelocity, while dynamic bodies can additionally receive gravity, forces, torque, damping, and collision response.

Set RigidBody.type with a BodyType value:

TypeGravity, forces, and torqueCollision responseTypical use
BodyType.DynamicAppliedMoves according to inverse mass and inertiaPlayers, projectiles, movable props
BodyType.KinematicIgnoredTreated as infinite massScripted platforms and obstacles
BodyType.StaticIgnoredTreated as infinite massWalls and level geometry

Velocity integration is independent of body type. Any entity with Velocity and Position moves each fixed step, and any entity with AngularVelocity and Rotation rotates each fixed step, including kinematic and static bodies. Omit velocity components from static geometry.

FieldTypeDefaultDescription
typeBodyTypeBodyType.DynamicDetermines whether the body has finite inverse mass
massnumber1Body mass used by force integration
inverseMassnumber1Cached inverse mass; falls back to 1 / mass when not positive
inertianumber1Rotational inertia used by torque integration
inverseInertianumber1Cached inverse inertia; falls back to 1 / inertia when not positive
restitutionnumber0Bounciness used by collision impulses
frictionnumber0Reserved field; not currently applied by the resolver
linearDampingnumber0Per-second damping applied after force integration
angularDampingnumber0Per-second damping applied after torque integration

Dynamic bodies with mass <= 0 have zero effective inverse mass, and bodies with inertia <= 0 have zero effective inverse inertia. Keep mass and inverseMass, and inertia and inverseInertia, consistent when changing them:

import { Define } from '@shell/ecs';
import { BodyType, RigidBody } from '@shell/physics';
const BouncyBodyPrefab = Define.prefab().with(RigidBody, {
type: BodyType.Dynamic,
mass: 2,
inverseMass: 0.5,
inertia: 4,
inverseInertia: 0.25,
restitution: 0.25,
linearDamping: 1,
angularDamping: 0.5,
});

Velocity stores movement in world units per second:

const MovingPrefab = Define.prefab().with(Velocity, {
x: 120,
y: 0,
z: 0,
});

Each fixed step updates position using position += velocity * deltaSeconds.

AngularVelocity.z stores rotation around the 2D axis in radians per second:

const RotatingPrefab = Define.prefab().with(AngularVelocity, {
z: Math.PI,
});

Each fixed step updates Rotation.z using rotation.z += angularVelocity.z * deltaSeconds.

Force accumulates force for the current fixed step:

const ForceDrivenPrefab = Define.prefab().with(Force);

The physics pipeline clears Force.x and Force.y at the start of every fixed step. Add persistent effects again each step rather than setting them once.

Torque.z accumulates scalar torque around the 2D axis for the current fixed step:

const TorqueDrivenPrefab = Define.prefab().with(Torque);

The physics pipeline clears Torque.z at the start of every fixed step. Add persistent torque again each step rather than setting it once.

A force- and torque-driven body needs Position, Rotation, Velocity, AngularVelocity, RigidBody, Force, and Torque. Add a collider when it should also participate in collision detection:

import { Define } from '@shell/ecs';
import { CircleCollider, DynamicBody, RigidBody } from '@shell/physics';
import { Position } from '@shell/transform';
const DynamicBodyPrefab = Define.prefab()
.with(DynamicBody)
.with(Position, { x: 0, y: 0, z: 0 })
.with(RigidBody, { mass: 2, inverseMass: 0.5, inertia: 4, inverseInertia: 0.25 })
.with(CircleCollider, { radius: 12 });
const body = world.get('commands').spawn(DynamicBodyPrefab).root;

Gravity is applied only to entities that have both RigidBody and Force. Force integration additionally requires Velocity.

CorePlugin binds the physicsForces resource as a PhysicsForces instance. The aggregate PhysicsPlugin installs CorePlugin, so it exposes the same resource:

const physicsForces = world.get('physicsForces');
physicsForces.applyForce(body, { x: 600, y: 0 });
physicsForces.applyImpulse(body, { x: 0, y: -200 });
physicsForces.applyTorque(body, 30);
physicsForces.applyAngularImpulse(body, Math.PI / 2);

All four methods require RigidBody; only dynamic bodies are affected when calls are drained:

MethodEffect when drainedAdditional required component
applyForce(entity, { x, y })Adds to Force.x and Force.yForce
applyImpulse(entity, { x, y })Adds a one-time mass-scaled change to Velocity.x and Velocity.yVelocity
applyTorque(entity, torqueNumber)Adds to Torque.zTorque
applyAngularImpulse(entity, impulseNumber)Adds a one-time inertia-scaled change to AngularVelocity.zAngularVelocity

The helpers assert at call time that the target has the required components. Vector components and scalar values must be finite. Static and kinematic targets accept calls but are ignored during the drain. Calls are queued and drained once at the next fixed integration stage, after all gameplay force producers in PhysicsSet.Forces and before force integration. A call made after that drain waits for the following fixed integration stage. Stale or despawned queued targets are skipped.

Static level geometry does not need velocity, force, or torque components:

import { Define } from '@shell/ecs';
import { BoxCollider, StaticBody } from '@shell/physics';
import { Position } from '@shell/transform';
const WallPrefab = Define.prefab()
.with(StaticBody)
.with(Position, { x: 400, y: 200, z: 0 })
.with(BoxCollider, { width: 64, height: 32 });
const wall = world.get('commands').spawn(WallPrefab).root;

Put gameplay force systems in PhysicsSet.Forces. This set runs after force and torque clearing and before the PhysicsForces queue is drained and force integration begins. Calls made from these systems affect the same fixed step. Gravity also runs in this set, so use .after(GravitySystem) only when a system specifically depends on gravity having already been added.

import { Define, Schedule } from '@shell/ecs';
import { Force, PhysicsSet, RigidBody } from '@shell/physics';
export const ApplyThrustSystem = Define.system()
.inSet(PhysicsSet.Forces)
.queries({
bodies: Define.query({ data: [RigidBody, Force] }),
})
.update(({ queries, resources }) => {
const physicsForces = resources.get('physicsForces');
queries.bodies.forEach((entity) => {
physicsForces.applyForce(entity, { x: 600, y: 0 });
});
});

Register the system in Schedule.FixedUpdate, where force integration runs:

world.registerSystem(Schedule.FixedUpdate, ApplyThrustSystem);

For an instantaneous velocity change, queue an impulse instead of applying force:

world.get('physicsForces').applyImpulse(entity, { x: 0, y: -300 });

Direct Mut(Force) or Mut(Torque) writes remain available for low-level, batch-oriented systems, but physicsForces is the recommended gameplay API because it also provides mass-scaled impulses, inertia-scaled angular impulses, input validation, and stale-entity protection.

The configured gravity is available through the physicsGravity resource:

const gravity = world.get('physicsGravity');
gravity.x = 0;
gravity.y = 200;

Gravity is acceleration. The gravity system multiplies it by body mass before adding it to Force, so all dynamic bodies receive the same acceleration regardless of mass.

SystemScheduleSetDescription
ClearForcesSystemFixedUpdateBefore PhysicsSet.ForcesResets accumulated force and torque
GravitySystemFixedUpdatePhysicsSet.ForcesAdds gravity to dynamic bodies
Gameplay force producersFixedUpdatePhysicsSet.ForcesAdd per-step force and torque
PhysicsForces queue drainFixedUpdateAfter PhysicsSet.ForcesApplies each queued call once
ApplyForcesSystemFixedUpdatePhysicsSet.IntegrateForcesUpdates linear and angular velocity and applies damping
ApplyVelocitySystemFixedPostUpdatePhysicsSet.IntegrateVelocityUpdates position and Rotation.z from velocity