# Starlight Menagerie: design notes

Seven, the keeper robot from FIELDWORK, works the night shift at Doorly Orbital, a zoo station in orbit around the gas giant Samhain. First he walks the zoo and logs it. Then the hull fails, the animals get out, and he runs for the last escape pod.

## The brief

- A walkable zoo inside one vast station chamber, with a room for each species group and a home planet for each room.
- The layout and size of Omaha's Henry Doorly Zoo and Aquarium.
- Models remodelled in Blender from the 3D asset collection. The station modelled in parts with levels of detail.
- Sounds from the SFX library, inspected, then ported from WAV to MP3.
- The Island V5 loader recipe, with every shader compiled before play.
- A Halloween twist: a breakout, emergency lights, jump scares, a stun gun that costs power, a shield that damage eats.
- A cut scene for the game over. A cut scene for the escape, which ends on an island.
- Desktop and mobile, portrait and landscape.

## The map

`data/layout.json` is the one source of truth. The Blender scripts and the game both read it.

- The chamber is an ellipse 1,000 m by 660 m, about 128 acres. That is the size of the Henry Doorly Zoo.
- x runs east, z runs south. The entrance (Glacier Gate Concourse) is in the west, like Omaha's main gate. Pod Bay C is the station's lifeboat dock at the far east end.
- Each habitat records its Omaha counterpart in an `omaha` field. The jungle hall sits just inside the gate, the dune dome on the rise with the night house beside it, the gorilla gorge below the dome, the aquarium and the insect pavilion along the north, the grasslands in the east, the highlands and sea lion shores in the south, and the long aviary in the south-east.
- Paths are a graph of named nodes and edges. The game routes over it with A* for the breakout's amber route.

## Two halves, one world

- **Explore.** The PA welcomes Seven. Placards glow at each habitat. Hold E (or Scan) for one second to log one. The logbook card tells you the habitat, its Omaha counterpart, its home planet and a line about each species.
- **The shift clock.** The rounds run on a five-minute countdown. When it reaches zero the meteors come; logging all twelve habitats first brings them nine to fifteen seconds later.
- **Breakout.** A cut scene shows the savanna barrier failing. Then the barriers drop, the lights go red, the fog closes in, and the hull timer starts: 8:00 on easy, 6:00 on normal, 4:30 on hard.
- The breakout reuses every mesh and material. Shared uniforms (`U.alarm`, `U.flicker`, `U.meteor`) change the look, and the light count stays constant per quality level, so nothing recompiles when the scare starts.

## Seven

- Tank controls after Island V5's robot, with a chase camera that stays out of walls and under roofs. In portrait the camera pulls back so Seven does not fill the screen.
- **Shield.** Animal hits and falling debris take it down. It repairs itself after three seconds without a hit, and the repair costs power.
- **Power.** The stun gun costs power per shot. The headlamp drains it slowly. Chargers along the paths fill it back up. At zero, the lamp goes out.
- **Stun gun.** Aim assist picks the animal nearest the reticle. A stunned animal sleeps, then wakes up angry.

## The animals

- 53 species from the polyperfect low-poly packs, each assigned to a planet in `blender/animals/species.py`.
- `build_animals.py` imports each FBX, flattens the hierarchy, bakes the mesh into the rig's space, gathers the clips (named takes, Unity `.meta` clip splits, clips borrowed from a sibling rig, or procedural slither, pulse and swim clips), and strips object-level channels.
- When the mesh file and the animation file disagree on the rest pose (the zebra), the clips are retargeted: each frame, every bone takes the source bone's armature-space matrix, solved back into the target rig's local channels.
- The giraffe file holds two meshes. Only the spotted one is bound correctly, so `meshpick` selects it.
- Remodels per planet: crystal horns, fronds, lures, spines, extra eyes. Each is skinned to the right bone. Textures are retinted in HSV by planet. Eyes are estimated from the head bone and exported in the JSON so the game can draw glowing eyes in the dark.
- `tools/pack-glb.mjs` packs each file: drop constant rest-pose channels, resample, prune, dedup, weld, quantize, reorder, meshopt. About 11 MB for all 53.
- Behaviour (`src/animals.js`): calm, wander, wild, hunt, attack, stunned, flee. Fliers dive. Tank swimmers stay in their tanks. Visibility and bone updates thin out with distance from Seven.

## The station

