gama3d
v0.53.0
Published
GAMA — Gaming And Motion Agent: a 3D game development library built on top of three.js, focused on game loops, entities, steering-based motion agents, animation, cameras and input.
Maintainers
Readme
GAMA — Gaming And Motion Agent
GAMA is a 3D game development library built on top of three.js. It gives you the pieces three.js deliberately leaves out — a game loop, entities and components, input, cameras, tweening, gameplay collisions — and its signature feature: motion agents, a composable steering-behavior system for bringing NPCs, enemies, flocks and companions to life.
three.js renders. GAMA makes it a game.
Vision
Most web games start the same way: a requestAnimationFrame loop, a pile of ad-hoc update() calls, keyboard flags scattered across event listeners, and enemy movement written as one-off vector math. GAMA packages those patterns into a small, typed, tree-shakeable library so you start at the gameplay layer, not the plumbing layer.
Design principles:
- three.js-native, not a wrapper. A
GameObjectis aTHREE.Object3D. Anything from the three.js ecosystem — loaders, materials, postprocessing — works unchanged. GAMA never hides the renderer or the scene graph from you. - Motion is the product. Character movement, steering-driven AI, animation cross-fades, camera rigs and tweens are first-class, because motion is what makes a 3D scene feel like a game.
- Composable behaviors over inheritance trees. An enemy is a
GameObject+MotionAgent+ a few weighted steering behaviors + aStateMachine. Swap behaviors at runtime to change how it moves. - Small and honest scope. GAMA ships gameplay-level collisions (spheres, boxes, triggers) in core, not a physics engine. When you need rigid-body dynamics, the optional
gama3d/rapieradapter binds rapier bodies and a stair-climbing character controller to GameObjects — core stays dependency-free either way.
Install
npm install gama3d threeStart from a template
The fastest way to a game that is actually a game — title screen, settings that persist, pause, results, best score, phone controls and a deployable build, all already wired:
node scripts/new-game.mjs my-game # or --template courier
cd my-game && npm install && npm run dev| template | what you get |
|---|---|
| starter | A complete small game (find five markers before the clock runs out) with the whole shell wired. Delete the round, keep the rest. |
| courier | Havenbrook Courier — a generated village, a delivery loop, townsfolk in the way, day turning to dusk. A worked example; the docs site serves it at /play/. |
Both depend on the published packages rather than this repo, so what you scaffold is exactly what an outside developer gets. See the shell & templates.
Quick start
import { Mesh, BoxGeometry, MeshStandardMaterial, AmbientLight, Vector3 } from 'three';
import { Game, MotionAgent, Seek, CharacterController, FollowCamera } from 'gama3d';
const game = new Game();
game.world.scene.add(new AmbientLight(0xffffff, 1));
// A keyboard-driven player
const player = game.world.spawn('player');
player.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial({ color: 0x60a5fa })));
player.addComponent(new CharacterController(game.input, { speed: 8 }));
// An enemy that chases the player
const enemy = game.world.spawn('enemy');
enemy.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial({ color: 0xf87171 })));
enemy.position.set(10, 0, 10);
enemy
.addComponent(new MotionAgent({ maxSpeed: 5, planar: true }))
.addBehavior(new Seek(player.position));
// A camera that follows the player
const cam = new FollowCamera(game.camera, player, { offset: new Vector3(0, 8, 12) });
game.onUpdate((time) => cam.update(time.delta));
game.start();Run the bundled demos:
npm install
npm run dev # player + pursuing chasers + flock + pickups (F3 = debug overlay)
npm run dev:flock # 400 boids: spatial hashing, obstacle avoidance, containment
npm run dev:navmesh # click-to-move: A* + funnel pathfinding around walls
npm run dev:physics # rapier: stairs, ramps, crate pyramid, jumping character
npm run dev:ai # behavior-tree guards: patrol → chase → give up
npm run dev:navgen # navmesh BAKED from level boxes + orbit camera
npm run dev:react # react-three-fiber: 120 boids as declarative JSXDocumentation
Docs site with live playground — run it locally with npm run site:dev,
or npm run site:publish to build it and push it to the docs branch for
GitHub Pages (Settings → Pages → Deploy from a branch → docs, / (root)).
It includes every guide below plus a dozen editable, runnable examples of
steering, flocking, navmesh baking, behavior trees and more.
Flow fields that are not 8% wrong. One flood from the goal, any number of agents reading it — but the usual eight-neighbour Dijkstra is not exact, because the path is still made of eight directions. A staircase at 22.5° is longer than the line it approximates by exactly √(4 − 2√2) = 8.24%, and it is a bias: refine the grid three times and the error does not move, because halving the cell gives you the same staircase twice as often. FlowField solves the eikonal equation |∇φ| = cost by fast marching instead — Pythagoras rather than a staircase — and its error does converge (3.67% → 2.57% → 1.66% as the cell quarters). What an agent does with the difference: the eight-way field points it up to 21° off the true bearing on open ground, the eikonal one 2.3°. Both solvers ship, because a number that is only ever right is a number nobody has checked against the alternative. Gated by npm run flow, which measures both against a Euclidean distance neither is ever shown.
A voice that is a tube and the speed of sound. Giving an NPC a voice normally means shipping audio files, calling a text-to-speech service, or playing beeps. Speech is a SOURCE through a FILTER (Fant, 1960): the folds carry pitch, the tract carries the vowel, and they are independent — which is why you can sing "ah" on any note, and why a whisper, with no folds in it at all, stays intelligible. A tract is a tube closed at one end, so its resonances are (2n−1)c/4L: 490 / 1470 / 2450 Hz for 17.5 cm, against a textbook neutral vowel of 500 / 1500 / 2500. And the IPA vowel chart is the formant table — F1 ranks against the chart's own vertical axis at ρ = 1.000, because a phonetician's ear in 1888 and a spectrograph in 1952 were measuring the same axis. renderVoice() returns a Float32Array; no assets, no network, no WebAudio. Gated by npm run voice, which renders a woman and a child from the adult-male table and one length and checks them against Peterson & Barney's own women's and children's rows — 5.0% and 5.4% off, against a floor of 4.9% and 4.3% that is the best any single ratio can do when it is allowed to see the answer.
Prosody, against a number measured on people. A correct vocal tract still sounds like a machine, and not because of its timbre — because the pitch is a constant and the syllables are evenly spaced. Rhythm is Klatt's duration rules (1979), whose form is the claim: shortening applies to the duration above a floor, so no stack of rules can squeeze a syllable to nothing. Grabe & Low (2002) then put a number on "stress-timed vs syllable-timed" — the normalized Pairwise Variability Index, measured off recordings of humans: English 57.2, Dutch 65.5, French 43.5, Spanish 29.7. This model was built from none of it and comes out at 63.8, inside the span the stress-timed languages cover; drop the stress reduction and it falls to 50.2, out of that group entirely, which is what "stress-timed" means. Take Klatt's floor out and it goes to 85.0, past every language ever measured. Melody is done in semitones, because that is the finding rather than a unit convention: a man, a woman and a child saying the same sentence differ by 70 Hz and agree to 3e-15 semitones. Gated by npm run prosody, which reads the pitch back out of the rendered samples by autocorrelation.
Consonants, and a consonant is mostly not a sound. Delattre, Liberman and Cooper cut the bursts off synthetic syllables at Haskins in 1955 and listeners still heard /b/, /d/ and /g/: what identifies a stop is where F2 is heading. The same /d/ runs 2067 → 2290 Hz before /i/ and 1378 → 870 before /u/ — opposite directions, one consonant, and nothing specifies that. /p/ vs /b/ is not a sound either but a duration, the voice onset time Lisker and Abramson measured in 1964, and the gate reads it back out of the audio against a budget of two pitch periods because voicing can only start when the folds next close. And a nasal needs a zero: the closed mouth hangs off the path as a dead end and subtracts a frequency, which no cascade of resonators can do at any setting — npm run consonants renders the same phone all-pole as the control and requires the notch to vanish.
And an NPC that talks. speak('the traveller stopped at the gate') returns a Float32Array — no assets, no network, no service. Under it: a vocal tract that is a tube, Klatt's durations and a semitone pitch contour, consonants with loci and voice onset times, and a 216-word pronunciation dictionary, because English spelling is not a function of its letters. Gated on two axes: 96% of 96 vowels identified by a template matcher given the vowel table and nothing else (against a 10% chance floor and 15% for the same audio cut from the wrong places) — that measures the filter, on whispered vowels — and separately the voiced-against-unvoiced balance against Fletcher's 1953 relative phonetic powers, which is what decides whether a line is audible at all and which nothing measured until a listener said they could not hear a word. And the handshake the trilogy exists for: visemeOf returns the mouth ANIMA draws, the two agree at r = 0.832 against 0.392 for a face 100 ms out of step, and neither package imports the other — because F1 is mouth opening, in the geometry and in the air.
Utility AI with a unit. A utility system's response curves and weights exist
to map metres, hit points and cooldowns onto an invented 0..1 axis. Don't invent
the axis: an action is worth something and it costs seconds, so utility is
value / seconds — a rate, which compares, with nothing to shape or balance.
And the harder half, knowing when to stop, is Charnov's marginal value
theorem (1976) rather than a threshold somebody picked: leave when this patch's
instantaneous rate has fallen to the average rate available elsewhere. Forager
runs it with the environment's rate measured off its own life, so it has no
parameters at all, and npm run forage checks it against an exhaustive sweep of
6000 fixed leaving times — landing within 0.35% of a rule it was never given,
reproducing both of Charnov's predictions (longer travel → longer stay; a richer
world → an earlier exit), and showing what the alternative costs: a depletion
threshold tuned optimally for a 2 s walk loses 29% of the harvest rate on a
40 s one.
- Getting started
- The shell & templates — the part of a game that is not the game
- Levels: prefabs & format — a scene as data, and the round trip back
- The editor —
Editor+gama3d/editor: selection, snapped edits, undo that merges a drag into one step, andmountEditorfor a whole tool in one call - The asset pipeline — a generated manifest (keys, byte sizes, groups, hashes),
AssetLibrary(byte-weighted progress, shared instances, reference-counted release) and a--checkgate - Networking —
gama3d/net: an authoritative server, client-side prediction, reconciliation, entity interpolation, delta snapshots, and a simulated link that makes all of it testable without a socket - Dialogue — conversations as JSON so
lintDialoguecan read them: dangling links, unreachable lines and misspelt variables found before a player finds them; hidden vs locked choices, mid-conversation saves, exact counters - Replay & determinism — a run is its seed plus its inputs, a few hundred bytes:
Recorder,replay, and a per-tickworldChecksumthat names the FIRST tick two runs disagree on. It found thatcreateFlockcould not be replayed at all —Math.randomin the scatter and in every boid'sWander— and brought GAMA a seededRngto fix it with - Utility AI — utility is a RATE (value per second), and Charnov's marginal value theorem (1976) says when to leave a patch, checked against an exhaustive sweep of 6000 fixed leaving times
- Flow fields — one flood, any number of agents, and the eight-way grid is
√(4−2√2)= 8.24% wrong at 22.5° in a way a finer grid does not fix - Voice — a formant synthesizer: a tract is a tube, so its resonances are
(2n−1)c/4L, and the IPA vowel chart IS the formant table (ρ = 1.000). Checked against Peterson & Barney's 1952 women's and children's rows, which build nothing - Prosody — Klatt's duration rules and a semitone pitch contour: English rhythm measured out of the model at nPVI 63.8 against a published 57.2, with the stress reduction removed as the contrast and the floor removed as the control
- Consonants — loci and the transition that identifies a stop, voice onset time against 1964, and the antiformant that made this the one rung needing a new filter rather than new numbers
- Diction — text to speech: a pronunciation dictionary, the maximal onset principle, and an intelligibility gate whose budget is a ratio to its own control
- The perf gate —
npm run perf: calibration-relative timing with honest noise handling, exact work counters, render budgets in headless Chromium — and the deliberate regressions it was made to fail on - Using all three libraries — the catalog seam: how GAMA, SCENA and ANIMA compose into one game without importing each other
- Characters — templates: third-person/top-down players, guards, companions, flocks, locomotion
- Motion agents & steering — behaviors, flocking at scale, avoidance, state machines, tuning
- Core — loop, fixed timestep, entities, events, pooling
- Animation — tweens, easing, clip cross-fades
- Gameplay — input & actions, camera, collisions, audio, assets
- Audio —
Soundboard: procedural sound from a seed — footsteps, impacts, engines, weather, crowds, captions - Game feel & HUD —
GameFeel(trauma shake, hit-stop, slow-mo, rumble) andHud(score, hearts, banner, prompt, captions, radar) - The pickup loop —
Collector(sweep, values, respawn timers) andCheckpointRun(order enforced, laps, progress) over SCENA-shaped props - Stakes —
Health(i-frames, death as an edge, knockback vectors) andProjectiles(pooled instanced shots, teams, arcs) - Opposition —
Harass(ring-keeping, seeded strafe) andWaveDirector(staggered waves, rest, rubber-band pressure) - Retention —
GameFlow(title/playing/paused/results,gate()as the pause),Objectives,SaveSlot(versioned, corruption-safe), ghosts (GhostRecorder/Ghost— race yesterday's you) - Platformer —
PlatformerController: gravity, coyote time, jump buffering, variable height, moving-platform carry — and the coin-run payoff demo - Light as gameplay —
Illumination(the how-lit-am-I field over structural sources),Flashlight(battery, gutter, cone reveal test),MoodGrade(lerped lighting moods per game state) - Flight —
FlightController: arcade-honest fixed-wing flight (bank-to-turn, stall, taxi/takeoff/touchdown events) bridging to SCENA airframes viaaircraftInput - Rail —
RailController: the vehicle that does not steer. A schedule, a stopping curve, honest ETAs, and an overrun you can read — driving a scalar along anything with alength - Physics — the optional rapier adapter: rigid bodies & character controller
- React — the optional react-three-fiber bindings:
<Entity>,useComponent, flock hooks
Architecture
┌───────────────────────────────────────────────────────────┐
│ Game loop · fixed timestep · renderer · input │
├───────────────────────────────────────────────────────────┤
│ World THREE.Scene + GameObject registry │
│ GameObject extends THREE.Object3D + components + events │
│ Component onAttach / update / fixedUpdate / onDetach │
├────────────────────┬───────────────┬──────────────────────┤
│ Motion │ Animation │ Gameplay │
│ MotionAgent │ Tween/Tweens │ Input + ActionMap │
│ Seek/Flee/Arrive │ easing │ (keyboard, gamepad) │
│ Pursue/Evade │ Animator │ CharacterController │
│ Wander │ (mixer + │ FollowCamera │
│ Separation │ crossfade) │ OrbitRig/ShoulderRig │
│ (cont.) │ │ Sphere/BoxCollider │
│ Alignment │ │ CollisionSystem │
│ Cohesion │ │ (enter/exit events) │
│ FollowPath │ │ Pool │
│ ObstacleAvoidance │ │ AudioManager │
│ Containment │ │ Assets │
│ SpatialGrid │ │ DebugOverlay │
│ NavMesh (A*+funnel)│ │ │
│ NavMeshAgent.goTo │ │ │
│ generateNavMesh │ │ │
│ StateMachine │ │ │
│ BehaviorTree │ │ │
│ Forager (Charnov) │ │ │
│ FlowField (eikonal)│ │ │
├────────────────────┴───────────────┴──────────────────────┤
│ gama3d/rapier (optional entry point, peer dep on rapier) │
│ PhysicsWorld · RigidBody · PhysicsCharacterController │
├───────────────────────────────────────────────────────────┤
│ gama3d/react (optional entry point, peer deps react + r3f) │
│ GamaProvider · Entity · useComponent · useFlockGrid │
├───────────────────────────────────────────────────────────┤
│ gama3d/templates (characters in one call) │
│ createThirdPersonCharacter · createTopDownCharacter │
│ createGuard · createCompanion · createFlock · Locomotion │
└───────────────────────────────────────────────────────────┘
three.jsMotion agents
A MotionAgent is a component that steers its GameObject by summing weighted forces from classic Reynolds steering behaviors. Behaviors take fixed points, live Vector3 references, getters, or other agents — so targets can move.
import { MotionAgent, Pursue, Evade, Wander, Separation, Alignment, Cohesion, FollowPath, Path, StateMachine } from 'gama3d';
// A guard that patrols, then chases when the player gets close
const agent = guard.addComponent(new MotionAgent({ maxSpeed: 4, planar: true }));
const patrol = new FollowPath(new Path([wp1, wp2, wp3], true));
const chase = new Pursue(playerAgent);
const brain = guard.addComponent(
new StateMachine({ agent })
);
brain
.addState({
name: 'patrol',
enter: ({ agent }) => { agent.clearBehaviors(); agent.addBehavior(patrol); },
update: ({ agent }) => {
if (agent.position.distanceTo(player.position) < 8) brain.setState('chase');
},
})
.addState({
name: 'chase',
enter: ({ agent }) => { agent.clearBehaviors(); agent.addBehavior(chase); },
update: ({ agent }) => {
if (agent.position.distanceTo(player.position) > 15) brain.setState('patrol');
},
});
brain.setState('patrol');
// A flock, in four lines
boid.addComponent(new MotionAgent({ maxSpeed: 3 }))
.addBehavior(new Separation(() => flock, 1.5), 1.6)
.addBehavior(new Alignment(() => flock, 5))
.addBehavior(new Cohesion(() => flock, 6), 0.8);Available behaviors: Seek, Flee, Arrive, Pursue, Evade, Wander, Separation, Alignment, Cohesion, FollowPath, ObstacleAvoidance, Containment. Implement the one-method SteeringBehavior interface to add your own.
Flocks scale with SpatialGrid, a spatial hash for near-O(n) neighbor queries — examples/flock runs 400 boids with obstacle avoidance:
const grid = new SpatialGrid(5);
game.onUpdate(() => grid.rebuild(flock));
agent.addBehavior(new Separation(grid.near(agent, 5), 1.5), 1.8);Measured — npm run bench:throughput — the grid overtakes a plain array at around 500 agents (1000 agents: 8.8 ms/frame vs 25.7; 2000: 19.8 vs 91.9). Below that the array is faster and simpler, and the docs say so rather than assuming the fancy structure wins.
Navigation
Point-to-point movement through a level is one call — NavMesh runs A* over
triangle adjacency and string-pulls the result with the funnel algorithm:
const nav = NavMesh.fromGeometry(floorGeometry);
enemy.addComponent(new MotionAgent({ maxSpeed: 5, planar: true }));
const navAgent = enemy.addComponent(new NavMeshAgent(nav));
navAgent.goTo(clickPoint);
enemy.events.on('nav-arrived', () => attack());Physics (optional)
Real rigid-body dynamics via the rapier adapter — a
separate entry point, so core gama stays dependency-free:
import { PhysicsWorld, RigidBody, PhysicsCharacterController } from 'gama3d/rapier';
const physics = await PhysicsWorld.create();
physics.attach(game); // steps at the game's fixed rate
crate.addComponent(new RigidBody(physics, {
collider: { shape: 'box', halfExtents: new Vector3(0.5, 0.5, 0.5) },
}));
const controller = player.addComponent(
new PhysicsCharacterController(physics, game.input, { speed: 7 })
); // slopes, stairs, snap-to-ground, gravity, jump()Seeing what agents think
new DebugOverlay(game); // press F3Velocity arrows (cyan), steering-force arrows (magenta), collider wireframes, and an FPS/entity/draw-call panel — steering bugs stop being invisible.
Animation
import { Tweens, easing, Animator } from 'gama3d';
const tweens = new Tweens();
game.onUpdate((t) => tweens.update(t.delta));
tweens.to(door.position, { y: 4 }, { duration: 0.6, easing: easing.cubicInOut });
// Skinned characters: named clips with cross-fades
const animator = hero.addComponent(new Animator(gltf.animations, gltf.scene));
animator.play('idle');
// later, when the player moves:
animator.play('run', 0.2);Assets
import { Assets } from 'gama3d';
const assets = new Assets();
assets.onProgress = (loaded, total) => hud.setProgress(loaded / total);
const gltf = await assets.gltf('models/hero.glb'); // cached; repeated calls are freeRoadmap
- [x] Spatial hashing for
Separation/Alignment/Cohesionneighbor queries at scale - [x] Obstacle-avoidance (
ObstacleAvoidance) and bounds (Containment) steering behaviors - [x] Gamepad support and named-action input mapping
- [x] Audio manager (one-shots, positional audio, music cross-fade)
- [x] Debug overlay (steering/velocity arrows, collider wireframes, stats)
- [x] Object pooling and collision enter/exit events
- [x] Fixed-timestep simulation option
- [x] Navmesh pathfinding:
NavMesh(A* + funnel) andNavMeshAgent.goTo(point) - [x] Navmesh generation from level geometry (
generateNavMesh: grid sampling, slope/step/radius rules) - [x] Rapier adapter (
gama3d/rapier): rigid bodies + physics character controller - [x] Behavior trees (reactive composites, decorators, typed contexts)
- [x] Orbit and shoulder camera rigs (drag-orbit + pointer-lock mouse look with occlusion)
- [x] React-three-fiber bindings (
gama3d/react): Entity/GameObject bridge, component hooks, flock grid - [x] Documentation site with live, editable playground (
npm run site:dev) - [x] Character templates (
gama3d/templates): third-person & top-down players, guard/companion/flock NPCs, Locomotion animation glue - [x] Game templates (
gama3d/templates):createRace(whole racer),CricketMatch(ball flight, timing window, laws-accurate scoring) - [x] Procedural audio (
Soundboard): sample-free synthesized SFX, engines/weather/crowd beds, buses & ducking, captions, offline-render verification - [x] Game feel (
GameFeel): trauma-squared screen shake, hit-stop, slow-mo with ease-back, haptic rumble - [x] HUD (
Hud): DOM overlay — score/timer/hearts, banner, objective, prompt, Soundboard caption line, canvas radar - [x] Pickup loop:
Collector(structural pickups/fields, respawn scheduling) andCheckpointRun(ordered gates, laps, setState painting) - [x] Stakes:
Health(i-frames, one-shot death edge, revive with mercy window, computed knockback) andProjectiles(pooled tracers, structural targets, team filtering) - [x] Opposition:
Harasssteering (ring + band + seeded strafe) andWaveDirector(trickle spawns, rest, escalation under a ceiling, rubber-band pressure) - [x] Retention:
GameFlow(legal-move state machine,gate()pause),Objectives(clamped progress, once-only completion),SaveSlot(versioned envelope, null-means-null),GhostRecorder/GhostTape/Ghost(fixed-interval tapes, seam-safe yaw playback) - [x] Platformer:
PlatformerController(sub-stepped gravity, walls/ceilings, coyote time, jump buffer, variable jump height, moving-platform carry) + the coin-run payoff example - [x] Light as gameplay:
Illuminationfield (structural sources, live litness, pure math),Flashlight(battery drama, seeded gutter, cone reveal with angular slack),MoodGrade(structural rig targets, seamless interrupted blends) - [x] Flight:
FlightController(throttle→speed→lift, bank-to-turn, stall as a state, taxi/rotate/flare/touchdown with sink-rate events,apply()+aircraftInputbridges) - [x] Hover:
HoverController(collective/cyclic/pedals, rotor spool inertia, seeded hover breath, sink-rate touchdowns,helicopterInputbridge) +rotorVoicing/RotorSound(blade-pass tremolo — the wop-wop is amplitude, not pitch) - [x] Rail:
RailController(position is one scalar; the√(2·brake·remaining)stopping curve, exact landing at any step size, run-through when a mark was booked inside the braking distance, loop-aware schedules, ETAs that integrate the curve they drive) - [x] Dialogue: conversations as data (
Dialogue,defineDialogue) with a JSON condition/effect vocabulary chosen solintDialoguecan statically find dangling links, unreachable nodes, strandable choice lists and undeclared variables; hidden vs locked choices, save/restore mid-line, exactcounts - [x] Air combat:
Missiles(lead pursuit under a hard turn-rate limit with speed-bleed — evadability as physics; seeded one-chance flare seduction; pooled instanced) +LockOn(cone/range/time, no credit for past devotion) - [ ] Multi-layer navmesh generation (Recast-style voxelization)
Development
npm install
npm test # 524 vitest unit tests, no browser
npm run typecheck
npm run build # tsup → dist (ESM + CJS + d.ts)
npm run dev # vite dev server for examples/basicAnd the things that need a browser or a socket, none of which a unit test can stand in for:
npm run verify:playgrounds # every playground example, headless, pixel-checked
npm run verify:editor # the editor driven for real, 20 checks
npm run net:check # two clients over real WebSockets, 10 checks
npm run perf # timing + exact counters + render budgetsAll of the above run in CI on every push
(.github/workflows/ci.yml) — a pipeline that ran
npm test and stopped would pass while the playground rendered black, while
the editor's raycast missed, and while the netcode dropped a handshake. Each of
those was a real bug, and none of them was found by a unit test.
Release notes live in CHANGELOG.md.
License
MIT
