Skip to content

Physics: rigid bodies & forces

Galatrix runs a real rigid-body simulation (Rapier) in every runtime — the editor play-test, standalone builds, and the multiplayer server all step the same physics. This guide is the scripting side: how to make things fall, push, bounce, and — the part everyone gets wrong first — how to move a body correctly from a script.

New to scripts? Read Scripting basics first. Physics code lives in specific lifecycle hooks, and putting it in the wrong one is the single most common mistake (covered below).

What is this.entity.id?

this.entity is the GameObject the script is attached to (Unity's this.gameObject), and this.entity.id is its unique handle. The physics.* functions take that id string, not the object — so you always pass something.id. To act on a different object, get its id from this.game.findEntity('Name')?.id, a collision's other.id, or an Inspector reference field (this.target.id). findEntity returns null when nothing matches — guard it.

The one rule: physics owns dynamic bodies

Give an entity a RigidBody component and choose a type:

TypeWho moves itUse for
staticnobody — it never moves, but it still collidesfloors, walls, terrain, fixed props
dynamicthe simulation — gravity, forces, collisionscrates, balls, ragdolls, debris
kinematicyou (script or animation) — it shoves dynamic bodies but ignores forces itselfmoving platforms, doors, elevators

(A walking player usually uses a Character Controller instead — a different component with its own guide: Scripting your own player.)

Here's the rule that trips everyone up:

On a dynamic body, the simulation owns the transform

Writing entity.position = … or entity.rotation = … on a dynamic body is silently overwritten by the next physics step. To move it you talk to physics — forces, velocities, or a teleport (all below).

On static, kinematic, and script-moved entities the opposite holds: you write the transform directly and the collider follows. (The same split governs rotation — see Rotations → Physics owns dynamic bodies.)

Moving a body: forces vs impulses vs velocity

Three ways to move a dynamic body, from gentlest to bluntest:

Force — a continuous push. Apply it every physics step and it builds up like a rocket; stop applying and it stops. Measured in Newtons, scaled by the body's mass.

js
onFixedUpdate() {
  // steady lift while held — a jetpack
  if (this.input.getKey('space')) this.physics.applyForce(this.entity.id, [0, 200, 0])
}

Impulse — an instant kick: one call, one change in velocity. A jump, a hit, a launch.

js
this.physics.applyImpulse(this.entity.id, [0, 8, 0])   // pop straight up, once

Velocity — set the speed directly, ignoring mass and momentum. Precise but overrides the sim; reach for it when you want tight arcade control. Read-modify-write to change one axis and let gravity keep working:

js
const v = this.physics.getVelocity(this.entity.id)
this.physics.setVelocity(this.entity.id, [5, v[1], v[2]])  // drive X, keep the body's own fall

Each has a spin counterpart — applyTorque / applyTorqueImpulse / setAngularVelocity (radians/s) — and an at-a-point variant, applyForceAtPoint / applyImpulseAtPoint, that pushes at a world point instead of the centre of mass, so it also tilts and spins the body. (That off-centre push is the primitive a scripted vehicle's suspension is built on — a spring force at each wheel contact IS the suspension. See Vehicles.)

Forces belong in onFixedUpdate

onUpdate runs once per rendered frame — 60, 144, however many the machine manages — so a force applied there hits a different number of times per second on every device. onFixedUpdate runs exactly once per physics step (20 Hz), everywhere. Rule of thumb: if you're calling physics, you're in onFixedUpdate.

Unity style: the RigidBody handle

Everything above addresses a body by id — physics.setVelocity(this.entity.id, v). Unity instead hangs the same operations off the Rigidbody component, and Galatrix accepts that spelling too:

js
const rb = this.entity.getComponent('rigidBody')

rb.velocity = [0, 10, 0]        // same as physics.setVelocity(this.entity.id, [0, 10, 0])
const speed = rb.velocity.y     // reads the running body

The two are the same call — the handle just already knows which entity it belongs to. Use whichever reads better; physics.* is still the way to touch a body that isn't the one you're holding.

What the handle carries:

MemberNotes
velocity, linearVelocityget + set. Both names work — Unity 6 renamed velocity to linearVelocity
angularVelocityget + set, radians/sec (same unit as Unity)
worldCenterOfMassread-only
AddForce(f, mode?)see ForceMode below
AddForceAtPosition(f, pos, mode?)pushes off-centre, so it tilts and spins
AddTorque(t, mode?)spin
GetPointVelocity(worldPoint)how fast a point on the body is really moving
AddRelativeForce(f, mode?), AddRelativeTorque(t, mode?)the vector is in the body's local axes
GetRelativePointVelocity(localPoint)the local-space twin of GetPointVelocity
AddExplosionForce(force, pos, radius, upwards?, mode?)see below
friction, restitutionget + set, live on the collider
ccd, collisionDetectionModecontinuous collision — see Fast bodies
linearDamping, angularDampingdrag, live
gravityScale, useGravity1 normal, 0 weightless, negative floats up
massexact target mass, independent of collider size/density
freezePosition, freezeRotationUnity RigidbodyConstraints; a bare true freezes all three axes
isKinematic, bodyType, typeswap dynamic / kinematic / static mid-play
detectCollisionscolliders off, body still falling — no-clip / ghost / phase-through
position, rotation, MovePosition(p), MoveRotation(r), Move(p, r?)move the real body — see below
Sleep(), WakeUp(), IsSleeping()see Sleeping bodies

Every method also has a camelCase twin — addForce, getPointVelocity, wakeUp — so both house styles work.

AddExplosionForce acts on this body only (a common misreading is that it hits everything nearby — to blast a crowd, loop over physics.overlapSphere). Force falls off linearly to zero at the rim, bodies outside the radius get nothing, and upwardsModifier sinks the blast origin by that many units so debris lifts instead of skidding.

position and MovePosition zero the velocity

All the pose members go through warpBody, which is the right way to move a body — but unlike Unity's rb.position, it also clears velocity. That's usually what you want for a respawn or teleport. To move a body and keep its momentum, set velocity again afterwards.

ForceMode

AddForce and AddTorque take Unity's ForceMode as an optional second argument:

js
rb.AddForce([0, 500, 0])                          // ForceMode.Force — continuous, call every step
rb.AddForce([0, 8, 0], ForceMode.Impulse)         // instant kick, scaled by mass
rb.AddForce([0, 8, 0], ForceMode.VelocityChange)  // instant, IGNORES mass — +8 m/s whatever it weighs
rb.AddForce([0, 20, 0], ForceMode.Acceleration)   // continuous, ignores mass

VelocityChange and Acceleration are the mass-independent pair: a 1 kg crate and a 5 kg crate given the same VelocityChange end up at the same speed.

Torque ignores the mass-independent modes

AddTorque(t, ForceMode.VelocityChange) needs the body's inertia tensor, which no runtime exposes. On torque, VelocityChange behaves as Impulse and Acceleration as Force.

Tuning a body mid-play

Authored rigidBody fields are baked into the physics body when it is built, and nothing re-applies them afterwards. Writing one at runtime used to change the JSON and leave the simulation exactly as it was — setting gravityScale = 0 mid-fall didn't slow the fall by a single unit. Now they're wired to real runtime verbs, so they mean what they say:

js
const rb = this.entity.getComponent('rigidBody')

rb.linearDamping = 4         // drag, live
rb.angularDamping = 2
rb.gravityScale = 0          // weightless; negative floats it up
rb.useGravity = false        // the on/off face of the same thing
rb.mass = 50                 // exact, whatever the collider's size or density
rb.freezeRotation = true     // all three axes, Unity-style
rb.freezePosition = [true, false, true]   // or per-axis
rb.isKinematic = true        // solver stops moving it — drive it with MovePosition
rb.detectCollisions = false  // keeps falling, but phases through everything
rb.friction = 0              // ice
rb.restitution = 0.9         // bouncy
rb.ccd = true

detectCollisions vs isKinematic

They sound similar and do opposite halves. detectCollisions = false leaves the body falling but stops it touching anything (a ghost). isKinematic = true stops the body moving but it still shoves things (a platform). Turning a body's collisions off does not freeze it.

Each has a namespace twin (physics.setDamping, setGravityScale, setMass, setFreeze, setBodyType…) if you'd rather address a body by id.

isKinematic is how a ragdoll wakes up

Flipping a body to kinematic freezes it under script control; flipping it back to dynamic hands it to the solver with no leftover momentum. That's the switch behind ragdoll activation, a carried prop going inert, and freezing an enemy mid-charge.

Five fields still throw rather than pretend, because rapier has no runtime equivalent: startAsleep (use rb.Sleep() / rb.WakeUp()), collisionGroups (casts take a layerMask argument), frictionCombine, restitutionCombine, and autoCollider. The error names the field and the alternative. To change one, author it in the Inspector or replace the whole component:

js
this.addComponent('rigidBody', { type: 'dynamic', mass: 2, frictionCombine: 'min' })

Writing a read-only member (rb.worldCenterOfMass = …) throws too, rather than quietly storing a dead field.

freezePosition / freezeRotation read back what you wrote

Rapier can set the axis locks but not report them, so those two getters return the last value written through the handle (which is also mirrored into the component, so multiplayer clients see it). Every other live member reads straight off the body.

Cross-entity works too

The handle binds the id of the entity it came from, not the script's own: game.findEntity('Crate').getComponent('rigidBody').velocity = [0, 5, 0].

Teleporting: warpBody

To reposition a dynamic body — a respawn, a reset — don't fight the sim with entity.position. warpBody moves the actual body and zeroes its velocity:

js
this.physics.warpBody(this.entity.id, [0, 5, 0])              // drop it at a spawn point
this.physics.warpBody(this.entity.id, [0, 5, 0], [0, 90, 0])  // …and face 90° (3 values = Euler degrees)

Sleeping bodies

A body at rest eventually sleeps — it stops simulating (free performance) until a contact or force disturbs it. You can drive that directly, like Unity's Rigidbody.Sleep/WakeUp/IsSleeping:

js
this.physics.sleep(this.entity.id)          // stop simulating now
this.physics.wakeUp(this.entity.id)         // resume
if (this.physics.isSleeping(this.entity.id)) { /* it has settled */ }

To spawn a pile of props that stays frozen until the player touches it, check Start Asleep on the RigidBody component — the bodies sleep from the first frame and wake on the first contact or impulse.

Fast bodies: continuous collision (CCD)

The simulation advances in fixed 20 Hz steps. Between two steps a body just teleports from its old position to its new one — so a body moving faster than its own thickness per step can jump clean over a thin wall and never register the hit. Classic case: a bullet, a launched prop, a fast car.

The fix is continuous collision detection (CCD) — instead of testing only the end position, the engine sweeps the body's whole path that step and stops it at the first surface. Turn it on where you need it:

  • Authored: tick Continuous Collision (CCD) on the entity's RigidBody component (the rigidBody.ccd field). Best for anything born fast — projectiles, vehicles at speed.

  • At runtime, like Unity's Rigidbody.collisionDetectionMode:

    js
    this.physics.setCcd(bulletId, true)              // this body now can't tunnel
    if (this.physics.isCcdEnabled(bulletId)) { /* … */ }

    A common pattern is to enable it on a projectile the moment you spawn/launch it:

    js
    const b = this.game.spawnEntity('Bullet', 'sphere', muzzle)
    this.physics.setCcd(b.id, true)
    this.physics.setVelocity(b.id, [dir[0] * 80, dir[1] * 80, dir[2] * 80])

CCD costs a little CPU per body, so it's off by default — reserve it for the handful of bodies that actually move fast, not every crate. (The 20 Hz step itself is fixed on purpose: it keeps the editor, standalone, and multiplayer server in lock-step so physics is deterministic everywhere. CCD, not a smaller timestep, is the right remedy for tunnelling.)

For almost every map the default is enough. If you have several extremely fast bodies that must resolve against each other in a single step, raise CCD Substeps in Game Settings ▸ Physics (project-wide, 18, default 1). Each substep re-runs the swept CCD pass, so higher is more accurate but more CPU — leave it at 1 unless you actually see fast bodies passing through each other (a single body vs. the world is already handled at 1).

Surface material: friction & bounce

Every collider carries two surface properties, exactly like a Unity Physic Material:

  • Friction — grip. 0 is ice; higher is stickier. Default 0.5.
  • Restitution — bounciness, 01. 0 is a dead thud; 1 is a superball. Default 0.

For a fixed value, set friction / restitution in the RigidBody component's Surface group in the Inspector — they bake in when the map loads, and they matter on static bodies too (an ice floor is static).

To change it live from a script — flip a floor to ice mid-game, make a power-up ball extra bouncy:

js
this.physics.setFriction(this.entity.id, 0)       // this surface is now ice
this.physics.setFriction(this.entity.id, 3)       // …now grippy
this.physics.setRestitution(this.entity.id, 0.9)  // …and bouncy
const f = this.physics.getFriction(this.entity.id)

One friction coefficient

The engine uses a single friction value per collider — there's no separate static vs dynamic friction like Unity's Physic Material. Friction and bounce are the two coefficients; the combine rule below decides how they mix when two surfaces touch.

When two bodies touch, their two frictions (and restitutions) combine — by default they average, so friction 0 against a 0.5 crate still leaves ~0.25 grip. That's what the combine rule is for (Unity's frictionCombine/bounceCombine, the Friction Combine / Bounce Combine dropdowns in the Surface group): average (default), min, multiply, or max — when the two surfaces disagree, the higher-priority rule wins (max > multiply > min > average).

  • True ice: friction 0 + combine min on the floor → slippery against anything that steps on it.
  • Trampoline: restitution 1 + combine max → bouncy no matter how dead the thing that lands is.

Gravity

The world pulls down at -50 on Y by default — deliberately strong, for snappy Roblox-style jumps rather than floaty ones. Don't hard-code that number; a map can change it, so read it:

js
const g = this.physics.getGravity()      // e.g. [0, -50, 0] — reflects THIS map's setting
this.physics.setGravity([0, -20, 0])     // low-gravity moon level

Per body, the RigidBody's gravityScale multiplies world gravity: 1 is normal, 0 floats (weightless), 2 is heavy, a negative value falls up. Air resistance is linearDamping (a feather drifts down with high damping); spin resistance is angularDamping.

Colliders & collision events

The Collider component gives an entity its physical shape — box, sphere, capsule, convex, or trimesh. It has two modes:

  • Solid (default) — bodies bounce off it, and contacts fire onCollisionEnter / onCollisionStay / onCollisionExit.
  • Trigger (isTrigger, Unity's sensor) — no physical response; overlaps fire onTriggerEnter / onTriggerStay / onTriggerExit instead. This is how you build pickups, checkpoints, and damage zones.
js
class Target extends Behaviour {
  onCollisionEnter(other) {                 // something physically hit me
    if (other.compareTag('Bullet')) this.hp -= 10
  }
  onTriggerEnter(other) {                   // something entered my sensor volume
    if (other.compareTag('Player')) this.game.findEntity('Door').setActive(false)
  }
}

other is an EntityRefcompareTag is the Unity-style tag check (the player is tagged 'Player'). For the common "a player stepped in this zone" case there's also onPlayerEnter(player) / onPlayerLeave(player). For a worked example — a trigger pad that rewards the player who steps on it — see Scripting basics.

Sensing the world: raycasts & overlaps

Ask the physics world what's out there — line-of-sight, ground checks, shooting, proximity:

js
// clear line of sight from me to the player?
const from = this.entity.position
const target = this.game.findEntity('Player').position
const dir = [target[0] - from[0], target[1] - from[1], target[2] - from[2]]
const hit = this.physics.raycast(from, dir, Math.hypot(dir[0], dir[1], dir[2]))
if (hit && hit.entityName === 'Player') this.shoot()   // nothing in between → fire

raycast returns the nearest hit — a RaycastHit of { entityId, entityName, point, normal, distance } — or null. The family:

QueryReturns
raycast(origin, dir, maxDist?, layerMask?, ignoreEntities?)the nearest hit, or null
raycastAll(origin, dir, maxDist?, layerMask?, ignoreEntities?)every hit along the ray, nearest-first
sphereCast(origin, radius, dir, maxDist?, layerMask?, ignoreEntities?)the first entity a swept sphere would touch — the thick ray
overlapSphere(center, radius, layerMask?)entities within a radius — an explosion's blast list
overlapBox(center, halfExtents, orientation?, layerMask?)entities inside a box

The two trailing options on the ray/sweep queries — layerMask (which collision layers to see) and ignoreEntities (specific entity ids to pass through) — are both optional; skip them, or pass -1 for layerMask to mean "all layers", when you only want the one after it.

sphereCast is Unity's Physics.SphereCast and the reliable ground check: a thin ray slips through a crack or off the edge of a ledge your character is half-standing on; a swept sphere the size of the body doesn't. Its distance is how far the sphere's center travelled before touching; point/normal are on the surface it hit.

Notes that save debugging time:

  • A ray starting at an entity's own position can hit its own collider first. The clean fix is the ignoreEntities list — this.physics.raycast(from, dir, 20, -1, [this.entity.id]) passes straight through you (see below). You can also offset the origin past yourself — e.g. start a ground-check just below the feet: [x, y - 0.6, z] casting [0, -1, 0].

  • sphereCast reports distance: 0 when the sphere already overlaps a collider at origin (it's a shape sweep — a shape starting inside geometry contacts at once), and the returned hit sets overlapping: true so you can detect exactly that case instead of guessing why the distance is 0:

    ts
    const hit = this.physics.sphereCast(origin, 0.4, [0, -1, 0], 5)
    if (hit && hit.overlapping) { /* already touching it — distance 0 is contact, not a gap */ }
    else if (hit) { /* hit.distance is the real gap to the surface */ }
    else { /* nothing within maxDistance */ }

    So a sphereCast that begins touching the thing you're measuring is contact-at-start (overlapping), and its distance is a real gap only when the cast starts clear of everything (overlapping is falsy). For a true gap-to-surface from inside contact, start the sphere back from the surface (offset origin along -direction) or use a thin raycast, whose distance is the straight origin → point length. (overlapping is a sphereCast-only field — rapier returns distance 0 on contact-at-start no matter what, so this flag is the honest way to tell it apart from a genuine zero gap.)

  • The optional layerMask limits which collision layers a query sees: 1 << 3 hits only layer 3; omit (or -1) for all layers.

  • ignoreEntities is the LAST argument, after layerMasksphereCast(origin, radius, dir, maxDist, layerMask, ignoreEntities). To skip only yourself, pass undefined for the mask: sphereCast(o, r, dir, 5, undefined, [this.entity.id]). Putting the id list in the layerMask slot is the classic slip; the engine now detects an array there, treats it as ignoreEntities, and warns in the console — but write it in the right slot and the warning goes away.

  • The optional ignoreEntities array (trailing arg on raycast, raycastAll, sphereCast) lists entity ids the cast passes straight through, as if they weren't there — so the hit you get back is the first thing that isn't on the list:

    js
    // A hitscan shot from the shooter that never hits the shooter or the gun it's holding:
    const hit = this.physics.raycast(muzzle, aimDir, 100, -1, [this.entity.id, this.gunId])
    if (hit) this.game.findEntity(hit.entityId)?.send('damage', 25)

    Where layerMask filters broad categories (all Enemies, all Terrain…), ignoreEntities skips a few specific objects — most often yourself ([this.entity.id]) or the thing you're carrying/riding. They combine: a collider is skipped if ignoreEntities names it or layerMask excludes its layer. Unity has no such parameter (it uses layers + filtering the results in code); this is a convenience on top of layerMask. Pass entity ids (e.g. this.entity.id, or an entityId from another hit) — not names.

  • Aim with a direction the entity already gives you — this.entity.forward is where it points (see Rotations).

  • To see a ray while tuning it, Debug.DrawRay(origin, dir) draws it in the editor viewport and in published builds.

Recipes

Explosion — everything nearby gets kicked away from the blast, harder the closer it is:

js
explode(center, radius, power) {
  for (const e of this.physics.overlapSphere(center, radius)) {
    const p = e.position
    const d = [p[0] - center[0], p[1] - center[1] + 1, p[2] - center[2]]  // +1 biases the kick upward
    const len = Math.hypot(d[0], d[1], d[2]) || 1
    const falloff = Math.max(0, 1 - len / radius) * power
    this.physics.applyImpulse(e.id, [d[0] / len * falloff, d[1] / len * falloff, d[2] / len * falloff])
  }
}

Ice-patch trap — a trigger zone that makes the floor slick while a player is on it, then restores grip:

js
class IcePatch extends Behaviour {
  floor = null            // an entity-reference field, set in the Inspector
  onPlayerEnter() { this.physics.setFriction(this.floor.id, 0) }
  onPlayerLeave() { this.physics.setFriction(this.floor.id, 0.5) }
}

Grounded double-jump — sweep a sphere down to check for floor before allowing a jump (a thick check never misses the edge of a platform the way a thin ray does):

js
class Jumper extends Behaviour {
  jumps = 0
  onFixedUpdate() {
    const p = this.entity.position
    const grounded = !!this.physics.sphereCast([p[0], p[1], p[2]], 0.4, [0, -1, 0], 0.75)
    if (grounded) this.jumps = 0
    if (this.input.getKeyDown('space') && this.jumps < 2) {
      this.physics.applyImpulse(this.entity.id, [0, 9, 0])
      this.jumps++
    }
  }
}

Editor & multiplayer

Everything here behaves identically in the editor play-test and a standalone build. In multiplayer the simulation is server-authoritative: forces, velocities, friction, and gravity are applied on the server and the result streams to every client. So physics.* calls only affect bodies the server actually simulates — a purely cosmetic client-side entity, or a character-controller-driven player, won't respond to them. (Character-controller players are the deliberate, client-authoritative exception — see Scripting your own player.)

See also

  • Scripting basics — the onUpdate / onFixedUpdate lifecycle this code runs in.
  • Rotations — orienting and spinning bodies through physics.
  • Vehicles & driving — a full controller built on applyForceAtPoint.
  • physics in the Script API reference — every method, with signatures.