- `blender/station/build_station.py` builds the station from the layout in parts:
  - the hull in eight sectors, each with a near (L0) and far (L1) level of detail, plus the glass crown and the monorail;
  - the floor in quadrants;
  - one file per habitat, each with L0 and L1, built by kind: hall, dome, hex pavilion, aquarium with tunnel, aviary, open enclosure with cliff, moat, rock, glass or fence rim, plaza, dock and pod bay;
  - a prop kit on one shared texture atlas (polyperfect nature props plus placards, lamp posts, benches, chargers, force-field posts, signs, beacons, crates and debris);
  - the exterior, the escape pod and the island, used only in cut scenes;
  - the stun gun.
- The game places the kit with `CulledInstances` and swaps habitat and hull LODs by distance.

## Detailing pass (1.1.0)

- **Hull services** (`wall_services` and `rib_detail` in `blender/station/build_station.py`): every 20 m stretch of wall gets a hazard plinth and floor grate, stringers, a three-pipe bundle on brackets, a hose loop (every other stretch) and a hose to a floor socket (every fourth), a cable tray with three cables, a catwalk with grating, posts, rails and braces, a vent grille, a junction box with a feed pipe, and a maintenance door with keypad, lamp and ladder every eighth stretch. Ribs are I-beams with a downpipe and clamps. Portholes get frames, and the vault gets purlins with a lamp ring.
- These fittings are built in their own part and merged after the shell's faces are turned inward, so closed shapes keep their outward faces.
- **Habitats**: `wall_runs` adds eave pipes, brackets, downpipes and vent boxes to every solid wall at least 8 m tall, and pipes along the inside for halls, the aquarium, the dock and the pod bay. `ceiling_truss` adds roof trusses, cable trays and hanging hoses; `roof_units` adds air handlers and fans.
- **UVs**: `Part.to_object` writes UVs in metres. Hull plating is mapped round the chamber (u is distance along the wall, v is height plus distance up the vault); everything else is box-projected by the face's main axis. Each face is shifted by a whole number of 40 m and divided by 1024, so the web packer stores UVs as 16-bit; the game sets each texture's repeat to 1024 / tile.
- **Painted fittings**: Pipe, PipeY, PipeR, Hose, Cable, Bracket, Rib, Frame, Trim and Warning fold into one `Paint` material with a per-vertex colour; alpha carries bare metal (1) or paint and rubber (0).

## Surfaces and light (1.1.0)

