Skip to content

Internals

It is small: one portal per UI element to the screen layer; anchored panels project their entity each frame and skip entities beyond a distance; one modal stack; one context each for Game, Scene, World and Entity, with the hooks reading the nearest. Three-mmorpg’s runtime on React Three Fiber and Koota sits behind these components; the wrappers, the hoist, the anchor and the stack are the new engine work.

The engine exposes a devtools API with no UI: the entity tree, selection, pick under the pointer, pause and step, and a registration point for panels. @daniel-zarinski/devtools draws the generic overlay and a game adds its own panels. See the devtools API and the debugger.

Every file in packages/engine/src sits in the folder that names what it is. One name means one thing: world is the World level, and the koota instance is runtime. A thing that crosses layers keeps its name in each, so daylight is a render helper, a system and a view.

packages/engine/src/
├── index.ts the barrel; a game imports @daniel-zarinski/engine and nothing deeper
├── core.ts the simulation alone, as @daniel-zarinski/engine/core: what a room server and the harness import in Node
├── components/ the components: Frameloop, Game, Scene, World, Entity, Player, Camera, Sound, and the headless mount
├── data/ the tables that name the files in packages/assets: hero, scatterModels, scatterShapes, openingModels, sounds
├── maths/ geometry with no browser and no React: axes, facing, hash, solids, escape
├── platform/ the browser: keyboard, stick and camera bindings, and the audio context
├── gameplay/ the simulation on koota, never React
│ ├── runtime.ts the koota world factory
│ ├── step.ts the fixed step, the systems it runs and its accumulator
│ ├── harness.ts a headless game: create from a scene and a seed, step seconds, dump state and its hash
│ ├── save.ts the save record read out of the world and put back
│ ├── entity/ what every entity carries: traits, transform, resources
│ ├── hero/ traits, movement, path, pose, actions
│ ├── world/ traits, daylight, round, spawn, actions
│ ├── map/ the ground and what stands on it, baked: surface, scatter, obstacles, navmesh
│ ├── physics/ Rapier: the ground as a heightfield, a hull per obstacle, the floor walk
│ └── camera/ the eye settling out of solids
├── render/ three.js with no React: palette, textures, light maths, VRM prep, the Draco decoder
├── stores/ the zustand stores the hooks read: scenes, modals, wallet, input and time; usePlayer and useRound query the world
└── views/ React Three Fiber components and their hooks
├── entity/ useEntityRef, StandingModelView
├── vrm/ VrmView
├── world/ GroundView, ScatterView, NavMeshView, DaylightRig, SkyDome, SkyClouds
├── audio/ SoundView
├── texture/ useKtx2
└── hero/ HeroView, HeroAnimationView, FootstepsView, animation

The line between render and views: a file that calls React or React Three Fiber at runtime is a view; a file that only builds three.js objects, textures or numbers is render code, and a view calls it. render/vrm.ts and render/compressed.ts are the exceptions: each names a drei type. views/hero/animation.ts calls neither but drives the bones of a VRM a view owns, so it sits with that view. A view that maps a koota query over a component of the same name keeps both in one file, so GroundView.tsx holds GroundView and GroundRenderer.

The layer order runs maths and platform, then render and data, then gameplay, then stores, then views, then components. One import runs the other way: data/hero keys the hero’s clips by the pose enum in gameplay/hero, so a pose and its clip share one name. Lint in packages/engine/eslint.config.mjs holds the lines that matter. It checks a line between layers on the file an import resolves to, so a barrel is refused along with the layer it re-exports:

  • platform imports no React.
  • maths and gameplay import no React, no store, no view and no component.
  • stores import no view and no component.
  • No file imports the engine’s own barrel, which re-exports every layer.

A store is reachable from outside React, so a devtools command or a test drives the game without a hook; a game’s own stores live in the game. components sits on top and is the only layer a game is meant to see once it exists.

maths and gameplay are the simulation. The room server and the harness run them in Node with no page, and the same inputs have to build the same world twice. So their lint also refuses the page, the timers, the wall clock, unseeded randomness, three’s loaders, three addons not known to run in Node, and the render barrel. test/gameplay/index.test.ts imports gameplay through its barrel in the node environment and steps a world there on every run. A bake that needs what only a view loads is still a gameplay action: the view calls it with the floor mesh, so a test and a server can call it too. The scatter’s collision shapes are data, data/scatterShapes.json, which pnpm nx shapes @game/assets writes from the model files, so the obstacle bake needs no view at all. The table is imported dynamically, so it stays out of the entry chunk a page downloads first. The World dresses its ground when the table lands rather than waiting for it, and loadRapier loads the table with the wasm, so the Frameloop, the harness and any other caller that awaits the physics has the table before its first step or bake.

The hero’s files sit in the engine, each in its layer’s folder so lint applies to it: data/hero, gameplay/hero and views/hero.

The engine asks a mesh where a ray meets it and which of its points is nearest through three-mesh-bvh’s MeshBVH: the camera’s eye settle, the springs’ obstacle collider, the escape query and the pointer’s ray on the ground. Every one is built by buildQueryTree in maths/queryTree.ts, which picks the tree’s layout from the geometry’s groups:

  • A geometry with several groups is built in three-mesh-bvh’s indirect mode. Without it, three-mesh-bvh builds a separate tree for each group, and every query walks all of them. The obstacle bake keeps one group per placement, because the physics cuts one collider per group: built per group, that is 123 trees for every ray on the mmorpg map, and a short ray costs what a long one does. Indirect mode builds one tree over a buffer of its own and leaves the geometry’s index and groups as the merge wrote them.
  • A geometry with one group or none, such as the ground’s heightfield, is one tree either way. It keeps the default, which sorts the index in place and reads each triangle without indirect mode’s extra lookup.

A mesh’s detail does not decide the layout: a model of a hundred thousand triangles in one group is one tree without indirect mode.

A game has an entry, an app, scenes, its own components and its HUD:

games/mmorpg/src/
├── main.tsx the entry: preload
├── App.tsx <Game> with one scene and the global HUD
├── scenes/Meadow.tsx <World map> <Player> <Camera follow> and the scene's HUD
├── components/ Hero, HealthBar (the bar over her head)
└── hud/ DaylightSetting, QualitySetting, SaveIndicator