Senkei 0.2.0 - Unity-like Gamedev Math for TypeScript

Marco Kammer / September 25, 2026
Senkei is my small math library for games in JavaScript and TypeScript. It has been sitting at 0.1.1 since 2023, and I finally gave it the update it deserved. Version 0.2.0 is pretty much a rewrite: the API now feels like Unity, the Transform actually works like a scene graph, and there are a lot of fixes and a proper test suite behind it.
npm install senkei
Unity-like API
If you have written C# in Unity, senkei should feel familiar. Everything uses PascalCase names like Vec3.Lerp, Quaternion.Slerp, Mat4.TRS, Transform.LookAt and Mathf.Approximately.
import { Vec3, Quaternion, Mat4, Transform, Mathf, Space } from 'senkei'
const a = new Vec3(2, 4, 10)
const b = new Vec3(34, 3, 2)
const sum = Vec3.Add(a, b)
const mid = Vec3.Lerp(a, b, 0.5)
const dir = Vec3.Normalize(b)
Here’s what that looks like in a game loop: a turret that turns towards an enemy at 90 degrees per second, and a player that walks towards a goal at 5 units per second.
const turnSpeed = 90 // degrees per second
const moveSpeed = 5 // units per second
function update(deltaTime: number) {
const toEnemy = Vec3.Subtract(enemy.position, turret.position)
const targetRotation = Quaternion.LookRotation(toEnemy)
turret.rotation = Quaternion.RotateTowards(
turret.rotation,
targetRotation,
turnSpeed * deltaTime
)
player.position = Vec3.MoveTowards(
player.position,
goal,
moveSpeed * deltaTime
)
}
A lot of old method names got cleaned up too:
mult,div,scalarMult,scalarDivandMultiplyWithVectorare replaced byMultiplyandDivide, which take either a scalar or a vector. Component-wise multiplication isScale.Quaternion.Multiplycombines rotations or rotates a point.Mat4.Multiplymultiplies matrices or transforms aVec4.Transform.translation/localTranslationare nowposition/localPosition.Mathf.Lerpclampstto 0..1 like Unity. UseLerpUnclampedif you want the raw blend.Vec2,Vec3andVec4now share the same naming table, withnormalized,SqrDistance,ClampandToVec2/ToVec3/ToVec4conversions.
The old names that conflicted are marked @deprecated rather than removed, so existing code keeps working while you migrate.
Transform hierarchy
Transform was rewritten from scratch. It now has parents and children, world and local position / rotation / scale, and Space.Self / Space.World just like Unity.
const parent = new Transform()
parent.position = new Vec3(10, 0, 0)
const child = new Transform()
child.SetParent(parent) // keeps world transform by default
child.Translate(new Vec3(0, 0, -1), Space.Self)
child.LookAt(new Vec3(0, 1, 5))
const world = child.TransformPoint(new Vec3(1, 0, 0))
Children follow their parents, so a simple solar system is only a few lines. Rotating the sun carries the earth and the moon with it:
const sun = new Transform()
const earth = new Transform()
earth.SetParent(sun)
earth.localPosition = new Vec3(10, 0, 0)
const moon = new Transform()
moon.SetParent(earth)
moon.localPosition = new Vec3(2, 0, 0)
sun.Rotate(new Vec3(0, 90, 0))
console.log(moon.position) // Vec3 { x: 0, y: 0, z: -12 }
World matrices are cached and only recalculated when something in the chain moves, using dirty flags that propagate down the subtree. Reading position or rotation no longer walks the entire parent chain every time. hasChanged also propagates to children when an ancestor moves.
Parenting a transform onto one of its own descendants now throws, instead of creating a cycle that would hang.
SmoothDamp without side effects
Vec2.SmoothDamp and Vec3.SmoothDamp used to mutate the arguments you passed in. Now they return the new value and velocity, and you pass the velocity back in on the next frame:
let velocity = Vec3.zero
function update(deltaTime: number) {
const result = Vec3.SmoothDamp(current, target, velocity, 0.3, 10, deltaTime)
current = result.value
velocity = result.velocity
}
This also fixed the overshoot guard, which could never trigger before because of an aliased reference.
Predictable memory
One of the biggest goals for this release was no hidden aliasing. The rules are simple:
- Static methods allocate:
Vec3.Add(a, b)returns a new vector and leavesaandbalone. - Instance methods mutate:
v.Normalize()changesv. - Getters return copies:
magnitude,normalized,positionandlocalToWorldMatrixcan’t be used to corrupt internal state.
const a = new Vec3(3, 4, 0)
const n = Vec3.Normalize(a) // n is (0.6, 0.8, 0), a is still (3, 4, 0)
a.Normalize() // now a is (0.6, 0.8, 0)
const t = new Transform()
t.position = new Vec3(1, 2, 3)
const p = t.position
p.x = 100 // t.position is still (1, 2, 3)
Functions like MoveTowards and ClampMagnitude used to sometimes hand you back your own object. Now they always return a fresh one.
Explicit failures
Instead of silently spreading NaN through your scene, senkei now fails loudly or handles edge cases the way Unity does:
- Inverting a singular matrix throws.
AngleBetweenguards against zero-length vectors.ProjectOnPlanewith a degenerate normal returns the vector unchanged, andVec4.Projectreturns zero instead ofNaN.ToAngleAxisused to return nothing. It has been replaced byToAxisAngle, which returns{ axis, angle }with the angle in radians.
try {
Mat4.zero.inverse
} catch (e) {
// Error: Cannot invert matrix with zero determinant
}
try {
sun.SetParent(moon)
} catch (e) {
// Error: Cannot parent a transform to one of its own descendants.
}
0.1 + 0.2 === 0.3 // false
Mathf.Approximately(0.1 + 0.2, 0.3) // true
Mathf.Lerp(0, 10, 1.5) // 10, clamped like Unity
Mathf.LerpUnclamped(0, 10, 1.5) // 15
Degrees are used at the API boundary (Euler, eulerAngles, AngleAxis) and radians inside:
const { axis, angle } = Quaternion.AngleAxis(90, Vec3.up).toAxisAngle()
// axis is (0, 1, 0), angle is 1.5707... (π / 2)
Performance
The two biggest wins are Mat4.TRS and the Transform hierarchy.
Mat4.TRSnow computes translation × rotation × scale directly in a single allocation, instead of building three matrices and multiplying them together.Transformcaches its world matrices, so readingposition, callingTransformPointor readinglocalToWorldMatrixno longer walks up the whole parent chain every time.
Here’s the benchmark from bench/perf.mjs, run on the commit right before the performance work and on 0.2.0. The Transform numbers are for a leaf three levels deep in a hierarchy.
Time per operation, lower is better
Mat4.TRS
Transform.position
Transform.TransformPoint
Transform.localToWorldMatrix
Show as table
| Operation | Before (ns) | 0.2.0 (ns) | Speedup |
|---|---|---|---|
| Mat4.TRS | 147.0 | 33.6 | 4.4× |
| Transform.position | 235.4 | 28.4 | 8.3× |
| Transform.TransformPoint | 366.4 | 21.0 | 17.4× |
| Transform.localToWorldMatrix | 359.5 | 13.7 | 26.2× |
Everything else stayed about the same. Here’s the full run on 0.2.0:
$ pnpm run benchmark
Vec3.Add: 11.3 ns/op
Vec3.Normalize: 14.6 ns/op
Vec3.Dot+Cross: 10.3 ns/op
Vec3.Angle: 21.6 ns/op
Vec3.SmoothDamp: 31.2 ns/op
Quat.Multiply(q,q): 14.8 ns/op
Quat.Multiply(q,v): 19.4 ns/op
Quat.Slerp: 40.5 ns/op
Quat.LookRotation: 65.6 ns/op
Quat.eulerAngles: 35.8 ns/op
Mat4.Multiply: 58.0 ns/op
Mat4.TRS: 33.9 ns/op
Mat4.inverse: 54.4 ns/op
Mat4.LookAt: 54.2 ns/op
Transform.position x3 chain: 28.4 ns/op
Transform.TransformPoint: 21.0 ns/op
Transform.localToWorldMatrix: 15.2 ns/op
Mathf.Lerp: 7.4 ns/op
You can run it yourself with pnpm run benchmark.
Bug fixes
This release fixes a lot of bugs:
Quaternion.Inversehad the wrong sign onw, andAngleAxisnow takes degrees.Slerpno longer mutates its input.LookRotationbuilds the correct basis, andFromToRotationhandles opposite vectors.Mat4.Translateused the wrong column layout, andSetRow/SetColumn/SetTRSnow actually write their values.- Several
Vec3/Vec4instance methods that did nothing now work, includingwcomponent handling onVec4. Mathf.Approximatelynow compares relative to the larger of the two values, like Unity.
New additions
Vec3.OrthoNormalizeQuaternion.RotateTowardsandLerpUnclampedMat4.clone()MathfandSpaceare exported from the package entry- Static direction helpers like
Vec3.up,Vec3.forward,Vec3.rightandVec3.zero
Conclusion
Senkei is finally in a state where I’m happy to use it in my own projects, like my physics engine butsuri. If you are making games in TypeScript and miss Unity’s math API, give it a try. There are docs for every class to get you started. Let me know what you think on GitHub.