- `tools/make_textures.py` writes `assets/tex/{panel,hull,deck}_{albedo,normal,orm}.webp`. Panel plating tiles at 4 m on fittings; the hull set (wider seams, bigger plates, stronger tone) tiles at 8 m on walls; deck plating tiles at 10 m (sixteen 2.5 m plates matching the deck's seam lights).
- `surfacePatch` (src/station.js) adds world-space wear to the textured materials: broad tone, soot and a contact shade near the floor, roughness from the soot, and on walls the strip-lamp wash and a vault that darkens with height. `hazardMaterial` draws the stripes in world space.
- The deck samples the plating only on plated zones; lawns get a two-scale mottle instead. A light map painted at start-up (`paintLightMap`) adds warm pools under lamp posts and along the plinth lamps (R) and contact shade along every wall (G).
- `Station.environments` prefilters a small stand-in chamber into calm and alarm environment maps. Swapping them does not recompile, since both are the same kind of texture.
- The four pooled point lights now follow the nearest lamp posts in the calm (warm white) as well as in the breakout (red beacons). Ambient fill is lower (hemisphere 0.42, environment 0.7).

## Clutter and biome generators (1.2.0)

- `build_clutter` in `blender/station/build_station.py` models a clutter kit (`assets/station/clutter.glb`) in the shared Paint material: toolboxes, cable spools, barrels upright and fallen, cones, pallets, crates, loose panels, pipe offcuts, hose coils, buckets, carts, wet-floor signs, bins and litter; grass tufts and tall grass, leaf litter, twigs, branches, logs, pebbles, boulders, bones, hay and a trough, ice shards, snow drifts, wax comb, shells, mushrooms, feathers and a termite mound; and for the breakout, rubble, broken glass and fallen plates. `tools/kit-sheet.html` renders the kit as a contact sheet.
- `src/clutter.js` places it with seeded generators, so every visit looks the same:
  - **Biomes.** Each enclosure's home world names its ground (grass, sand, snow, soil or tank), and each ground has a recipe of pieces per 100 square metres. Planets add their own (crystal mushrooms on Thallo, bones on Rakh, feathers in the aviary, wax in the insect pavilion). Grass is tinted per planet. Half of each kind is scattered loose and half in clumps.
  - **Lawns.** Patches of tufts with leaves, pebbles, twigs and worn earth, plus loose single tufts between them.
  - **Path edges.** Litter, leaves, tufts and dirt along every path, oil stains on the plating, and bins, wet-floor signs, cones and carts at the junctions.
  - **The wall foot, the buildings, the dock and the pod bay.** Clusters of tools, spools, barrels, crates, panels and hose coils where crews leave them, with oil and scuffs on the deck.
  - **Wreckage.** A hidden layer of glass under every barrier, rubble, fallen plates and scorch marks along the paths and the hull. `Station.breakBarriers` shows it when the breakout starts.
- Stains are flat quads on one canvas atlas (oil, dirt, scorch and scuffs) drawn at start-up.
- Every piece goes through `CulledInstances`: one instanced draw per material part, bucketed into 24 m cells, culled per cell. Small pieces draw to 68 m, medium to 120 m, large to 180 m. Counts scale with quality (low 0.45, medium 0.75, high 1); on low nothing in the clutter casts a shadow.

## The hull spiders (1.3.0)

- **Model.** `blender/spider/build_spider.py` builds the spider from primitives in Blender: a cephalothorax that rises to an eye mound, eight eyes, chelicerae with curved fangs, pedipalps, a waist, an abdomen with spinnerets, and eight legs of four segments (coxa, femur, tibia, tarsus). Each vertex belongs to exactly one of 43 bones, because an exoskeleton does not bend. Colour is per vertex (pale knee bands, chevrons, a red mark beneath); vertex alpha is hair density. The 3D collection's polyperfect spider is a 529-vertex cartoon, too coarse to carry hair or a leg rig, so the giant is built from scratch. Pack with `tools/pack-glb.mjs`.
- **Legs.** Nothing is keyframed. `src/spiders.js` solves each leg every frame in the spider's own frame, where the surface it stands on is always the plane y = 0, so the same gait works on the deck and up a wall. The coxa turns toward the foot, the femur and the tibia-plus-tarsus pair are a two-bone solve with a fixed bend at the tarsus, and each bone's rotation is the turn that takes the straight rest leg to the solved one. An alternating tetrapod gait sets the feet: stride and cadence from speed, each foot's slide from walking and turning, lift on the swing. Threat, strike and tuck poses blend over it.
- **Hair.** Cards grown over the chitin at start-up (about 4,200 on Full, 2,000 on Light), each skinned to the bone of the skin it grows from. The strand direction rides through the skinning as the normal; the vertex shader then widens the card sideways to face the camera. Hair dissolves out past 30 m.
- **Where they live.** Bays of the hull between the ribs, 20.6 to 25 m up (above the top stringer and the catwalk), and the outside (and, for halls, inside) faces of the tall buildings. They come down and go back up on silk lines.
- **Behaviour.** Lurk, drop, hunt, circle, lunge and bite, back off, recoil, flee, climb. Fire is checked every frame: inside a fire's radius they shriek and flee; when Seven stands in firelight they pace its edge and hiss, and give up after a while. In the breakout, up to two or three come down for Seven when he is out of the firelight near their wall; if none is close, one drops on a line out of the dark in front of him.
- **Fire** (`src/fire.js`). The held flare has its own point light, created at start-up so the light count never changes. Thrown flares and burning barrels borrow the station's pooled lights. One Points cloud draws every flame. Fifteen flare lockers stand beside the paths.

## Sound pass (1.3.0)

- The rain-forest and cricket beds measured 29% and 94% of their energy above 4 kHz, and the RMS levelling made them the loudest beds to the ear. `tools/build_sfx.py` now notches 3.5 to 6.5 kHz and shelves off the top for them, and `src/audio.js` trims every bed to about -20 LUFS from an EBU R128 measurement.
- The reverb impulse was plain white noise; it now has a pre-delay and darkens as it decays. A -3 dB shelf at 9 kHz and a limiter sit on the master. Positional sounds lose their top end with distance (air absorption) and get a little more reverb.
- `tools/make_chime.py` synthesises the PA chime that replaced the static burst.

## Pop-in and distance (1.3.0)

- **Screen-door fades** (`src/perf.js`). Each faded mesh carries a one-element instanced attribute (start, end, amount, mode), because three.js only re-uploads material uniforms when the material changes between draws. Instanced sets dissolve per instance over the last fifth of their draw distance, and a cell stays drawn while any part of it is inside that distance. Habitats and hull sectors cross-dissolve between their two levels with complementary dither patterns over a band of 30 to 45 m.
- **Full quality** draws props to 290 m (signs and lamps to 320), clutter to 92, 155 and 230 m by size, the detailed habitats to 290 m (open pens 330), and casts shadows 100 m out on a 3072 map.
- Far surfaces get rougher with distance, so sub-pixel highlights stop shimmering.

## Living surfaces (1.3.0)

- `sssPatch` (src/shaders.js) replaces three.js's direct diffuse term per light: wrapped N.L, a subsurface tint in the terminator band, back-light transmission through thin parts, and a velvet sheen. The specular term is untouched. A per-material GLSL mask decides how much of each texel is living: animals are all skin, the nature atlas's leaves are masked by colour (rocks and bark stay solid), and the clutter's natural pieces get their own non-metal copy of the paint material.

## Mobile notes

- Low quality (the default on touch devices): pixel ratio up to 1.25, half-size surface textures, 1024 shadow map, no hull shadow pass, shorter LOD distances, frame cap at 60.
- Measured on low in a 390 x 844 portrait view in 1.3.0 (clutter, spiders, lockers and fires included): about 200 to 370 draw calls and 195k to 260k triangles per frame, including the shadow pass. Full, at 1280 x 720: about 500 to 575 calls and 570k to 690k triangles.
- Background and foreground: the game pauses and audio suspends; the next touch resumes audio. Orientation and visual viewport changes re-fit the view twice. The full-screen button hides without a full-screen API. A lost WebGL context shows a reload button.

## Sound

- `tools/build_sfx.py` inspects every source WAV before use: codec, sample rate, channels, duration, peak, RMS loudness, DC offset, clipping, and leading and trailing silence. The report is `assets/sfx-report.json`.
- Two beds (savanna, desert) were dropped because their sources were near silent. One stun hit had a DC offset, which was removed.
- Everything else was trimmed, levelled and encoded to MP3. Ambience beds are 30-second loops with a crossfade at the seam.
- A few animal calls came from the polyperfect packs as OGG and were converted the same way.
- The three music cues (explore, chase, island) are synthesized by `tools/make_music.py`.

## Loader and performance

- After Island V5: `loader.js` and the `assets.js` manifest (`tools/make_manifest.py` writes it), version cache-busting, a session flag for a fresh start, and named build steps with progress.
- Before the start screen, every material is compiled for the station (calm and alarm), the space scenes and the island.
- `perf.js` from Island V5: culled instances for the prop kit, rigid skinning and a shadow diet for Seven's treads, adaptive resolution and a frame cap on touch and low quality.
- Quality presets (low, medium, high) set lights, shadows, bloom and pixel ratio. The island ending turns bloom off; the page reloads on restart.

## Cut scenes

- **Intro.** Arrival at the station from orbit, then into the concourse.
- **Breakout.** Meteors, the savanna barrier failing, the PA, and Seven's line.
- **Game over.** Seven powers down while the animals gather, then the station breaks up as seen from orbit.
- **Escape.** Seven boards the pod, it launches past the explosions, falls toward Lumen, Samhain's blue moon, and lands off an island at sunrise. The parrot and toucan from Island V5 land beside him and the gulls circle. The last card links to Island V5.

## Testing

- `tools/views.mjs` drives the game in headless Chromium (SwiftShader) with `?autostart=intro|explore|breakout|now` and `window.__zoo` hooks (`step`, `escape`, `gameOver`). It was used to check the scan flow, the stun gun, both endings, and portrait and landscape touch layouts.
- `tools/playthrough.mjs` plays the whole game on autopilot (routing over the path graph, holding scan at placards, striking flares when spiders come down, stunning what gets close, detouring to chargers and lockers, boarding the pod) and captures it as a time-lapse, stepping the game with a fixed timestep because SwiftShader renders far slower than real time.
- `tools/animal-sheet.html` renders any set of animals at any clip and time, which is how the giraffe and zebra rig faults were found.

## Paths

The build scripts expect the asset packs unpacked under `/home/claude/src3d` (see `tools/unpack.sh`) and Blender's `bpy` module. Set `PP_PACK`, `ZOO_OUT` and `STATION_OUT` to point them somewhere else.
