Skip to content

Rotations: Euler & quaternions

Galatrix speaks two rotation languages, and every rotation you'll ever touch is one of them:

  • Euler angles — three numbers in degrees, [x, y, z]: "tilt 30° on X, turn 45° on Y". This is the human language: the Inspector shows it, the terminal prints it, the animation timeline is authored in it, and [0, 90, 0] obviously means "quarter-turn about Y".
  • Quaternion — four numbers [x, y, z, w]. This is the math the engine actually runs on: it composes cleanly, blends smoothly (slerp), and never gimbal-locks. It is what entity.rotation reads back, what the map file stores, and what physics uses. The trade-off: the numbers mean nothing to a human — you never type one by hand, you build it with a helper.

You don't have to pick one. The API takes both and tells them apart by shape.

Writing a rotation: the 3-vs-4 rule

Every rotation write in the script API — entity.rotation, entity.eulerAngles, this.transform.rotation, Instantiate(...)'s rotation, physics.warpBody(...)'s rotation, camera.setRotation(...) — follows one rule:

You writeIt means
3 values[x, y, z], a Vector3, or {x, y, z}Euler angles in degrees
4 values[x, y, z, w], a Quaternion, or {x, y, z, w}a quaternion (re-normalized for you if its length drifted)
js
this.entity.rotation = [0, 45, 0]                 // 3 values → Euler degrees: 45° about Y
this.entity.rotation = { x: 30, y: 0, z: 0 }      // same idea, object form
this.entity.rotation = Quaternion.Euler(0, 45, 0) // 4 values → the same rotation, as a quaternion
this.entity.eulerAngles = [0, 90, 0]              // explicit Euler property (eulerAngles)

Three numbers are never a valid quaternion, so there's no ambiguity — the natural write does the natural thing. Euler angles apply in the standard order (Z, then X, then Y), so values copied from another engine rotate the same way here.

That order is the same everywhere: scripts, the Inspector's three Rotation fields, the rotationDeg field on the spawn / modify ops, and the rotation keys on the animation timeline all compose degrees identically, so the same three numbers always mean the same orientation — including in multiplayer, where the server samples your clips with the same order the editor plays them. (Single-axis turns are order-independent anyway; the order only shows up once you combine two or more axes.)

Setting a rotation from an op

The editor-side ops take either form, matching what scripts accept:

FieldMeaning
"rotationDeg": [x, y, z]Euler degrees, in the order above. Prefer it for readable single-axis turns.
"rotation": [x, y, z, w]A quaternion — exact and order-free. Use it for a compound aim, or to copy an orientation from another object.
json
[ { "op": "modify", "name": "Fan", "rotation": [0.3919, 0.2006, 0.3604, 0.8224] } ]

Both are accepted by spawn and modify; if you send both, the quaternion wins.

Degrees, not radians

Everything Euler in Galatrix is degrees — script writes, the Inspector, the terminal, animation keys, rotationDeg in AI/terminal ops. If something turns ~57× too far, you passed radians.

Reading a rotation

Reads give you the quaternion — with an escape hatch back to degrees:

js
const q = this.entity.rotation        // Quaternion [x, y, z, w] (indexable like an array)
const e = this.entity.eulerAngles     // Vector3 of degrees, e.g. [0, 45, 0]

Two things to know about reading Euler angles back:

  • The degrees you read are equivalent, not always identical, to what was written — one rotation has many Euler spellings ([180, 0, 180] is the same turn as [0, 180, 0]). Compare rotations with Quaternion.Angle(a, b), not by comparing Euler triples.
  • Often you don't want angles at all — you want a direction. entity.forward / right / up are unit vectors already rotated into world space, ready for raycasts, aim checks, or movement:
js
const aim = this.entity.forward                     // where this entity points (-Z rotated)
const hit = this.physics.raycast(this.entity.position, aim, 50)

this.transform.eulerAngles is the world rotation (it walks the parent chain); entity.rotation / entity.eulerAngles are the entity's own transform (parent-local on a child).

