Physics: I wrote my own, then chose Rapier over Jolt
Everyone hits the physics question early with the same two devils on their shoulders. One says write it yourself, it's just vectors. The other says use the library, you have a game to build. I listened to the first one first. I usually do - and I'd do it again, because it's the fastest way to actually understand the thing.
The one I wrote (and learned from)
The first physics in Awaken was hand-rolled: rigid bodies, colliders, gravity, ground contact, a separation pass, collision callbacks into scripts. Brilliant to build - nothing teaches you contacts like making two boxes shove each other apart and getting the maths wrong until they stop exploding. Also, predictably, not something to ship on. Past "two boxes on a floor," physics becomes a thousand edge cases: stable stacking, resting contacts that don't jitter, restitution that feels right, thin walls you don't tunnel through, friction, sleeping bodies. Each is a small research project; all of them at once is a career.
Rapier vs Jolt
So I shopped. It came down to two serious open-source engines: Jolt (Guerrilla's, the physics behind Horizon Forbidden West) and Rapier (Rust, by the Dimforge folks). Jolt is superb and battle-proven on a huge AAA world - but it's a large C++ engine, and for a browser tab that means a chunky WebAssembly payload and a heavier integration. Rapier is smaller and lighter, ships a clean -compat WASM build, and covers exactly what Awaken needs: rigid bodies, the full collider set, joints, raycasts, and a proper kinematic character controller. For an engine whose whole pitch is "downloads fast, runs in a tab," lighter won. If I later need soft bodies - which Rapier doesn't do and Jolt does - the section below is why that's a swap, not a rewrite.
The abstraction: yes, it's swappable
Awaken never talks to Rapier. It talks to an engine-agnostic interface, and Rapier is one implementation behind it:
flowchart TD ECS["ECS: RigidBody / Collider components"] --> RN["PhysicsRunner
(ECS ↔ physics sync)"] RN --> BK["PhysicsBackend / PhysicsWorld
(interface - opaque handles)"] BK --> RA["rapier.ts
(the ONLY file importing Rapier)"] BK -.-> JO["jolt.ts
(future - same interface)"]
export interface PhysicsBackend {
readonly name: string;
readonly supportsSoftBody: boolean; // Rapier: false. Jolt: true.
init(): Promise<void>; // WASM, async
createWorld(gravity: Vec3): PhysicsWorld;
}A body is an opaque number handle; no Rapier type ever crosses the boundary. Bodies, colliders, shapes and joints are plain engine-neutral descriptors. The interface leaks exactly one capability bit - supportsSoftBody - the honest admission that engines aren't perfectly fungible. Adding Jolt later is a new file implementing PhysicsBackend/PhysicsWorld and a one-line change at the single construction point (new RapierBackend()). The ECS components, gizmos, inspector and body↔Transform sync don't move.
The character controller - and two footguns
A player is a weird physics object: not a tumbling dynamic body (you don't want it shoved by a crate), not a fixed one (it has to move). The answer is kinematic - you tell it where to go, it resolves collisions along the way but is never itself simulated. Awaken lazily promotes a player's capsule to a kinematic body on first move and drives Rapier's KinematicCharacterController: computeColliderMovement(desired) → computedMovement() (your delta, slid along walls, stepped over small ledges) → computedGrounded(). That's move-and-slide, exposed to scripts as api.moveAndSlide(dx, dy, dz).
Now the two details that are invisible until they ruin an afternoon - both real, both in the code with comments so I never forget:
EXCLUDE_SENSORS. Rapier's controller treats every collider as solid unless told otherwise, so a trigger volume (a checkpoint, a pickup radius) will physically block the player like an invisible wall. The flag makes it slide through sensors while the narrow phase still reports the overlap.setActiveCollisionTypes(ALL). By default Rapier only reports collisions for pairs involving a dynamic body. But the player is kinematic and walls are fixed - so with defaults, a player walking into a wall generates zero events, andonCollisionEnternever fires against static geometry (i.e. most of the game). One line fixes it, and it's free on static scenery because two fixed bodies never re-enter the broad phase.
(There's a nice bit of craft in the events, too: Rapier gives you edges - began-touching, stopped-touching - so enter/stay/exit is built by keeping a live set of overlapping pairs and diffing it against last frame's.)
📷 Screenshot needed: the physics debug overlay - colliders drawn as wireframes over a scene (and if easy, a trigger volume in a different colour) to show the sensor-vs-solid distinction.
The lesson
The instinct to build everything yourself is a good instinct pointed the wrong way. Writing my own physics made me a better user of Rapier - those two footguns were findable precisely because I knew what "should" happen. But shipping is a different job from learning, and on a solo project your scarcest resource is the number of hard problems you can personally own. Build the version that teaches you; ship the version that's correct; keep the seam so you can change your mind.