@umicat/three-sdk
v0.17.3
Published
Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.
Readme
@umicat/three-sdk
The three.js runtime for Umicat games, per ADR-033. Platform services come from
@umicat/platform-sdk untouched; what lives here is the engine layer we own.
Status: published and in use. @umicat/three-sdk is on npm; Balaboo (a
tower defence that ships from woodland/) is built on it, and umicat-template-3d
is what new 3D projects start from.
What it has: the scene3d format and loader, physics wiring, a character controller and animator, an input layer that mounts on-screen controls on touch devices and binds keys everywhere, bone sockets, hit tints, audio, and the editor's screenshot/video-capture protocol. What it still does not have: an editor.
This paragraph said "seed, not a product — no input system, no character controller, no audio, and no published package" for ten minor versions after each of those arrived. A status line is the first thing anyone reads and the last thing anyone updates.
@umicat/platform-sdk identity · saves · gameData · rooms · ai · voice · dialogue
▲ (shared with @umicat/phaser-sdk)
│
@umicat/three-sdk ThreeUmicat · scene3d · loadScene3D · physics · Input3D
CharacterController3D · CharacterAnimator · GameAudio
sockets · tints · setupScreenshotListener/setupRecordingListener
▲
your game gameplayWhat is proven, and by what
npm test runs a real three.js game in a real browser against a host speaking
the real wire protocol. Only the host page and the backend behind it are
mocked — every line of SDK is the shipped code.
| claim | test |
|---|---|
| a 3D game does the real handshake and gets identity | host is umicat-home-ui, not standalone |
| cloud saves go over RPC, not to localStorage | round-trip, plus the host's RPC log |
| runtime AI uses the same channel | ai.complete returns {ok, text} per the protocol |
| scene3d builds the authored scene | entity count, parenting, the right clip playing |
| physics runs | a crate dropped from y=6 settles on the ground and does not fall through |
| design mode is render-only | same entities, zero mixers, zero bodies |
| authoring mistakes fail loudly | duplicate ids and missing clips throw at load, naming the clips that exist |
| music and effects are two volumes | the clip's gain hangs off sfxGain, not the master — audio-volume.test.mjs |
Eight suites, not one: the table above is the platform seam. The others cover the character controller, the animator, sockets, tints, input icons, the courtyard sample and the audio graph.
slice.png is the scene those assertions describe, rendered.
The scene3d format
src/scene3d.ts. It inherits ADR-021's answer to what does the editor edit —
design data on disk, no save loaded — because that answer is engine-neutral. It
does not inherit the 2D schema, which does not survive three dimensions.
Each rule is a decision:
- Rotation is a quaternion, not Euler angles — those are order-dependent and interpolate badly, so an editor round-tripping them drifts.
- Ids are authored and stable. The editor, the runtime and saves all refer to entities by id; regenerating them on load breaks every reference on first edit.
- Transforms are local to
parent. Storing world transforms makes reparenting a lie. - Assets are referenced by id, never by path — paths change on re-import.
- Colliders are explicit. A render mesh used as a dynamic collider is the classic way to ship a game that is correct and unplayably slow.
- Animation clips are mapped semantically per asset (
{ walk: 'Walk' }). Guessing that every model calls its walk cycleWalkfails silently; an independent review's cross-rig retarget returned zero matched bones and zero tracks, which is the same class of failure, quieter.
validateScene refuses duplicate ids, dangling parents, entities that would
render nothing, trimesh colliders on dynamic bodies, and malformed quaternions —
at load, because every one of them otherwise appears as a blank screen later.
Solved: the character that disappeared once the camera moved
Worth keeping, because the symptom pointed everywhere except at the cause.
In the Courtyard sample the fox rendered at boot and was gone after walking,
leaving flat ground. Every measurement said it should be visible: 18 draw calls
and 5,042 triangles that frame, hero.visible === true, bones at sensible world
positions, and three's own Vector3.project(camera) putting the character at
NDC (0, 0) — dead centre — while the centre of the frame was a single flat
colour. A canvas read-back agreed with the screenshot, so it was the render and
not the capture.
It was the ground. makePrimitive laid the plane down with
mesh.rotation.x = -Math.PI / 2, and applyTransform then set the object's
quaternion from the entity's authored rotation — identity — discarding it.
So every "ground" stood upright as a 40x40 wall. At boot the camera and the
character were on the same side of it and the picture looked right, with the
wall reading as ground. Walk past it and the camera is behind a wall, still
pointing correctly at a character it can no longer see. A raycast through the
frame centre said it in one line: ground at 9.79m, fox at 13.89m.
Fixed by rotating the geometry (PlaneGeometry(...).rotateX(-Math.PI/2))
rather than the object, so an entity's authored rotation is never overwritten.
After the fix the same raycast returns fox at 13.89m then ground at 18.05m —
which matches the hand-computed ground crossing exactly.
Two lessons kept deliberately. A correct-looking measurement can be measuring the right thing about the wrong scene: NDC (0,0) was true the whole time, the character was centred, behind a wall. And the first render looked fine, which is how a construction bug this total survived a screenshot.
One real bug was fixed while chasing it and is unrelated but worth having: a
SkinnedMesh's bounding sphere comes from the bind pose and does not follow the
bones, so three.js culls a character against a stale volume once it moves.
loadScene3D sets frustumCulled = false on skinned meshes.
0.3.0 — actions, not just locomotion
CharacterAnimator owns the two things every game was writing by hand, and
both are character behaviour rather than game logic (ADR-034).
Locomotion follows character.state, not the keys held — a clip chosen
from input leaves a character walking in mid-air.
An action is a one-shot that interrupts and returns. Attacking is not a
state you enter, it is a thing that happens: play('attack') runs the clip
once, holds its last frame rather than snapping to the bind pose in the gap,
and hands control back. busy is there so a game can refuse a second swing
from one press — firing twice is the usual reason an attack feels broken.
Changing locomotion mid-action does not blend a walk cycle into the middle of a sword swing: the new locomotion clip is swapped in underneath at zero weight and faded up only when the action releases.
Names resolve through the manifest's map first (attack -> attack-melee-right)
and then by raw clip name, so a game can reach a clip nobody mapped.
0.2.0 — the character's jump and controls belong to the platform
ADR-034 makes the platform the owner of the character, so two things that used to be each game's problem moved in here.
Jump (jumpSpeed, coyoteTime, jumpBuffer, jumpCut). 0.1.0 left jump
to the game on the grounds that how it feels is a design decision. That is true
right up until every game shares one character — then a badly-tuned jump is
badly tuned everywhere. update(dt, dir, { jump }) takes the button's CURRENT
state and the controller owns the timing: coyote time so a press a few frames
late still counts, buffering so a press slightly early is not lost, and a cut on
release so a tap hops and a hold arcs.
The cut applies once, on release. Applying it every airborne frame compounds (×0.45, ×0.2, ×0.09…) and swallows the jump entirely — caught by the release-before-landing test, not by reading the code.
state (idle | walk | jump | fall) so animation follows what the
character is DOING rather than what was pressed; deriving it from input leaves a
character walking in mid-air. teleport() is the respawn primitive, and it
clears vertical velocity — without that, a character rescued from a long drop
arrives still travelling at the speed it fell.
Touch (Input3D({ touch }), on by default for coarse-pointer devices). An
on-screen thumbstick and jump button, merged into the same direction() and
jump a keyboard feeds, so no game branches on input source. Before this, 3D
was simply unplayable on a phone.
The controls mount to <body>, not the game's #hud: a game that writes
hud.textContent = '...' wipes every child, and the controls vanish with no
error at all. That happened. The HUD belongs to the game; this layer belongs to
the platform.
What is deliberately missing
No character controller (Rapier's KinematicCharacterController is the intended
basis), no input, no audio, no editor, no HUD, no asset pipeline integration, no
published build. Those are the next slice's scope, and pretending otherwise
would be a worse lie than the gap.
ThreeUmicat.dialogue has no default renderer: a game passes opts.renderer
or it throws. A 3D dialogue box is a design question nobody has answered, and
shipping a broken default would be worse than requiring an explicit one.
