by sloemo01
Pixel-art diorama playbook and Three.js code kit: research-first build workflow, locked camera, palette snap, ordered dither, inked contour, animated toy-scale cast, per-entity frame QA.
# Add to your Claude Code skills
git clone https://github.com/sloemo01/dioramaSee how diorama compares with popular alternatives.
diorama is an open-source ai agents skill for AI coding assistants such as Claude Code, Codex CLI, and ChatGPT, built by sloemo01. Pixel-art diorama playbook and Three.js code kit: research-first build workflow, locked camera, palette snap, ordered dither, inked contour, animated toy-scale cast, per-entity frame QA. It has 186 GitHub stars.
diorama's catalog security scan is still queued. You can run an instant dependency and prompt-injection check now with the "Scan for vulnerabilities" button above.
Clone the repository with "git clone https://github.com/sloemo01/diorama" and add it to your Claude Code skills directory (see the Installation section above). diorama ships a SKILL.md manifest, so compatible agents can discover and load it automatically.
diorama is primarily written in JavaScript. It is open-source under sloemo01 on GitHub, so you can review or fork the full source.
Yes. SkillsLLM lists many other AI Agents skills you can browse and compare side by side. Open the AI Agents category from the badge at the top of this page, or use the Related Skills and comparison links further down to weigh diorama against similar tools.
No comments yet. Be the first to share your thoughts!
Available after the catalog security scan passes.
⚠️ Third-Party Software Notice
This skill is third-party open-source software developed and hosted independently on GitHub. SkillsLLM is an informational directory and does not control or maintain the underlying repository.
Any security checks, ratings, or warnings displayed by SkillsLLM are automated and limited in scope. They do not constitute a security certification or guarantee that the software is safe, error-free, or free from malicious code, vulnerabilities, compromised dependencies, or prompt-injection risks.
Review the source code, permissions, dependencies, and configuration before installing or running any third-party skill. Use is at your own risk. To the maximum extent permitted by applicable law, SkillsLLM is not liable for losses arising from third-party software.
The deep catalog scan for this skill is still queued. Run an instant dependency check now instead.
A diorama is a tiny staged scene on a slab: a locked three-quarter camera, a flat cut-out against white, a palette-snapped pixel grid with an ink contour, and toy-scale people with real joint animation. Any subject works (pool, market, kitchen, bank job, launch pad, aquarium). The slab and the cast stay; the props change.
This file is the whole guide. It carries no external reference documents:
everything needed to build a diorama is here, plus copy-in modules and a design
sheet template in templates/.
Read Research Before You Model, One Prompt, One Build, The Look Contract,
The Detail Bar, The Animation Bar and Pipeline first. Then read the sections for the systems
you are actually building; the rest is reference. Do not spend the first pass
reading every vehicle or water note for a kitchen scene.
| If the commission is... | Read next, in this order |
|---|---|
| market, food truck, bakery, cafe, shop, laundromat | Cast → Clothed Cast: Heads, Layers, Hands → Service And Queue → Machine Grammar → Carrying, Loading And Mounting → Verification |
| heist, defence, combat | Detail Bar → Tool/Tower recipes → System And Combat Animation → Combat Readability → Verification |
| creature battle, game scene, anime/movie fight | Research Before You Model → Field Notes: Two-Creature Combat → Creature Anatomy → System And Combat Animation → Combat Readability → Verification |
| aquarium, pool, harbour | Cast → base rig → Water templates → Vehicles (boat) → Effects → Verification |
| station, airport, hotel, transit | Service And Queue → Vehicles → Crowd Variation → Verification |
| stage, festival, parade | Cast → Secondary Motion → Crowd Variation → System Grammar → Verification |
| farm, garden, nature | Creature Anatomy → Ambient Loops → Effects → Verification |
The universal shape still applies: place + repeated ritual + interruption + consequence. The fast path only decides which reference notes to read first.
Don't use for full-resolution product renders, photoreal scenes, or anything that needs free orbit controls.
Four lookups come before the first mesh: the subject by frames, the asset libraries, the motion itself, and the capture loop. Skipping them is how a build ends up with a plausible silhouette that matches nothing and animation that reads as generic. Budget for them, they are usually faster than the modelling they save.
| Question the research answers | Where it lands |
|---|---|
| what does it look like, exactly | refs/<subject>/*.png contact sheets |
| what does it do, in what order, how fast | refs/SOURCES.md + the beat table |
| does a rig or clip already exist | CREDITS.md + assets/ |
| is it actually right on screen | qa/ captures, compared to the sheets |
Find the real thing moving, not stills. For games, anime, sport or machinery the footage is the specification: it settles what the shape is, where the motion starts, how long each stage lasts and what colour it is at each step.
yt-dlp --flat-playlist --print "%(id)s | %(title)s | %(duration)s" "ytsearch6:<subject> animation"
yt-dlp --extractor-args "youtube:player_client=ios,android" -f 18 -o "refs/clip.%(ext)s" <id>
ffmpeg -i refs/clip.mp4 -vf "fps=8,scale=300:-1,tile=6x4" refs/sheet-%02d.png
Read the contact sheets with vision, then write the grammar down in words before touching geometry: ball leaves the hand on an arc, opens with a white burst, the creature springs out at full size, the ball snaps shut and returns. A paragraph like that is the acceptance test for the effect you are about to build.
Rules that come out of doing this repeatedly:
refs/<subject>/ next to the build so the next person can
see what you were matching, and so QA has an image to compare against.Hand-building a creature from primitives is the last option, not the first. A rigged download with real deformation and usable clips beats four hundred lines of spheres the moment the camera comes within a metre. Search first, and keep a record of what you searched.
The ladder. Every hero asset walks down it in order.
tools/, assets/ or
model/ directory with GLBs in it. List it before anything else.Creatures, characters and rigs
| Source | Licence | What is actually there | How to search |
|---|---|---|---|
| Quaternius | CC0 | hundreds of low-poly creatures, characters, nature, buildings; the Universal Animation Library is 120+ clips | browse quaternius.com/packs, grab the pack GLB |
| KayKit / Kay Lousberg | CC0 | adventurers, skeletons, dungeon and RPG packs, rigged and animated | itch page, per-pack zip |
| Poly Pizza | CC0 or CC-BY | 10,000+ low-poly props, creatures, vehicles; GLB/OBJ, no login | search by noun, read the badge on each card |
| Kenney | CC0 | kits for props, vehicles, characters, UI, audio | category browse |
| Sketchfab | per model | the long tail: specific machines, animals, buildings | filter Downloadable + CC0/CC-BY before looking at anything |
| Smithsonian 3D | CC0 | scanned real objects, fossils, artefacts, vehicles | search, check the licence note under the download |
| NASA 3D Resources | public domain | spacecraft, rovers, terrain, helmets | browse by category |
| glTF Sample Assets | per model, mostly CC-BY 4.0 | canonical rigged test models (CesiumMan, Fox, BrainStem) | read LICENSES/ before use |
| Objaverse | mixed, per object | 800k research objects, good for odd props | verify the per-object licence, most are not clean |
| Meshy, Tripo, user-supplied | user owns the output | anything the pack libraries do not have, generated from the reference | export GLB, keep the prompt in CREDITS.md |
Animation clips (motion is usually harder to hand-key than shape is to model)
| Source | Licence | Notes |
|---|---|---|
| Quaternius Universal Animation Library | CC0 | 120+ humanoid clips, retargetable, best first stop |
| Mixamo | free with an Adobe account | thousands of humanoid clips + characters; check current Adobe terms, retarget in Blender or three.js |
| Rokoko Motion Library | 150 free clips, paid beyond | mocap quality, good for weight and follow-through |
| CMU Graphics Lab Motion Capture | free for research and commercial use with credit | raw BVH, huge, needs cleanup |
| Sketchfab animated models | per model | often ships with an idle/walk/attack set |
| Godot4 Open Animation Libraries | CC0 | Mixamo clips rebaked as Godot .bin/GLB banks, already retargeted; jwelchgames/Godot4-MixamoLibraries is the other mirror |
Props, environments, textures, light
| Source | Licence | Notes |
|---|---|---|
| Poly Haven | CC0 | HDRIs, textures, models; the HDRI set is the fastest way to fix flat lighting |
| ambientCG | CC0 | tiling PBR materials, HDRIs, some models |
| OpenGameArt | mixed | filter to CC0 / CC-BY; 3D section is thin but has good odd props |
| madjin/awesome-cc0 | index | a maintained list of CC0 sources when the usual four fail |
Searching them without a browser. Three of the libraries have open APIs, so a build can check them in a single command and read the licence back:
# Sketchfab: downloadable + CC0 only, name and page printed for each hit
curl -s "https://api.sketchfab.com/v3/search?type=models&downloadable=true&license=cc0&q=dragon&count=24" \
| python3 -c "import json,sys;[print(r['name'],'|','https://sketchfab.com/models/'+r['uid']) for r in json.load(sys.stdin)['results']]"
# Poly Haven: models, HDRIs, textures (filter by category)
curl -s "https://api.polyhaven.com/assets?t=models"
curl -s "https://api.polyhaven.com/assets?t=hdris&c=studio"
# ambientCG: materials and HDRIs by keyword
curl -s "https://ambientcg.com/api/v2/full_json?type=Material&limit=20&q=concrete" \
| python3 -c "import json,sys;print([a['assetId'] for a in json.load(sys.stdin)['foundAssets']])"
Poly Pizza, Quaternius, KayKit and Kenney are browsed by search on their own
pages; query them with the noun plus the style (low poly dragon, kaykit skeleton), not with a sentence.
Reference only, never geometry: The Spriters Resource, The Models Resource, sakugabooru, game rip archives, Pinterest. Use them to learn the shape and the timing, then build or buy the actual asset.
Shape is half the job; the other half is the motion, and it is researched the same way. Search the verb, not the noun.
# the motion itself, canonical first
web_search: "<subject> <move> animation" # a game doing it
web_search: "<subject> <move> slow motion" # real footage, for weight
web_search: "<subject> sprite sheet" # frame-by-frame timing
yt-dlp "ytsearch6:<subject> <move> animation"
ffmpeg -i clip.mp4 -vf "fps=12,scale=320:-1,tile=6x4" refs/move-%02d.png
Read the sheet, then count frames and convert: seconds = frames / fps, and
scale to the loop length you are building. That is how a 0.42 s anticipation and a
0.18 s contact become the beat table instead of a guess.
Write four lines per action before animating it:
wind-up 0.00 -> 0.42 weight back, head down, tail still
contact 0.42 -> 0.55 fastest frames, one pose held for two frames
follow 0.55 -> 0.90 overshoot, then settle
recover 0.90 -> 1.30 back to idle
Every attack in the beat sheet needs all four. Missing anticipation is why an effect reads as "it just happens", and missing follow-through is why it reads as "it snaps back".
For rigs, cross-check the reference against a real clip: if the library has a punch, a swipe or a jump that is close, retarget it and offset the acting, rather than keying a pose from scratch.
Every build writes refs/SOURCES.md. It is the proof the searches happened and
the reason the next person does not repeat them.
| Question | Searched | Found | Decision |
| --- | --- | --- | --- |
| what does the send-out look like | yt: "sword shield send out slow motion" | RoUJ_TWvB3g, orb + white burst | matched, timings below |
| hero creature rig | poly.pizza "dragon", quaternius characters | nothing matching the silhouette | hand-built from refs/sendout |
| hit reaction weight | yt: "pokemon take damage animation" | 0.9 s flinch, 1.7 s stagger | HURTS table |
A build with no SOURCES.md has not finished its research step, even if the
scene looks right.
Every beat gets frozen, captured and compared against the reference sheet before the next beat is built. The contact sheet of those captures is the review artifact, not a paragraph describing them.
?t=<second> in the page,
freeze(t) through the debug hook, screenshot the frame element.When the user gives a subject in one line, treat it as a commission, not a questionnaire. Make the creative decisions, state them in one short assumption line, and build the whole diorama. Do not stop at a plan, a blockout or a design sheet and wait for approval.
The only reasons to ask are credentials, a destructive action, or a path that would overwrite the user's work. Everything else is a default.
Run the three lookups in Research Before You Model first:
CREDITS.md line per import and the search in refs/SOURCES.md.qa/shoot.mjs, a frozen ?t= frame, a contact
sheet) so every beat can be reviewed as a picture.For an invented subject, spend the same time on reference density instead: find three images that agree on what the place looks like, list the props they share, and build those before any invented detail.
Use these unless the prompt explicitly says otherwise:
| Knob | Default |
|---|---|
| Slab | 21 x 14 x 0.42 m; 16 x 12 for one room, 24 x 16 for a street, port or launch pad |
| Loop | 46 s at 40 Hz; one readable cycle plus a quiet seam |
| Palette | 64 entries |
| Systems | 4 defences/machines/service stations/attractions; 3 is acceptable, 6 is the ceiling |
| Cast | 4 to 6 roles total, including one disruption; 2 ambient loops |
| Props / landmarks | 2 hero props, 1 landmark, 3 to 6 supporting props |
| Hero detail | every hero object meets the Detail Bar |
| Animation | every hero actor has all five layers |
| Output | index.html, <slug>.html, README.md, DESIGN.md, src/, dist/ |
| Path | <cwd>/<slug>/ unless the user named a folder |
| Minimum viable | 3 systems, 3 roles + 1 disruption, 1 hero prop + 1 landmark, 30 s loop; still passes the bars |
State the assumption in one line before coding:
Assumption: market archetype, 46 s loop, 4 service systems, 6 roles plus a late customer, 21 x 14 slab, 64 colours. Say if you want a different scale or genre.
That line is the contract. State it, then continue in the same turn; do not wait for approval. It gives the user a chance to redirect after seeing the assumption, without blocking the build.
A scene like this runs for hours of agent time and thousands of messages: render loops, one screenshot per beat, one probe per bug. The session will need to compress its context, more than once, and compaction is lossy. Build so that a compact does not cost you the work:
DESIGN.md, refs/SOURCES.md,
CREDITS.md, qa/report.json and the beat table in stage.js must carry every
decision. If a fact only exists in a message, it is already gone.qa/frames/<entity>/sheet.png and shots/t-12.40.png are
stable handles a post-compact agent can re-read without rerunning anything.frames.mjs and
shoot.mjs rather than trusting your summary of what the last frame looked
like: the numbers are cheap, and a confident memory of an old build is the
most expensive thing in the session.TODO.md or an issue) so the thread
that survives compaction is the plan, not the chat.| Prompt shape | What to do |
|---|---|
| one noun ("a bakery") | pick the closest router row; invent the verb, disruption and goal |
| one sentence ("a lighthouse under siege") | take its nouns and verbs literally; fill the missing cast/systems from the router |
| a reference image | match its composition, camera side and palette first; keep this skill's detail/animation bars |
| "like X but Y" | reuse X's structure, replace the subject and the detail anchors; do not copy X's nouns |
| "make it detailed" | use the full Detail Bar and five animation layers; do not add more systems |
| "keep it simple" | cut systems and cast, not detail per hero; two rich objects beat six blockouts |
A prompt that names only a place is still a commission. Decide what the place does, who arrives, and what goes wrong. State the assumption and build.
Pick the closest row. Do not invent a new genre from scratch unless none fit.
| If the prompt is... | Systems (3–5) | Cast | Goal / disruption | Detail anchor |
|---|---|---|---|---|
| defence, siege, castle, base, zombies | 4 towers + gate guns | defenders, 4 species, boss | boss reaches the gate | gatehouse |
| heist, bank, museum, casino | drill, lasers, cameras, getaway | 3 crew, guards, manager | vault opens / escape | vault door |
| market, kitchen, bakery, cafe, food truck, night market | stalls, ovens, prep, pass, dispatch | vendors, cooks, cashier, courier, customers, inspector | closing time / service rush / last bag | counter, hatch or oven |
| laundromat, workshop, gym, repair shop, arcade | washers, dryers, benches, tools, machine slots, ticket rail | folder, attendant, customer, courier, cat | last load / closing / power cut | machine bank or workbench |
| launch, factory, port, mine | gantry, press, crane, conveyor | crew, inspector, cargo | launch / ship / lift | gantry |
| aquarium, pool, harbour, reef | filters, pumps, slides, lights | fish, swimmers, diver, lifeguard | feeding / party / storm | reef or tank |
| station, airport, bus, hotel | signal, platform, desk, luggage | commuters, guard, porter | last departure | clock |
| festival, temple, carnival, funeral | stage, rides, altar, procession | performers, crowd, official | climax / storm | big top or altar |
| farm, garden, safari, nature | irrigation, harvester, weather vane | animals, farmer, crows | harvest / storm | barn |
| home, shop, library, workshop | rooms, counter, shelves, tools | family, clerk, customer, pet | opening / closing | window |
| arena, battle, mech, gladiator | traps, weapons, doors, lifts | fighters, champion, beast | champion enters | gate |
If none fit, use the universal shape: **place + repeated ritual + interruption
Do not freeze. Answer four questions about the noun:
Those four answers give you the system, cast, goal and residue. Then use the five beats: normal output → first interruption → escalation → goal contact → residue and seam. No combat required. A deadline, an inspection, a storm or a closing bell is a valid boss.
Worked one-prompt example:
"a tiny bakery at closing time"
DESIGN.md yourself. Do not ask the user to fill it.file://, README and DESIGN.md agreeing.If time is tight, cut the number of systems or actors, never the detail per hero or the animation layers. Two detailed towers with five layers beat six blockout towers with one fire animation.
Do not report "done" until all of these are true:
DESIGN.md exists and matches the build.file:// with zero console errors.When the subject is new, the first deliverable is a design sheet, not a scene.
With a one-line prompt, fill it from the defaults in One Prompt, One Build and
do not ask the user to complete it.
templates/DESIGN.md into the project as DESIGN.md.The rest of this file is the recipe for each pass.
Ten rules. Break one and it stops reading as a diorama.
t. No integration that carries state
between frames, no Math.random, no drift across the loop.A blockout with a camera is not a diorama. The difference between a demo and a diorama is three passes, and every hero object has to survive all three.
The numbers below are reference density from The Hollow Toll, a 21 x 14 m slab with four towers, two gate guns, five species, 42 units, one boss, 527 events and 183 shots. Treat them as a calibration target, not a cap.
| Element | Minimum visible assemblies | Hero target | Required story detail |
|---|---|---|---|
| Tower | 6 | 25 to 45 parts | crew, ammo, livery, wear |
| Creature | 8 | 12 to 20 parts; boss 40 to 60 | gear, scar, trophy |
| Prop | 5 | 8 to 15 parts | contents, tool, repair, rag |
| Landmark | 8 | 30 to 60 parts | one unmistakable silhouette |
| Death | 3 stages | 5 to 8 pieces + stain + soul | species-specific debris |
| Environment | 3 scale cues, 3 story props | 2 ambient loops | a before/after trace |
In a social diorama, read the Death row as Exit: a role-specific departure (turn, walk out, shutter down) with the prop and no debris. The five layers are unchanged; the residue is the changed place, not a stain.
An object can hold two roles. A lighthouse that is also a beam tower gets the landmark budget for its structure plus the machine's moving parts; list both rows in the design sheet. A boss weapon is a separate group: budget 8 to 12 parts for the weapon on top of the body, and say so, or the body will be blamed for a thin haft.
Primitive discipline. No naked box, sphere or cylinder at hero scale. A box gets a chamfer, a frame, plank divisions or a band. A sphere becomes a skull, bale, sack or head with a snout, ties or a neck. A cylinder gets a foot, cap, taper or iron rings. If a shape is still a primitive after the detail pass, it will read as a rock, a blob or a placeholder at 30 to 45 px.
Two to four palette entries per object, plus one accent. More entries turns into noise after the palette snap; fewer turns into a silhouette cut-out. Use value contrast for construction and hue for story.
Detail lives on the camera-facing surfaces. At the locked angle, model the front and the top; simplify the back. A perfect back is wasted geometry. Do not add detail under about 1.5 screen pixels: it aliases into speckle, not craft.
Every prop must touch the ground. Feet, wheels, a base, a contact shadow, a stain. A prop hovering by two centimetres is the single most common "it looks cheap" report. Check the shadow and the lowest vertex, not the group origin.
Every landmark must be the only one of its silhouette. Two skull trees, two red tents or two identical towers read as duplication, not composition. Move, scale or redesign the second one. Check overlap by projecting both bounding boxes to screen: if they merge into one blob, one is invisible detail.
Clear thresholds. Keep at least 2 m of empty ground in front of a gate, portal, spawn mouth or door. No crates, barrels, boulders, bones, vines or rising smoke in that pocket. A grey billboard above an arch reads as a rock flying out of the entrance even when it is transparent.
An actor is animated when it has all five of these, not just a walk cycle:
Background actors need 1, 2 and 5. Hero actors need all five. A tower is an actor too: idle hum, aim, fire, recoil, reload. A service actor's action is order/pay/receive, its reaction is a look-up or step-back, and its exit is the walk-out with the prop. A prop with a moving part (door, flag, wheel, pulley, cauldron) needs its own loop.
Motion is data. Author beats as absolute times or travel distances, then
read them at t. Do not hide a state machine inside render-frame conditionals;
it will pop on a seek, drift on a loop, and be impossible to screenshot.
Nothing at hero scale is static. A tower that spawns a projectile from its centre without recoil, a boss that stands at a door without touching it, a creature that slides without a step: all read as unfinished, even with good geometry.
| Knob | Value | Why |
|---|---|---|
| Camera | fov 20.5, az 45, elev 33 | long lens, near-ortho miniature |
| Framing | fillX 0.84–0.94, fillY 0.86–0.94 |
fit margin only: slab plus landmark tops |
| Subject scale | 1.0, or 0.70 for 30% smaller |
post-fit camera multiplier; never geometry |
| Camera distance | solved, 5 to 300 | bisection; actual distance = fit / subjectScale |
| Post buffer | 1× CSS; 1.2–1.5× for a small subject; 2× faces | extra detail without resizing the diorama |
| Palette | 64 entries (any N, but the shader array and loop must match) | skin, stone, asphalt, glass, livery, props |
| Dither | d*(0.0012 + 0.019*gate) + sparkle*0.0012 |
stipple in ramps only; gate = edge |
| Ink | smoothstep(0.13,0.34,edge), mix 0.62 |
outline without scribble |
| Palette tie-break | secondD-bestD < 0.0005 → lighter |
flat surfaces come out flat |
| Sun | 0xfff8ee at 3.3, hemi 0.24, fill 0.46, env 0.18 |
hard shadows, cool fill |
| Shadow cam | ±13, near 4, far 70, 2048 map, bias -0.00022 | tight around the whole slab, no acne |
| Figure | 1.69 m, hip 0.86, head 0.584 | toy proportions that read |
| Car | L 4.4, W 1.86, belt 0.90, roof 1.44 | door 1.30, leaf sits between 0.34 and 0.90 |
| Scooter | wheel r 0.26, board 0.30, seat 0.83, bars 1.06 | boots and hands land on built surfaces |
| Loop | 41 to 47 s | one robbery beat plus police, and a quiet seam |
The table mixes two different kinds of number, and treating them alike is how a new subject comes out wrong.
Invariants. Come from the look itself and hold on every subject: azimuth 45,
elevation 33, fov 20.5; one locked camera for the whole loop; palette discipline
(every material samples a palette entry, the shader array length equals the
palette length); gated dither, ink at 0.13/0.34 mixed 0.62, the palette tie-break;
the rig's proportions and joint signs; 2048 shadow map with a frustum tight to
the slab; everything a pure function of t.
Calibrated. Measured against one subject and only evidence for it: palette
count (64 for the bank job, 66 here), buffer size and renderScale (1×/1.25×/2×),
fillX/fillY, subjectScale and the camera size divisor, slab dimensions
and deck height,
loop length, sun and hemisphere intensities, and every prop dimension in the
vehicle and scooter rows. Re-derive these per build: start from the value, then
look at a screenshot and change it.
A scene may change any calibrated number if the subject needs it; change the
template and DESIGN.md in the same edit and record the reason. A template that
silently uses a 2x different sun is worse than no template, because the next
reader trusts it.
Each step ends in something you can look at.
palette.js plus a named C map.
Every material samples a palette hex, nothing else. Done when a grep for
colour literals in materials.js finds only palette names.templates/camera.js, slab sized so
its projected diamond leaves a margin. Done when a grey slab shot has roughly
25 to 40 percent white around it at subjectScale: 1.0; for a smaller subject,
apply subjectScale after the fit and expect more paper, not a smaller set.updateProjectionMatrix() after changing the frustum. Done when every
figure has a readable contact shadow (crisp by day, a soft pool by night).templates/post.js: gamma, grade, ink, gated dither, palette
snap, rim dither. Done when a 1:1 crop shows square pixels and a dark contour,
and flat pavement shows no speckle.templates/peopleModel.js (base) or templates/peopleModel-street.js
(clothed) rig, one entry per figure with a base pose. Done when isolated portraits show no gaps, no floating parts, knees
bending the right way.qa/audit.mjs for seams/glows/fades,
camera/render scale, exits and emitters, desktop/iPad/phone, file:// standalone,
zero page errors, motion and camera proven from numbers and strips. See
Verification.A new subject is not a new pile of props. Before modelling, fill this sheet.
Copy templates/DESIGN.md into the project and keep it in sync with the code.
PREMISE
[place] + [a repeated verb/ritual] + [who or what interrupts it] + [payoff].
One sentence. If it has no verb, it is a shelf model, not a diorama.
CAMERA SIDE
Which face is +Z? Where is the threshold, the hero tower, the foreground prop?
Which silhouette must never be occluded from this angle?
CAST SHEET
role | silhouette feature | locomotion | action | reaction | exit/death | signature
...
SYSTEM SHEET
loop length | three or four systems | crew | three or four visitor roles |
goal/disruption (optional) | economy/score (optional) | what changes
BEAT SHEET (absolute seconds)
0.0 establish / portal / doors
... first signal, first system output, first consequence
... disruption reaches the goal (or the last customer arrives)
... contact / handoff / finale, residue, loop seam
DETAIL BUDGET
systems n x parts | vehicles n | creatures/people n | props n | landmark n |
exits/deaths n | ambient loops n | triangle / draw-call ceiling
Rules that keep a new subject from becoming a blockout:
totem, hayCart, gatehouse, troll, not
prop12. Unnamed objects become clutter because nobody owns their silhouette.The Hollow Toll shipped at this density. Use it to calibrate a new subject; a smaller subject should have fewer objects at the same detail per object, not fewer details per object.
| Measure | Value |
|---|---|
| Slab | 21 x 14 x 0.42 m, 64-colour palette |
| Towers | 4 + 2 gate cannons |
| Species | 5 (goblin, orc, slime + split, bat/imp, troll) |
| Units | 42, one boss |
| Combat events | 527 |
| Tower shots | 183 |
| Scene meshes | ~3,100 |
| Triangles | ~164,000 |
| Scene draw calls | ~1,700 at 1440 x 900 |
| Textures | 5 |
| Loop / sim | 46.0 s at 40 Hz pre-sim |
| Standalone | ~783 kB with three.js inlined |
A 12 x 9 m scene with two towers and three species should land around 1,000 to 1,600 meshes and 60 to 100k triangles. Raw count is not the goal; the detail per hero object and the number of readable beats are.
This is the reference build the numbers above come from. Read it as a calibration of what "finished" looks like, not as a scene to copy.
Fixes that became rules in this file:
| Report | Root cause | Rule |
|---|---|---|
| "rocks flying out of the entrance" | camp smoke billboards above the portal | clear the threshold of smoke, props and fireflies |
| troll inside the gatehouse | stop at length - 1.05, body through wall |
stop at body depth + weapon reach; keep the door visible |
| club looked like a rock | end-on pose, haft hidden, no bands | yaw 0.35 rad, build haft + bands + studs, sync contact |
| beam had a black segment | black backing plate in the additive beam texture | clear the canvas; transparent gaps, never black |
| deaths were spheres/rocks | generic burst | species collapse + debris + dropped gear + residue |
| skull tree looked like a monster | a second totem overlapped the tree | one landmark per silhouette; mount the skull on a board |
| cart looked like a blob | pale spheres in an open box | five-part prop: bed, slats, wheels, load, tools, story |
standalone broke over file:// |
String.replace expanded $& in the bundle |
function replacer; test the standalone |
A new subject should copy the density and audit discipline, not these nouns. A lighthouse, market or launch pad needs the same number of readable systems, the same five animation layers and the same proof frames.
The social-scene calibration: no combat, no boss, no deaths. A food truck, a queue, a courier, one scooter and a cat. It proves the same discipline applies to a life scene.
subjectScale: 0.70
after the fit (distance = fit / 0.70), not scaling the slab, cast or canvas;
geometry scaling breaks the world clip and shadow maps, and shrinking the
canvas breaks the 1:1 crops. Backing buffer 2x CSS for the faces, CSS
downscale.Fixes that became rules in this file:
| Report | Root cause | Rule |
|---|---|---|
| "the building bottom/right is cut" | slab edge/backdrop closed too tight | close side walls and undersides; camera fit includes the slab corners |
| "bike lights look unnatural" | large additive disc | emissive lens + small halo; cone only at night |
| "doesn't put the box on the bike" | prop teleported from hands to crate | carry in world space from wrists; lower over the rim; release with a beat |
| "doesn't sit on the bike naturally" | car seat/root constants reused | measure scooter seat; board for the left boot; abduction swing; bars to hands |
| "the human/cat doesn't look human" | sphere head, cap at theta 1.85 | skull+jaw head, hair cap 1.22, ears/brow/lids, tapered limbs |
| "carry the bags more human-like" | prop at root, hands nearby | two-hand hold at the prop's top, object origin at its bottom |
| "no text outside the diorama" | DOM overlay on the paper | empty-paper rule; copy on modelled signs only |
| "30% smaller" | scaling the scene | move the camera; keep world clipping and shadow scale |
| "leg swings over the handlebar" | hip X flexion for a side mount | abduct with hip Z; knee tucked; root arc 0.1–0.2 m |
| "single human slides near the shop" | standing gesture blended during walk | gate gestures on !state.moving; serve after arrival |
A new subject should copy the timing discipline and contact logs, not the food truck. A pool, a station or a bakery needs the same queue/service, carry/load and close-up proof.
templates/post.js renders the scene to an offscreen target, then a full-screen
shader turns it into pixel art. Order matters; each stage assumes the last.
mix(vec3(1.0), c, smoothstep(0.26, 0.72, cov)).c = pow(c, 1/2.2). The target is linear; skip this and the frame is
dark and muddy, which reads as a lighting bug.edge = max(abs(d)), brought to display
scale with pow(edge, 0.4545), then
ink = smoothstep(0.13,0.34,edge) * smoothstep(0.20,0.55,cov) * (1 - smoothstep(0.86,0.985,cov))
mixed 0.62 toward vec3(0.085,0.082,0.076).d = bayer8(gl_FragCoord.xy) - 0.5,
gate = smoothstep(0.004,0.055,edge),
c += (d*(0.0012 + 0.019*gate) + sparkle*0.0012) * uDither. Keep uDither as a
uniform: it is the fastest way to prove whether a speckle is the dither.(1.0, 1.12, 0.93) so
greens do not beat skin. Keep the runner-up and break near-ties toward the
lighter entry, or a surface halfway between two entries flips entry per pixel
on the dither noise and stipples a whole pavement.The palette length is a shader constant: uniform vec3 uPalette[N] in the
fragment source and the JS array must be the same length. A mismatch loses the
last colours or reads out of bounds, and the symptom is a colour that "does not
exist in the palette" appearing on screen.
| Symptom | Cause | Fix |
|---|---|---|
| Water looks like TV static | dither ungated | add the edge gate |
| Frame dark and muddy | missing gamma | pow(c, 1/2.2) before grade |
| Everything mid-grey | no grade before snap | saturate 1.07, contrast 1.028 |
| Scribble outlines | ink threshold too low | 0.13 / 0.34 |
| Missing outline on figures | rim suppression too wide | narrow to 0.86 / 0.985 |
| White speckle on flat deck | material grain aliasing | cut grain, not resolution |
| Bands with hard edges | palette hole | add 4 to 6 intermediate entries |
| Speckle only where a tone sits between entries | palette tie | break near-ties toward lighter |
| Speckle at zero dither | sub-pixel geometry | widen detail to ~1.5 px or delete it |
| Beam goes flat or vanishes on paper | additive white over white | saturated palette core + dark halo; fade before the edge |
| Glow disappears after the snap | sub-pixel or pure-white FX | widen to 2 px+, use a palette entry, add a contour |
| Beam ends in a hard vertical cut | world clip plane on a read-through FX | taper intensity to zero before the slab edge |
| Flame reads as a grey smudge | normal-alpha smoke over a bright source | lighten/remove smoke in the flame corridor, draw flame last |
Isolate noise in this order and stop at the first that removes it: dither
uniform to 0 → widen or delete sub-pixel geometry → disable shadowMap. Judge on
a 1:1 crop; a downscaled preview softens all three into "texture".
Default: setSize renders one target pixel per CSS pixel, capped by maxDim
(1600), and CSS image-rendering: pixelated handles the upscale. The chunky
look comes from the palette snap and the ink, not from a low buffer.
When the diorama is deliberately small on the page (subjectScale under ~0.8),
raise the buffer a little instead of enlarging the subject:
new PixelPost(renderer, 2000, 1.25) renders 1.25 target pixels per CSS pixel;
at a 1312 × 760 CSS canvas that is a 1640 × 950 buffer, CSS still 1312 × 760.
The post pass quantises at the finer grid and the browser downsamples; the page
size, camera solve, slab and clip do not move. 1.2–1.5× is the useful range; 2×
on a full arena is usually a frame-rate tax, not more art. Log post.size and
canvas.getBoundingClientRect() and check a 1:1 crop for shimmer.
For faces and close-up contact beats, use renderScale: 2 and
image-rendering: auto (not pixelated), with maxDim high enough (2400) to
avoid the cap. The post pass still quantises at 2×; the CSS downscale is a clean
box filter, not a change to the art. Keep the CSS size as the layout size so the
camera solve and the 1:1 QA crops stay comparable. Re-run the portraits after
any resolution change: raising the buffer exposes hats, stripes and noses that
hid at the old grid.
CAMERA = { fov: 20.5, azimuth: 45°, elevation: 33°, target: (0,-0.15,0),
fillX: 0.84, fillY: 0.86,
subjectScale: 1.0 } // 0.70 = 30% smaller, more paper
The distance is solved, not typed: build a direction vector from the angles,
project each fit point (slab corners at deck and underside, plus the tallest
building corners) into camera axes, and bisect the distance until every point's
|x|/(depth*tan*aspect) and |y|/(depth*tan) fit inside fillX and
fillY. 48 iterations is plenty. fillX/fillY are the fit margin, not the
page-size control; tune them together, because raising one alone crops the other
axis on a portrait phone.
Subject scale is a second, post-fit move. Solve the fit first, then pull the camera back:
const fit = solveDistance(aspect, points);
const scale = CAMERA.subjectScale ?? 1;
const distance = fit / Math.max(0.1, scale);
camera.userData.fitDistance = fit;
camera.userData.subjectScale = scale;
camera.userData.distance = distance;
subjectScale: 0.70 renders the diorama at 70% width/height — 30% smaller
linearly and about 49% less screen area — so the paper margin grows. 1.0 is the
fitted size. Keep the slab, cast, world clip, shadow camera, CSS canvas and
frame at full size; raise the backing buffer only with renderScale (next
section), never by scaling geometry. Shrinking the geometry also shrinks contact
shadows and the clip volume; shrinking the frame/canvas changes the 1:1 crop
and does not change the model's scale. Move the camera.
A phone is already height-limited, so 0.70 can make the trucks unreadable. Try 0.70, then 0.80–0.90 if needed; the scale is per scene, not a universal 30%.
Verification. Log both distances and assert
actualDistance ≈ fitDistance / subjectScale within 2%. Also project the slab
and compare its width to the fitted width: the ratio must be the scale, not just
the distance. On Dust Kings at 1.73 aspect: fit 67.93, scale 0.70, actual 97.05;
projected slab width 872 → 611 px (0.70), with slab, clip and shadows unchanged.
updateCamera(camera, aspect) on every resize and orientation change, with a
debounced call after orientationchange. A canvas measured at under 2 px is a
layout race: return early rather than dividing by zero.
groundY(x,z) returns street, pavement or step height
so every track and shadow samples the same function.Night is a new light ratio and a new palette, not the day rig dimmed. The sun table is a daylight default; a 2am scene needs its own numbers and its own test.
| Key | Night default | Why |
|---|---|---|
| moon / key | 0x93a7c4 at 0.45 to 0.65 |
cool, low, soft shadows |
| hemisphere | sky 0x1b2740, ground 0x090b12, 0.16 |
blue ambient, never black |
| fill | 0x6d7f9e at 0.10 to 0.16 |
keeps the shadow side readable |
| practical | 0xffb066 at 1.0 to 1.4 |
warm bulb/lamp, 2700 K |
| pool | unlit radial plane, opacity 0.28 to 0.45 | light on the ground, cheap |
| headlight | 0xfff0c8, lens + cone + floor ellipse |
source, throw and landing |
0x000000 is forbidden, as in daylight.0x0b1020, keep warm
highlights from clipping. Test a 1:1 crop: three dark values, a visible source,
a readable pool, no white blob.A night scene passes when the unlit areas still have shape and the light sources are in the frame. "It is dark" is not a pass.
The +Z street convention is for an exterior. If the hero action is inside a shop and the street is between the camera and the hero, the bus/traffic will occlude the beat. Choose one:
For glazing, build the frame, mullions, sill and a few reflection streaks. Do not lay one transparent plane across the opening: it tints the interior, hides the practical lights and turns the cutaway into a fish tank. If the bus must read through the glass, give it lit windows and a dark body against the warm interior; do not brighten the whole room to compensate.
Detail is a hierarchy, not a texture. Build in this order and stop adding when the silhouette is explained.
Every readable prop answers five questions with geometry:
A hay cart from the reference build: bed, six side slats, two spoked wheels with iron tyres, four corner straps, three tied bales, loose straw, pitchfork, sack, red rag, two draft shafts. That is ten assemblies for a prop the user sees for two seconds. A crate with a pale sphere inside is none of them.
A defence tower is a small building, a machine and a crew in one silhouette.
| Layer | What it adds | Failure if missing |
|---|---|---|
| 1 foundation | plinth, steps, rubble, contact | tower floats or looks pasted on |
| 2 shaft | taper, buttresses, bands, ladder | reads as a cylinder |
| 3 platform | deck, rail, hatch, brackets | machine has nowhere to stand |
| 4 machine | weapon, pivot, rail, recoil travel | attack has no origin |
| 5 crew | gunner, cranks, spotter, ammo | tower feels automated/empty |
| 6 livery | banner, number, pennant, paint | tower has no owner |
| 7 wear | scorch, chips, rope, spent cases | tower looks unplaced |
The animation pivot belongs to layer 4. The muzzle or launch point belongs to the weapon, never the tower centre. Recoil travel is 0.1 to 0.25 m for a ballista or cannon, 0.02 to 0.06 for a beam crystal. A tower with no recoil, no crew and no ammo is a monument.
A hollow object must show its opening. A cannon with a solid brass cap is a baton, not a gun; the same failure turns a pipe into a rod, a chimney into a post, a lamp into a mushroom and a tunnel into a painted arch. Build the opening as geometry, not a dark decal:
DoubleSide on a cloned material, never on the shared palette
material);At 1:1 the opening needs a dark centre at least 5 to 6 px across and a lit lip. If the centre is brass, the hole failed.
A rotating assembly owns only its moving parts. The yaw/turret group may contain the carriage, barrel, recoil slide and muzzle. Fixed stores — powder keg, shot pile, rammer, tools, ready rack, crew — belong to a static group on the tower root, never the yaw group. If they are children of the yaw group they sweep around the tower when it tracks a target, which reads as the whole gun deck rotating.
Do not simply reparent the props at deck height: the barrel and carriage sweep through that volume at some yaw. Put them outside both the yaw sweep and the barrel's vertical arc. If the deck has no clear sector, bolt a ready locker to the outer wall below the deck: shelf, two iron brackets, powder chest with corner straps, shot tray, and a rammer hung on two hooks. It reads as part of the tower and never collides.
Test two ways at minimum and maximum yaw: each fixed child's world position delta must be 0, and each fixed mesh's world bounding box must not intersect any mesh under the yaw group. Both checks pass, or the station is still in the arc.
Worked example, Bombard Bastion: 17 fixed parts — shelf, two iron brackets, powder chest, lid, two corner straps, rope handle, shot tray, tray lip, three iron shot, rammer shaft, iron cap and two wall hooks. It sits at y 1.1 to 1.6 on the outer shaft, below the deck and outside the barrel arc. At both yaw extremes all 17 world positions are identical and the station has zero bounding-box hits against the yaw group.
A creature needs a silhouette feature before it needs a face.
The boss gets 40 to 60 parts and one signature weapon. Build the weapon as a separate group with a haft, head, bands, studs or spikes, and a hand attachment. A stone head with no haft reads as a rock floating at the door.
A cat is not a small human on all fours. Build a horizontal spine and four contact legs; the silhouette is the back line, ears and tail.
Shoot the cat at 1:1 in profile, three-quarter and sitting. If it reads as a rabbit, the ears are too long; if it reads as a dog, the muzzle is too broad and the back line too straight. No gear, no trophy, no human jacket.
Before the animation pass, inspect each hero object at 1:1 and answer:
If any answer is no, fix the object before animating it. Animation multiplies detail; it does not create it.
A 1.69 m figure built from primitives, with an anatomical group per joint so limbs overlap when they bend. Faces +Z.
| Constant | Value | Notes |
|---|---|---|
| hipY | 0.86 | pelvis group height above the root |
| hipX | 0.10 | half stance width |
| thigh | 0.42 | hip to knee |
| shin | 0.40 | knee to ankle |
| shoulderY | 0.415 | above the pelvis |
| shoulderX | 0.182 | half shoulder width |
| upperArm | 0.30 | shoulder to elbow |
| foreArm | 0.27 | elbow to wrist |
| headY | 0.584 | neck to head group |
The base swimwear rig uses two spheres (cranium 0.115, jaw 0.089) plus a nose box. For a clothed cast, replace that with the skull/jaw/cheek head below. Torso and pelvis are lathes (0.135→0.198 and 0.105→0.180), flattened 0.72 in Z so the body is an oval, not a tube.
Construction rules:
Pose API: setPose(person, pose), easePose(p, pose, 1-exp(-22*dt)),
blendPose(a, b) (component-wise add). Angles are [x,y,z] radians per joint:
torso, head, shoulderL/R, elbowL/R, wristL/R, hipL/R, kneeL/R, ankleL/R,
optional pelvis.
Sign conventions, get these right once:
Detail that pays, in order: the skull/jaw head in Clothed Cast (hair cap theta 1.22, eyes at ±0.037, 0.110, 0.101); cap as a crown cylinder, band and brim; brow and lid creases; a narrow nose bridge; a three-layer mouth; ears as flattened spheres; hands as a palm plus a finger block and thumb; feet with a toe box. Fingers, nostrils and micro-seams do not survive 30 px.
Balaclava: one sphere shell over cranium and jaw (r 0.134, scale 1.0/1.10/0.99)
plus a neck gaiter cylinder running into the collar. A cover cut at the equator
leaves a band of skin under the hem that reads as a bare head, and eye openings
tucked under the shell disappear: put pale lids a few millimetres proud of it. A
darker skin material is skin.clone() with color.multiplyScalar(0.86).
The skeleton is shared; the person is made by the head, the garment silhouette
and the hand contact. For a clothed cast, start from
templates/peopleModel-street.js; templates/peopleModel.js remains the
swimwear/aquatic variant.
Head. A skull plus a jaw, not two stacked balls.
thetaLength 1.22, positioned at y 0.132 and stopping
at the hairline. A 1.85 cap swallows the forehead and reads as a helmet. Add a
back mass, sideburns, and for a bun a high sphere at y 0.204. Under a hat show
a nape tuft; under a full helmet show none.Clothing is geometry, not paint.
topK 1.0 tee, 1.05 jacket, 1.12
puffer), plus a hem/waistband cylinder. The garment must have a thickness step
at the cuff, collar and hem or it reads as body paint.Hands are the contact proof. Palm box 0.070 x 0.078 x 0.054, a finger block with two grooves and a thumb capsule. A glove is the same geometry in glove material, with a cuff. When a hand holds something, the palm and finger block must touch it; a wrist near a handle is not a hold.
If the head or the clothing is wrong, fix it before the walk. A good gait cannot rescue a sphere head or a painted jacket.
Every motion is a pure function of t or of a distance along a track.
const swing = Math.sin(phase), cs = Math.cos(phase);
const kneeL = 0.05 + 0.46*Math.max(0,-cs) + 0.22*Math.max(0, swing);
const kneeR = 0.05 + 0.46*Math.max(0, cs) + 0.22*Math.max(0,-swing);
pose = {
torso: [lean, swing*0.05, 0],
shoulderL: [-swing*arm, 0, -0.09], shoulderR: [swing*arm, 0, 0.09],
elbowL: [-0.16 - Math.max(0, swing)*0.28, 0, 0],
elbowR: [-0.16 - Math.max(0,-swing)*0.28, 0, 0],
hipL: [swing*amp, 0, 0.03], hipR: [-swing*amp, 0, -0.03],
kneeL: [kneeL,0,0], kneeR: [kneeR,0,0],
ankleL: [-kneeL*0.32,0,0], ankleR: [-kneeR*0.32,0,0],
};
footTravel = 2*legLength*sin(amp),
speed = footTravel * phaseRate / PI. A 0.86 leg at 0.34 rad and 5.2 rad/s is
about 0.9 m/s. Drive phase from distance travelled, not from time, and the
walk always matches the path.groundY heights
smoothed by three 1-2-1 passes, so steps read as ramps. at(d) returns
position and heading.const declared in one branch and read in another throws every frame and
freezes the figure. Pose targets are full poses, not deltas.T,
vy = (landY - launchY + 4.9*T*T)/T. A guessed vy either lands late or
stops in mid-air.C with p' = p + C - R*C, C about 0.55 up.These are the beats closest to the camera and the ones users report first. A prop that teleports, a hand that misses it, or a leg that swings through the handlebars destroys the whole miniature.
Carry. Keep the prop in world space and solve its transform from the hands every frame; do not parent a big prop to a wrist group (it inherits the wrist roll and swims).
const hl = new THREE.Vector3(), hr = new THREE.Vector3();
J.wristL.getWorldPosition(hl); J.wristR.getWorldPosition(hr);
const mid = hl.clone().add(hr).multiplyScalar(0.5);
bag.position.set(mid.x, Math.min(hl.y, hr.y) - 0.42, mid.z);
Loading into a crate or hatch.
Never translate the prop from hands to slot in one frame, and never let it descend outside the crate wall. A bungee or lid that intersects the prop on the way in must animate open and closed; hiding it for the whole beat reads as a missing part.
Mounting a scooter, bike or horse.
Author the mount in the vehicle's local frame, from a measured seat/board, not by eye.
Proof. A nine-frame side strip at 0.15 to 0.25 s spacing: approach, lift, over-rim, lower, release, step, leg-swing, seat contact, ride. Shoot it at 1:1 and log wrists, ankles, prop base and seat. A still cannot prove a mount.
The character section above covers body mechanics. This section covers the
systems that carry a diorama: towers, projectiles, reactions, deaths,
bosses, crowds and secondary motion. Every one is a pure function of t, driven
by a pre-simulated event list.
| Layer | Driver | Typical period | Use |
|---|---|---|---|
| ambient | sin(t*f + phase) |
1.5 to 5 s | breath, flame, flag, scan |
| locomotion | distance along a path | stride length | walk, fly, hop, slither |
| action | absolute event time | 0.3 to 1.2 s | fire, swing, slam, dive |
| reaction | event time + direction | 0.15 to 0.5 s | flinch, flash, knockback |
| death | absolute death time | 0.6 to 1.4 s | collapse, debris, residue |
If an action and a reaction share a clock, derive both from the same event timestamp. Two independent cycles will drift apart even if they match on the frame you happened to inspect.
const DT = 1 / 40;
const LOOP = 46.0;
const pulse = (t, t0, tau) => (t < t0 ? 0 : Math.exp(-(t - t0) / tau));
const win = (t, t0, dur) => clamp((t - t0) / dur, 0, 1);
const easeOutCubic = (u) => 1 - Math.pow(1 - u, 3);
const spring = (t, t0, freq = 9, damp = 6) => {
const a = t - t0;
return a < 0 ? 0 : Math.exp(-damp * a) * Math.cos(freq * a);
};
Run the battle at DT from 0 to LOOP once at load. Store:
events.push({ t, kind: 'shot', tower, target, from, to, flight, damage });
events.push({ t, kind: 'impact', x, y, z, species, dir });
events.push({ t, kind: 'death', id, type, x, y, z, t });
events.push({ t, kind: 'gate', x, y, z, hit: n });
The render loop reads the events whose time has passed. It never re-simulates
and never carries state between frames. ?t=12.4 freezes the whole system,
including effects, so every QA shot is reproducible.
A tower attack needs five visible stages. Cut one and the tower reads as a spawner, not a weapon.
| Stage | What the player sees | Time |
|---|---|---|
| acquire | head/turret turns; crew leans; crystal brightens | 0.15 to 0.4 s |
| wind-up | rail pulls back, arm cocks, coil charges, barrel vents | 0.1 to 0.35 s |
| release | muzzle flash, string snap, beam origin flare, arc jump | 1 to 2 frames |
| travel | bolt, ball, beam, chain, projectile with trail | 0.05 to 0.6 s |
| impact | flash on target, dust/goo/ice/feathers, ring | 0.1 to 0.25 s |
| recovery | recoil returns, crew resets, smoke clears | 0.2 to 0.5 s |
Tower archetypes:
theta(t), add a short dwell window when a target crosses the
beam, and ease in/out of the dwell so the housing never snaps. Fire inside the
dwell; return to the sweep with the same phase it would have had. If the beam
can point into the goal pocket, mask that sector physically (back-screen,
shroud) rather than breaking line of sight with a special case.If the tower has a crew, the crew reacts: brace, pull, cover ears, crank. A crew that stands in the same pose through a cannon shot is a mannequin.
A loom, press, crane, oven, pump or lighthouse lens is not a tower, but it needs the same causality. Replace "attack" with work:
| Stage | What the player sees | Time |
|---|---|---|
| engage | clutch drops, belt tightens, valve opens, lamp housing turns | 0.15 to 0.4 s |
| work | beam sweep, flywheel spin, ram stroke, dough stretch, water flow | 0.3 to 3 s |
| output | light pool, pressed sheet, lifted crate, baked loaf, water rise | 0.1 to 0.5 s |
| settle | flywheel coasts, valve closes, belt slackens, lamp returns | 0.2 to 1 s |
Audit: input (what feeds it), motion (what turns/strokes), output (what changes in the world), residue (waste, heat, crumbs, steam). A machine with a beautiful motion and no output is a kinetic sculpture; give it a product and show the product.
A boss or disaster must act on a goal, not just a door. Pick the response from the goal's material:
| Goal | Contact response | Failure |
|---|---|---|
| door / gate | flash, shake, crack, dust from the lintel | leaf hangs, ward closes |
| valve / pipe | spray, gauge drop, pipe shudder, hiss | pressure falls, lamp starves |
| rope / bridge | slack, sag, strands snap, deck drops | span falls, route closes |
| wall / dam | chip, crack, water seep, block falls | breach, flood |
| tree / mast | bark split, creak, tilt, leaves fall | trunk breaks, canopy drops |
Put the contact point on the silhouette edge of the goal, not buried in its body, and keep it clear of the boss's shoulder/weapon. If the goal is tall, put the contact at chest height and turn the boss 0.3 to 0.4 rad so the weapon shaft crosses the frame. The response and the contact must share the same timestamp.
A projectile that is visible after the target is dead is a bug, not a flourish.
| Archetype | Locomotion | Action | Reaction | Death |
|---|---|---|---|---|
| small scrapper | scamper, 2-beat | quick stab | flinch + skid | cloth, knife, stain |
| brute | heavy walk, shoulder roll | overhead cleaver | stagger, shoulder dip | iron plates, sword, dust |
| slime | squash-hop | lunge | wobble, split | goo splat, two lets |
| flyer | flap, bank, hover | dive | spiral, feather burst | feathers, spiral fall |
| boss | weight shift, slow stride | telegraph + slam | recoil, knee dip | collapse, gear, weapon, soul |
Every creature gets a hit flash on the body material for 0.08 to 0.12 s and a directional flinch opposite the impact. Do not reset the whole pose on a hit; blend a flinch over the current pose and recover.
Death is a sequence, not a switch:
A death that is a sphere burst, a grey puff or a rock shower fails the bar even if the particle count is high.
A boss exists to act on a place, not to have more hit points.
phase = hash(i) * TAU, never one global sin(t).0.9 + i * 0.8 along their own path.Flags, banners, cloth, chains, hanging bones, smoke and fire are the cheapest way to make a static scene feel alive. Rules:
sin(t * f + phase) and a phase offset per object.For every hero actor, capture:
The strip passes when feet plant, knees bend backward, no joint opens a wedge, no part floats, and the silhouette changes between frames. A still cannot prove an animation; a strip can.
A market, hatch, ticket window or bakery is a queue machine: people arrive, advance one slot, order, pay, receive and leave. Build the queue before the props; it is the scene's clock.
Slots. Lay four to six positions along the counter, 0.65 to 0.80 m apart, perpendicular to the hatch. Everyone in the queue shares one lane and one heading; a diagonal queue reads as a crowd, not a line.
const SLOTS = [q0, q1, q2, q3]; // q0 is at the hatch, q3 is the tail
const toSlot = (p, slot) => ({ x: slot.x, z: slot.z });
seg(t0, t1, track) legs: enter →
tail → advance one slot per serve → counter → exit. Keep each leg on its own
track; re-pathing every serve makes feet slide.Customer state machine.
| State | Visible action | Gate |
|---|---|---|
| arrive | walk with distance-driven gait | until slot reached |
| order | point at menu/board, head turns | only when speed = 0 |
| pay | hand to counter / phone | after order, only when still |
| wait | idle, weight shift, phone | until bag lands |
| receive | both hands or one hand out | at bag contact |
| exit | turn, walk out, carry bag | after receive + 0.3 s |
The gate is the whole fix. Never blend a standing gesture (PAY,
POINT_MENU, RECEIVE) into a walking pose. If the gesture has straight legs
and the walk has bent knees, the mix straightens the legs while the root keeps
translating: the figure glides. Compute state.moving from the mover and write:
if (state.moving) pose = walkPose(phase, { amp, arm, carry });
else if (t < orderEnd) pose = mixPose(idle, POINT_MENU, bell(t, order));
else pose = idlePose(t, phase);
Machine slots. A laundromat, workshop or gym uses the same clock with machines in the slots: washer drum → dryer drum → fold table. Advance the batch on the end-of-cycle timestamp, not on a fixed beat, and land the next action 0.25 s after the lid/door closes. A drum that starts tumbling before the door shuts is the machine version of serving before arrival.
Staff side. The cook works the grill; the cashier takes payment and hands the bag across the hatch. Their gestures are independent loops, but the bag's release comes from the cashier's hand position, not a fixed world point. Keep a stack of paper bags at the hatch and decrement it per serve; the last bag is the goal object.
Proof. Freeze the approach every 0.25 s for two strides and log root speed,
moving, hip/shoulder angles and slot. The strip passes when the feet alternate,
the root advances smoothly, no standing gesture appears while moving is true,
and the serve timestamp is after the arrival timestamp.
A vehicle is the prop the user inspects the moment a door opens, so build it to be opened. Model facing +Z.
| Part | Size and place | Note |
|---|---|---|
| floor pan | 1.62 x 0.10 x 4.02 at y 0.35 | dark; its top is the cabin floor |
| rockers | 0.12 x 0.16 x 3.92 at x ±0.87 | stay with the body |
| front fender | 0.10 x 0.56 x 1.05 at z 1.62 | body side ahead of the front door |
| rear quarter | 0.10 x 0.56 x 0.72 at z -1.84 | body side behind the rear door |
| belt moulding | 0.10 x 0.08, one per fixed panel | never one rail across the apertures |
| bulkheads | 1.84 x 0.56 x 0.16 at z ±2.12 | close the tub |
| roof | 1.48 x 0.08 x 1.94 at y 1.44 | drip rails at x ±0.76 |
| pillars | A raked -0.64, B upright, C raked +0.60, 0.075 thick | |
| windscreen | 1.44 x 0.62 x 0.05, rotation.x -0.64 | glass between the A pillars |
| side glass | none | windows down: open holes between the pillars |
Cabin: dash 1.46 x 0.15 x 0.30 at y 1.00 z 0.80; wheel torus r 0.125 at (-0.40, 1.10, 0.68) rotated -0.42; front cushions 0.46 x 0.10 x 0.50 at y 0.57 z 0.30 with backs at z 0.04; rear bench 1.20 wide at z -0.50 with its back at z -0.74.
The windscreen is not a plane at the front of the cabin: rotated -0.64 it sweeps back to about z 0.59 at its base. A front seat at z 0.72 therefore sits inside the glass volume, and every occupant's head prints on the windscreen from every angle. Keep the cushion's front edge at least 0.25 behind the glass base and move the dash, wheel and column back with it.
Doors. Hinge at the shut line, leaf extends backward in -Z: front 1.15 to
-0.15, rear -0.15 to -1.45, leaf 1.30 long and 0.55 high. Pivot at
(side*0.86, 0.62, hingeZ), children are outer skin, inner card, sill strip,
handle, and any livery; rotation.y = -side * open swings it out. Swing 1.28 rad
for a saloon, up to 1.52 for a cruiser. Split every full-length body moulding at
the shut lines: a moulding left on the shell reads as the top of the door left
behind when it opens.
Boot. Pivot at (0, 0.90, -1.46), lid extends -Z, rotation.x = +0.95 lifts
the rear edge. Well floor y 0.44 against a lid underside at 0.89, so cargo must
fit 0.45: bags r 0.165 at y 0.63 (tops 0.80) clear it. Close the boot off from
the cabin with a parcel shelf (1.74 x 0.05 x 0.66 at y 0.86, z -1.10) and a
bulkhead behind the rear seat back (1.74 x 0.46 x 0.06 at y 0.63, z -0.82);
without them the boot is open to the cabin through the rear windows and the load
reads as sitting on the closed lid.
Cargo carries visible = false until it lands and userData.noMerge = true.
A throw needs an object that travels: keep one world-space bag per thrower and arc
it hand → slot with p.y += sin(f*PI)*0.6, switching the slot's bag on when the
flight lands, not at release.
A scooter is not a car with two wheels. Build it around the contact points: two tyres on the ground, a floor pan for the boots, a seat the pelvis can sit on, bars the hands can reach, and a cargo crate whose opening is visible from the camera.
Reference local frame, facing +Z, for a delivery scooter at 1.0 scale:
| Part | Place | Note |
|---|---|---|
| wheels | r 0.26 at z ±0.62 | tyre + hub + fender; both touch y 0 |
| floor pan | 0.60 x 0.04 x 0.30 at y 0.30 | top is the boot surface |
| running boards | 0.12 wide at x ±0.235 | outboard, full length; the left boot lands here |
| body / shield | 0.46 wide, up to y 0.72 | front leg shield, not a box |
| seat | top 0.83 at z -0.18 | measure the cushion top, not the body |
| bars | y 1.06, grips x ±0.33, z 0.52 | raise bars to the rider's hands |
| cargo crate | 0.52 x 0.44 x 0.46, base y 0.86, z -0.56 | open top, interior floor ~0.91 |
| bungees | two cords across the opening | clip open during loading |
seat = local(0, 0.83, -0.18); set the root so the pelvis
mesh contacts the cushion, then prove it in a seated portrait. On the Night
Market scooter that is root y 0.09 against a 0.83 seat; do not copy a
seatY - hipY formula from a chair, because the pelvis mesh hangs below the
hip joint. A copied root puts the rider on the front edge or floating.baseY + 0.01 sits inside. Keep bungees as a separate group so
they can unclip during loading and close over the lid afterwards.Mounting is authored with the sequence in Carrying, Loading And Mounting: left boot on the board, right leg abducted over the saddle, small root arc, pelvis on the seat, hands to the grips. Prove it with the nine-frame side strip.
A bus is a moving room. Its job is a window band, a door, a route sign and a headlight sweep; the chassis is the least visible part.
| Part | Size / place | Note |
|---|---|---|
| body | 9 to 12 m long, 2.5 wide, 3.0 high | one box with a chamfered roof |
| window band | y 1.35 to 2.45, full length | dark glass + warm interior, not a transparent plane |
| door | 1.1 wide, front or centre | step well, grab rail, lit interior |
| wheels | r 0.50, wheelbase 5.5 | two axles; arches cut into the skirt |
| route sign | front above the windscreen | emissive text/colour blocks, readable at 1:1 |
| livery | one band + one number | never a full advertising wrap at this scale |
| headlights | two lenses at y 0.65 | plus cones and a floor ellipse |
| interior | 4 to 6 lit windows + driver silhouette | a dark box with one warm strip reads as a bus |
A bus that reads as a white box with black windows is not finished: the window band, route sign, wheel arches and one warm interior strip are the minimum.
Seat root = cushion top minus hipY, which is -0.38 in car space on this rig: the hips settle into the cushion and a masked head clears the roof with 6 to 8 cm. At -0.18 a hooded head lands about a centimetre under the roof panel and any bob prints a dark pixel on the white roof. Measure against the head mesh, not the head joint, and lower the seat rather than raising the roof, which is pinned to the pillars.
The stand spot is 2.00 m out from the car's centreline and 0.30 behind the hinge for a front seat, 0.55 for a rear:
const exit = toWorld(car, side * 2.00, seatZ - 0.30);
An open leaf hangs in the plane of its hinge, so an exit level with the seat puts shoulders and head through the door skin. 0.30 still sits inside the aperture and leaves room for a torso plus a swinging arm. 2.00 out is about 1.07 of daylight from the flank, and that is not padding: at 45/33 a figure 0.7 off the flank sits wholly inside the car's silhouette with his feet on the rocker line and reads as welded to the body.
The climb is authored in the car's own frame, so it works at any parking
angle. It ships: templates/transfer.js is the whole move (transfer,
climbAt, CLIMB, stepPose, standSpot), and templates/poses.js carries
the four climb poses plus the walk and run cycles it blends between. Copy them,
do not retype them. k runs 0 at the open door to 1 at the seat, dy is the root height
above the seated root, and the pose is a real sequence: stand, reach, crouch in,
sit on the edge, swing the legs in, settle.
const CLIMB = [
{ u: 0.00, k: 0.00, dy: 0.380, pose: STAND },
{ u: 0.16, k: 0.03, dy: 0.330, pose: REACH }, // hand to the roof edge
{ u: 0.36, k: 0.16, dy: 0.120, pose: CROUCH_IN }, // knees deep, hips dropping
{ u: 0.56, k: 0.38, dy: 0.010, pose: SIT_EDGE }, // perched, feet still down
{ u: 0.76, k: 0.66, dy: 0.000, pose: LEGS_IN }, // knees up, feet over the sill
{ u: 0.90, k: 0.88, dy: 0.000, pose: mix(LEGS_IN, SEATED, 0.55) },
{ u: 1.00, k: 1.00, dy: 0.000, pose: SEATED },
];
The transfer wraps that climb: the first STEP_SHARE (0.26) of the window is one
stride from the stand spot to the door, the rest is the climb, and on the way out
the stride away comes last. The body only reaches cushion height once the hips are
over the sill, so nobody sinks, floats or slides through the bodywork. DOOR_STAND_X
1.34, DOOR_STAND_DZ -0.06, durations 1.05 to 1.20 s each way. Never derive the
duration from the next beat's start: under about 0.9 s the whole move reads as a
slide or a blur. Move the following beat later instead.
Facing is the trap. The outward yaw points away from the car, so a figure entering
with it climbs in backwards: body into the seat, face to the street. On the way in
every key before the seated ones faces the car, including the standing key that
gets forgotten. Resolve it from geometry, never from the approach path's tangent:
a path's opening segment runs along the street, so path.at(0).yaw + PI points
down the kerb and lands the figure a right angle off whichever way he arrived.
const faceYaw = car.yaw - side * (PI / 2); // square to the open door
const face = Math.atan2(seat.x - stand.x, seat.z - stand.z); // stand -> seat
// turn to the windscreen only once the hips are on the cushion
const turn = smooth(clamp((v - 0.62) / 0.30, 0, 1));
yaw = lerpAngle(faceYaw, car.yaw, turn);
Staging rules that came out of real reports:
v.project(camera) and compare pixels
before concluding they are spaced.0.9 + i*0.8 along his own path, stagger the walk-in windows by 0.25 s, and let
the lead actor alone reach the threshold: the kick, the first step through. Four
men then read as a queue filing in rather than a scrum.Vehicles enter and leave the block, and the slab is a window. Four world-space planes on the renderer for the scene pass, cleared before the post quad:
const CLIP = [
new THREE.Plane(new THREE.Vector3( 1, 0, 0), hx + 0.05),
new THREE.Plane(new THREE.Vector3(-1, 0, 0), hx + 0.05),
new THREE.Plane(new THREE.Vector3( 0, 0, 1), hz + 0.05),
new THREE.Plane(new THREE.Vector3( 0, 0,-1), hz + 0.05),
];
r.clippingPlanes = clipPlanes; r.setRenderTarget(rt); r.render(scene, camera);
r.clippingPlanes = []; // before the full-screen quad
A car entering nose-first shows its own front surfaces at the cut, so the cut
reads clean; one leaving tail-first exposes the cut face, so keep the interior
modelled and it reads as a cutaway. Route every vehicle one way, in at one end of
the street and out at the other, no reversing and no angled parking: an angled
park reads as a collision, and backing in reads as a bug. Two vehicles sharing a
lane need a departure gap (pLeaveGap ~1.15 s) or the far one drives through the
near one.
MeshPhysicalMaterial with vertex colours, not a custom shader, so
it receives shadows. Ramp shallow to deep teal, never to navy: a near-black end
quantises into a hard band the user reads as a hole.(1-k)^2.trigger(x, z, power, burstSeed)), never Math.random: a frozen frame must
reproduce the same burst, and the same frame must reproduce it again after the
clock jumps.reset(). Wake and splash advance by
dt, so jumping the clock to freeze a frame does not jump their state, and the
frozen splash shows whatever the last played frame left behind. Both templates
expose reset(); the harness calls it and re-simulates in fixed steps from
zero, which took a two-frame diff from 334 differing pixels to 18.sin(n*127.1)*43758.5 fractional)
keeps every replay identical.The slab is a window, but not every effect is a solid. Split them:
Every scene effect goes through the palette snap. That is the look, but it punishes sub-pixel tricks:
?dither=0 and at 1:1. If the effect only reads in a downscaled
preview, it is not in the render."Touching the ground" is not one rule:
A diorama is judged on whether the cause and the consequence are both visible at the locked camera. Geometry that is correct in world space can still read as a bug on screen. Run these gates before calling a combat beat finished.
Run one row per tower, at the timestamp it fires. A tower that never fires in the loop is scenery, not a defence.
| Check | Pass condition |
|---|---|
| origin | muzzle, rail, crystal or hand is visible and offset from the body |
| wind-up | at least 0.1 s of visible anticipation |
| travel | projectile or beam is traceable across at least 4 frames |
| impact | lands on the target with a species-specific effect |
| recovery | weapon returns; crew resets; no snap to rest |
| count | shots > 0 in the sim stats for this tower |
| no stale | no projectile or beam outlives its target |
If a tower fails "origin", move the spawn point; adding more impact particles will not fix it. If it fails "travel", slow the projectile or widen the trail; a 1-frame hit is a hitscan, which is fine only for a beam.
A support, aura or ground field has no projectile and must not be failed for that. Audit it on five field stages instead:
| Check | Pass condition |
|---|---|
| source | emitter, valve, horn mouth or caster hand is visible |
| area | radius/cone is readable on the deck; no floating ring |
| ramp | effect grows over at least 0.15 s; not an instant decal |
| affect | affected units visibly change (speed, colour, stagger, burn) |
| decay | effect fades/expires on the loop, leaving residue |
| count | events > 0 for this field in the sim stats |
A field that never expires is a lighting change, not an attack.
| Species | Body collapse | Debris | Dropped | Residue |
|---|---|---|---|---|
| goblin | fold forward, knees first | cloth scraps, 2 bone chips | knife or spear | dark stain |
| orc | drop to one knee, then side | 3 iron plates, 2 tusk chips | cleaver or sword | dust + blood |
| slime | flatten, then pop | 6 goo blobs, 1 core | none | glossy splat |
| flyer | spiral, wings fold | 5 feathers, 1 bone | none | feather on ground |
| human / crew | knees, then hat/coat fold | 3 cloth scraps, 1 tool | hat, keys or hook | oil or blood stain |
| boss | knee dip, torso fall, weapon drop | 6 armour plates, 2 horns | club | scorch + rubble |
Every death also gets a 0.2 to 0.4 s additive soul flicker. It is the punctuation that says "this was a creature", not "this was a particle system".
targetId on the beam event
and hide the beam on the frame that id dies.templates/build.mjs bundles src/main.js to dist/app.js with esbuild (IIFE),
writes index.html and a standalone HTML with the bundle inlined, escapes
</script in the payload, and passes the replacement as a function, not a
string. String.replace expands $&, $', $` and $1 inside a string
replacement; a minified bundle routinely contains those sequences, which
silently injects the <script src=...> tag into the middle of the app and
breaks the standalone file. Always test the standalone over file://, not just
the served page. --minify for shipping; expect roughly 780 kB with
three.js inside.
Freeze and seek come free if everything is a function of t:
const params = new URLSearchParams(location.search);
const frozen = params.has('t') ? parseFloat(params.get('t')) : null;
const startAt = params.has('from') ? parseFloat(params.get('from')) : 0;
window.__diorama = { THREE, scene, camera, renderer, post, director, cast,
robbers, cops, mats, TL, CLIP, freeze(t){ frozen = t; draw(t); }, play(){}, view(){} };
?t=12.4 is the whole QA surface: every screenshot and every diagnosis is a
frozen frame, so a screenshot never depends on timing luck.
A build is not finished until these exist. Check them before the summary:
refs/<subject>/ has at least one contact sheet stepped from real footage.refs/SOURCES.md answers the four questions (shape, motion, asset, proof)
with the queries actually run. "Nothing found" is a valid answer only with
the query written down next to it.CREDITS.md has a row per imported asset, and every licence is CC0, CC-BY,
public domain, or owned by the user.A missing SOURCES.md means the research step was skipped, and the scene is
probably wearing the generic version of its genre.
These are the failures that survive a good first render: a finished-looking set with one raw seam, a clean loop with a vehicle that teleports out, or an effect that is technically present but reads as a grey rectangle. Audit them as a system, on every subject.
visible = false, no parking at the gate, no portal. If the
scene uses a clip plane, the mover may be cut by it, but the cut body must
continue moving out; a cut that freezes is the same bug.(±0.66, 3.74, -0.74) * scale on the truck recipe), not
the engine centre, cab roof or a point chosen by eye. Put the tip
in userData during construction and use that same object for the smoke. A
plume that starts above or behind the stack reads as a floating cloud. If a
canopy hides the tip, offset the birth point downstream in the open direction,
not the source anchor; the audit's maxOffset allows that.PlaneGeometry is a light-grey rectangle at the palette snap, especially over
a dark board or wall. Use haloPlane/softDiscTexture from detail.js, a real
emissive lens, or no halo. This is the single most common "what is that
rectangle?" report.templates/qa/audit.mjs runs the mechanical ones; the 1:1 crops remain
human evidence.dist/ in place, and a refused or blocked edit leaves the source
untouched, so in both cases every screenshot shows the old app and the fix
looks like it did not land. Read the build's own output line, grep for a
sentinel string from the change, and only then judge the frame.group.rotation.y and compare it with atan2(dx, dz) of the expected facing
vector before re-shooting a facing fix.state.moving, hip/shoulder angles and slot. No
standing gesture may be non-zero while speed is non-zero; the leg angles must
alternate; serve must be at least 0.25 s after arrival.actualDistance / fitDistance ≈ subjectScale, the projected slab width fell
by the same factor, post.size ≈ CSS size × renderScale (capped), the
slab/world clip/shadow camera and CSS frame are unchanged, and the standalone
still frames on the page.visible. getWorldPosition on a merge-eaten object returns local coordinates
because the original was unparented; that is the signature of the merge pass.A still cannot prove a jump. A six-frame strip cannot prove that the third
character in the scene is animating, because the other two are moving in the
same picture. qa/frames.mjs records one dense series per entity, and prints
a verdict for each, so a dead actor cannot hide behind a live neighbour.
BASE_URL=http://127.0.0.1:4180/index.html \
ENTITIES='({ hero: d.cast[0].model, guard: d.cast[1].model })' \
FROM=4 TO=9 FPS=12 OUT=qa/frames STATIC=guard \
node templates/qa/frames.mjs
What it does per entity:
ISOLATE=scene (default) hides every mesh not on that
entity's branch, so the signature is really about this actor; siblings hides
the other registry entries only. Restored afterwards.FPS (12 by default) across FROM..TO, and for each frame logs
the world bounds centre in NDC, plus a 32x32 luminance signature.empty counts frames where the entity is not on screen,
energy is the largest mean luminance delta between consecutive on-screen
frames (a flat line means the actor is not animating), step is the largest
NDC jump (a teleport), and OFFSCREEN counts frames outside the frustum.qa/frames/<name>/sheet.png, and the
numbers to report.json. Exit code 1 when anything is flagged, so CI can gate.Reading it:
| Verdict | Means | What to do |
|---|---|---|
ok |
moved, stayed inside, no jump | still read the sheet for pose quality |
FROZEN |
energy under MIN_ENERGY |
the pose blend is not reaching this actor |
TELEPORT |
an NDC jump over MAX_STEP |
a state boundary is snapping, not blending |
EMPTY n/N |
not rendered for over half the window | wrong window, or it spawns later than you think |
NOT-IN-WINDOW |
never rendered in the window | the entity name is wrong, or it is invisible |
OFFSCREEN |
left the frustum | staging, camera fit, or a run that goes too far |
Two rules for the numbers. A blank frame beside a full one must never be allowed
to supply the energy for a window, so only consecutive on-screen pairs are
compared. And an entity that is meant to hold still (a seated figure, a guard at
a post) goes in STATIC=, or you will get a FROZEN that means nothing.
Run it for every hero actor, and for each scripted beat that involves them: send-out, attack, hit, death, exit. The sheet is the evidence; the verdict only tells you which sheet to open first.
Run this after the build and before shipping. It is deliberately visual; a number cannot prove that a club is touching a door.
events, shots per tower, deaths per species, maxD
per species, loop length and console errors. A species with zero deaths did
not finish its route; a tower with zero shots never joined the fight.file:// once. ready: true, no
console errors, and a frame that matches the served page.Keep the proof pack: shots/hero.png, shots/tower-*.png, shots/death-*.png,
shots/boss-contact.png, shots/boss-recoil.png, shots/beam-off.png. A future
chat can compare its own pack to these crops.
When a different diorama is requested in a fresh chat, the skill should produce the same level without re-deriving it:
templates/DESIGN.md.qa/audit.mjs, not just "does it
load".DESIGN.md, README.md and the code agreeing.If the new subject has no combat, replace tower grammar with Machine Grammar (loom, press, crane, oven, pump) and creature grammar with character grammar (customer, worker, animal, vehicle). Audit outputs as fields when there is no projectile. The five layers and the audit still apply: idle, locomotion, action, reaction, residue.
When the user marks a region, project the object instead of guessing pixels:
obj.getWorldPosition(v); v.project(camera); px = (v.x*0.5+0.5)*w.
If it is not obvious, raycast that pixel and read the first few hits' materials and
heights: the first hit is the surface they see, the second is what is under it.
Then fix the marked region and nothing else, and prove it with a zoomed crop at the
same timestamp.
visible flag, so hidden cargo comes back as a
single invisible mesh that never appears, and the survivor's getWorldPosition
reports local coordinates. Tag runtime-toggled meshes userData.noMerge = true.renderScale 1.2–1.5×
on a small subject sharpens it without making it larger; measure fps and inspect
a 1:1 crop before keeping it.rotation.z, and put the feet on
pivotY + h*cos(flex) - arm*sin(flex).PAY, ORDER and POINT on !state.moving; a still at one timestamp
will not catch it, a 0.15 s strip will.subjectScale instead; the frame and backing buffer stay fixed.DESIGN.md filled design sheet: premise, cast, systems, beats, budget
CREDITS.md every imported model: URL, author, licence (CC0/CC-BY/owned)
refs/ reference frames: contact sheets stepped from the source clip
refs/SOURCES.md research log: query -> found -> decision, per question
src/
config.js scene constants + clamp/lerp/smooth/pulse/hash
main.js renderer, scene, clip planes, resize, rAF, ?t= freeze
core/
palette.js N entries + named C map
post.js PixelPost: gamma, grade, ink, dither, snap, rim
materials.js every material, palette-validated
geometry.js roundedBox, addBox, lathe helpers
merge.js mergeSiblingMeshes: one draw per material per group
scene/
camera.js lighting.js street.js bank.js props.js
car.js shell, doors, boot, seats, cargo
peopleModel.js base rig (swimwear/aquatic)
peopleModel-street.js clothed rig: heads, layers, hats, hands
people.js the cast: poses, walk/run, tracks, buildCast
stage.js the beat sheet + every actor as a function of t
effects.js contact shadows, smoke, spill, siren
build.mjs esbuild bundle, index.html, standalone HTML
qa/
shoot.mjs viewports + console errors + named timestamps
probe-*.mjs one-off numeric probes (facing, anchors, NaN, spawn pose)
audit.mjs scene lint: halos, fades, camera/render scale, exits, FX
portrait.mjs isolated figure/framing shots through a debug camera
strip.mjs N-frame motion strips (fails on a frozen loop)
probe.py colour runs across an edge
sweep.mjs pairwise distances + jam/teleport verdict
tile.py tile a motion strip
README.md audit contract, commands, failure table
Wire order in main.js: renderer → scene → camera → post → clip planes → lights →
materials → street/bank/props → cars → cast → effects → merge → stage. Merge once,
before the stage is created, so the timeline binds to the surviving meshes.
A defence or arena subject replaces street/bank/car with path/towers/keep:
src/scene/
path.js route samples, arc length, at(d), ground height
towers.js foundations, machines, crew, recoil pivots
monsters.js rig, poses, walk/fly/hop, boss club
keep.js gate, walls, door leaves, cracks, goal response
roster.js species stats and wave table
battle.js deterministic 40 Hz sim, shots, impacts, deaths, gate hits
props.js camp, skull tree, cart, torches, trees, portal dressing
effects.js beams, projectiles, dust, smoke, shards, rings, souls
The same wire order applies: build the set, then the cast, then effects, then merge once before the battle timeline reads the survivors.
When the beat sheet moves into its own module, delete the superseded director and update the README's beat table in the same pass. An unreferenced six-hundred-line timeline and a table that disagrees with the code are exactly what the next reader trusts.
templates/ carries runnable copies, not excerpts. Foundations: post.js,
palette.js, camera.js, lighting.js, geometry.js, build.mjs. Cast and
motion: peopleModel.js, peopleModel-street.js, poses.js (the pose library,
including the four climb poses and the walk/run cycles), transfer.js (seating,
the climb, stand spots, facing), track.js (arc-length paths with lane offsets
and smoothed ground height), stage.js (beat table, keysAt, place, the
actor loop, a car state machine). Props and systems: detail.js for prop
assemblies (including haloPlane/softDiscTexture for soft light cards),
combat.js for deterministic timing and death grammar, creature.js for a
fight between two rigged actors (stage-local aimYaw, bone-space headAnchor,
uprightQuat, the makeHurtModel severity table and sendOut throw-and-return
geometry). Effects: water.js, wake.js, splash.js. Sheets: DESIGN.md,
CREDITS.md, SOURCES.md (the research log, filled before the first mesh).
stage.js ships as a skeleton with one worked actor chain: a figure leaves a
car, walks a route, comes back and climbs in. It is deliberately not a story. The
structure it does ship is the part that is tedious and easy to get wrong: beats
ordered monotonically in t, one branch per actor per frame, the contact shadow
following place(), and the door as a timeline rather than a state. Replace the
cast, the routes and the beat table; keep the skeleton.
car.js is still not in the kit. A vehicle is the most subject-specific prop in
the scene, and the recipe (dimensions, door pivots, windscreen versus seat, boot
well, livery split) is written above at the length it needs. Everything else you
need is a file.
qa/ is the harness: shoot.mjs (viewports, named timestamps, console errors,
the buffer/camera/draw-call line), audit.mjs (flat-glow, whole-object fade,
camera/render-scale, early-hide, teleport, idle/orphan emitter and margin checks
against the optional scene contract), portrait.mjs (isolated figures through the debug
camera), strip.mjs plus tile.py (motion strips, which fail on a frozen
loop), probe.py (colour runs across an edge, the tool for "why is there a band
here"), and sweep.mjs (pairwise distances across a beat, with a two-sample jam
verdict and a teleport limit). They need playwright-core and a page that
exposes window.__diorama with freeze(t); verify/water/ is a worked example
that exercises the three water modules and shows what the acceptance output
looks like.
The skill is part of the deliverable. After every shipped diorama, do a ten-minute postmortem and write down what the user actually reported, not what you intended.
SKILL.md.bak-v<old> beside it.DESIGN.md for a different diorama. Fix
every contradiction it finds; that is cheaper than discovering them after a
build.A useful postmortem entry has this shape:
BUG: the club read as a rock at the gate
CAUSE: end-on pose; haft hidden; no bands
RULE: turn the boss 0.35 rad to the goal; build haft + bands + studs
TEST: contact crop at the gate-hit timestamp; haft visible, head on door
Two entries from the truck-diorama correction pass:
BUG: a light-grey rectangle floated beside the jumbotron
CAUSE: a flat, untextured, additive PlaneGeometry used as a glow
RULE: halos need a radial-alpha map, an emissive lens, or deletion
TEST: qa/audit.mjs FLAT_GLOW plus a 1:1 crop of the board at the hero time
BUG: both trucks vanished at the gate before the run-out finished
CAUSE: visible=false keyed to a beat, not to world bounds clearing the slab
RULE: hide only after the box is outside slab + 0.2–0.5 m; never fade solids
TEST: qa/audit.mjs EARLY_HIDE over the full loop; exit strip at 0.2 s spacing
BUG: the yellow truck smoked while parked, from a cloud above the cab
CAUSE: continuous idle haze plus a guessed emitter point inside the body
RULE: one start/rev event window, puffs born at the real stack tip; offset the
birth point downstream if a canopy hides it, never the source anchor
TEST: qa/audit.mjs IDLE_EMITTER / ORPHAN_EMITTER; event frame present,
event + 1.5 s absent
From a Pikachu vs Charizard battle diorama built against Sword/Shield and anime footage. The rig, the effects and the send-out all broke in the same three ways, and the fixes generalise to any fight between two rigged actors.
Both rigs look down their own +Z, and both sit under a stage root that is
rotated (here 90°). Reading a world-space bearing to the opponent and writing it
to a local rotation.y aims the two past each other, toward the lens. Compute
the yaw inside the stage's own frame:
const camLocal = cameraDirection.clone().applyAxisAngle(UP, -STAGE_ROT);
const aimYaw = (a, b, cheat) => {
const dx = b.x - a.x, dz = b.z - a.z;
return Math.atan2(dx, dz) + camLocal.x * cheat; // local, not world
};
Then prove it numerically, every half second, through the whole loop: forward vector versus vector-to-opponent should sit at 0.97+, and it may only dip during a deliberate spin. Eyeballing this at 300 px hides a 20° miss.
Flames leaving a mouth must be anchored to the head joint, or they slide off the jaw whenever the head turns. Convert the parent's forward and up into the target bone's local space once at build time and keep the emitted point there:
const localFwd = bone.worldToLocal(pivotPoint.clone().add(forward)).normalize();
Same idea for the aim of a projectile: take the muzzle from the bone matrix, not from a hardcoded offset.
quaternion.setFromUnitVectors(+Z, viewDir) picks an arbitrary roll, which is
why a kanji-shaped blast can arrive upside down or rotated 40°. Build the basis
from the lens explicitly:
const right = new THREE.Vector3().crossVectors(worldUp, viewDir).normalize();
const up = new THREE.Vector3().crossVectors(viewDir, right).normalize();
mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(right, up, viewDir));
A hit burst is a hot core with ten short sparks radiating out of it, plus a ring on the ground. That is the whole recipe; it reads at any size.
m.scale.set(k,1,1) per layer) — sharing one
geometry between layers makes the layers invisible.An eruption anchored to actor.position every frame crawls across the slab while
the actor slides out of it, which is exactly the "something feels off, it moves a
bit" report. Latch the anchor once, at the moment of impact, and animate from
that fixed point. Same for the projectile's target: aim at the latched point, not
at the live chest position.
templates/creature.js ships these five as runnable functions: aimYaw,
headAnchor, uprightQuat, makeHurtModel, sendOut.
One hit model, four severities: a light flinch (0.9 s, a tip back), a knocked-back
stagger (1.7 s, air time), a spin-out (1.3 s), and a full collapse (3.5 s, falls,
lies down, gets up). Rotate about the axis of the blow ([dz, 0, -dx]), pivot at
mid-body so the actor rocks rather than hinging at the feet, and add a small
vertical arc so the feet leave the ground.
qa/frames.mjs: a per-entity frame recorder. Samples one dense
series per entity across a window, isolates it (branch, siblings or full
scene), fits one camera to its swept bounds so travel stays visible, and scores
every entity: energy (flat means frozen), step (NDC jump means teleport),
empty frames, OFFSCREEN, with STATIC= for actors meant to hold still. Each
entity gets a contact sheet plus a row in report.json, and the script exits
non-zero on any flag. New Per-entity frame recording section in Verification.
New Long builds and context section: compaction is lossy, so state lives in
DESIGN.md, SOURCES.md, CREDITS.md, report.json and the beat table, and a
post-compact agent re-runs the harness instead of trusting its summary.Research Before You Model section: step the source footage into
contact sheets and write the motion grammar down before modelling, prefer
game footage over fan cuts, and check free commercial-use libraries (Poly
Pizza, Sketchfab CC0/CC-BY, Quaternius, Kenney, user-supplied AI meshes) before
hand-modelling a creature; CREDITS.md + licence gate + fitModel pipeline +
forward-axis probing; shots-as-acceptance-test. New Field Notes: Two-Creature Combat section: stage-local aiming with a numeric facing sweep, bone-space
effect anchors, upright billboard basis, explosion vocabulary (no cones/bars/
scaffolding, per-layer scaling, one continuous stream), latching effect anchors
to the impact point, send-out vs recall grammar (throw → arc → open → burst →
full-size exit → ball returns; red beam only on recall), and severity-scaled
hit reactions about the blow axis. Project layout gains refs/ and
CREDITS.md.subjectScale is a post-fit
multiplier (0.70 = 30% smaller linearly), while fillX/fillY stay a fit
margin. The camera template now exposes fitDistance and subjectScale;
qa/audit.mjs now proves distance ≈ fitDistance / subjectScale when those
two fields are exposed, and post.size ≈ CSS × renderScale when the post
exposes it. Added the
Dust Kings calibration (fit 67.93 → actual 97.05, projected slab 872 → 611 px)
and a rule against shrinking the geometry or canvas frame to create margin.
PixelPost gains a renderScale argument; Dust Kings renders at 1.25× (buffer
1640 × 950 on a 1312 × 760 CSS canvas) so the smaller diorama keeps its edge
detail without changing page size. The resolution section now separates buffer
from layout and warns about the frame-rate cost.qa/audit.mjs checks flat glows,
whole-group fades, early hides, teleports, idle/orphan emitters and paper-margin
leaks; shoot, portrait, sweep and strip now fail with a nonzero exit
code; QA README documents the optional audit contract. New DESIGN audit sheet;
detail.js gains haloPlane/softDiscTexture so a soft light card is the
default.poses.js,
transfer.js (climb, stand spots, facing), track.js and a stage.js
skeleton with a worked actor chain; qa/ as runnable scripts (shoot, portrait,
strip, probe, sweep) instead of a checklist; Numbers split into invariants and
calibrated values. Water, wake and splash re-verified in verify/water/, which
found two real defects: the splash used Math.random (now seeded) and neither
effect could be frozen (both now expose reset(); the harness re-simulates from
zero and a two-frame diff fell from 334 to 18 pixels). build.mjs writes a
favicon stub so the 404 stops polluting the console gate.moving gates, Night Market worked
example and fix table, 2x backing/scale-by-camera guidance, empty-paper rule,
social verification logs, night/practical/headlight guidance, cutaway and
glazing rules, bus/van anatomy, cat/dog quadruped rig and gait, generalised
DESIGN.md with Life/Service and Contact/Choreography sheets, new
peopleModel-street.js template, scene fast path, and a laundromat/workshop
router row.DESIGN.md; new detail.js rope, trophy-board and
contact-shadow helpers.templates/DESIGN.md, templates/detail.js,
templates/combat.js.String.replace guard.A playbook and code kit for building small pixel-art dioramas in Three.js.
Pikachu vs Charizard, 45 s · Dust Kings, 38 s · Pool, 26 s · Bank job, 48 s
Four scenes built with this playbook. Click any tile for the full capture.
Install it in one prompt · What is in here · Start a diorama · Verification
A diorama here is a tiny staged scene on a slab: a locked three-quarter camera, a flat cut-out against white paper, a 64 colour palette with ordered dither and an inked contour, and toy-scale people with real joint animation. The subject changes (pool, night market, bank job, arena battle); the slab, the cast and the presentation stay.
Everything is deterministic. The scene is a pure function of t, so any frame can
be frozen, screenshotted and compared. A screenshot is evidence, not a lucky
capture.
SKILL.md is the whole playbook: it carries a research-first workflow, the look
and animation bars, the pipeline, the recipes, the QA harness and the pitfalls,
in one self-contained file. Everything else here is copy-in code that supports it.
Pikachu vs Charizard is the fight at the top of this page: Leon throws a ball, Charizard bursts out of it and the ball comes back to his hand, four attacks trade across the arena with their own wind-up and hit reactions, Pikachu takes a knockdown, and the red recall beam only appears once Charizard is beaten.
Dust Kings follows one night's billing: cars on the arena floor, the billboard running through LIVE, NEW RECORD and THANK YOU, and a fire that gets away from them.
Pool is a slow afternoon: one swimmer working laps with a wake trailing behind, one standing waist-deep by the ladder, one folding into a dive beside the board, a lifebuoy and a ball left floating, a lifeguard pacing the far edge, and a sunbather who does not move from the towel.
Bank job runs in daylight: a sedan noses into the curb, a group in dark clothes climbs the bank steps and the windows go red behind them, the sedan pulls away, two cruisers arrive and officers fan out across the street, then the street empties again.
Paste this into an agent that can run shell commands. It asks where to put the
folder, downloads the whole repository (playbook, templates, QA harness and every
worked example), reads them, and reports what it learned before it builds
anything. SKILL.md carries skill frontmatter (name, description, version),
so an agent with a skills directory can load it by name later.
Install the Diorama playbook here, then use it to build scenes.
Repo: https://github.com/sloemo01/diorama
1. Ask me where the folder should go before downloading anything,
and wait for my answer. Propose ~/skills/diorama as the default.
Do not pick a path silently.
2. Download the whole repository, not just SKILL.md:
git clone https://github.com/sloemo01/diorama.git "<target>"
Without git:
curl -L -o diorama.tar.gz \
https://github.com/sloemo01/diorama/archive/main.tar.gz
tar xzf diorama.tar.gz && mv diorama-main "<target>"
3. Prove the install before describing it: SKILL.md, README.md,
templates/, verify/ and LICENSE at the root, and print the
commit hash (git -C "<target>" rev-parse --short HEAD) or the
archive date. If you have no shell or no network, say so and
stop. If the download failed or a file is missing, say so and
stop. Never describe this playbook from memory.
4. If you have a skills directory or plugin folder, install it
there so you can load it by name in later sessions; otherwise
treat "<target>" as the folder.
5. Read before you build: all of SKILL.md, then
templates/DESIGN.md, templates/SOURCES.md, templates/CREDITS.md,
templates/qa/README.md, the header comment of every file in
templates/, and the worked examples under verify/. SKILL.md is
the source of truth; the README is only a summary.
6. Report back in this order: install path and commit, the Look
Contract in one paragraph, the Pipeline as ordered steps, the
QA scripts and what each one checks, and three rules from
SKILL.md with the section each came from.
7. Then ask me what the first diorama should be. Do not ask me to
fill the design sheet. When I give you a subject, research it
first (frames, assets, motion), fill DESIGN.md from the
defaults, copy the modules you need into my project instead of
editing the installed copy, build through the Pipeline, and run
the QA scripts before you show me anything. Give me the paths
of the frames you kept and what each one proves.
If two parts of SKILL.md contradict each other, list them before
you build. A successful clone is not an install: 5 and 6 are it.
Needs git, or curl and tar for the fallback. The QA scripts need Node with
three, esbuild and playwright-core. The media/ previews are about 20 MB
and can be deleted after the clone.
| Path | What it is |
|---|---|
SKILL.md |
The whole playbook: research gate, look contract, detail and animation bars, pipeline, recipes, QA, pitfalls. Start here. |
templates/ |
Copy-in modules: palette, post pass, camera, lighting, geometry, props, rig, poses, vehicle transfer, paths, combat, creature, water, effects, build script. |
templates/DESIGN.md |
The design sheet you fill before modelling. |
templates/SOURCES.md |
The research log you fill before modelling: query, what was found, decision. |
templates/CREDITS.md |
The licence ledger for every imported model, clip or texture. |
templates/creature.js |
Rigged-actor combat: stage-local aiming, bone-space effect anchors, upright billboards, hit severities, send-out geometry. |
templates/qa/ |
Eight verification scripts that check a build against frozen frames and printed numbers, including a per-entity frame recorder. |
verify/water/ |
A worked scene that exercises the water modules, with the screenshots the QA loop produced. |
verify/climb/ |
A unit test for the vehicle transfer maths. |
verify/creature-battle/ |
What a two-actor fight build proved: aiming, effect anchoring, send-out and hit numbers. |
media/ |
The preview GIFs and source captures shown above. |
SKILL.md: Research Before You Model, One Prompt, One Build, The Look Contract, The Detail Bar, The Animation Bar, Pipeline.refs/SOURCES.md. Imported
assets get a row in CREDITS.md.templates/DESIGN.md into the project as DESIGN.md and fill it. Premise
as a verb, cast, systems, five beats with timestamps, detail budget.src/core/ and src/scene/, then a config.js
that holds the scene's numbers. verify/water/src/ is the reference layout.templates/qa/ before calling it done, and check the research gate
in Build And Ship (reference sheets, SOURCES.md, CREDITS.md, per-beat
captures).The playbook opens with four lookups, because a plausible silhouette that matches nothing is the most common way a commission fails:
templates/SOURCES.md and templates/CREDITS.md are the two artifacts that make
that research visible in a project, and Build And Ship will not pass wi