Turning things — the recipes

Spin foreverrotate() applies an Euler-degrees delta (world-relative), so scale it by deltaTime:

js
onUpdate() {
  this.entity.rotate([0, 90 * this.time.deltaTime, 0])   // 90°/s about Y
}

Snap to face a pointlookAt aims the entity's forward (-Z) at a world position:

js
this.entity.lookAt(this.game.findEntity('Player').position)

(For "always face X" with no script at all, use the Look At component.)

Turn smoothly toward a target — build the goal rotation, then close the angle at a fixed degrees-per-second with RotateTowards (or ease with Slerp):

js
onUpdate() {
  const to = this.game.findEntity('Player').position
  const from = this.entity.position
  const goal = Quaternion.LookRotation([to[0] - from[0], 0, to[2] - from[2]]) // yaw only: flatten Y
  this.entity.rotation = Quaternion.RotateTowards(this.entity.rotation, goal, 180 * this.time.deltaTime)
}

Face where you're movingLookRotation on the velocity:

js
const v = this.physics.getVelocity(this.entity.id)
if (Math.hypot(v[0], v[2]) > 0.1) this.entity.rotation = Quaternion.LookRotation([v[0], 0, v[2]])

Compose rotations — quaternion multiply applies right-to-left (a.mul(b) = "b first, then a"):

js
const lean = Quaternion.AngleAxis(15, [0, 0, 1])       // 15° roll
this.entity.rotation = this.entity.rotation.mul(lean)  // lean on top of the current facing

The Quaternion toolbox

You never write [x, y, z, w] by hand — build and blend with these (all on the global Quaternion, with the usual names):

HelperGives you
Quaternion.Euler(x, y, z)rotation from Euler degrees
Quaternion.AngleAxis(deg, axis)deg degrees around an axis
Quaternion.LookRotation(forward, up?)rotation whose forward is a direction
Quaternion.FromToRotation(a, b)the turn that carries direction a onto b
Quaternion.Slerp(a, b, t) / Lerpblend between two rotations (use for smoothing — never lerp raw Euler numbers across 180°)
Quaternion.RotateTowards(a, b, maxDeg)step toward b, at most maxDeg
Quaternion.Angle(a, b)degrees between two rotations
Quaternion.Inverse(q) / Quaternion.identitythe opposite turn / no turn
q.mulVec3(v)rotate a vector (q * v) — turn local offsets into world ones
q.eulerAnglesread any quaternion back as degrees

Physics owns dynamic bodies

On an entity with a dynamic rigidBody, the simulation drives the transform between your writes — contacts and torque decide where it settles. A plain entity.rotation = … write does turn the body, keeping its angular momentum, but it is a teleport: fine for placing something, wrong for steering it every frame. To turn a dynamic body, go through physics:

js
this.physics.setAngularVelocity(this.entity.id, [0, 3, 0])  // spin it (rad/s)
this.physics.applyTorqueImpulse(this.entity.id, [0, 5, 0])  // kick it
this.entity.rotation = [0, 45, 0]                           // place it, momentum intact (3 values = Euler deg)
this.physics.warpBody(this.entity.id, [0, 3, 0], [0, 45, 0]) // place it AND stop it — a respawn

Kinematic and script-moved entities are yours: write entity.rotation freely and the collider follows.

Where each language shows up

SurfaceSpeaks
Inspector rotation fieldsEuler degrees
Terminal — get Box.rotationEuler degrees (labeled (euler deg))
Terminal — set Box.rotation = 0 45 0, rotate Box 0 45 0Euler degrees (absolute)
AI / terminal ops (spawn, modify)rotationDeg — Euler degrees
Animation timeline rotation tracksEuler degrees per key
Script reads (entity.rotation)quaternion (.eulerAngles for degrees)
Script writesboth — 3 values = Euler degrees, 4 = quaternion
Map file (components.transform.rotation)quaternion — edit via the tools above, not by hand

See also