通行証 · HALL PASS DOCS · PLAY

HALL PASS — systems reference

Environment campaign (2026-09-05): all ten stages have enhanced art. The subsequent layout campaign gives Pool, Library, Second Floor, Cafeteria, Detention, Boiler, Science and Rooftop distinct connected floor plans. The Yard retains its approved campus layout; the Gym deliberately remains square at Chris's request. size is an enclosing half-size for room-authored maps, not a square floor. All layout changes pass through the real 3D level editor. Gameplay and CURRENT/ENHANCED previews use the same collision geometry and room union. Enemy tuning, roster, reward, difficulty and simulation rules remain unchanged.

environment-kits.js registers the six later-stage presentation kits. Each owns architecture, footprint-local prop models, physical floor materials, optional reflections and quality/review/hour hooks. Floors partition overlapping room and zone rectangles instead of stacking coplanar surfaces. Architecture stays outside the playable union; solid scenery inside it has an authored obstacle. Upper foreground walls and overhead fixtures are cut away during combat. Explicit kit light intensities persist through world.nightLightScale as the view advances the night curve. Local lights remain optional and shadowless; one cached directional shadow handles detailed static art. Each later-stage showroom has five views, including a gameplay-height camera. No paid generation or new external asset dependency was needed.

Detention suite (3 AM): five joined rooms form an offset classroom, records room, discipline office, short connecting passage and arrival foyer. Size 34, start (-14,25), 12 solids and 10 authored decals. Four desk clusters provide low movement barriers; records and office cabinets provide tall projectile cover. The classroom/records/passage/office circuit works in both directions with a 1.7-radius boss. detention-suite.js adds dark walnut boards, slate records flooring, faded burgundy inlays, chalkboards, timber paneling, slatted blinds, radiators, files, desk stationery and a 3 AM clock. Cool window stripes and restrained warm fixture pools keep the broad lighting dark. Six desktop/three mobile local lights; overhead hardware is visible only in CLASSROOM overview. The old square kit remains an explicitly tested fallback. Review stage=detention&shot=classroom|desks|blackboard|records|office.

Boiler industrial circuit (4 AM): five joined spaces connect the furnace hall, pump gallery, turbine bay, service return and stoker entrance. Size 40, start (-24,29), 9 solid machine clusters and 13 decals. The central sealed steam riser is outside the playable union and interrupts sightlines. Four furnaces, a pump manifold, turbine, copper tanks, control bank and tool trolley create intentional aisles and six reversible local circuits. boiler-plant.js builds exact union masonry, high clerestories, pipe services, recessed riser grilles and clear gameplay cutaways. Charcoal concrete, neutral steel, copper and oxidized metal contrast amber fire doors with cool pump/control lights. Footprint-aligned hazard paint and drains share one physical floor material. Six desktop/three mobile lights; low effects pauses steam and hides lights. The warm furnace lights waver like firelight (2026-10-16): three unrelated sine periods, about ±15% intensity, deepening toward orange as they dip; the cool pump/control lights hold steady. The heat reaches the floor: every furnace (not the turbine) throws a soft additive pool out of its fire door (the +z face) that breathes on its own phase within ±30%, because a real light barely shows on the concrete from the gameplay camera (measured: the lamps carry about 4% of a frame). Tagged practicalWash, so Blackout Grid takes it down (test/boiler-firelight.test.js). The full PLANT view reveals suspended services; focused views hide them. Review stage=boiler&shot=plant|floor|furnaces|pumps|turbines.

Science suite (5 AM): seven joined spaces connect the chemistry hall, prep annex, reagent gallery, cold room, instrument gallery, specimen bay and arrival. Size 40, start (-16,28), 13 solids and 14 decals. Two routes run around a sealed containment core; the specimen bay has two 7-unit doors around a solid partition. Staggered epoxy benches, archive cabinets, cold storage and specimen tanks provide meaningful low/tall cover. The west chemistry bench was shifted to (-22,-2) in the editor to widen its outer aisle to 7 units. lab-suite.js adds a violet containment experiment (alive since 2026-10-16: its three glowing gyro rings precess round the orb, the ice crystal turns and the orb breathes, all in their own root because the architecture is transform-cached; a live special grade excites it, up to 3.5x the pace with the orb, crystal and rings flaring, through world.setThreat -> lighting.setThreat; test/threat-rooms.test.js), detailed mineral tanks, laboratory instruments, microscopes, sinks, hoods and clinical windows. Hood bays have real openings through the shell, dado and curb, revealing recessed chambers, raised sashes, worktops, glassware and extraction ducts. Slate/ivory finishes, ice-blue and violet lighting, and restrained amber burners distinguish this room from the Pool. Static elevations merge by material; fixture geometry is instanced and floor light pools are batched and clipped to floor/obstacles. Six desktop/three mobile lights; ceiling fixtures appear only in the overview. Review stage=lab&shot=laboratory|benches|fumehood|containment|specimens. The generated floor (suiteFloor, 2026-10-16) lays 2-unit tiles that alternate a shade, with sparse chips and pixel-safe joints, plus painted safety markings: yellow keep-clear outlines around the specimen tanks and reagent cabinets, and yellow/black hazard stripes in the wall band of the cold room and specimen bay. Markings wear in about one pixel in seven. No new assets or draws; it is the same canvas the floor already generated.

Rooftop terraces (6 AM to First Bell): seven rectangles form two roof wings, north/south crossings around an open light well, a tank deck, antenna deck and arrival. Size 38, start (-21,28), 12 solids, one raised surface and 18 decals. Two HVAC banks, glazed skylights, a stair headhouse, water tank, vents, antenna and tool rack create local cover circuits; both bridges remain open for bosses. The north bridge is now the Bell Walk, a 3-unit-high, 14-by-12 deck with two 9-unit stair flights (12 treads each). It spans x=-17..15, z=-28..-16, with a lower return crossing to its south. Two solid side rails prohibit side entry and also block shots. The headhouse is flush to the west boundary at (-29,-24); the northern AC bank moves to (22,-10) to leave clearance at the east stair mouth. roof-terraces.js builds exact parapets, wind screens, service fixtures, school facades descending into the light well and a grounded bell tower beyond the northern terrace. The tool rack is flush to the southwest boundary, removing a 1-unit pocket. Gravel and concrete paths form a single partition with continuous texture scale and phase.

roof-elevation.js derives the exact tread/deck heights from the editor-authored roofwalk surface; roof-walk.js builds that structure in both current and enhanced graphics. The renderer places characters, modeled bosses, shadows, shots, pickups, companions, sprites, hazards and FX at their local floor height. Camera elevation follows the player with damping; mouse rays intersect the actual treads/deck and the reticle projects from that height. Navigation and combat keep their deterministic X/Z rules: no jumping, over/under crossings or new height-based damage advantage is introduced. Shots follow the surface; side rails remain tall cover. The solid raised plinth has no playable underpass. Editor preview and server save reject rotated, obstructed, unsupported or unguarded Bell Walk layouts. Moving the deck requires moving its two guardrails.

School/city structures reach ROOF_CITY_GROUND=-28; below-arena scenery is tagged and tested separately from gameplay-level geometry. A darker city with shaded building faces supports the playable roof. Nearby water tanks, fire escapes, aerials and illuminated billboards, plus distant construction cranes, add recognizable city silhouettes. The rooftop gains copper services, AC return lines, brass deck seams and an illuminated Bell Walk gantry. A continuous sky shader, city/street tones and quantized PMREM updates transition only with the actual Rooftop dawn mix. Clock progress alone cannot create sunrise; it advances the bell-tower hands from 06:00 to 07:00. Four desktop/two mobile practical lights fade at dawn. First light (2026-10-16, src/roof-first-light.js): the sunrise arrives as light, not a filter. Warm bands of low sun rake across the deck from the east-northeast, where the sky shader puts the sun; the front sweeps in from the sun-side edge as dawnMix rises (full reach at .62) and the bands drift a little as the sun climbs. One additive draw, masked to the walkable floor so nothing lights the light well. It replaces the view's indoor dawn shafts (Gym, Halls, Pool), which no run could ever show. FIRST BELL uses the same authored brightness scaling as gameplay, faces the sun and returns cleanly to blue hour on other views. Review stage=roof&shot=rooftop|combat|lightwell|equipment|first-bell. The equipment URL now opens the lower RAINWATER view facing the reflected bell tower.

Enhanced Rooftop weather uses fine aggregate/concrete color, bump and roughness maps, satin roofing and weathered metal on the equipment and Bell Walk. roof-weather-materials.js retains shared textures/materials and projects continuous world-space UVs without changing geometry. Glossy skylights, a darker clouded sky and warmer practical lamps support the cool night palette. The authored lighting intensities are hemisphere 1.28, key 2.1 and rim 0.70 before the shared hour scaling; the practical-light budget remains four/two.

Ten editor-authored puddles replace the enhanced roof's old photo decals. roof-puddles.js clips their merged geometry to the real floor, outside all cover and the raised route. One planar reflection target (512 desktop, 256 mobile, no MSAA) reflects actual scene geometry with soft edges and slight ripple distortion. Mobile refreshes every other weather update. Low quality or missing float-color support uses a wet PBR fallback; current graphics keeps the original decal path. Capture excludes weather and screen-facing FX and restores scene/renderer state. roof-atmosphere.js adds batched wind-driven rain, steam at the real vents/AC fans and a restrained bell glow, without extra lights or simulation RNG. Rain fades only with the actual final dawn transition. The red aviation beacons on the terrace corners flash together (0.45 s on in every 1.5 s) over the brightness setHour gives them, dimmer toward dawn (2026-10-16; they are merged geometry, so they blink through their shared material in the kit's update).

The puddle shader shares crossed capillary-wave slopes between its reflection distortion and directional moonlight highlights. Thin edges soften into damp concrete; the center retains the real scene reflection. Three reflection taps, one target and the existing capture cadence remain fixed. Highlight color warms only from the Roof's explicit First Bell dawn mix. Paused weather cannot move the water; reduced motion holds its ripple clock still. Entering a new room creates a fresh clock and resets the dawn tint.

Contact rings (2026-10-10). Footsteps and blasts now ring the rainwater, as they already did in the Yard. createRoofPuddles().ripple keeps four uniform ring slots (x, z, phase, strength, plus reach). The shader adds each expanding crest to the capillary slope and lights it with the same moonlight key, so a ring warms only with the First Bell dawn mix. Idle water pays one uniform branch (ringsLive); there are no new draws, textures, targets or passes. world.step/impact forward to kit.lighting.step/impact, which only the Roof kit defines. View._syncYardSurface feeds its hero and wading-curse footfalls on the Roof too and returns before the Yard-only energy and domain layer (measured in node: 0.75 µs/frame with 200 enemies). A step rings only inside water: the puddle's clipped floor rects intersected with an ellipse at 0.85 of its radii, so a step on the dim rim may stay quiet and nothing rings under cover, the Bell Walk or the light well. An impact with reach of 2 or more (capped at 7) may start on dry roof if a puddle lies within reach. Slots rank curse steps < hero steps < light impacts < heavy impacts (slam, blast, domain, boss, cell or death, or strength ≥ 0.75). A ring is never replaced by a lighter one, and a step waits for a same-rank ring to half fade. There are no rings at low quality, on the PBR fallback or under reduced motion. The capture filter that hides weather, other mirrors and screen-facing FX now reuses one list and one visitor. It allocates nothing per object, where it used to build an array and a closure for every scene object on every capture (about 30 per second on phones). Measured in node on the mobile Roof world plus 300 stand-in meshes, 18,000 captures ran 58 garbage collections before and 2 after, one of which the benchmark forced.

Shared asynchronous texture guards make late callbacks inert after disposal; world teardown is idempotent and restores reflection/shadow state. Tests cover actual floor unions and open voids, continuous doors, rotated prop footprints, architecture confinement, GPU lifecycle, quality budgets, player/boss routes, projectile cover and seeded combat/snapshot lockstep. Desktop and 393×852 portrait browser checks exercise review views and stage changes. These are correctness and visual checks; physical-device FPS and new-layout balance remain unmeasured. Source, tests and documentation live in the repository; screenshots and ad-hoc audits stay outside it.

Pool aquatics wing (2026-09-05): the 2 AM room now has five connected spaces: the swimming hall, spectator gallery, locker wing, filtration plant, and arrival lobby. The real 3D editor owns the authored rooms union, start, obstacles and decoration. size: 38 is only the enclosing half-size; it is no longer a square playable floor. The map has 26 movement obstacles including the water, three decorative starting blocks, twelve authored decals, and player start (0,26). The offset basin is 12×23 at (5,1). Two locker portals, two plant portals, gallery connections and broad decks provide alternate routes; locker banks, filtration tanks, columns and bleachers provide deliberate cover.

pool-environment.js follows that same footprint with layered poolhouse glazing, clerestories, a north gable, exposed trusses, services and camera-facing wall cutaways. Combat hides suspended roof structures. The recessed basin retains physical water, submerged lane markings, ropes, coping and ladders, with animated caustics on its tiled interior. groundHoles removes floor beneath the basin; room-clipped base geometry has no floor in the exterior notches. Mosaic tile, wet borders, drains and room-specific floor palettes share continuous world coordinates. Cool pool lighting contrasts with warm service and arrival areas; six desktop/three mobile local lights and reduced mobile geometry follow the quality controls. Low quality hides the local lights and mist. Deck stencils (2026-10-16, deckStencils): worn painted depth markers on the deck 2.4 units out from the water (9 FT at the deep north end, 6 FT midway, 3 FT 6 IN at the shallow south end) and a red NO DIVING at the shallow end, on both long sides and both ends, each with the tops of its letters toward the water as painted for someone on the deck. One 512px atlas drawn from the kit's bitmap font, one lit, alpha-tested floor mesh; the architecture tests treat paint lying flat on the floor as neither a building nor a barrier.

Pool water crosses two scrolling normal samples from the same retained texture; the existing environment and practical lights supply its highlights. Warped caustic cells and a faint surrounding glow belong only to submerged basin tile, fading below the waterline without spilling onto the combat deck. Low quality skips the second normal sample and uses dimmer, simpler caustics. The water, normals and caustics share one bounded clock; pause freezes them and reduced motion holds them still and hides mist. Domain and room-reclaim shader hooks compose with these material hooks without replacing them or recompiling on transitions. No extra lights, textures, reflection targets or scene passes.

Showroom supports stage=pool&shot=poolhouse|deck|water|lockers|filters; DECK uses the gameplay lens over the west deck. Gameplay and showroom use the same architecture and authored map. The new simulation foundation is described under Room footprints and navigation below. The Pool layout and its old seeded trajectories have changed; new snapshots round-trip in lockstep. Other stages retain their existing layouts and simulation behavior. No paid assets or configuration tuning were involved. Physical-phone FPS and balance have not been validated for this redesign. The playtest runner's simulation fingerprint includes map-space.js, navigation.js and their ground-layout.js dependency, so later geometry or routing edits cannot masquerade as unchanged simulation code.

The deep version of CLAUDE.md. Read the section you are about to touch. Update this file in the same commit that changes a system. A stale reference is worse than none: it is how an agent "fixes" something that was a deliberate decision.

Format for each system: what it is, where it lives, and what must stay true — the invariant an agent can break without any test going red.


School Run — the ten-room expedition (2026-09-12)

School Run is an opt-in ten-room expedition alongside the existing Room Run. It starts in the Yard and reaches First Bell only by clearing all ten rooms. Normal Room Runs keep their existing unlocked-room selection and per-room progression, and can now grow into a carried route after a Final. The full expedition route advances through these wings:

  1. Yard.
  2. Gym and Library, in either order.
  3. Halls.
  4. Cafeteria and Pool, in either order.
  5. Detention.
  6. Boiler Room and Science Wing, in either order.
  7. Roof, unlocked only after the other nine rooms are cleared.

Every room in a wing must be cleared before the next wing opens. The night clock and sky advance by completed rooms rather than a branch's fixed map index. Quiz, Midterm and Final retain cumulative 3/6/12-minute checkpoints; the HUD and sky use the full 12-minute hour throughout, so Push On does not rewind the clock. Only the Roof's Final triggers the First Bell celebration. This is roughly two hours of combat before pauses and boss holds; shorter expedition pacing and the growing carried build still need balance playtests.

src/school-run.js owns the pure route contract. A Final clear stamps that room CLEARED for this expedition and reveals its next choices. Cleared rooms cannot be fought again but remain safe passages: after either paired room, the other remains reachable through the cleared wing. Quiz/Midterm checkpoints never open the next room. Route snapshots validate their version, rooms, clear order, current room and phase before restoration; malformed routes are rejected. Each expedition also carries a unique game-created id. A bounded list of the last 16 settled IDs in the matching mode's record prevents a stale pre-settlement snapshot from banking the same haul again after a crash.

School map (updated 2026-09-24): Room Run uses the approved architectural survey from src/quiet-blueprint.js, with room buttons and compact details (see Stage select below). Expedition routing uses the compact wing cards and original stage thumbnails. Only rooms belonging to that expedition appear; validated clears and the frontier determine Cleared, Enter room and Locked states. Career records never grant expedition clears. The illustrated atlas was rejected and removed from the active UI. Its image assets and provenance in docs/school-map-art.json are retained for possible future art work, but the map no longer loads them. Final room reclamation and the later lighting improvements remain active.

The crew carries its run upgrades, XP, weapons and surviving members' resources between rooms. Each member has independent HP. An active member's knockout removes them for this expedition and hands control to a surviving teammate; an eliminated member cannot be selected or revived. Losing every member ends the expedition. Bench members retain their own health. Checkpoints and room transitions do not grant the normal Room Run full heal; earned healing remains available. The existing Room Run crew and healing rules below still apply to that mode.

School Run offers a temporary second crew seat when two students are unlocked; it does not purchase a permanent seat or unlock a character. Owned third/fourth seats remain available. Final-boss weapon drops can be inspected and bound at a stopped checkpoint before the next room discards ground loot.

Coins remain one pending expedition haul across every checkpoint and room. Checkpoints do not provisionally bank it, reverse payouts or apply a boost. Only explicitly returning to the Club banks the haul, applying the active Coin boost once through the normal economy layer. Clearing all ten rooms ends combat and offers this return; merely displaying the completed checkpoint does not settle the haul. A wipe or abandonment loses only the pending haul, never an already banked wallet. Permanent kills and mastery earned during the expedition are retained. No mid-expedition payout can be duplicated by resume.

Full expedition records live in save.schoolRun as {clears, bestRooms, bestScore, settled}. Banking a completed Roof Final also increments nightsCleared once and grants firstBellBy to surviving crew members who contributed active time. School Run does not manufacture normal room scores, stars, runsWon, period unlocks or daily awards. Wiping or abandoning can retain a best-rooms record and permanent mastery, but cannot create a completed score or First Bell credit. Record migration clamps full progress to ten rooms; cloud merges retain maxima and settlement receipts.

Legacy School Run prototype: suspended v1 saves keep their original Yard/Gym/Library route and three-room objective. They are never silently extended into v2 expeditions. Completion remains in save.schoolPrototype, with the same fields and a three-room limit; it never grants a full-school clear, nightsCleared or First Bell credit. New expeditions use v2. The route picker and checkpoint label legacy saves explicitly.

Room Run → Push On (2026-09-12): an ordinary tier-1, non-daily Final outside the Roof offers Push On instead of starting a fresh room. The current Sim becomes an expedition at a stopped checkpoint: weapons, skills, XP, banked level choices, crew, resources and unclaimed boss drops remain intact. Inspect/bind the Final's weapon drops there, then Choose Next Room. Starting in Yard creates the full v2 route with Yard cleared; finishing all ten rooms can earn a full-school victory. Starting later creates a v3 partial route from that room's canonical position through Roof. Earlier rooms are omitted, never marked cleared; for example Library continues into Halls without claiming Yard or Gym. Later paired wings still allow either order. The clock retains the omitted-room offset so it does not rewind, and Roof ends at 7 AM. Partial completion says Route Cleared and records only save.roomChain (clears, bestRooms, bestScore, settled), never full-school/prototype or First Bell credit. Retrying a failed partial route starts its original room.

Conversion preserves the active HP exactly and gives bench members the same health fraction using their starting max-HP baseline plus earned shared gains. There is no free heal or added crew seat. Individual HP, permanent knockouts and the expedition's no-revive rule apply from then on. Already earned room stars, unlocks and assignment rewards stay earned. The latest provisional Coin payout is reversed by its exact paid amount, including its original boost; the haul stays at risk until returning to the Club. Subsequent settlement uses saved deltas for Coins, kills and mastery and the existing run-count flag, including after resume/replacement, so the first room cannot be counted twice. Daily, higher-tier and After Hours runs retain their own flow; any action that starts fresh is explicitly labeled New Run. Existing v1/v2 saves are unchanged.

Suspend/Continue preserves the current simulation, crew health and knockouts, route, build and pending haul. A School Run save does not expire after 24 hours. Suspending is a real-life break, not extraction: it gives no healing, reroll or payout. Updates still follow the title-screen-only apply contract; no update or room transition reloads the page during combat.

One night — the whole game (Chris, 2026-08-31)

Canon: the entire game is ONE night. The story starts at the last bell — the club kills time in the clubroom (the menus) until 9 PM, when the school stops being a school. Every stage is one HOUR of that night, the payoff of the whole game is First Bell at 7:00 AM, and sequels are other self-contained nights (Homecoming, not "Night 2"). A round's bell is not daybreak: the 10 PM bell is just 10 PM, and nothing sun-related happens before the tenth room. Internally the sim still calls every hour's bell dawn (_dawn(), the dawn event, dawnBonus) — read those as "the bell", never as sunrise.

The MAPS array order IS the night — index 0 opens at 9 PM, ten rooms, one hour each. Do not reorder it casually; test/night.test.js trips.

MAPS lives in src/maps.js (2026-09-03; config.js re-exports it, so every importer still says from './config.js'). The split exists because config.js belongs to the tuner loop and room geometry now has a mouse-driven editor writing to it — see docs/LEVEL-EDITOR.md and playtest/OWNERSHIP.md.

Props may turn (rot, 2026-09-03). w/h are a prop's own width and depth; rot (radians, three.js rotation.y) turns it in place. The sim only collides with axis-aligned boxes, so a SOLID obstacles entry may turn in quarter turns only — footprint() in src/maps.js swaps width and depth for an odd count and prepObstacles in sim.js collides with that. Decoration (props) and stickers turn freely. night.test.js and the editor sidecar refuse a solid at any other angle.

Must stay true: anything that reasons about a solid's box (collision, cover, the decal scatter, the editor's corridor and wall-pocket checks) reads footprint(o), never o.w/o.h directly.

Floor decals are authored or random, per room. A room with a decalPlacements array uses it verbatim. A room without one falls back to the original rejection-sampled scatter in world.js, which re-rolls on every build — so an un-authored floor is a different layout every page load. Opening a room in the level editor bakes the current roll into data, which is what makes the floor stop shuffling.

Ground patches are a partition (2026-09-05). ground-layout.js subtracts later zones from earlier zones and all zones from the base. Each visible point has one material owner at the same elevation; overlapping authoring rectangles cannot z-fight. world.js retains each source rectangle's UVs across its cut pieces, so tiling stays continuous. This applies to every room.

# Hour id Room
1 9 PM yard THE YARD
2 10 PM gym THE GYM
3 11 PM library THE LIBRARY
4 12 AM halls SECOND FLOOR
5 1 AM cafeteria THE CAFETERIA
6 2 AM pool THE POOL
7 3 AM detention DETENTION
8 4 AM boiler THE BOILER ROOM
9 5 AM lab THE SCIENCE WING
10 6 AM roof THE ROOFTOP — the finale

What follows from one-hour runs (all in place, verified in pane):

Stage select is THE BLUEPRINT (ui.showStages, src/quiet-blueprint.js): the approved quiet architectural concept is the playable room selector as of 2026-09-24. Connected school floors, courtyard, grounds, annexes and basement use a shallow angled night survey composed for a horizontal display. Compass, sheet rules, wing/floor labels, circulation notes, dimensional guides and a title block retain the architectural context. One affine projection aligns the existing 189.6 KiB WebP, room perimeters, touch polygons and anchors; seven independently clipped SVG groups expose data-bp-layer for roof, upper, ground, yard, gym, pool and basement. Each layer has a shallow vector slab edge beneath its art, giving the exploded floors depth without new textures. Shared art is decoded once. The first opening fades those groups in with a bounded stagger; reduced-motion and keyboard openings are immediate. No map WebGL renderer or idle animation loop.

Desktop overview restores all ten upright room name/hour/state labels in the drawing's margins, using the body font at screen size. Compact views use the selected/hovered callout until the drawing is large enough for labels. Numbered room markers retain a minimum 44px target; the larger invisible room hit polygons follow the projected floor, avoiding overlapping bounding boxes. Ten vector perimeters follow the walls: haunted rooms have muted red fill, cleared rooms green, sealed rooms dashed gray, and selection warm gold. A matching status legend remains visible in overview. Hover/focus shows the room name; an open selection owns its callout until closed. The first available haunted room has a warm marker. All/Grounds/1F/2F/Roof/B1 shortcuts frame sections of the same sheet, without rearranging the school. The overview uses the whole width until a room is selected. A compact dock sits right on desktop and below on portrait phones, showing name/hour/status, the selected challenge and Play. Details expands the existing art, description, records, periods, tier and Second Heart controls. Phone selection frames the room above the dock; expanding details recalculates the available map space. Zoom, Fit and mouse/touch pan remain available. Fit closes details and restores the full-school overview. Keyboard/gamepad focus reveals offscreen rooms. The ten MAPS entries, hour order, unlock requirements, periods, tiers, stars, Second Heart and on.pick(mapId, tier, period) contract are unchanged; School Run's separate frontier selector is unchanged too. If map art fails, the room list and detail actions remain usable.

Room controls use fixed registration from QB_ROOMS, with .bppin owning centering so button focus/hover cannot displace the target. Layout measures client dimensions, retries initially zero-sized embedded views and follows resize; focus reveals offscreen rooms. Zoom state refreshes enabled navigation controls. Drag clicks are suppressed, and pending drags clear on an uncaptured pointer exit. Deferred layout, image and purchase callbacks guard screen identity. Short landscape hides card art/blurb to keep actions reachable. Short landscape phones (height ≤ 500px, 2026-10-10) carry the room list in the masthead: every hour by number, name and state, and "Explore the school" closes the dock and fits the school. The map starts under the masthead and takes the space reserved for the hidden toolbar and legend. With the dock open these phones keep the whole school framed beside it rather than zooming onto the room, so all ten rooms stay on screen, none sits under the card, and picking another room moves only the selection. Each room wears a one-line 11px name tag beside its badge wherever one fits without covering another badge, another tag or the dock (selected room first, then tonight's, then hour order). Open badges fill red, cleared ones green; sealed badges stay hollow and dashed with a padlock. On touch only the selected room's badge is ringed. Stage detail thumbnails remain the existing in-engine captures. The original src/blueprint.js stays available to the standalone campus comparison study.

Campus study (2026-09-24): /campus-study.html is a standalone design preview using the original school blueprint with ten baked room miniatures made from actual game furniture. Area controls crop this fixed school drawing; zoom/pan and a Models toggle compare its render overlay. It does not replace stage select or School Run navigation. Its mode switch and clear/reset controls use an in-memory route preview, with no save access or gameplay entry. See docs/CAMPUS-MAP-EXPERIMENTS.md for the art budget and prototype contract.

Room dressing (2026-08-31): the five new rooms own their look end to end — per-room base ground textures (parquet / cafelino / detlino / boilerfloor / labtile; NIGHT_GRADE + BASE_TINT + MIRROR + tileUnits entries in world.js), per-room env lighting (ENV_TINT in view.js AND WALL_TINT in world.js — an env missing from WALL_TINT crashes buildWorld, the table now has a halls fallback), a room-flavour decal sheet (DECAL_E_IDS in bake-art.py: books, tray, chalksums, sludge, beaker, valvewheel…), and a generic obstacle face: field: any boxy obstacle can carry face/faceH/faceW and world.js draws the assets/faces/{face}.webp texture on both long sides — how library cases wear bookshelves, the serving line wears its shutter, detention gets a chalkboard, boilers wear gauge panels, the lab case wears a chemical cabinet. Taste rulings: the cafeteria checker shipped too loud — muted via NIGHT_GRADE (0x6e7f7a) and tileUnits 18, not a re-roll; labtile dimmed the same way. Fix loudness with the grade/tiling knobs before regenerating art.

Yard render slice (2026-09-04): the opening Yard has an enhanced view-only path: generated normal/roughness maps, a convolved night reflection environment, authored wet pockets and storm drains, batched contact grounding, rain ripples, camera-facing rain, and lightly bevelled foreground props. None of it changes the flat simulation plane or obstacle footprints. View.setQuality('low') removes the physical wet layer, ripple/rain animation, and foreground bevels; mobile loads dedicated 512px detail maps. /showroom.html?stage=yard provides a seed-stable CURRENT / ENHANCED comparison through the real game camera and post chain; returning to CAST must restore its neutral environment.

Yard campus pass (2026-09-05): yard-campus.js replaces the enhanced school shell with three floors of recessed classroom glazing, interior light and blinds, a clock tower/entrance, west teaching wing, covered connection, roof plant, tank/ladder and perimeter trees. Architecture batches by material and spatial section. yard-props.js replaces the Yard's seven furniture kinds with cached, material-batched models: slatted benches, planted concrete beds, ribbed dumpster lids/casters, tubular racks, corrugated shed, masonry walls and braced basketball hoops/nets. Their existing editor pivots and collision data remain authoritative. New surface/height textures are generated locally; mobile uses smaller maps and lower geometry detail. yard-sky.js provides an infinite storm sky, including for reflection cameras; the Yard's light grade balances cool ambient light against warm practical lights.

Showroom links accept ?stage=yard&shot=campus|court|entrance and graphics=enhanced|current. Yard defaults to enhanced campus view; COURT retains gameplay framing and fog. The architectural views use thinner fog to inspect the building, which is beyond the opening combat camera. Combat camera, spawn bounds and simulation RNG are unchanged. Low quality retains the detailed static campus/props but disables the expensive existing weather, reflection and shadow passes. Switch back to CAST to review characters.

Yard layout follow-up (2026-09-05): authored in the 3D editor. The west track ends before the north entrance apron; court paint now fits the hoops and both keys extend inward. Paired waiting benches sit under the relocated entrance shelter, opposite court-facing benches on a south spectator apron. Bike parking and the dumpster occupy opposite entrance edges; the shed faces the court from an east service pad cut into the grass. Planters flank the approach, grime follows equipment, and lamps follow paths/court corners. YARD_SHELTER_Z also anchors the wet band to the front gutter. The 13 solids retain their kinds and dimensions but change positions/orientations: the 32×20 clear rectangle remains empty, minimum pair clearance is 7.1 units and minimum boundary clearance is 7. New tests protect geometry coverage and traversal. This changes layout-dependent trajectories, not simulation rules.

Gym environment pass (2026-09-05): enhanced graphics now also apply to env: 'gym'. gym-surfaces.js authors full-room staggered maple strips, navy keys/border and an original hawk crest, shallow linear bump seams and wear-driven varnish roughness. The shipped maple photograph supplies varied fine grain within each strip; a guarded async load preserves single-file support and does no work after disposal. Court paint now aligns with the existing hoops (36×18), with a quieter second-sport layout underneath.

gym-props.js builds cached, material-batched retractable bleachers, stitched mats, wooden/leather vaults, padded hoops and a ball-storage rack. Furniture keeps the original editor footprints and north/south orientation. All 13 colliders and the gaps between bleacher blocks remain unchanged. Six small floor details were authored in the 3D editor around equipment and entrances; the center no longer receives random towels or papers.

gym-architecture.js supplies layered masonry/padding, columns, recessed windows and doors, banners, steel perimeter trusses and a code-drawn amber scoreboard. The scoreboard is live in a run (2026-10-16): HOME counts the club's kills, GUEST the curses on the court, the clock runs down to the bell and PERIOD is 1/2/3 for Quiz/Midterm/Final (scoreboard.set via world.setScoreboard). The view checks twice a second and repaints only when a shown value changes while the board, high on the north wall, is in the camera's frustum (zoomed out); the title keeps the old face. Solid architectural vertices stay outside the playable bounds; the south wall is a low cutaway. The reflection environment matches the high-bay rows: four unshadowed real spots on desktop, two on mobile, plus one cached directional shadow. Overhead fixture models are hidden in combat to avoid occlusion; world.setReviewView() reveals them only in architectural showroom views. Low effects disables spots, shadows, moonlight panes and dust while preserving static details. Reflection/shadow state restores on disposal.

The showroom cycles CAST → YARD → GYM → LIBRARY → HALLS, with Gym shot=arena|court|bleachers and graphics=current|enhanced. COURT matches the real Gym camera. Gameplay lighting retains the hour gradient with a cooler ambient/neutral warm key grade and reduced fog; combat framing, spawns and simulation RNG are unchanged.

Library wings and archive (2026-09-05): the third room, at 11 PM, now uses six authored floor rectangles: the grand reading hall, staggered stacks wing, north and south galleries, entrance foyer, and east study bay. Two routes connect the wings around a sealed rare-books archive. size: 40 encloses the irregular footprint; the player arrives at (17,24). The gallery openings into the reading hall are 8 units wide. The Gym and every other stage retain their existing layouts.

The real 3D editor owns this layout: 16 solid obstacles, ten authored floor stickers and no decorative collision substitutes. Four bookcases, five reading tables, a card catalog, four piers and two foyer benches supply cover and routes. Table tops allow techniques over them; tall shelves, the catalog, piers and the archive boundary stop shots. This is physical cover, not a stealth system. The narrowest intentional pier/table passage is 4.2 units; player and 1.7-radius boss bodies physically complete it and the wider loop routes in navigation tests.

library-architecture.js follows the actual union boundary with book-lined walnut walls, brass trim, plaster, moonlit windows and a recessed north circulation hatch. The clock still marks 11 PM. The central glass archive contains rare-book cabinets, manuscript displays and a brass celestial globe. Every architecture vertex stays outside the playable floor. South/east facades are cut away for combat and interior review; changing facade visibility requests a fresh cached shadow. The legacy square builder remains available for layouts without room rectangles.

library-props.js batches detailed furniture by material and caches deterministic shelf variants. Writing pads, books and tucked chairs stay inside the authored table footprint. LIBRARY_TABLE_TOP and LIBRARY_CASE_TOP align banker-lamp hardware with the furniture. table and catalog are also available in the editor and comparison renderer; the independent sidecar gate recognizes both.

library-surfaces.js preserves the continuous photographic parquet and adds oxblood reading carpet, emerald gallery/foyer/study rugs and brass perimeter inlays. Floor pieces partition the union once, so seams cannot flicker and no floor crosses the archive or exterior notches. Inlays derive from exterior edges rather than rectangle seams. Four instanced draws hold all fourteen banker lamps. Four desktop/two mobile point lights, clipped window projections, soft table pools and restrained window dust provide the warm/cool contrast. Low quality disables these light effects and shadows while retaining furniture. All instance buffers, materials and textures belong to world disposal; late floor-image callbacks stop after disposal.

Showroom links use stage=library, shot=reading-room|aisles|catalog|stacks|study and graphics=current|enhanced. The old catalog URL now opens the ARCHIVE view. AISLES keeps the actual indoor gameplay lens and targets the central reading-room cover. Full room, archive, stacks and study views fit their authored bounds in portrait and landscape. New layout snapshots remain deterministic; older Library saves recover actors that would now lie in scenery, but cannot reproduce their former square-layout trajectories. This pass does not retune rewards, enemies, camera behavior or the tuning-loop configuration.

Second Floor corridor maze (2026-09-05): The fourth room remains at MIDNIGHT. Its authored floor now joins 21 rectangles into a fixed corridor network with three furnished adjoining rooms. The western locker loop surrounds Classroom 201; the northern window gallery circles an art room; the eastern service route connects through the staff room and southern return. The projecting stair landing starts the player at (-28,31). A trophy junction and moonlit window bay provide visual landmarks. The enclosing half-size is 40, but exterior notches and two enclosed voids are outside the arena. The Gym and all other stage layouts retain their prior data.

The real 3D editor owns the floor, arrival, 31 obstacles and 17 cosmetic decal placements. Eighteen thin partition segments form staggered eight-unit doors; room shortcuts have two exits. Low gray cutaway wall models retain tall projectile cover. Classroom desks, art tables, wall-mounted locker banks, staff coffee/mail counter, benches, trophy case and a recessed vending machine use authored rotations once and stay within their collision footprints. The small staff-bench end gaps and one art-table side gap are optional pockets, not required boss routes. Dedicated tests physically traverse the doors and circulation routes at player and 1.7-radius boss clearance. Navigation, collision, spawning and snapshot behavior use the existing shared room-union implementation; the new layout changes trajectories without changing those simulation rules or tuning.

halls-architecture.js derives the exact perimeter from the union, with gray ceramic wainscot and charcoal-blue trim, numbered school doors, transoms, notice boards, fire cabinets, north windows, radiators and a 12:00 clock. Tall facades occupy only the true north/west silhouette; interior returns and foreground walls stay cut away. The glazed stair vestibule and its visible steps are scenery outside the playable floor. Narrow void walls compress to avoid the opposite floor and overlapping caps. The detailed renderer runs before the generic room-union comparison fallback in world.js.

halls-surfaces.js reuses photographic tile grain with separate albedo, bump and roughness maps. Dark graphite-gray terrazzo corridor slabs with quiet joints, maple art flooring, warm classroom floors, slate-blue service tile, a trophy medallion and the 2F entrance mat identify places without layering coplanar floors. Window projections and dust remain on legal floor; light washes stop at authored partitions. Fluorescent fixtures are instanced and visible only in the floor-plan review. Six unshadowed local lights on desktop/three on mobile supplement the cached directional shadow and PMREM reflections. Neutral white local lights, cooler window glass and a corrected Halls gameplay tint keep the gray surfaces free of a green wash. Broad Halls lighting is subdued so local fixtures and moonlight remain the brightest landmarks; low effects disables local lights, shadows and dust. Geometry, textures, instances and late image callbacks obey world disposal.

Showroom links use stage=halls, shot=corridors|lanes|stairwell|classrooms|gallery|service and graphics=current|enhanced. FLOOR PLAN shows the complete network; JUNCTION uses the gameplay camera; STAIRWELL, CLASSROOM 201, MOON GALLERY and STAFF WING inspect the distinct routes. Current/enhanced share the exact playable layout. Physical-device FPS and balance have not been measured for this redesign.

Cafeteria dining and kitchen redesign (2026-09-05): the 1 AM room joins six editor-authored rectangles into an L-shaped dining floor, projecting window bay, entrance foyer and a playable kitchen/service return. The union is 3,444 square units within an enclosing half-size of 40; arrival is (-14,26). Four staggered communal tables, window seating, a tall folded-table storage bank, a 25-unit open serving counter, stainless prep island, dish return, tray trolley, sorting bins, vending and entrance planters create 14 movement obstacles. Fourteen authored floor decals replace scatter. Kitchen entries reconnect the west dining area and east service passage; both sides of the prep island keep roughly seven-unit clear aisles. The service lightwell stays outside the floor.

cafeteria-environment.js composes room-aware architecture, furniture, surfaces and lighting from the three dedicated Cafeteria modules. The legacy square kit remains available for older layouts. The current comparison renderer shares the same playable union and obstacle data. No simulation rules or tuning changed; shared navigation uses the new geometry for movement, shots, spawns and drops.

cafeteria-architecture.js derives the exact union perimeter with moody ivory, terracotta and oxblood finishes. Three tall rain-streaked west windows and five recessed north kitchen stations provide depth: tapered extraction hoods, filter fins, cookware, burners, sinks, ducts, menu details and a 1 AM clock. Tall walls occupy only the true north/west outer silhouette; interior and foreground returns stay cut away. All scenic geometry stays outside the playable floor. cafeteria-props.js keeps detailed mustard seating, trays, cups, warming wells, transparent sneeze guards, folded tables, sinks and prep utensils inside their authored colliders. The serving line is open rather than a solid shutter. Authored rotations apply once; shared batches and reduced mobile geometry keep resource ownership explicit.

cafeteria-surfaces.js paints dark terracotta aggregate, an oxblood entry and inlays, cooler kitchen/service tile and timber in the window bay. Photo grain stays grayscale; all finishes share one non-overlapping floor. Warm dining pendants and cooler kitchen fixtures align with the furniture. Six desktop/three mobile local lights, clipped floor pools and west-window moonlight keep bright landmarks against subdued broad lighting. Ceiling hardware appears only in the LUNCHROOM overview. The Cafeteria kit supplies explicit broad-light intensities; its brightness scale persists as the gameplay night curve advances. The later Detention, Boiler, Science and Roof kits use the same mechanism; earlier approved rooms retain their existing behavior. The Cafeteria gameplay tint uses warm contrast without a green cast. Quality switches, instance disposal and late photo-load guards retain the existing lifecycle contract.

Showroom links use stage=cafeteria and shot=lunchroom|tables|serving|kitchen|windows. LUNCHROOM fits the full connected floor; TABLES uses the combat camera; SERVING LINE, KITCHEN and WINDOW BAY inspect the new landmarks. Dedicated tests move the player and a 1.7-radius boss through every major route and both prep aisles, plus seeded combat/snapshot checks. Device FPS and balance are unmeasured for this redesign.

environment-kits.js registers presentation-only stage modules. world.js composes their architecture, footprint-local props, authored floor materials, optional ground cutouts, reflection target and lighting hooks. Base and zone geometry subtract cutouts without touching simulation obstacles. Quality, review, hour and disposal hooks propagate through the shared world; special water/sky meshes can opt out of generic shadows. View._nightSky forwards the existing hour and Rooftop-only dawn mix. Module registration enables showroom stages incrementally. No simulation/config changes or paid generation. Headless camera-fit, finite geometry, footprint, color-space and complete GPU resource disposal checks pass for current/enhanced desktop/mobile builds. The full Node suite and local build pass; desktop browser review covers all three views. Physical-device FPS remains unmeasured.

The core loop (do not break this)

A stage runs one hour of the night. Survive to the top of the hour and it is cleared; die and the hour ends badly. Kills drop essence (XP) and Coins. Level-ups pause the world and offer three techniques. Coins buy permanent perks, students, electives and club seats between runs. All player-facing currency labels use Coins, including store packs, results and accessibility labels. Internal fragments save fields stay unchanged for compatibility; legacy tuning descriptions are translated in UI.

The bell waits for an active domain. step() only calls _dawn() when domainFx is null; otherwise it sets bellHeld and lets the domain play out. A domain runs PLAYER.domain.duration (0.8s), so the wait is bounded. While bellHeld is set, _damage floors HP at 1: the bell time was reached, the night is survived, and the flourish must not be able to take that back.

Must stay true: the run is the unit of risk. Everything valuable is earned inside a run and only banked by surviving it.

Janitor encounter and combat choreography (2026-09-21)

Fresh normal runs opt into combatVersion: 5 in Game.startRun. Daily runs and callers that omit the option use version 0. Snapshots without the flag restore as 0; an existing run never gains new attack rules on resume. Versions 1, 2, 3, 4 and 5 survive saves and school-room continuation. All include the Janitor encounter and Ren–Yui crew link below. Version 2 adds the three encounters, enemy poses and Yard breaker described next; version 1 saves retain their previous boss kits. Version 3 adds the below-half remixes and two-card awakened primaries described below. Version 4 adds Library Falling Stacks. Version 5 adds Hall Monitor's pass-check and baton rush. Unknown version values normalize to 0.

Hall Monitor: pass check and baton rush (2026-09-28)

src/hall-monitor-encounter.js adds a baitable move to the exact Hall Monitor identity in combat version 5. His original spotlight hunt, shots and caught reinforcements continue between sequences. At 3–12 world units, clear sight, a usable rush path and a reachable sideways escape allow a pass check. The escape check leaves reaction time and requires no Flash Step or speed upgrade. Cramped or obstructed approaches defer the check rather than trapping the hero.

The 1.2s stationary amber warning locks an origin, direction and capsule at most 12 units long. Its rounded boundary includes both bodies' radii: this is the player-center danger region. The small clock counts down independently; inspection rays stay inside the outline. After commitment the player can sidestep, but cannot redirect the rush. The boss travels straight for up to 0.8s, stops at scenery without sliding, and then has a harmless stationary 1.6s recovery followed by 3s of his original hunt before another check. Silk, chill and secondary slows shorten travel without stretching the warning or increasing rush duration. Each rush can deal at most one normal-damage hit; the hit latch survives saves. Contact uses the existing touch cooldown, invulnerability and Flash Step rules; the swept path prevents missed hits on large steps. Only the currently travelled segment can hurt, not the full red outline at once. Warning and recovery cannot cause body damage or shove.

The moving spotlight, its shots and wisps stop throughout the commitment. The same saved capsule drives damage and the existing fixed warning instance pool: amber check, red rush, cyan recovery with opening brackets. The existing model runs during the rush and plants its animation during warning/recovery. No new lights, textures, post-processing passes, RNG calls, stat bonuses or spawn increases are added. Warning and impact cues use the existing positional audio bank at reduced impact gain, filtered against the live boss phase.

Pause and drafts freeze the sequence. Snapshot restoration preserves the locked path and phase; death, ghosting and pool reuse clear maintained tells. Older runs and dailies keep their previous Hall Monitor encounter. Parent-Teacher Night's separate version-2 encounter is unaffected.

Awakened primaries and boss remixes (2026-09-28)

Two existing cards in a student's hand deliberately transform one original primary cast every eight simulation seconds. Recipes live in src/awakening.js; the club hand shows a compact readiness link opening the existing card inspection with the recipe, effect and restrictions. No additional draw, currency, rank or random upgrade choice is required. Boss weapons do not awaken. Version 0/1/2 saves and dailies retain their original behavior.

Student Equipped recipe Awakened cast
Ren Honed Edge + Thick Skin Reversal Guard clears up to three hostile shots within the real palm arc and line of sight.
Yui Honed Edge + Track Team Binding Needle nearly pins enemies pierced by the first needle for 0.9s; bosses receive only a 10% slow. Stronger or longer existing slows are preserved.
Kaito Wide Pockets + Thick Skin Static Anchor plants the first orb and pulls ordinary curses inside its existing field. Bosses resist the pull.
Sora Track Team + Second Wind Returning Script turns the existing volley back halfway through its lifetime. Charms keep their original hit limits and never hit the same enemy twice.

No additional shots, base damage, projectile life or RNG draws are added. These are real utility changes; unchanged base damage is not a claim of unchanged balance. Cooldowns and the first-use announcement persist per student across swaps, saves and chained rooms. Changing the hand cannot reset the cooldown. Only the first activation announces itself; later casts use distinct compact marks from AwakeningFx: guard crescent, barbed needle, anchor brackets and returning paper chevrons. One fixed instanced draw, at most 128 marks, no new textures/lights/passes; phone/low tiers lower the visible cap. Motion follows simulation time and respects reduced motion.

In version 3, the three school bosses sample their below-50%-HP state only at the start of an attack sequence. Crossing half health never changes a committed warning. Group Project adds a second freshly aimed crossing with a complete 1.4s warning and checked safe wedge. Parent-Teacher Night adds a joint gaze after its two individual cones, with a new 1.5s warning and safe wedge. Vice Principal repeats the same three seals in reverse order after a fresh 1.7s warning; the pips reflect the reversed order. Cramped follow-ups cancel into recovery. Recovery lengths, stats, reinforcement count and RNG remain unchanged. The optional remix state is saved with the committed sequence.

School encounters and readable attacks (2026-09-27)

src/school-boss-encounters.js replaces three duplicate kits in version 2. Each attack fixes its footprint when the warning starts. The shared geometry drives both damage and CombatWarningFx; there is no invisible player-radius padding. Tall props provide cover even in rectangular Yard/Gym maps. A bounded search must find reachable safe floor before an attack commits; cramped placements cancel into recovery. No attack RNG, stat buffs, extra projectiles or extra XP-bearing summons are introduced. Group/Vice retain their existing one-time six-grunt reinforcement at 60% HP; Parent's replaced spotlight no longer summons wisps.

Boss Commitment Opening
Group Project — Shared Credit Two fixed spectral sources alternate 1.15s warned lanes, then a 1.4s warned crossing. A cyan wedge marks a checked safe space for the crossing. Active lanes last 0.3s. 1.65s stationary recovery, then 2.4s cooldown.
Parent-Teacher Night — Under Scrutiny Two stationary eyes alternate 1.3s warned gaze cones, each active for 0.65s. The previous pursuing spotlight and shots do not overlap these tells. Both eyes shut for a 1.8s recovery, then 2.6s cooldown.
The Vice Principal — Final Warning All three floor seals appear before the first strike, with one/two/three pips showing order. Initial warning 1.7s; subsequent gaps 0.38s; each strike lasts 0.18s. Destinations never retarget. 2s recovery after the third strike, then 2.6s cooldown.

The Vice Principal only blinks onto legal unoccupied floor; if the player occupies a preannounced destination, its seal still strikes while the solid boss stays at its prior location. Warning/active/recovery commitments have no body-contact shove or damage. Bosses remain pinned against crowd separation and knockback; their normal movement/contact resumes between sequences. Pending shapes, phase clocks and targets are saved on the enemy and reset on pool reuse. Death, ghosting or interruption cancels their maintained markers.

The warning presentation uses one fixed instanced pool with no new textures, lights or post-processing passes. Amber is warning, red is active, cyan marks an opening. Outer boundaries stay fixed while a separate small clock counts down. Exam Season's existing circles also receive this presentation in every combat version; their gameplay timing and collision remain unchanged. Pause and drafts cannot advance it. A safe-screen warm draw primes its shader and buffers before combat; reduced motion retains stationary information.

Spitter/Warden now inflate through their actual windup, recoil once on the saved shot timestamp, then settle. Brutes compress, lean into their charge and brace through recovery. Shared instanced body/eye/outline transforms keep the original faces and floor origin. Pose data is sampled from simulation time, uses one reused scratch object and respects reduced motion. Elite timing is unchanged and keeps its existing tells.

Yard breaker pilot: a single non-blocking electrical cabinet near the court at (21,-14) offers a deliberate escape tool in fresh version 2 runs. Hold manual aim and fire at it for 0.6s within 5 units with clear line of sight. Automatic aim, companions and incidental damage cannot spend it. The visible charge ring confirms the hold. It fires once per room, stunning visible living non-boss curses within 7 units for 1.8s, interrupting attacks and held Conductor bullets. It awards no XP, kills, Coins, drops or mana and deals no damage. Bosses are immune. Its spent state, partial charge and stun deadlines survive save/resume; a later map edit covering its footprint omits the prop. The map's collision/navigation data is unchanged.

Library Falling Stacks (version 4): the existing small reading-hall shelf at (18.5,-4) has a marked front face at (18.5,-2.85). Hold manual aim and fire for 0.6s within 5 units with clear line of sight; auto-fire and incidental attacks cannot trigger it. A 6-unit-wide wave travels 4.2 units south through the aisle over 0.55s. Each visible living ordinary curse is hit once, pushed up to 3 units forward over 0.3s through normal collision checks, and interrupted for 1.15s from contact. Bosses and ghosts are immune. No damage, RNG draws, XP, Coins, mana, kills or extra drops are added. The shelf frame and navigation stay intact; its falling books never become new obstacles.

Partial charge, spent state, wave progress and enemy shove/stun state survive save/resume. Older runs/dailies do not gain this fixture. Missing or moved authored shelves and blocked aisles omit it rather than inventing new geometry. Three bounded instanced draws and one constructor-created label depict loaded books, a directional edge and loose pages; phone/reduced-motion budgets are smaller. Animation uses simulation time and pauses with play. A single short wood-and-paper cue is positioned at the shelf and stops with other combat cues.

src/boss-encounter.js supplies the Janitor's attack intent to the existing enemy movement pass. He alternates these two moves without additional RNG:

Move Tell and commitment Recovery
Rotating mop sweep 1.35s windup, then the existing 3-radian sweep over 1.7s at radius 9. Direction locks when the tell begins; later sweeps alternate rotation. 0.9s, stationary.
Straight mop rush 0.95s locked-direction lane, then up to 0.9s at 4.4× movement speed and 1.4× contact damage. Ordinary status slows still apply. 1.1s, stationary; hitting scenery or the arena boundary ends the rush early.

Each recovery is followed by a 2.6s attack cooldown. Rushes use the normal movement/collision code and never pathfind around a corner after commitment. The visible lane conservatively includes the boss/player collision radii. Existing six-enemy reinforcements at 60% HP remain unchanged. No extra boss HP, new spawns, or bonus punish damage are added.

Break the sweep: once per Janitor encounter, its windup exposes a floating seal. An accepted Special aimed at the seal within 10 world units plus the boss radius, in a 90-degree forward cone and with clear line of sight, breaks it. The cast uses its original position/aim, so Flash Transfer and Curtain Call work consistently with offensive Specials. The normal mana, active-cast, pause and availability checks must succeed first. A deliberate Threadbreaker that reaches the pinned Janitor can also break it. Either action cancels the pending sweep and creates a 2.2s stationary opening; dodging remains valid. Primary fire, summons, automatic Domain damage and delayed secondary damage do not break it. A manually accepted Special during a Domain still counts. The one-use flag and locked phase survive snapshots; pooled enemy bodies clear that state before reuse.

BossEncounterFx reconstructs lanes, sweep sectors, the seal and recovery markers from live simulation state. A canceled windup or dead boss therefore cannot leave an active-looking tell behind. Six preallocated slots share geometry, use no new textures or post-processing passes, and reset with the view. Windup/rush/break audio follows the same events; a linked break has one combined impact rather than stacked cues. Boss death takes presentation priority over a same-frame link or break. Reduced motion removes the new camera accents while retaining the information needed to dodge or interrupt.

Engine-art pilot (2026-09-22): EngineArtFx replaces the Janitor's generic seal-break ring/burst with an ivory fractured seal and a bristle-edged jade pressure slash. Rush and wall recovery use the pressure slash. These are isolated alpha textures animated by two bounded instanced pools, not videos; the danger sector, attack timing and collision envelope above stay unchanged. Fracture expires after 0.62s, pressure after 0.38s; pause freezes both. Reduced motion keeps a static 0.3s seal confirmation and the original rush fallback. Death, round clear, demo and new-run reset retire the art. Desktop uses 512px textures, phones 256px. Download completion only stages pages; the existing safe-screen GPU upload/compile/first-draw gate must activate them. Missing, late or saturated art keeps the original particles, never a delayed replay.

Shikigami entrances (2026-09-23): Kuroinu appears through violet-black claw rifts, Shirasagi through ivory/cyan feather arcs, Raiju through an angular lightning crown, and Kitsune through folded golden-paper tails. These brief camera-facing apertures frame the existing creature at its actual spawn; their hollow centers leave the model readable. ShikigamiEntryFx replaces only the generic shiki entrance stack when its page is prepared. Attacks, departure particles, models, audio and simulation are unchanged.

Ownership comes from the event's who, with sx/sz preferred over the caster's position, so tagging does not recolor or erase an outgoing summon. Only one reveal may be active, lasting 0.48–0.58s with no idle draws. Pause freezes it; reduced motion uses a stationary 0.28s fade. Room/new-run reset, death, clear, demo and low quality retire it. Four 512px maps total 200,012 bytes on desktop; four 256px phone maps total 74,932. Downloaded pages must pass the existing safe-screen upload/compile/first-draw gate before use; missing, slow or low-quality art retains the original effect. No combat-time upload, queued replay, new lights or full-screen pass. The bake script is scripts/bake-shikigami-entry-art.mjs; it preserves transparent centers and empty sampling gutters. Standalone builds embed both variants.

Shikigami attacks (2026-09-23): Kuroinu bites with closing crimson fangs, Shirasagi thrusts an ivory/cyan feather spear, Raiju snaps branching violet lightning, and Kitsune opens a gold-edged paper slash. ShikigamiAttackFx replaces only the generic shikibite burst after its atlas is prepared; the actual companion, damage, timing, audio and simulation are unchanged. Ownership comes from the attack event, so tagging preserves the outgoing familiar's identity. The painted strike follows its direction projected through the current camera and stays local to the creature.

All four images share one instanced draw, with six desktop/four phone slots. Normal strikes last 0.22–0.30s; reduced motion uses a stationary 0.2s fade. Pause freezes the effect. Death, stopped run, room/new-run reset, clear, demo and low quality retire it. The 1024px desktop atlas is 187,812 bytes; the 512px phone atlas is 68,130. Both have empty tile gutters, preserved RGB and no black matte. Downloads join the existing safe-screen upload, compile and first-draw gate; missing/late/low-quality art retains the old effect without delayed replay. A full pool suppresses extra bursts. scripts/bake-shikigami-attack-art.mjs produces both standalone-embedded tiers and provenance. No new lights, fullscreen passes or combat uploads.

Hero primary contacts (2026-09-23): Ren uses a compact crimson/ivory palm compression, Yui a cyan needle puncture with snapped thread, Kaito an angular violet electrical knot, and Sora a folded golden-paper flare. HeroImpactFx animates four pooled alpha paintings above the contact point. Their upward texture axis follows the incoming strike projected through the current camera. Normal marks last 0.16–0.22s; critical marks are 28% larger with a slightly longer local flash. The new art replaces the shared burst/glow and global red crit flash for those contacts; damage numbers and enemy hit tint remain.

Only explicitly marked direct base-primary hit events qualify. Their primary: true field is presentation metadata: damage, RNG and saved state are unchanged. who remains the firing student's identity after binding a boss weapon, tagging or restoring a run. Boss-drop weapons, recursive procs, domains, orbit/trail damage, Shikigami, specials and Threadbreaker retain their existing presentation. A direct primary fired during a domain still qualifies.

At most 12 marks live on desktop or 7 on phones, with at most 4/2 admitted per frame and a 28/18-per-second token budget. Per-target cooldowns and a one-per-frame Kaito limit keep repeated orb contacts restrained. Saturation suppresses additional artwork instead of replaying it or spawning a fallback storm; enemy tint and damage numbers continue. Missing/unprepared art and low graphics retain the original hit path. Pause freezes marks; reduced motion uses a stationary 0.14s confirmation. Run/room reset, death, demo and clear retire them. Four 512px textures total 127,488 bytes on desktop; four 256px phone textures total 50,298. Pages download once but must pass the existing safe-screen upload/compile/first-draw gate before activation. No video decoder, extra lights or full-screen pass. scripts/bake-hero-impact-art.mjs centers the contact origins and preserves empty sampling gutters in both variants.

The Boiler Room's painted machinery jackets use one shared enamel albedo (512px desktop / 256px phone). The offline bake reconciles repeat edges; existing scalar bump/roughness maps, lights, floor and editor geometry remain authoritative. Failed/abandoned image loads preserve the procedural fallback. No extra floor overlay, draw call or full-screen postprocessing pass is added.

School furniture art (2026-09-23): Library oak shelves/tables and their trim share one painted wood albedo; the Second Floor's blue/teal lockers share a neutral worn-enamel albedo. Both retain their existing tint, bump, roughness and lighting. The offline bake preserves mean room colors and quiet contrast with matching repeat edges. Runtime files are 512px desktop / 256px phone; only the current room's texture loads (oak 193,728/46,098 bytes, enamel 53,642/14,492). The maps, floors and prop geometry are unchanged. Image completion only stages the new texture. The existing safe-screen preparation queue uploads it before assigning it to the shared materials; late or failed downloads keep the procedural surface during active play. Abandoned room loads cannot publish or allocate textures after disposal. Showroom/editor explicitly prepare these surfaces in their review render path, never from the shared world update. No extra draws or shader features.

Pool/Science Wing materials (2026-09-23): Pool lower-wall/divider tiles and coping share a painted ceramic glaze map. Its bake starts with the exact runtime ten-cell 256/128px pattern and retains all grout pixels, sampling direction and mean palette; generated variation affects tile interiors only. The separate twelve-cell basin, water, walkable deck and all bump/roughness maps remain unchanged. Science Wing epoxy countertops, sills, plinth coping and related resin fixtures share one dark-violet painted albedo with quiet scuffs and reagent stains. The legacy green Laboratory retains its original surface. Texture scale, physical material properties and room geometry stay the same; no new draws or lights are introduced.

These room kits expose their staged textures through world.surfaceArt, using the same safe-screen upload/activation and disposal contract above. No downloaded map promotes itself during combat. Desktop exports are 512px (Pool 100,858 bytes / Science Wing 57,484); phones use 256px (25,994 / 13,788). Each room loads only its own map. scripts/bake-lab-pool-art.mjs rebuilds both from ignored masters, preserving runtime grout and seamless resin edges.

Static opaque room casters share a room-owned shadow-depth material, disposed before the room's textures. This prevents the renderer's shared depth sampler from reuploading an already disposed room image on a later visit. Cutout, transparent, displaced, skinned/morph and existing custom-shadow paths retain their original handling; geometry, shadow resolution and draw counts do not change. Repeated Library/Hall visits are checked with authored art both on and off so material loading cannot conceal a teardown regression.

The other bosses' secondary moves are backlog only, documented with their current behavior and counterplay proposals in BOSS-ENCOUNTER-ROADMAP.md. Group Project, Parent-Teacher Night and the Vice Principal still share existing kits.

Boss primary drops — run weapons (2026-09-07)

Every defeated special grade gives one prize: a card, a weapon binding, or one large heart, plus ordinary XP and coins. No guaranteed 25-HP heart, level orb, energy cache, or additional card accompanies that prize. The initial table lives in src/boss-loot.js: 40% eligible signature card, 35% weapon, 25% large heart. A disabled or already-awarded signature's 40% becomes a weapon instead. A card identity is spent only when that card drops. Legacy level/energy items already in a save still collect normally. Weapon attribution follows the killing attack: projectiles, shikigami, decoys, Ghost Step trails and domain fields retain the original student after a tag. Delayed damage also retains that student's permanent damage multiplier, with shared run upgrades applied.

Large hearts restore 35% of the active student's maximum HP. A level-up orb grants one current level threshold of XP when collected, preserving XP already earned; it respects banked/immediate level-up mode and never chooses an upgrade for the player. Energy caches restore 50% maximum cursed energy and 20 percentage points of domain charge, capped at full, and clear energy exhaustion at its usual threshold. A heart at full health, or a cache with both meters full, gives 25% of a level's XP instead. These benefits remain within the current run.

Essence flames use authored crop boundaries in scripts/bake-art.py: the source sheet has uneven spacing, so equal grid cells clip blue tips and include adjacent flames. python test/essence-bake.test.py checks isolated, reproducible exports.

Gem streaks — src/gem-streaks.js (2026-10-17). A pickup being pulled in trails a soft additive streak of its own colour (cyan essence, gold, violet mana, green heart, gold coin), so a crowd's worth of XP reads as a vacuum converging on the student. The streak is measured, not guessed: each pickup's last drawn position lives in a view-owned WeakMap (nothing is written into the sim's objects), and its speed is the distance the sim moved it over the sim time that passed, so routes around walls are followed. Under 3 units a second (bobbing, settling) there is none; it grows 0.05 s of travel long (about 1.15 units at the full 23 u/s pull), lies behind the pickup in the camera's plane along its on-screen direction, shrinks into it over the last 0.9 units, and holds through hit-stop. One extra draw (120 streaks, 48 on phones), drawn under the sprites; off on low effects. The art itself stays upright. test/gem-streaks.test.js.

Gem pop — gem-streaks.js gemPop (2026-10-17). A dropped pickup bursts out of the curse instead of appearing in place: over 0.38 s it rises in an arc to 0.7 units and lands, growing from 40% to a 112% swell and settling at full size exactly on the spot the sim gave it (an offset only; the sim never sees it). Each pickup's birth is noted on first sight in a view-owned WeakMap, for every pickup whether drawn or not, so one scrolled into view later never pops then; when a run is resumed or rolled back (its pickups come back as new objects) or the art finishes loading mid-run, everything already on the floor counts as landed. Boss rewards keep their own reveal.

Non-weapon bonuses use larger existing heart/essence sprites, a named drop cue and collection feedback. They magnetize like normal pickups, get first claim on the bounded sprite pools, and retain their value through gem overflow. Unclaimed bonuses survive snapshots and come to the checkpoint on Push On/After Hours. The separate seeded reward hash uses the run seed, boss ID and cumulative boss kills; neither the reward roll nor its visual phase consumes combat RNG. BOSS_LOOT owns the probabilities/amounts and src/boss-loot.js supplies the shared reward identities. Existing weapon bindings and pending drops are kept.

Student Three possible primary replacements
Ren Cinder Palm: a ranged fireball with impact splash. Thunder Palm: a focused palm plus three lightning links. Rupture Palm: a broad, heavy, piercing pressure wave.
Yui Shatter Thread: explosive needles. Winter Thread: piercing needles with a two-second chill. Horizon Thread: long-range, high-speed thread lances.
Kaito Gravity Well: wide orbs pull ordinary curses inward. Storm Relay: compact orbs chain beyond their core. Nova Core: expanding orbs burst when their life expires.
Sora Hunting Script: seeking paper charms. Festival Script: an explosive three-seal fan. Razor Script: five widely spread, deeply piercing paper blades.

The ground beacon names its weapon and owner. It is independent of gems: magnetism, gem overflow and XP collection never bind a weapon. Walking within 1.65 world units opens a paused comparison after any pending level-up/domain cast. Leave on Ground (also Escape/back) leaves the item available; walk beyond 2.8 units and return to inspect again. Bind destroys the owner's previous primary and replaces it, including when that owner is on the bench. Subsequent loot can replace it again; there is no discarded-weapon inventory. Damage/rate/range/multicast/piercing upgrades remain effective as appropriate to the attack type. Energy and cooldowns are never refunded by a binding.

Bindings live only on party[].runWeaponId, and ground loot lives on sim.weaponDrops. Both survive tag swaps and JSON autosave/resume. Old saves default to the student's original attack. Fresh starts/retries or choosing a room for a New Run clear all weapons. Push On within a room and After Hours continue the same Sim and retain bindings; unclaimed ground loot is brought to the player at that checkpoint so a held-bell kill's reward remains usable. Final Push On converts eligible Room Runs into a carried route: bindings survive each room transition, and a stopped checkpoint can inspect unclaimed boss drops before the route changes rooms and discards ground loot. The clear card's Run Details link counts unclaimed rewards; that separate screen owns drop information and inspection. Returning to the club ends the run; nothing is added to the permanent loadout or store. Reviving the same run retains its state, consistent with other run upgrades.

Rewards use a separate seed/boss-id/drop-serial hash, consuming no combat RNG. The currently bound weapon is excluded; variants not already on the floor are preferred. The floor pool is capped at twelve, retiring the oldest drop if an exceptionally long run exceeds that limit. Explosions and built-in lightning do not recursively proc themselves. A lightning budget applies once per palm cast or orb pulse. Chill refreshes duration rather than stacking; bosses retain at least 80% movement speed and resist gravity pull. Walls block secondary splash/chain effects. Projectile FX handles are stripped from snapshots and reconstructed by the view.

RUN_WEAPONS / WEAPON_LOOT in src/config.js own tuning; src/run-weapons.js resolves primaries; src/weapon-view.js owns bounded loot beacons and the twelve primaries' distinct instanced projectile silhouettes. Ground weapons use twelve solid equipment models from src/weapon-loot-models.js: Ren's armored gauntlets, Yui's needles/trident/lance, Kaito's caged-core scepters, and Sora's kunai, explosive scroll and bladed fan. Each variant has its own silhouette, beveled metal and colored energy details. Assemblies are merged to one mesh per weapon and share one shader; all geometry is built locally. View's safe-screen queue prepares all twelve label canvases, texture uploads, model buffers and pickup shaders one weapon per loading/menu frame. Detached samples retain the shaders between drops; invisible priming preserves the frozen scene, uses its fog/output target and adds no ongoing draw. Interrupted preparation resumes on the next safe screen; context restoration or a change between low and postprocessed graphics invalidates GPU readiness. If preparation cannot finish, the physical reward remains available with its label hidden; the unprepared model can still upload on first display. Combat never paints a label or explicitly starts preparation. No model or texture download occurs at a boss death. A ground ring, narrow beacon, rising glints and WEAPON / owner nameplate mark the pickup. The model gently rocks without rotating edge-on. Motion follows view time (frozen during a skill pick); reduced motion keeps a fixed pose, and low quality omits glints. Reveal still follows the boss exorcism, and terrain placement, leave/reapproach and binding rules are unchanged. Geometry is reused across drops and disposed with the view. src/weapon-fx.js layers their cast, flight and impact effects from the same licensed Dyomin 2D Cartoon FX atlas as the original weapons. Its Envato masters are ProRes 4444 videos; scripts/bake-fx.py turned selected clips into transparent WebP flipbooks. Existing HD pages are reused, without new video decoders, downloads or rebaking. The base renderer skips equipped run-primary shots so they cannot draw twice. Generic hit/crit/kill feedback remains shared. Cast and impact events carry the actual weaponId, so a projectile retains its art after tagging to another student. Particle handles live in the view, respect mobile/quality budgets, and release on expiry or run reset. test/run-weapons.test.js covers all twelve behaviors, ownership, replacement, constraints, placement and deterministic restoration. test/run-weapons.browser.mjs covers real movement/taps, pause, leave/reapproach, binding/tagging, page reload, fresh-run reset and all twelve comparison cards at six desktop/phone viewport sizes. Initial values are a playable baseline; these correctness tests are not a human difficulty/balance study.

Cards — the club deck (2026-09-13)

docs/CARD-DECK-PLAN.md, as decided with Chris on 2026-09-13: the deck is a LOADOUT built in the club and carried until changed, and the random moment is the PULL, not a deal at run start. Training ranks stay and stack with the deck for now (an additional system; whether both survive is a later call). On by default (settings.cards, developer-enabled Options › Developer › Cards); off makes runs ignore the deck, the collection stays. Dailies and the bot ride without cards so their scores stay comparable.

Cards at risk (2026-09-13 night)

Chris's turn on the system, pulled apart and built with three augments (docs/CARD-DECK-PLAN.md, "Decisions taken"): the cards a student carries can be lost, but only once the player has chosen to push.

Boss signatures — ground pickups and live hands (2026-09-14)

Every boss identity has one universal signature in src/cards.js. New normal Cards-enabled attempts can roll one of each identity as their single boss prize, alongside ordinary XP and coins. cardBossesAwarded spends that identity for the whole attempt, including when its card is left behind or replaced. Dailies, bot runs and older saves without boss-card opt-in do not start awarding signatures.

Boss Card Equipped behavior
The Janitor Clean Sweep Flash Step lands in a forward crescent: 42 base damage, 3.8 range, 153° arc; 3-second cooldown. Tagging during the step cancels that sweep.
Hall Monitor Marked Late A primary hit slows its target for 1.5 seconds to 65% speed, or 80% for a boss; 4-second cooldown.
Pop Quiz Answer Key Every fifth primary hit bursts for 18 base damage within 1.8 range of the target.
The Substitute Stand-In Tagging in deals 16 base damage and shoves curses within 3.2 range; 6-second cooldown.
Exam Season Final Warning A primary hit leaving a living target at or below 30% max health adds 12 base damage; 3-second cooldown.
Detention Stay After A primary hit pulls other nearby, visible non-boss curses toward its target, within 3 range; 5-second cooldown.
Group Project Shared Credit A primary hit arcs 16 base damage to one other visible curse within 3.5 range; 4-second cooldown when a target exists.
Parent-Teacher Night Watchful Eye Flash Step clears visible hostile shots within 2.5 range at its starting point; 4-second cooldown.
The Vice Principal Office Hours A primary hit against a boss restores 6 energy to the attacking student; 5-second cooldown.

Cooldowns and hit counters belong to the student and survive tags, save/resume and school-room doors. Only the live equipped hand grants effects; KO holders cannot proc. The extra damage uses the existing special-damage path, with no crit, chain or incidental mana refund; Office Hours is the explicit energy effect. Reward creation and signature target selection draw no combat RNG. Signature kills still produce ordinary seeded loot. The scans are bounded by the existing enemy/bullet arrays and use existing world effects without a toast on every proc. These numbers have correctness coverage, not balance approval.

Deferred: the Hall Pass consumable, wear instead of sudden loss, Kaito's and Sora's own cards and more rares, whether Training folds into cards, dailies with a loaned deck, packs.

Grades — the night gets harder the deeper a run goes (2026-09-13)

Chris, after a night with carried weapons and cards: push on to the second round and "I'm immediately so overpowered that the beginning of the round is super easy... it's kinda boring". Measured before any change (scripts/playtest-runner.mjs --graduate, standard-16, full kit): the ninety seconds after each Push On bell were the calmest stretch of the whole night — at skill 0.25 Ren took 4 hits with health barely dented right after a stretch of 4–9 hits and a dip to 55% — and a further school room was worse, because continueSchoolRoom starts a fresh sim at t=0 with the base spawn ladder and no difficulty carry (GRADUATE steps are 1.0) while level, ranks, weapons and cards all come along.

His answer, built here: not the late roster at the door, but harder RECOLOURED versions of the same base enemies mixed in from the start, more of them the further in. GRADES in config.js; Sim.depth counts every push (+1 in graduate) and every further room (+roomSteps in continueSchoolRoom); Sim.periodStartT marks the push. In _spawn, at depth > 0 and never for a boss, one roll picks a grade: the grade-1 share is p1PerStep × depth (to p1Max), grade 2 appears past p2After steps at p2PerStep per step (to p2Max), and both are scaled by a factor that starts at 1 right after a push and thins to floor over fade seconds, so the harder bodies are thickest in the measured dead calm and the period's own ramp takes over. A grade's strength grows with depth: hp × (1 + hpPerStep × depth) to hpMax, speed and dmg by a fixed factor, xp × (1 + xpPerStep × depth) to xpMax — deliberately slower than hp, because the first cut (xp ×1.5/×2.2 flat) raised win rates from 8 to 12 of 16, the graded bodies feeding the very loop they were meant to check. e.grade rides the enemy and the snapshot; e.tier takes the larger of the density tier and the grade, so the view's existing tint does the recolour: grade 1 ember (rust on a navy grub), grade 2 violet, always brighter than base — the tint became a real hue shift for this, because the old nudge did not read from across the court. At depth 0 no roll is made, so a fresh run's stream is untouched (the determinism canaries hold).

Measured on the landed values (standard-16, full kit, same seeds):

Carry — rooms scale to the carried level (2026-09-17, Chris's decision A on board #62). The pushon baseline (four cells, n=16, sim 1507447) found the late game flat for a reason depth could not fix: a run walks into the gym at level 28, the library at 39, the cafeteria at 48, against rooms built for a fresh level 1, and the depth-driven hp caps are spent by room three. So continueSchoolRoom records carryLevel = level − GRADES.carry.baseline, and every graded body in that room has its hp multiplied by min(carry.hpMax, 1 + carry.hpPerLevel × carry) and its contact damage by min(carry.dmgMax, 1 + carry.dmgPerLevel × carry) on top of the depth term, while the post-push share floor rises to min(carry.floorMax, floor + carry.floorPerLevel × carry) so the pressure holds for the whole room instead of thinning. A carried room's graded bodies keep only carry.xpBonusKeep (0.5) of the depth xp bonus: the first cut scaled hp and share alone and measured FLAT — tankier bodies never hurt a level-40 build, and the lifted share fed more graded xp, so the levels went up (43/55/65 vs 39/48/56). Damage is the channel that moves minHP, and harder bodies that pay less xp keep the power loop from feeding on its own answer. The knob sweep (full kit, competent, n=16) landed on 0.015/1.8/0.5: the stronger settings walled the library (1–5 wins of 16); the pick keeps gym and library FINAL clears at 93%/92% with minHP falling 79%→16%, and makes the cafeteria FINAL a 58% coin flip (7/16 full clears vs 13/16 before). Room 1 is bit-identical to before — the carry is zero there, so novices (who wall at the first FINAL, heuristic 4) are untouched. Bosses are untouched too (they already have carriedBossHealthFloor). Period pushes inside a room carry nothing — QUIZ→FINAL with your build is the Graduate design. The carry uses no rng and rides the generic snapshot copy. The lab's stages[] record it as carry.

Knobs for the tuner: GRADES.carry.hpPerLevel, carry.hpMax, carry.dmgPerLevel, carry.dmgMax, carry.xpBonusKeep, carry.floorPerLevel, carry.floorMax, and the older GRADES.p1PerStep, p2PerStep, fade, floor, g1.hpPerStep, g2.hpPerStep. The measuring scripts are in the session log. Next levers if the competent-play calm still reads as boring in hand: a base bump so the first push bites (Yui shrugs depth 1 off), and whether a carried weapon should lose a tier at a door.

Preload — the run starts when the room is ready (2026-09-14)

Chris: starting or continuing a run chokes the browser as everything loads at once; the game should only start once enough is in. Measured first in headed Chrome (scratchpad startprof*.mjs, real GPU): the yard was never the problem, because the title demo had already paid for it. Entering a fresh gym or library built the world synchronously for 0.4–0.9s and then spent 5.5–6.3s in its FIRST FRAME, compiling and linking every program and uploading every texture at once — and the head rig's GLB was fetched and parsed mid-run for another 300–500ms stall a second or two in. A further school room did the same at each door.

Determinism — src/sim.js, test/sim.test.js

The sim is a pure function of (seed, inputs, dt sequence). mulberry32 is consumed in a fixed order; rngState is externalised so snapshots round-trip bit-identically.

Must stay true: no Math.random() in sim, ever. New sim features that need variation must draw from the seeded rng, and adding a draw in an existing code path changes every downstream roll — that is what the lockstep tests catch. Display-only fields (a position for an FX event, a label) may be added to emitted events freely; they consume no rng and are never read back.

Time — src/game.js frame loop

Three independent factors multiply into the sim's dt:

Must stay true: platter is driven off sim.pendingLevel, NOT the level/pick events. Bot runs resolve a level inside one frame and never call the pick handler; event bookkeeping would park the platter at zero and freeze the run. Keep it self-correcting.

Cursed Energy — src/config.js ENERGY

CE is the shared specials pool behind Flash Step recharge, the equipped elective and the student's shikigami. Basic weapon fire is free (weaponCostMult: 0): dealing weapon damage and making weapon kills earns CE, while damage caused by an elective, shikigami or Domain cannot refund its own cast. Attempting a special without enough CE exhausts the pool until 15% recovers; basic fire continues during that lockout.

Passive recovery (2026-09-15): src/energy.js applies a 0.4 multiplier to the tuner-owned ENERGY.regen, reducing the base from 1.5 to 0.6 mana/s. The active student recovers 0.69/s while idle; a normal bench member gets 0.3/s before mastery/lull bonuses. Flow State retains its multiplicative 18% per rank, reaching 0.708 / 0.83544 / 0.9858192 mana/s at ranks 1–3 while firing. The loadout sheet reads the same base-rate helper as the sim. Combat earnings, purple-orb rewards, starting capacity and spell/recharge costs retain their existing values. This is a slower passive floor so recovery upgrades and earned mana carry more weight, not a measured win-rate or difficulty target; previous pacing results need remeasurement.

Flash Step pays on recharge, not use (Chris, 2026-09-14). A lit charge always works, including at zero mana or while a failed special has exhausted the pool. requestDash() spends one charge and grants the existing movement and invulnerability; it never spends mana or causes exhaustion. Spent charges refill sequentially, drawing ENERGY.dashCost (currently 20 mana) gradually over one complete recharge. Tempo and Late Slip speed up that transfer, not its total cost. Progress is capped by available mana: an empty pool holds the partial charge until regeneration or a pickup can fund more. Existing special exhaustion pauses recharge until the ordinary recovery threshold is reached, so automatic spending cannot trap the pool below that threshold.

The HUD pip shows funded progress and only lights a ready charge. The loadout screen labels the cost Mana / Recharge. Recharge uses the existing dash.timer/dash.charges state, so partial funding survives save/resume and room continuation. The shared step pool retains partial progress on a tag; the active student's mana funds subsequent progress. Newly available capacity starts empty, while an Afterimage level-up still grants its existing filled bonus charge. Time stops pause both payment and recharge.

sim.dashBlocked() reports busy (a step in flight), charges, or paused, side-effect free. game.js retains the 0.2s input buffer for a busy step and the rate-limited recharge toast/deny tone when no charge is available. Mana is never a reason to refuse a charged step. Bot input policy is unchanged; the new payment timing changes game rules, so older balance measurements must not be treated as measurements of this version.

Must stay true: CE claims are made on kitted runs only; a fresh student has almost nothing to spend the pool on. The current kitted target is 35–65% average CE. Never reintroduce trigger discipline into the basic weapon unless the whole specials economy is deliberately redesigned.

The controls guide — ui._controlsGuide (Chris, 2026-09-03)

Desktop mouse-wheel zoom during a run now reaches 1.815× camera distance (about 25% farther than the former 1.45× limit). Default framing, closest distance and smoothing are unchanged. The persisted zoom level spans 0–1.5 on desktop; phone/tablet and touch controls retain the original 0–1 range, including when a farther desktop setting arrives through save sync.

Options → Controls → How to Play, reached from the title or PAUSE, opens the controls guide as a subview. Closing it returns to Options. Four tabs — KEYBOARD, TRACKPAD, GAMEPAD, TOUCH — each a list of key → verb rows plus one note (mouse lock, auto-fire, stable crew slots, one-thumb). The opening tab follows input.lastDevice (passed as on.device); a trackpad cannot be told from a mouse, so that tab is one tap away and is remembered (ui._guideTab). Pause options keeps the frozen arena visible through the same 38% dim layer as the time-stop draft.

Must stay true: the rows describe what input.js actually binds. Change a binding there and the guide in the same commit — there is no generated link.

Two experiments behind flags — THE BANK and GRADUATE (Chris, 2026-09-03)

Both ship ON for new and existing saves. Bank Level-Ups stays in Options → Controls; Graduate is in the session-gated Developer category. Both flip without a restart (settings.bankLevels, settings.graduate, read as !== false). Both are built so the old flow is the code path when the flag is off, not a re-implementation: turn a flag off and the game is the game that shipped the day before.

THE BANK (LEVEL_BANK in config; sim.levelMode, sim.bank, sim.openBank()). In 'bank' mode _gainXp counts levels instead of raising pendingLevel: the level number climbs at once (xp thresholds are per level), the hand is dealt when the bank opens, and chooseUpgrade chains straight into the next banked hand — three back-to-back levels are one pause. The player opens it with TAB / SELECT / the Upgrade button (input.queue.bank); the game opens it for them at a lull (spawning paused, no boss up) or once autoAt levels wait, and at once if the mode is switched back to NOW mid-run. The HUD shows a rectangular Upgrade N button only while upgrades are waiting. It sits top-left beneath the clock on every device, in a compact 96×36px cyan button with the same count badge and desktop TAB hint. The level-up kicker shows the remaining bank. The platter wind-down is untouched: it still keys off pendingLevel. The SIM DEFAULT IS 'now' and game.js passes 'bank' only for a human run — every bot night, lockstep pair and recorded take is unchanged. openBank goes through _drawHand, so the deliberate stop has the live level-up's gem vacuum and knockback.

The three-card draft uses a 38% dark scrim with no background blur. Once the draft opens, simulation, particles, character animation and camera shake hold their current frame, leaving the action visible behind the cards. Resizing reprojects damage numbers and updates the post-process aspect without advancing time; choosing the last card resumes the scene normally. The combat HUD hides during the draft so its clock and meters do not compete with the card heading, then returns when play resumes.

GRADUATE (GRADUATE in config; sim.graduate(nightLen), game.pushOn). A room is one continuous night: startRun forces the QUIZ, and the bell (_dawn, which clears the board) leads to a win screen whose fork is RETURN TO CLUB (the bell's commit already banked the haul) or PUSH ON. Pushing calls sim.graduate(nextPeriod.dur): won and running flip back, night moves to the longer period, gradHp/gradSpeed step by hpStep/speedStep per push (kept off enemyHpMult, which is derived from mods and not snapshotted), the boss cadence re-seats on the new period's schedule (the next BOSS_INTERVAL multiple, as a fresh run of that length has it), life refills to full (Chris, 2026-09-04), a short iframe covers the restart, and view.nightfall() undoes the bell's dawn lighting — the 'dawn' event snaps the room to morning light, which the win screen used to hide and a push-on would otherwise play under ("the library lights turn on"). HP, speed and damage ramps run on absolute run time, so they simply keep climbing.

A continuous night is ONE full night. startRun passes continuous and the sim sets nightK = 1 from the first minute: density taper and the scripted surge timeline run on the 12-minute schedule, and the quiz and midterm are simply its first three and six minutes with a bell in them. periodK (the period's own fraction) keeps the short-period boss floor so a quiz-bell Held Bell duel is still sized to a quiz player. Measured before this (bot 0.8, yui, yard, n=16): a quiz-compressed taper followed by a midterm-compressed one XP-starved the run — level 14.2 at the midterm bell against 15.0 fresh — and only 2/16 continuous nights reached the final bell against 5/16 fresh finals with NO difficulty step at all; the boss cadence carried over also met five or six special grades against a fresh final's four. The probe is scripts/graduate-probe.mjs; at n=48 with no step the continuous night reached the midterm bell 36/48 and the final bell 10/48 against 34/48 and 9/48 fresh — parity — while an 8% HP step cut the final bell to 3/48, which is why GRADUATE ships at 1.0. The haul: pushOn reverses the bell's fragment banking through the same _preCommit bookkeeping a rewarded revive uses, so a death before the next bell forfeits everything the run has earned and the next bell banks it all again. Records, stars, the periods ladder and the next-room unlock are earned at each bell exactly as a fresh run of that period would earn them. Period chips on the room card become a read-only ladder. Dailies and Night II+ keep their own flow.

Must stay true: difficulty steps land on HP and speed, never on spawn counts (the difficulty rule); sim.levelMode defaults to 'now'; the bot never passes 'bank'; graduate() refuses a night no longer than the current one and anything after the FINAL.

Bosses after a school-room door (2026-09-15). Ordinary grades exclude bosses, and a door deliberately restarts the local clock, boss lineup and Quiz period. Previously that also reset the first boss to 616 HP against a carried Final build. src/boss-scaling.js now supplies a boss-only health floor once schoolElapsed > 0: the last scheduled fresh Final boss's health scale (currently 8,600 base HP), multiplied by 1 + 0.04 × max(0, depth - roomSteps) and 1 + 0.15 × localBossIndex. Use the larger of this floor and the ordinary local boss ramp, then apply the existing enemy-HP and graduation modifiers. Depth already crosses doors and snapshots; no new save migration is required.

A normal Quiz → Midterm → Final → next room therefore starts its next boss at about 9,288 HP, with further growth through that room and later rooms. Promoting a directly entered Final also gets the floor. Fresh rooms and their within-room pushes keep the existing curve. Player damage, level, cards and equipment do not set boss HP. Boss identity, cadence, speed, damage, drops and XP stay unchanged; no extra enemies or artificial minimum hit count is added. Already-spawned bosses retain their saved current/max HP. School elapsed time only gates the floor: it never feeds the local exponential late-night ramp.

The shared playtest runner additionally records bossEncounters: room/depth, spawn HP, global spawn time, killed status and spawn-to-death active seconds (including approach time). Unfinished fights have seconds: null, not zero. The four-seed-per-hero before/after check is diagnostic on a dirty working tree, not a validated win-rate or ten-room difficulty claim. Setup: first four standard-16 seeds, each hero at skills 0.25/0.8, full kit with their signature secondary, graduate through Yard → Gym → Library. Across all 32 paired runs, first-room encounters/checkpoints were identical. At skill 0.8, second-room median encounter times changed from 13.57→31.37s (Ren), 14.43→25.53s (Yui), 13.13→19.87s (Kaito), and 12.60→26.60s (Sora). This verifies the reset correction, not overall balance or a target win rate.

Domain — src/sim.js activateDomain, _domainFields

Own meter, refunds CE on cast, four kits (Iron Cell drag+crush, Silk Cathedral time-slow, Blackout Grid bolt storm, Paper Storm homing charms). Domain-dealt damage recharges the meter at domainSelfRefill = 0.6.

src/domain-fx.js animates the sustained fields: Iron Cell has rotating lights and an inward closing pulse; Silk Cathedral has a fixed diamond floor frame beneath raised ribbons; Blackout Grid sends light along its circuit lines; Paper Storm carries orbiting charms above six simple paper-seal stamps. The field retains its caster's appearance after tagging, follows terrain, and reads the existing field timers without changing damage, radius or simulation RNG. At most three draws serve all kits. Paper Storm hides its low spiral ribbons and renders only the floor and airborne forms.

Airborne domain relics (2026-09-23): src/domain-relics.js replaces the generic mote draw with six desktop/four phone folded pieces: Ren's broken crimson iron, Yui's cyan cathedral needles, Kaito's violet conductors and Sora's gold paper wings. Their actual folded geometry has quiet facet shading; it stays world-oriented and outside the central 3.5-unit combat lane. There are no new floor marks, dynamic lights, collision objects or video decoders. The shared padded RGBA atlas is 85,360 bytes desktop / 32,452 bytes phone. Network completion only stages the page. Upload, compile and transparent buffer preparation happen through View's safe-screen queue before activation. The art/fallback choice is latched for each cast, including hero tags, so late art cannot pop into a running field. Low graphics or unavailable art uses the original mote draw. Switching to low retires the art immediately; returning to high waits for the next cast. Relic placement derives from the existing field age, retaining its phase through tags and snapshot restoration. Pause freezes it exactly; reduced motion holds it still with only an entry/exit fade.

Room transformations (2026-09-12): src/domain-room.js projects each caster's domain onto the existing floors, walls and furniture in every room:

A wave reveals each treatment at cast, it holds through its existing field timer (cellT, silkT, gridT, hasteT), and releases over its last 0.75s. The original combat footprints and collision geometry are unchanged.

buildWorld installs the projection on its own built-in mesh materials before their first render. It composes existing shader hooks (including Pool water), retains material identity for asynchronous textures/lighting, and changes only shared uniforms during casts. There are no additional draws, lights, textures or downloads. Transparent particles, sky backfaces and custom ShaderMaterials are excluded. Teardown restores original hooks; the world still owns GPU resource disposal. Colors come from the casting kit, even after tagging. Practical-light intensity is restored before each room's lighting updates, then scaled once for Grid. Reset/disposal restores it immediately; quality and Roof dawn retain ownership of light visibility and authored brightness.

View.sync advances the sustained choreography and room projection with simDt, so their motion freezes during pause/manual time stop. Reduced motion uses static surface patterns and a slower fade-in instead of a traveling reveal. Room changes, same-room restarts, title demo, terminal states and disposal clear the treatment; an active field reconstructs from the snapshot's existing timers on resume. After Hours deliberately retains the room's won flag, so both sustained fields and room projections remain live while afterHours and running are true. Dead/stopped states still clear them immediately.

A domain is refused while ANY kit field is still on the board — silkT, cellT, gridT, hasteT — not merely while domainFx runs. NOT stormTimer: that is Storm Circuit's talisman-arc cooldown, an EVOLUTION that re-arms every 0.5s, so listing it here nullified the domain for the whole run for anyone who took that card. Paper Storm's domain field is hasteT. Covered by a test. domainFx is the 0.8s cast; the field that follows it is the actual domain, and casting over the top stacked two fields of the same kit.

Tagging in applies TAG_DOMAIN_LOCK (2s), NOT DOMAIN_SWAP_LOCK (8s). The 8s prices a post-cast rotation — dump a domain, tag to a frozen full bar. Simply arriving is not that, and 8s made a tag feel like a punishment.

On touch the domain BUTTON is the meter — --domp fills it from the bottom, a red-to-white conic stroke appears at the rim only at full charge, and .busy greys it whenever the sim would refuse the cast. The old .track.dom bar is hidden on touch and kept for keyboard players, who have no button to fill. The ring is inset: 0, not negative: the button needs overflow:hidden to clip the fill to its circle, which would clip a ring hanging outside it.

Must stay true: 0.6, not 1.0 — at full refund a wipe-kill refunds half the next bar and the domain becomes a crowd-size-gated perpetual loop. Solo has NO post-cast lock (back-to-back earned through play is the expert fantasy); the 8s DOMAIN_SWAP_LOCK exists only to price frozen bench charges in a club. h.domainReady must include domainLockT <= 0 or the bar advertises casts the sim refuses.

The Club — party of up to 4

Swap happens ONLY in the level-up modal, before the card pick (chooseUpgrade clears pendingLevel synchronously, so "after" does not exist). Shared HP; stats.maxHp stays captain-seeded for the whole night. Bench CE regens at half rate; bench domain charge is FROZEN. Party entries are scalar-only so snapshot()'s generic copy serialises them for free. Snapshot envelope is v: 2 and accepts v1 as a solo party.

TAGGING IS LIVE (changed 2026-08-29). It used to be allowed only inside the level-up modal, which made the club a menu chore competing with the three cards on a phone screen. Now: a TAG button under the LEFT thumb — which otherwise only steers, while the right thumb's arc is full with DOMAIN, STEP and ELEC — or C on a keyboard. The level-up strip is gone and that screen is one decision again.

Direct crew switching (September 8): tap Tag or C to return to the previous teammate. Before the first swap, this targets the first other crew member. Hold for 180ms or drag more than 12px to reveal temporary portraits; release over a teammate to choose them, or return to the trigger/release outside to cancel. Portrait positions keep roster order, including a dimmed active student, with energy and summon readiness visible. Combat keeps running at normal speed and the other touch finger can keep moving or firing. Crew portraits preload when a run starts or resumes, so opening the selector reuses decoded images instead of beginning downloads during the gesture.

Desktop 1–4 directly choose crew slots during play; the same keys retain their menu/card behavior outside a run. Hold C and point (or glide on a trackpad) for the portrait selector. Mouse lock stays engaged: relative movement drives a separate selector cursor while combat aim is held. Gamepad B returns to the previous teammate; physical D-pad up/right/ down/left selects slots 1/2/3/4. Movement-stick navigation never tags.

sim.previousCharacterId records the outgoing student only after a successful swap. quickTagId() validates that target and falls back for old saves; the scalar survives snapshots. HUD portrait and energy ring show this target. Legacy sim.tagNext() remains sequential for existing simulation callers. All human paths converge on Game._tryTag. Pending gestures and queued tags clear on menus, upgrade wind-down, death, blur, cancellation and rotation.

Must stay true:

Ren–Yui crew link: Threadbreaker (2026-09-21)

In combat version 1, equip Binding Stitch on Yui and bring Ren in the crew. With Tag available, cast Stitch into live targets and manually tag Ren before the two-second pin expires. The existing Tag button gains a red/cyan ready treatment and accessible label; quick Tag selects Ren during this opening instead of the previous teammate. Direct slot selection and the crew selector work too. No additional action button is introduced.

src/crew-link.js derives readiness from the live Stitch cast and at most six living, non-ghost pinned targets. Both students must be available, Yui must be active, Tag must be off cooldown, and the run cannot be stopped, in a level-up pause or inside a Domain. Knockout/forced substitutions and tags to another student do not trigger the link. Ordinary tag cooldowns and arrival benefits still apply.

The cast captures Yui's scheduled snap damage when she casts, including her current damage multiplier/afterglow. A successful manual tag to Ren spends that pending snap once, releases its pins, and strikes the captured targets immediately with bounded knockback. It removes the cast before any hits resolve, so the natural delayed snap cannot repeat it, even when the strike kills an enemy or ends the room. Incoming Ren stats do not inflate the hit; this is the promised second hit delivered deliberately, not a second full attack or a switching damage bonus. If the player does not link, the normal snap still resolves using that same cast-time value. Version-0 casts retain their old damage timing/behavior.

CrewLinkFx supplies cyan threads that visibly split at the snap, a broad red/ivory strike and an eight-ray impact on each captured target. Geometry is pooled, bounded to six targets and 18 desktop/12 phone shards, and finishes within 720ms. Ren winds up, holds the punch and recovers toward live aim; tag-in invulnerability cannot blink him invisible during the first 240ms. This changes only the shield's visual flicker, not its protection or duration.

The impact replaces ordinary tag rings and stacked hit sounds, briefly ducks the mix, and uses an optional 65ms hit-stop. The camera accent now triggers at the 65ms snap instead of decaying before it. A short tonal pull leads into one 35ms filtered crack and two pitched impact layers, with no sustained noise tail or increased voice budget. A 900ms Broaven move title and partner credit appear under the HUD. Reduced motion omits camera/hit-stop, ring and shards, uses static impact marks and halves the pose strength. Presentation freezes during pause, resets on scene/run changes, and stays suppressed behind menus or the title demo. No new recordings, textures or rendering passes are used.

The Shikigami — a SECOND slot, on Q

It is not an elective any more. It used to be the kuroinu elective, which made calling your animal interchangeable with a 40-CE zap and meant a student could never carry both. Now it is its own slot with its own binding, and Chris set the two rules that give it its character:

Bindings (2026-09-12): Q calls Shikigami on the keyboard, E casts the equipped special attack, and Space activates Domain. Q no longer aliases Domain; R is unbound. SHIFT/F/CTRL remain Flash Step and C remains the tag. Shikigami still uses RB on a gamepad (Flash Step keeps LB and LT) and its own touch button, whose ring drains the cooldown so the long wait is legible instead of a dead button. Every refusal answers back with the reason.

Ownership is per student (save.shikiBy), bought once for 550 at that student's own school store — no ranks, the animal is the animal. A fresh save owns none: it is earned, which is the first half of Chris's "first domain should feel like a breakthrough" design (board #13).

In the sim: hasShiki / shikiCdT mirror the ACTIVE member's party entry exactly the way electiveId does, so the cooldown rides the student. Tagging out banks it rather than refunding it, and tagging in a clubmate who has not spent theirs is a real reason to rotate. castShikigami() consumes no rng. Which creature answers is still SHIKIGAMI[charId] — four different animals, baked onto the summon at cast time so tagging out mid-hunt never morphs the beast into the next student's.

Migration promise: every student who owned the kuroinu elective now owns their animal outright, and the elective slot it occupied is freed. This runs BEFORE the elective whitelist (which no longer contains kuroinu and would otherwise drop the evidence). Test: the shikigami split: everyone who bought it keeps their animal.

Electives — one timetable per STUDENT

An elective belongs to the student who enrolled in it, not to the club. Buying Backdraft for Ren does nothing for Yui. A member who walks on without one simply has no E that night, even while the student beside them casts theirs — swapCharacter re-reads electiveId from the incoming member, so tagging takes the button away and tagging back hands it straight over.

Enrollment happens on the student's own sheet. The SHOP's ELECTIVES pane is a signpost and a per-student summary, not a till; do not put buy buttons back on it.

Hero secondaries

src/secondaries.js owns the per-hero catalog; the original three techniques retain their tuning in config.js. Each student has exactly two choices and one equipped special. Purchases enforce the same pairs as the menu:

Student Signature secondary Returning secondary
Ren Backdraft Flash Transfer
Yui Binding Stitch Flash Transfer
Kaito Dead Zone Circuit Break
Sora Curtain Call Paper Decoy

Flash Transfer is shared to keep all four kits equal. The new signatures cost 600 coins before the existing per-student tuition escalation:

Timed secondaries cannot be recast until their effect expires; the E/touch button shows that effect's remaining duration, independently of shikigami. Delayed damage retains its caster across tags; a knocked-out caster loses their pending effect. Specials never refill their own energy from damage. The new state is scalar/array data in the deterministic sim and survives suspension; crossing into another room retains equipment but clears fields.

Old purchases convert into unowned choices for that student. A surplus third purchase refunds its ordered tuition tier (historical receipts were not stored). Equipment follows the converted choice. Re-reading a migrated save does not repeat the refund. Already-suspended runs keep their original loaded technique until that run ends, including legacy off-hero choices.

secondary-fx.js precreates bounded guard/void/needle geometry. Threads are straight and airborne, rather than floor scribbles. Casts and releases reuse the existing sprite/particle pools and add distinct directional audio cues; no new downloads, dynamic lights or per-frame geometry allocation are needed.

The student sheet — skill grid and four build categories

_charDetail pairs the roster portrait and calling card with a compact loadout workbench (30% / 70% in landscape). Four small category tabs and the coin balance stay above a scrolling .cdbody; Pick / Close stay below it. Phone portrait uses a short character banner above the grid. Field Notes holds advice and run records beside the portrait.

_loadoutTab presents square art cards with earned stars or ownership state. Details are hidden until a card is selected; opening one never spends coins. A centered native dialog contains the skill description, current → next effect, price and explicit purchase/equip action. The dimmed build stays visible behind it. Done, Escape/gamepad Back, or a backdrop tap dismisses just the skill dialog and returns focus to its card. Keyboard/gamepad focus stays within the dialog; purchases keep it open with refreshed ranks and costs. If an action becomes disabled, focus moves to Done. Maxed and unaffordable skills remain inspectable. Each category retains its selection; purchases preserve the grid scroll and portrait node.

Every purchase still names a student. Builds are save.perksBy[id] and ride the party entry in the sim (m.build), applied as ratios on a tag so level-up multipliers survive the swap. The +10 max HP perk remains retired: the club shares one HP bar within a round, so it had no owner. Legacy saves are refunded for it and unlocked students inherit the old club-wide ranks. Health and energy recovery upgrades are earned during a run; the sheet does not invent permanent purchases for them. Transfer students and club seats are still bought on the roster, and existing prices, callbacks and save records are unchanged.

Layout and focus contracts:

Migration from the club-wide shape: an old electives list is granted to every student who was already unlocked, then converted to that student's two current choices with surplus tuition refunded. Students unlocked afterwards enrol on their own coin.

Every student calls a different shikigami

SHIKIGAMI[charId] in config, shikigamiFor(id). Ren KUROINU (hound), Yui SHIRASAGI (heron), Kaito RAIJU (thunder beast), Sora KITSUNE (paper fox). These are sidegrades, not a ladder — sustained damage lands within ~10% (86/90/78/92) and the difference is shape, reach and uptime.

Two rules that are easy to break:

Run start/resume prepares reusable companion geometry. Models keep independent materials; their final-second fade updates every material continuously and resets on the next summon. All cached resources release on view disposal. The actual shiki event now drives arrival FX, with caller-specific attacks and departure accents from src/summon-fx.js. Event ownership survives tags. Including outline hulls, models use 15–25 meshes and 2,100–5,424 triangles; these are geometry budgets, not physical-device FPS measurements.

Paper Doll / Paper Decoy: src/paper-doll.js replaces the yellow plane with a folded paper human effigy: cream cut edges, red ink, binding ribbons and an arming seal. Its 9 meshes / 1,280 triangles share static resources across live dolls, while each seal pulses independently. Flutter follows the decoy's own elapsed time; the thin ground ring marks the actual taunt radius. The existing four-second taunt and explosion rules are unchanged. The cast effect lands at the doll even if a shikigami is already active.

test/combat-art-events.test.js verifies artwork ownership and spawn anchors; model tests verify geometry, animation and disposal. The built-game test/combat-art.browser.mjs exercises all twelve primaries, four summons, and the doll on desktop and phone graphics profiles, including expiry, recasting, continuous fading and unchanged simulation snapshots.

Enemies — the late shift

Nine types now. lurker (340s+, locker-tall chaser), moth (420s+, weaver) and warden (500s+, gliding ranged bell) arrive after the brute and dilute the fast-cheap mix: grub+wisp held 58% of spawn weight forever, one of the two measured causes of the late-game domain-farming collapse. With the three live, the late fast share is ~46%. All reuse existing AI kinds (chase / weave / spit) — no new sim logic, no rng-order change before their minT.

A new type MUST be added to every map's roster or it never spawns: _minT reads the map roster first and returns Infinity for ids a stage does not list. This bit on day one — the config row alone changed nothing.

Behavioral elites — src/elites.js, src/sim.js

Fresh runs can promote one ordinary budgeted spawn to an elite after 60 seconds, then no sooner than 65 seconds after the previous promotion. Only one may be alive. Boss encounters, the twelve seconds before a boss, held bells and active domains suppress promotion. Surges, summons and bosses are never promoted. Skipped opportunities do not accumulate a burst of elites.

An elite keeps its original enemy model, collision radius, contact damage and XP reward, with 1.8× HP and its own readable insignia/health indicator. This is separate from e.tier, the older density-compensation tint. Type and seed/serial select the behavior without an extra combat RNG draw:

Defeated elites drop an additional 8 Mana and 6 Coins, alongside ordinary drop rolls. These are pickups, not instant banking. No bonus XP or extra bodies are introduced. Enemy/bullet state uses scalar fields and owner IDs for snapshot reconstruction. Saves predating this feature keep eliteVersion=0 through the whole expedition; start a New Run to enable elites. Each new school room gets a fresh local encounter schedule.

src/elite-fx.js adds a fixed four-mesh presentation pool around the original body: amber shield, coral dash chevrons, or violet diamond crown. Ground warnings use the saved facing/timer and follow terrain; their motion and elite body animation freeze with simulation time. A compact name/HP label and one arrival hint identify counterplay without opening a menu or changing music. Boss notices keep priority. Held Conductor shots have cyan halos; released ones turn violet, using the existing bullet instances. Reduced motion keeps ornament motion static.

Kill feedback: the last two Blobs & Blotches sprites are gone. bloodsplat (droplet burst, on ~40% of small kills and on crits) and bloodstain (a ground mark that dries off) are DRAWN in bake-fx.py (__synth family, seeded), tinted crimson in-shader — blood is always blood-coloured; enemy- tinted blood read as paint.

Coin collection uses the complete bubbles animation (Fume #4), tinted gold at size 1.8. It pops into flecks over 0.8 seconds. The former bubbles2 (Blobs & Blotches #21) had particles cut off in the source video itself; atlas padding cannot restore them. The replacement reuses the loaded atlas and leaves pickup values, glow and collection rules unchanged.

Large effects retain their original licensed particle animation, layers and timing. scripts/bake-fx-hd.py bakes the large clips directly from the 3072×1728 masters into 512px desktop / 256px mobile frames. Source-clipped edges receive a soft alpha taper, with a curved outer fade on clips that fill all four source boundaries. The central artwork and authored motion are retained. HD pages load independently and route new effects through pooled sprite meshes, with the original atlas available during loading or after a failed HD download. All flipbooks sample inside each cell's texel centres without whole-atlas mipmaps, preventing neighbouring frames from bleeding into distant effects. npm run test:effects checks GPU atlas isolation, captures the large effects at desktop/portrait/landscape sizes, and exercises the death-to-results path.

The Line Change — LINE_CHANGE, why you tag

Tagging is a CE rotation, not domain stacking. Each active student spends their own specials pool on Flash Step, an elective and a shikigami while the bench recovers. A pressure-wave cycle and the bench refill create a natural cast → break the wave → tag rhythm; the Line Change points the player at it:

Solo is untouched by construction: no party → no tag → freshT never set, bench loop never runs. The constants are the TUNER'S levers (board item #8, approved); they shipped by feel and have not been measured. I-018's club-tax finding predates this — rotation should be the first genuine club>parts effect, but that is a claim for the lab, not a fact.

Pressure waves — PRESSURE_WAVE, sim._openLull

A resolved domain (and a boss kill) opens a lull: spawning freezes and BANKS (deferredBudget) for lull seconds, then the bank drains back on top of the live rate (crash) so the crowd returns as a wave, not a wall. The surviving crowd is shoved back once (recoil event) at the moment the wave breaks. Surges hold their fire during a lull.

Three rules that took measurement to learn — change them only with the lab:

Shipped 2026-08-31 against the measured "domain farming" collapse (docs/IDEAS.md): kills inside domain windows hit 72% by t=600 and pressure was monotone. The playtest lab's baselines predate this — re-baseline before trusting any ledger number against the new surface.

Recovery valve — panic heal drops (config.js DIFFICULTY.panicHeal*)

While the player is below 35% of max HP and the night is younger than 300s, the per-kill heal-drop roll passes 6x as often. Same roll, wider window: no extra rng draws, so lockstep and snapshot tests are untouched.

Shipped from the lab's I-028/I-029/I-030 (2026-08-31): ungated, the valve lifted competent FINAL 47%->83% — a balance break; hard-gated at 300s it keeps the novice benefit (near-miss share 8/64 -> 27/64, survival +93s) while the competent cell moves +0.50 SE, indistinguishable from flat, across ren, yui and sora.

Must stay true: the TIME GATE is the containment, not the HP threshold. Novices are desperate before 5:00; competent players are past 300s before they are ever desperate — that asymmetry is the whole design. Tune the multiplier or the HP line freely; removing or widening the gate re-runs I-028's 83%.

Density taper — fewer, tougher, late (DIFFICULTY.density*)

From 50% of the night's LENGTH, spawn rate and cap ease linearly to 0.62 by 90%; every non-boss enemy spawned under the taper carries 1/taper of the hp and xp. Threat budget, XP-per-minute and the fragment economy hold; what falls is SIMULTANEOUS CONTACT — I-054's kill mechanism (7-8 attackers stacking touchCd into 14-16 damage events/sec). Bosses are excluded: set pieces, not density.

Keyed on night FRACTION deliberately, where the absolute difficulty curves are not — density is readability, not difficulty, so QUIZ and MIDTERM deserve the same shape as FINAL.

Shipped measurement (standard-16, valve build): MIDTERM 0.8 wins 12/16 -> 15/16 with hits down 24 -> 21; FINAL 0.8 wins 2/16 -> 5/16. Novice MIDTERM moved 5/16 -> 2/16 — inside noise at n=16 but flagged to the lab as a watch item.

Must stay true: the hp/xp compensation and the rate taper move TOGETHER — tapering the rate without the comp cuts the XP economy and makes the night easier (the exact trap the spawn invariant warns about), and comping without the taper is a stealth hp buff.

Spawn placement — off-screen or not at all (sim._spawnPoint, sim._surge)

Two rules, both Chris's (2026-09-01/02), both tested from every corner of yard and gym: no enemy ever appears inside the viewport, and no wave ever lands on the player. The view measures the visible half-extents on the ground (view.spawnBox(), frustum corners + 2.5u for camera lag) and hands them to the sim every frame as spawnBox; spawnRadius stays the farthest visible point. _spawnPoint tries, in order: the CURRENT's arc (a front with a heading), a random wall off-screen and ≥ min(minRadius, 12)u away, then the farthest hidden corner or wall midpoint. An off-arena candidate is never clamped back inside — clamping was the corner trap that folded arrivals onto the wall the player was pressed against. Surges ring the player just past spawnRadius (so they surround from off-screen) and any ring point that falls off the arena or on screen takes an off-screen point instead. The lab's default box is {hw 13, hh 9}, a phone landscape.

Room footprints and navigation (map-space.js, navigation.js, sim.js)

An optional rooms: [{id,x,z,w,h}, ...] defines the union of playable floor. compileMapSpace() removes shared seams and derives the exterior boundary and disjoint floor pieces for simulation, rendering and editor checks. An adjoining room edge is open unless authored obstacle boxes form a partition. The editor and its save sidecar own rooms, start, and obstacles in src/maps.js; see docs/LEVEL-EDITOR.md. Room rectangles stay inside the enclosing size, at most 40. The Pool and Library currently opt in. Maps without rooms keep their original square movement, placement, AI and random-number consumption.

Navigation builds one-unit grids at 0.65, 1.05 and 1.85 clearance, covering the player, ordinary enemies and the 1.7-radius boss. Connected components reject unreachable placement. A deterministic reverse breadth-first field is shared by all actors pursuing the same target cell at a given clearance, with a bounded eight-target cache per tier. Clear routes retain existing chase/weave/retreat behavior; blocked routes steer through the field. There is no per-enemy A* and no navigation RNG. Derived grids and fields are excluded from snapshots and rebuilt on restore. Layer builds (2026-10-10): each grid cell is tested only against the walls and obstacles whose box, grown by the clearance plus 1.5, contains its centre (_near), and edge tests skip the room-shape check both open ends already passed. Layers are byte-identical to the old full scan (test/navigation-near.test.js, every room, six radii) and 5–20× faster: Halls 229→6 ms, Library 83→6 ms per tier on the dev desktop. Game._openRun also calls navigation.prepare() behind the loading card, so no tier is built on a curse's first spawn (that step cost 67–73 ms in the Library). navigation.js is in the playtest fingerprint, so lab runs see a new sim version although outcomes are unchanged. Segment tests (2026-10-10): crossesBox/hitsRoundedBox are allocation-free scalars. The array literals and destructuring they used made 1.4–2.7 MB of garbage per sim step indoors, the source of 17–60 ms hitches; same operations in the same order, fuzz-pinned to the frozen original (test/navigation-alloc.test.js, 200k cases). Sim step means on the dev desktop: Library 0.58→0.30 ms, Halls 1.25→0.60 ms.

Player motion, knockback, enemy separation, charges and domain drag use swept steps of at most 0.22 units with axis sliding. Motion never projects an actor through a partition. Exact swept-circle checks match rounded obstacle corners and permit tangent bodies to turn away from exterior walls; square-expanded visibility had stranded otherwise legal actors in these cases. Placement recovery, rather than movement, selects a legal reachable floor position for starts, summons, boss transfers and drops. Flash electives require legal connected destinations and clear tall-cover/exterior sight; they can still cross water.

Room _spawnPoint keeps the seeded current heading but chooses legal connected floor at 1.85 clearance, outside spawnBox plus its two-unit margin and at least nine units from the player. Surges replace invalid or visible ring positions through the same check. If no hidden reachable floor exists, that arrival is not placed; it never falls back to a visible point or an empty square corner. Explicit summons may appear on screen, but still require legal connected floor.

Tall obstacles and the exterior stop swept projectiles. Water still blocks movement while allowing shots across it. Melee cones and shikigami bites respect tall cover and exterior sight. Orbs retain their intentional tall-cover piercing, but their travel and area damage cannot cross exterior notches. These rules do not implement stealth or remove enemy awareness behind cover.

Shikigami heel/lunge paths and attracted pickups use the same routing. Once a pickup becomes magnetized on a room map, attraction persists until collection: a route around a wall can temporarily leave the original pickup radius. Old square maps retain their original straight attraction behavior. Restoring an old Pool or Library save relocates actors or drops that the new footprint leaves inside water, scenery or exterior space. Such a save cannot reproduce its old-layout trajectory; valid snapshots from the new layout remain deterministic. Yard and Halls each matched the previous simulation's snapshots exactly after 960 seeded frames.

test/navigation.test.js covers actual pursuit, both locker portals, all authored Pool annexes/doorways at player and boss clearance, swept dash and projectile collision, legal spawns/blinks/transfers/summons/drops, component rejection, rounded/tangent corners, exterior attack occlusion and pickup detours. Its seeded 30-second Pool combat smoke includes a boss, moving/firing player and a snapshot resume compared in lockstep. These are correctness checks, not balance or physical-device performance measurements.

The Edge economy — pressure domains + rally (config domainPressure*, RALLY)

Chris's discovered playstyle — wade in, trade HP, charge the domain, detonate — made explicit and priced. Tag pre-edge-economy marks the world before.

Pressure-charged domain. Charge scales with bodies within 9u: min(max, base + per × pressure) / (1 + t/domainLateDamp). Pressure decides WHERE charge flows (an empty perimeter trickles at 0.3× — safety is poor, not free); the gentle clock damp decides how much the deep night allows; and for domainPressureCastLull after a cast the multiplier floors at base, so a wipe cannot chain into the next wipe. domainPerKill/PerDmg were scaled DOWN when this landed: the multiplier redistributes income toward danger, it must not raise the total. Measured: without the damp, competent players — who live in dense crowds all night — ran 16/16 in every arm.

Rally. From t = RALLY.from (300 — the moment the panic valve closes: early mercy is the valve's job, the late night is rally's), a recoverable share (recoverFrac 0.3) of each hit taken sits in a pool for RALLY.window seconds; WEAPON damage claws it back at base + perRegenRank × Rally ranks. Elective, shikigami and Domain damage never rallies: a paid special cannot also erase the hit that paid for it.

Must stay true:

Periods and stars — config.js PERIODS, SCORE_STARS

QUIZ 180s / MIDTERM 360s / FINAL 720s. nightK compresses the surge timeline and the fiction (clock, sunrise) while ABSOLUTE difficulty curves stay put — that asymmetry is the tuning. Only a FINAL clear sets stageCleared.

The win screen follows the night's fiction instead of trapping the player in one room: QUIZ leads to that room's MIDTERM; MIDTERM opens and points NEXT at the following hour. The current room's FINAL remains available on its blueprint card as an optional mastery run. On the Rooftop there is no later room, so its MIDTERM correctly leads to FINAL.

Stars are per STAGE, judged on run SCORE (kills × chain multiplier, boss bounties, −30 per hit taken). Silver 65k, gold 330k — SCORE_STARS is the source of truth and has been retuned before, so read it rather than quoting this line. Gold sits deliberately above the fresh-save FINAL ceiling.

Chain milestones (2026-10-16, view and HUD only). The kill that makes the chain 25, 50, 100 or another hundred flares the hero in their colour (View._chainFlare: a ring pair, sparks, a glow, a short combat light, and a 0.38/0.5 light wave that phones skip, being under PHONE_WAVE_MIN). The HUD counter heats up at the same thresholds: gold below 25, then amber (hot), orange (blazing) and crimson (inferno), with a bigger pulse on the kill that crosses one. The chain's score multiplier itself is unchanged.

Economy — game.js _commitRun

Run-count Coin boosts (boosts.js): boost_7d grants 1.5x on the next 10 surviving runs ($0.99 reference price), boost_30d grants 2x on the next 30 ($1.99). The ids keep their old timed names because App Store Connect ids are permanent. They were timed until Apple rejected 1.0 on 2026-09-23 (guideline 3.1.1): time-limited purchases count as non-renewing subscriptions that must restore to every device, which needs accounts. A count used up through play is an ordinary consumable. Display prices always come from RevenueCat.

A purchase persists save.boost = {mult, runs}: runs add up (capped at 999) and the multiplier is the higher of the two. Every boost stays on sale while one is active. Club-card merge keeps the record with more runs. Old timed records (until) fail validation and drop to null.

The first surviving bank of a run spends one charge (game._runBoost) and stores the rate in committed.boostMult, so later banks of the same run (After Hours) use that rate without spending again, and the resume snapshot cannot charge twice. Death/quit pay zero and spend nothing. Pushing on or converting to a School Run reverses the exact _preCommit.paid and gives the charge back (_refundRunBoost), so the next bell spends it again. Only the newly banked delta is multiplied, rounded down; the simulation, drops, XP, kills, mastery and assignment rewards are unchanged.

The Patron shelf shows Restore only when a non-consumable (Supporter) is on sale; iOS sells boosts only, and Apple forbids offering to restore consumables. On a landscape phone the Clubhouse Store lays the offers in one row (title over price; Supporter outlined in coral, so the free pull stays the loudest thing) and Restore is a quiet underlined link beneath them, 44px tall.

Fragments bank only if you survive. Death and quit both forfeit the run's takings; the death screen shows the number struck through with a line saying why. Kills, mastery, best time, stage records and assignments still count — those are records, fragments are loot.

Must stay true: quit must forfeit too, or the optimal play becomes "farm, then quit before it kills you". After Hours works because committed tracks a delta: what was banked at the bell is safe, only what is earned after choosing to stay out is at risk.

Historical, pre-runner-3 measurements (ren@yard, 10 runs; not a current balance baseline, payout includes the dawn bonus of 100 + 10/level):

Period novice 0.25 competent 0.5
QUIZ 5/10 win, +302 10/10 win, +286
MIDTERM 0/10 win, −117 6/10 win, +502
FINAL 0/10 win, −102 5/10 win, +1110

The ladder is meant to point DOWN before it points up: a stalled player drops to a tier they can clear, gets paid, buys power, comes back.

Measuring current loadouts and progression — September 9

The September price pass keeps the basic first ranks at 35–90 fragments, summons at 550, and first specials at 450–600. Later stat and step ranks carry most of the increase: all training now totals 20,150 per student; a complete core build with a summon and the cheapest first special is 21,150. Including paid characters and crew seats, four core builds cost 96,400; collecting every alternate special brings that to 108,200–110,000 depending on purchase order. Kaito/Sora cost 600/1,200 and the third/fourth seats cost 2,500/7,500. Ren/Yui remain free and the second seat remains earned. Existing wallets, ownership and mastery are preserved; no save migration or retroactive charge is added. See the measured price pass for the full curve, optimizer coverage, early-career evidence and limits of the 8–12 active-hour crew pacing target. Rewards, upgrade effects and rank counts are unchanged.

scripts/balance.mjs now uses the shared version-4 playtest runner, including independent policy RNG, per-member builds, max mastery with --kit=full, solo honors, tier rewards, and natural boss weapon pickups. --graduate plays the actual Quiz-to-Midterm-to-Final chain; initial --weapons fixtures are separately stamped and never presented as earned pickups. Weapon policy can bind the first drop, the latest, a preferred primary, or ignore drops. Run every equipped special separately: owning all specials does not equip them simultaneously. Version 4 also matches the live bot's summon readiness: it waits for sufficient CE instead of exhausting itself on refused casts. Runner-3 equipped measurements and earlier balance results are not comparable.

scripts/progression.mjs models legal per-student purchases between actual graduated runs. It counts the bell payout once, forfeits failed-chain loot, keeps checkpoint access and mastery, and equips purchased specials. Purchase milestones are separate from completion that also requires earned mastery; the between-run crew policy trains unfinished students instead of leaving them permanently benched. A price catalog can be supplied as data for paired experiments without editing combat config. Its explicit shop/retry policy excludes assignments, ads, insurance, menu time and higher-tier farming, so simulated hours are pacing estimates. Censored careers remain unfinished in summaries. --progress prints attempt timing separately from deterministic evidence. The hours budget stops between runs, so the final attempt can cross it without artificially forfeiting that run's earnings. Records are append-only under HALLPASS_ARCHIVE.

Cloud saves — the CLUB CARD (src/cloud.js)

Capability-key model, no accounts. The first time a save is pushed, the client mints HP-XXXXXX-XXXXXX-XXXXXX-XXXXXX (120 random bits, crockford-ish base32, no 0/O/1/I) and stores it at localStorage['hallpass.card.v1']. The code IS the credential: whoever holds it is that player. It shows on the OPTIONS → CLUB page (tap to copy) next to a LINK input for typing it on another machine. This is the right amount of identity for the audience — no email, no password, nothing to forget — and it becomes the guest tier under any real OAuth login added later (Phase 2).

Server: Supabase project uvgetkgbdiguynzvions, table hallpass_saves (RLS on, all direct access revoked). Only two SECURITY DEFINER RPCs are exposed to the publishable anon key: hallpass_get_save(k) and hallpass_put_save(k,d) (64KB cap). The table is never queryable, so cards cannot be enumerated — a wrong card gets null, not an error you can probe.

Sync paths (all three verified live 2026-08-31):

Merge keeps permanent progress — mergeSaves in save.js: numbers take the max (fragments, best, kills, mastery xp, per-track step ranks), sets take the union (unlocked, per-student electives), and taste stays local (settings, which elective is equipped). Cards reconcile purchases, intentional losses and recoveries under their separate ledger contract above; taking the maximum owned count alone would revive lost cards. Both sides are migrateSaved before and after, so a hostile cloud blob is clamped like any other garbage.

Traps: the CLUB page card row needed pointer-events: auto (#ui > * kills it); linkCard normalizes sloppy input (case, missing dashes) before validating; never push synchronously in persist — the debounce is what keeps a 60Hz sim from DDoSing the RPC.

Difficulty — config.js DIFFICULTY

hpQuadSec 380, hpLateStart 630, speedLatePer30 0.06. Enemy introduction is gated per stage by MAPS[].roster {id -> minT}; Night II+ and dailies pass fullRoster: true.

Must stay true: difficulty comes from HP and speed. Spawn count is the XP economy — raising it makes the game easier. Verify any tuning change with the current scripts/balance.mjs fresh/full matrices and scripts/progression.mjs for purchase pacing. Use named banks, at least 16 seeds per cell (64 for novice continuous metrics), and a second bank before promoting a finding. Legacy playtest.mjs and night.mjs omit current loadout/economy behavior and cannot establish the new baseline.

Touch controls — TAP THE FIRE STICK TO FLASH STEP (Chris, 2026-08-31)

Combat HUD refinement (2026-09-08)

Each living, visible boss has its own name, HP percentage and health bar above its head. view.bossToScreen uses the current camera and terrain height, plus the loaded model's scale/hover offset (or the primitive fallback). After each view sync, ui.updateBossBars updates existing DOM labels; it allocates only on arrival and removes labels on defeat. Offscreen/behind-camera bosses and paused/menu screens hide the labels (an off-screen boss gets an edge chevron instead: Off-screen boss, 2026-10-17). Safe-area clamping keeps edge labels readable. The old combined bar below the player's HP is gone.

The shikigami button uses a student-specific animal silhouette, a thin cooldown ring that fills toward readiness, and a glow only when casting is allowed. Desktop also shows READY, ACTIVE, remaining cooldown seconds, or LOW CE. Action key hints switch to SELECT, RB, X and Y/RT when a gamepad is used. The entire slot, including its key hint, hides when the active student has no summon. The separate Upgrade slot prevents either control from pushing shikigami out of a narrow desktop window. Touch retains the existing action arc and sticks; meters flex to leave a 44px-high rectangular Upgrade button reachable, including short landscape, notched safe areas, tablets and one-thumb mode.

Verification: node test/controls.browser.mjs checks real pointer and keyboard actions, HUD state changes and screen bounds. test/controls.test.js covers camera projection, offscreen hiding and terrain/model height. These are layout and functional checks, not measurements on physical iOS hardware.

Existing stick gestures

The STEP button is gone. The rule the player learns is one sentence: HOLD the fire stick to shoot, TAP it to Flash Step — anywhere on the whole controller circle (Input.TAP_R_FRAC = 1, i.e. STICK_R 58px against a 50px drawn base, so it forgives a thumb landing just off the ring). Input.TAP_MS (220ms) bounds the tap, and st.maxD — the furthest the gesture ever travelled from the base — is what separates a tap from a push.

You step where you tap. The tap sets aim on its way through, so a standing player dashes in the tapped direction (verified: right/left/up/down each step that way). A moving player still steps along their movement, which is the existing requestDash rule.

The tradeoff, stated plainly: outside the aim deadzone a tap also lets one shot go before the release is recognised — "step and strike" — and a player who taps purely to squeeze off a single shot spends a step charge doing it. That is inherent: the two gestures ARE the same gesture. It is defensible here because firing is a hold verb (auto-fire covers the rest), and TAP_MS / TAP_R_FRAC are the two knobs if the balance turns out wrong. Dropping TAP_R_FRAC to 0.25 restores the centre-only tap, which costs nothing at all but is a much smaller target.

Verified with real elapsed-time gestures: 99ms tap on the ring steps; 415ms hold on the ring only fires; 407ms hold in the centre does nothing; and an aim sweep that leaves the circle and comes back never steps, however fast it ends.

What the freed slot bought: the touch arc around the fire stick is a clock — DOMAIN at 12, the 10:30 slot, ELEC at 9. The 10:30 slot was STEP; it is now the SHIKIGAMI, which is how the animal reaches a phone at all. Same coordinates as before in all three touch layouts (phone, short landscape, one-thumb), so the spacing is the geometry that already shipped: circle gaps 6.7px / 10.2px, every button tappable at 8/8 rim points.

The Flash Step charges moved to the BARS. They used to ride the STEP button's rim on touch and sit above the stack on desktop; _placeDashPips had a device branch for it. There is one home now — the dashrow beside cursed energy and domain — because charges belong next to the meters they are a meter of. No device branch, one layout.

Device-tilt parallax — src/tilt.js (Chris, 2026-09-02)

Tilting the phone nudges the camera a touch so near things slide against far things: it sells the depth of the engine without touching play. View-only by construction — the sim and the input never see it, so it cannot change a run, and the determinism suite is untouched.

The Safari catch. Since iOS 13, deviceorientation events are gated behind DeviceOrientationEvent.requestPermission(), which MUST be called from a user gesture or it rejects. Everyone else just gets events. So request() rides the very first tap — the wake handler in main.js that also unlocks audio — so on a real iPhone the sheet lands on the title screen. It was first wired to the run-start tap; Chris hit it there and it stacked awkwardly with fullscreen, so it moved up (2026-09-02). The run-start call remains as a fallback for a setting switched on after that first tap; granted == null guarantees it is asked exactly once. Denied or unsupported simply means no effect. The prompt itself is the one thing the headless pane cannot exercise — it needs a real phone; everything downstream of it is verified with synthetic events.

How it feels, and why:

Traps for whoever touches this: the camera nudge is applied to camPos AFTER shake and BEFORE camera.position.copy, using last frame's quaternion (fine, the pitch is fixed). Do not nudge camTarget — that would move what the player is aiming relative to. And in the pane, a hidden tab reports a 0×0 viewport and the camera standoff goes NaN; resize_window first.

HUD and action buttons

The clock sits in the top-left corner with the active student's name next to it, time first. The central readout is SCORE → LIFE/EXPERIENCE → FRAGMENTS: score directly left of health, the gold fragment count directly right, and a thin cyan XP track underneath. These float over the arena with no enclosing panel. CT Headstone lettering, local text shadows, the faceted gold currency symbol and bright segmented health preserve the game's visual identity without filling the gaps with a black surface. Pause stays in the top-right corner: its visible face is 35.2px (20% smaller), with a 40% opaque dark background and an invisible margin retaining a 44px pointer target. The compact 96×36px Upgrade button sits top-right beneath Pause, with their right edges aligned. On narrow screens it also clears the lower vitals row.

The purged counter is absent from the gameplay HUD; the result screen still reports the full count. The clock's gold warning and the existing notices communicate the hour transition without a second warning line beneath it.

Both centre bars widen with the number behind them, within the available space. --hpw tracks max HP; --xpw tracks what the NEXT level costs, so a longer bar means a longer climb and the bar itself says how far the level is. xpNext runs 12 at level 1 to 3100 by level 50, so the map is 110 + sqrt(xpNext) * 5.5 clamped to 340. The XP bar is capped at the health bar's displayed width to leave room for the adjacent readouts. At 760px and below, the centred score/health/fragments row moves 28px below the corner clock to prevent overlap. It reserves room for Pause and safe insets. Counters use compact totals at 1100px and below (e.g. 12K), with exact totals preserved in accessible labels and result screens. Score grows outward to the left, fragments to the right, so changing a count does not move the bars or require empty number slots. The full After Hours clock remains visible even in portrait. The rotate hint sits below Upgrade and yields during announcements, including the boss film.

The bar container stays at screen centre on every device. test/controls.browser.mjs checks both corner anchors, score/fragment adjacency, transparent containers, the absent kill counter, growing health, large and compact counters, After Hours, rotation, safe areas and Pause.

There is no level number. The XP bar already says where you are inside a level, and the figure was never something anyone acted on.

On phones, Domain is a 56px circle; Shikigami, Elective and Tag are 46.4px circles. The secondary buttons remain 20% smaller than the former 58px standard. Sizes apply in portrait, landscape and one-thumb mode. Landscape button centres stay in their existing positions around the sticks; portrait Tag clears the energy meters. This rule applies to touch viewports with either dimension at most 500px; tablets retain their 72px action buttons and 92px Domain button. Stick sizes are unchanged; Upgrade uses its separate compact size above. test/controls.browser.mjs verifies phone sizes, tablet sizes, safe-area clearance, rotation and actual taps on all four actions.

The Domain button fills as a centered circle expanding outward with charge. Its fill, border and ready rim use the active character's color, including after tags. The fill resets immediately on cast and keeps the existing locked/active dimming. Domain readiness appears inside the glowing meter on every screen size. Available Shikigami and Elective buttons have bright purple faces, white rims and halos; cooldown and active states keep their distinct treatment. Domain closing recoil rings retain the casting character's color through crew swaps and saved resumes.

The bottom stack is one group of "what can I do right now": CURSED ENERGY over DOMAIN. They are separate resources, but both answer the same combat question: which high-impact action is ready? The level bar used to live here and has moved up under the life bar.

There is no domain NAME in the HUD. You picked the student; you know what their domain is called. The label element survives only to carry the two TRANSIENT states, READY and LOCKED, and it is position: absolute so that appearing and vanishing cannot shove the bar stack — and with it the desktop action circles — up and down.

Flash Step charges share one home: a centered row above Mana/Domain on both touch and desktop. There is no separate Step button; tapping the fire stick performs the touch step. ui._placeDashPips() keeps the same charge elements in that row when the input device changes.

Responsive HUD alignment (2026-09-15). Mid-size touch layouts leave a separate horizontal lane for Tag and its cooldown ring beside Mana/Domain. The large-tablet layout keeps its meters at the floor and sticks at the sides; phone button sizes and thumb destinations are unchanged. Projected boss health labels clear the measured bottom of HP/XP, clock/name, weapon label, Pause and Upgrade. Their own height participates in the CSS clamp, so neither the name nor the bar can rise into those readouts. A label briefly yields only when a notice or toast covers its position, and returns when the message clears. The portrait rotation hint yields to the open crew picker and active bosses. The existing layout observer caches boundaries and message sizes; no geometry measurement is added to the per-frame HUD or boss update. test/hud-layout.browser.mjs checks source UI snapshots, notices, crew selection, safe edges and rotation without playing or tuning combat.

The desktop action cluster

On a phone the action circles live under a thumb. On desktop nothing is being thumbed, so they gather around the bars they describe:

    TAG   [ MANA / DOMAIN / EXP ]   DOMAIN  ELEC
     C     step dots centred above   SPACE    E

--stackc is the BAR stack's centre measured up from the viewport floor, and every circle hangs off it, so the centreline of the bars and of each circle are the same line. The step dots sit above the bars in the same column and deliberately do NOT enter that measurement.

--stackc is MEASURED, not computed. ui._syncClusterCentre() reads the bar block and sets it on <body> (on body, because the CSS default lives on body:not(.touch) and would beat anything inherited from the root). The hand-arithmetic version drifted twice — once when the step dots arrived, once when the xp bar moved up to the life bar — and each time the circles quietly came off the line. It runs on showHud and resize, synchronously as well as in a rAF: the embedded pane and the drive harness never run rAF, so a measurement that only happened in a frame callback never happened there at all. The CSS value is a first-paint fallback only. It is not rounded — the bar block is an odd number of pixels tall, so its centre sits on a half pixel and rounding put every circle 1px high. --stackw gives width back to the bars below a ~680px window so the elective never falls off the right edge.

The keycap labels are SIBLINGS of their buttons, in a .btnslot wrapper, not children. #btn-tag i { inset: -6px } claims it as a cooldown ring, #btn-domain's overflow: hidden (needed to clip the domain fill) crops it. The elective uses the same wrapper to keep labels aligned. The wrapper is display: contents on touch, so it generates no box and every touch layout is exactly the one already approved. Labels are out of flow so they cannot pull a circle off the centreline.

The action circles are the SAME control on every device — same shape, same screen position, desktop included. They used to be touch-only, which is why desktop grew a separate elective badge in the corner (a wolf with an E stamped on it). That badge is deleted.

The whole touch rig is gated on body.inrun, and that gate is the LAST rule in the stylesheet. Sticks and action circles used to key off body.touch, which is a fact about the DEVICE, not the game state, so they drew themselves over the title, the roster and the shop. .inrun is set by ui.showHud and cleared by ui.hideHud. The gate must stay last and !important: the phone and tablet blocks re-declare body.touch #touch-buttons { display: block } to lay the circles out radially, which ties body:not(.inrun) #touch-buttons on specificity — an earlier gate lost, and STEP and DOMAIN kept drawing on the menu after the sticks had gone.

Night start — ONE arrival card

A run announces itself once. The club splash and the map banner used to fire together and land on top of each other, and a tier run fired two banners back to back, the second wiping the first before it could be read. startRun now decides the words once (arrival) and hands them to ui.clubIntro, which prints them inside the splash — kicker for where you are, sub for who walked in. The standalone banner is the FALLBACK for a lineup with no club art, the same shape bossSplash already used. Never add a second banner alongside it.

The domain button's fill must be clipped by the BUTTON. overflow never clips a descendant whose containing block sits outside the clipping box, so while #btn-domain was position: static on desktop the fill resolved against an ancestor and painted as a bare red rectangle behind a round button. .tbtn is position: relative for that reason; do not remove it. Touch only ever looked right by accident, because body.touch #btn-domain sets position: absolute for its radial placement.

Combat announcements — September 8

src/notices.js owns one combat reading lane. Ordinary announcements and toasts occupy the left-centre of the display, leaving the player's centre clear. The lane stays at the safe left edge with a 50% vertical anchor on phones, tablets and desktop. Standalone toasts are centred on that line; announcements end there, with concurrent toasts stacked below. Boss warnings take priority over waves, waves over banked levels, and levels over ordinary announcements. At most two pending kinds are retained; repeated level gains keep only the latest count. Pending notices expire after two seconds. Replacements cancel their previous timers; opening a menu clears the lane and pending work.

Notices explicitly use absolute positioning: a missing position declaration previously left them at the top of the screen underneath the player HUD. Entrances/exits animate only transform and opacity, without forced layout. Reduced motion removes the movement. All announcements are pointer-transparent; the reading lane clears the centre and the controls in short landscape layouts. The approved boss film remains full-width, with separate safe-area offsets. The night-start club vignette remains separate; stale image loads, input listeners and timers are cancelled so it cannot replace a newer warning.

Boss entrance preparation (2026-10-06): loading gates request the next scheduled model, and combat requests the following model as soon as its index changes. Boss3D.preload only requests/stages source models. Conversion, spare construction, texture uploads, room/HDR shader preparation and buffer priming run through the serialized safe-screen asset queue. Initial entry, saved-run resume, room transitions and Push On hold their existing loading card for this preparation; natural pauses and choices can prepare later downloads. Restored living bosses receive independent prepared instances, including same-art overlaps. Every asynchronous slice rechecks the room generation and whether gameplay is stopped. Claiming an unprepared model performs no construction or GPU preparation: the primitive remains until a safe screen can finish it. Cold rooms initialize their real shadow maps through the ordinary world draw before an isolated boss prime can use those samplers. Populated shadow maps and the frozen color/depth background are preserved. Ready/no-request polls skip shadow-map discovery entirely. Room changes clear instances while retaining bounded decoded templates; context restoration invalidates preparation. Boss films and musical cues retain their twenty-second approach window. No extra gameplay pause is added.

Boss arrival no longer injects 120 ms of hit-stop and 1.15 seconds at 22% speed. Its sound and existing world effects supply the impact while movement continues. Domain timing is unchanged. Simulation rules, RNG, boss schedule, rewards and authored particle assets are unchanged.

Verification: npm run test:notices checks real built-game announcements, timing, small layouts, reduced motion, missing art, blocked autoplay, media reuse/cancellation, health/Upgrade clearance, all nine prepared boss models and films, and normal simulation progress during an entrance. test/notices.test.js and test/boss-warmup.test.js pin timer priorities, expiry, instance reuse, scene changes and disposal during asynchronous work. test/boss-preparation.browser.mjs tests real cold startup, delayed GLBs, fallback visibility, cancellation during an upload, pause recovery and graphics target changes on desktop and phone-rendering branches. Set BOSS_PREPARATION_SOURCE to a built bundle for release verification; reports and screenshots go outside the repository. The older notices browser harness still assumes synchronous game startup and requires adaptation before using its boss-preparation assertions against the current loading gates.

Effects — src/view.js, src/fx.js

Impact pass, death dissolve and curse shimmer (2026-09-19)

Chris asked for lighting, graphics and effects that take the rendered night "to the next level". A headed survey of all seven rooms mid-run found the picture flat in two places the eye lands most: the crowd (one navy body with red eyes, toon-lit, popping out of existence on a kill) and the frame itself (bloom + vignette and nothing else). Three view-only additions, none touching the sim's rng:

Impact pass — src/post.js. The composite shader that the bloom already pays for now owns a screen-space impact language. A shockwave is a gaussian ring that pushes the scene sample toward its centre, so the frame bulges outward as the ring passes, with a thin bright lip so it reads on dark floors too; up to three live at once (waves[3]), radius grows at speed, amplitude eases out over life, width opens with age. A chromatic split pulls red outward and blue inward radially, from the aberr pulse (decays over ~0.3 s) plus a touch of the ring itself. Film grain is a per-pixel hash, heavier in the shadows, re-seeded each frame — it stops the large single-colour floors reading as flat fill. view.shockwave(x, z, {amp, speed, life}) projects a world point to uv (and skips it off-screen); view.aberration(amount). Hooks: Domain cast (amp 1, split 1), level-up (0.55), a boss exorcism's sever beat (amp 0.8, no split, no camera kick; the boss-kill branch with amp 1 only runs if the exorcism is missing), heavy kill (0.32), hurt (split 0.6, no wave — a wave on every hit in a crowd is noise), the clutch (amp 0.8, split 1), a dash (split 0.22), a boss arriving (amp 0.9, split 0.9). Both honour the Screen Shake setting (view.shakeOn, set in applySettings since 2026-10-16; before that nothing set it, so turning shake off left speed lines, waves, the split, the camera punch, light scatter, afterimage and the Yard's lightning on), and the wave also honours the OS prefers-reduced-motion setting. On phones fancy is 0 from the constructor: a single scene fetch, no split, no grain; the desktop pays two extra fetches while a wave or a split is live. Measured in headed Chrome (1280×720): three waves plus a split live, median/p90 frame unchanged at the 16.7 ms vsync.

Phones take the big waves (2026-10-10). Until then post.shockwave() returned early when fancy was 0, so a phone got none of the bend, yet the wave loop and the lip were already pure ALU on the composite's one scene fetch (only the split is * fancy). Phones now take waves with amp >= PHONE_WAVE_MIN (0.6, exported from post.js): the Domain cast, a boss arriving, a boss kill and the clutch. Heavy kills (0.32) and level-up (0.55; its wave would freeze under the draft menu) stay desktop-only, so a crowd can never keep a wave live on a phone. A live wave adds no pass, target, fetch, uniform or shader variant: about 15 ALU ops per pixel per live wave for at most 0.9 s, and nothing while none is live (the three-slot loop already ran and skipped every slot). test/post-shockwave.test.js holds this: phone waves live with fancy 0, sub-threshold waves dropped, identical draw list with a wave live, split still gated on fancy. If the bend feels busy on a small screen, raise PHONE_WAVE_MIN. Phones compile their own composite variant (MOBILE; see The phone post chain is trimmed under Depth). The wave loop and the lip sit outside every guard in it.

Death dissolve — src/dissolve.js. A purged body burns away over 0.7 s instead of vanishing. The pools remember which enemy sat in each slot (pool.ids), and the kill event now carries id and tier; since events land before the frame's enemy sync, the pool still holds the matrix that was on screen, and _dissolveBody copies it into a dying InstancedMesh of the same geometry (one per non-boss type, cap 48 desktop / 24 phone, oldest recycled) together with the tier tint and an edge colour (eye red, ember, violet). The shader keeps the body whole with its ink outline (an inverted-hull twin sharing every instance buffer) for the first fifth — a beat the eye catches under the burst — then a 3D value-noise threshold (two octaves, fine enough to turn over inside a sub-unit body) sweeps through it with a hot lip that the bloom picks up and a fainter heat just ahead, while it swells and lifts; the last frames are sparks. Bosses fall through (the exorcism owns their end); a body killed before it ever drew gets an upright fallback. With every pool full (48 bodies burning) the frame is unchanged at vsync.

Instance data is static between births and slot compaction; only the shared time uniform animates a living dissolve. Spawn and oldest-slot replacement mark their written slots, and expiry marks a destination only when it copies a surviving body into a hole. The next update publishes both body/ink counts and uploads the bounded changed span of surviving slots across all six attributes (five until the kill fling below added aFling). Tail-only expiry changes counts without an upload. Never submit a zero-length update range: WebGL treats that as the remaining source array. Pending ranges from earlier updates stay queued until the renderer consumes them, including when several updates happen before a render. Clearing resets the CPU dirty bounds without discarding those pending uploads. Pool capacity, draw order, shaders and first-use preparation are unchanged.

Kill fling (2026-10-10). A dying body no longer burns in place: it skids away from the killing blow and topples over its feet, so a sweep through a crowd reads as a sweep. The sim emits a body's killing hit (which carries the knock direction dx/dz) immediately before its kill, with no other hit between them (test/dissolve-fling.test.js holds the sim to that). handleEvent notes the latest blow in four scalars, and _dissolveBody uses it when the ids match. With no matching blow (the title demo drops hits) or a directionless one, the body goes away from the player. Round-clear (dawn) kills, kills on top of the player and prefers-reduced-motion burn in place as before. The skid is flingReach(type) = 0.7 / √max(1, mass) units at full push, ×1.3 for a crit kill: a grub or wisp 0.7, a husk 0.49, a brute 0.35, a warden 0.31. It ends only where the body could have stood. If the body, at its radius, would cross the room outline or overlap any obstacle box at the end point (low benches and water count too), the skid is halved; if that is blocked too, the body burns in place. Each slot stores the world-space skid as aFling (a vec2 the ink hull shares). The vertex shader eases it out (vP·(2−vP)) and multiplies it by 1 + 0.6·height above the instance origin, a shear about the feet that tips a grub ~23° and a brute ~12°. It samples the burn noise before applying the skid, so the burn pattern is today's and rides with the body, and the lift is added after the shear is measured, so it cannot deepen the topple. A body killed mid recoil-lean keeps the lean and topples further the same way. View only: no sim reads or writes beyond the event and the obstacle boxes, and no randomness (the dissolve still draws one Math.random per spawn). Cost: 8 bytes per dying slot, uploaded inside the existing dirty span (one more small bufferSubData on frames where a set changed), about 20 vertex ALU ops on at most 24 burning bodies per type on phones, and per kill one or two clearance probes (flingClear: ≤31 padded boxes plus the room outline, measured 0.1–1.3 µs each on desktop, the halls being the worst room). No draws, passes, textures, uniforms or allocations are added.

Curse shimmer — src/curse-shimmer.js. Injected into the pooled toon body material (onBeforeCompile, one program for every pool): a fresnel rim in the body's own tint — an ember body has an ember rim — and a thin band of 3D value noise drifting up the body in world space, in the eye colour, pulsing slowly so a still crowd is never quite still. Not on the low tier. The vein reads at gameplay scale as the curse showing through the skin; the recolours of the Grades keep their hue in both terms.

September 21 refinement: filter the veins with screen-space derivatives so small bodies retain soft energy rather than broken bright wires. Idle veins are subdued; an instanced threat value brightens the upper body during windup (1), then active charge/cast/slam/sweep (0.7). Idle elites retain a small 0.15 accent. Eyes use the same state, reaching 3x their original colour at windup, including on low graphics. Ghost/dead bodies do not advertise an attack. The view reads enemy state without changing it, reuses the existing body/eye draws and uploads only the active instance prefix. Veins no longer brighten with the player's kill streak.

Lamp shafts — src/lamp-shafts.js. Generic five-unit lamp cones retain their geometry and warm colour, with view-facing side feathering and fades at both ends. Depth-tested additive light stays behind opaque scenery; forceSinglePass avoids separate front/back draws. Domain colour/opacity controls remain on the material. Authored room fixtures and moon beams are unchanged; this adds no texture or rendering pass.

Motes — src/motes.js. Dust hanging in the light. Every practical fixture (the room's practicalLights, or the generic lamps where a room has none) gets a cloud of drifting specks in the cone beneath it — wider lower down — plus a soft halo that breathes at the fixture; a thin scatter fills the rest of an indoor room's air (none outdoors, where the yard's petals already do this job). One instanced additive draw, depth-tested, never writing depth; the drift on three unrelated periods, the twinkle and the billboarding all run in the vertex shader from the world's time uniform, so nothing is uploaded per frame. Desktop 22 motes per fixture + 140 ambient, phones 10 + 40; not on the low tier (setQuality hides it). Built after the shadow traversal and flagged noWorldShadow. Measured on/off in the gym, library, halls and yard: frame unchanged at vsync. Colour (2026-10-16): indoors each fixture's specks and halo take that fixture's light colour (scaled so its brightest channel is 1), and the free dust takes the room's average, so the Pool's dust reads cyan, the Science Wing's cold white and the Library's amber. A per-instance aTint attribute replaces the two colour uniforms. Outdoors keeps cool dust and warm halos. Raised mid-room (2026-10-17): a room that opened on the low tier used to stay without motes (and the Gym without its floor mirror) until the next room; setQuality now builds both the moment the tier rises, through keep(), so each is built once and disposed once with the room. test/world-lifecycle.test.js.

Eyes in the dark — src/void-eyes.js (2026-10-16). From the midnight room on (view.hourIdx >= 3), in the indoor suites whose walls give onto black void (Second Floor, Cafeteria, Pool, Detention, Boiler, Science Wing), a pair of dim amber eyes sometimes opens out in that void, blinks once or twice and fades: first after 20-40 s, then every 12-25 s, never more than two pairs. Candidates are random screen points carried into the room at eye height and kept only when the point and a ring around it lie 2+ units beyond every floor (VOID_CLEARANCE), so they are always on camera and never over the arena. Amber with a slit, not a curse's red, so they never read as a target. One instanced additive draw of four billboards, compiled in the run warm-up; none in the title demo or on low quality; reduced motion keeps them but drops the blinks. When a special grade stands whole, every free pair opens at once to watch it (witness, same rules). View-only randomness. test/void-eyes.test.js pins the placement.

Battle scars — src/battle-scars.js (2026-10-16). The floor remembers the fight. Every heavy world.impact (kinds blast, slam, strike, heavy, death, boss, cell; never dash or domain) leaves a dark scorch of curse ink where it lands: a procedural splat with a ragged edge, char streaks and a faint rim in the attack's colour, sized 0.4 x the impact radius (1.1-3.4 units). A fresh blast on a fresh mark deepens it instead of stacking a second one; a room keeps 28 marks (14 on phones) and the oldest fades out when a new one needs the room; marks pale to 55% between 20 s and 90 s; nothing lands on water obstacles (the Pool). At the bell of a Final the reclaim wave washes them away as its front passes (roomReclaim.amountAt), and a washed mark stays gone. One instanced draw, no textures, fog-correct; none behind the title's demo (battleScars.enabled). Gotcha: the splat quad is built in the vertex shader on the floor as (x, 0, -y); written as (x, 0, y) its winding faces down and front-face culling drops it from the top-down camera, which the node tests cannot see. test/battle-scars.test.js.

A Domain takes the room — world.js, view._pushDomain (2026-10-16). The Yard always had a Domain light and lamp grading; the other nine rooms had nothing, because the view pushed the technique's state to the world from the Yard's surface sync only. Now _pushDomain runs every frame in every room, and outside the Yard a Domain other than the Grid takes the room's own light: the practical lamps lean 75% toward the caster's colour and lift 45%, their floor pools tint 85% toward it, and the rim light grades as in the Yard (toward the colour, up to 2.55x). The Grid blacks the lamps out instead (its dimming is unchanged) and only grades the rim. No new lights: the local-light budget (6 desktop / 3 phone) is pinned by tests. Eased with the cast, it snaps to exactly zero when the cast ends, and resetDomain restores at once. test/domain-takes-room.test.js.

Every room answers a special grade (2026-10-16). Beside the lamps' lean and haze in every room and the stopped clocks: the Pool's water goes dark (below), the Science Wing's specimen gets excited, the Yard's storm breaks, the Boiler's furnaces roar (firelight up to 1.7x and its flicker up to 1.5x, its fastest swing staying near 3 Hz and 6%), the Rooftop's aviation beacons hurry (about 40 to 67 a minute) and the Gym scoreboard's GUEST turns red. The kits take it through lighting.setThreat(amount, color), fed every frame by world.setThreat with the view's eased threat. test/threat-rooms.test.js. The moment it stands whole, its arrival also blows the room's loose litter outward (roomAtmosphere.impact, radius 16, power 1.6: about 8 of the Library's 58 pieces, against 3 for an ordinary blast).

The water goes dark — pool-environment.js (2026-10-16). While a special grade is alive the Pool's water turns toward a deep wine (55%) and its basin glow toward the curse's colour, with the view's eased threat (world.setThreat passes the amount and colour to kit.lighting.setThreat); both return exactly when it is gone. test/threat-rooms.test.js.

Live wall clocks — src/wall-clocks.js (2026-10-16). Every clock on a school wall keeps the run's time. A run is one hour, so between the room's hour and its bell the minute hand makes one turn and the hour hand moves one mark: the Yard's tower reads 9:00 to 10:00, the Library 11:00 to midnight, the Second Floor midnight to 1:00, the Cafeteria 1:00 to 2:00, Detention 3:00 to 4:00. Like a school's master-clock system the minute hand jumps once a (game) minute, swings a hair past its mark and settles within ~3% of the minute (clockAngles), as the red second hand sweeping round the dial (one turn a game minute) passes twelve; the hour hand steps with it. The kits paint their dials without hands and tag the dial material (markWallClock, with the ink and hub colours); buildWallClocks (run by buildWorld once the room is assembled) finds every tagged dial, merged batches included (each dial is one connected piece, a painted quad or a disc, sized by its width on the wall), and pins tapered hands and a hub to it: two instanced draws per room, rewritten only while a hand is settling or when a dial's wall is shown or hidden (a hidden facade hides its clock's hands). Digital clocks tag their label instead (markDigitalClock): the Pool's gable clock reads 02:00 to 03:00 and the Science Wing's sign SCIENCE WING / 05:00 counts to 06:00, repainted once a minute. Detention's chalkboard keeps a tally of the hour being served: one chalk stroke a (game) minute from 3:00, twelve gates of five by the bell, none past it (detention-suite.js, a digital-clock tag whose baseline is the first time it hears, so no hour is written into the kit). The view sets world.setClock(startHour + hourIdx + t / nightLen) every frame (the same nightLen as the sky, so overtime keeps going), and a room opens at its own hour before any run touches it. A live special grade stops every clock in the room where it stood (it is holding the room, as it holds the bell; the HUD keeps the true time); exorcised, the clocks spin forward to the true time in about two seconds (shownClock; none behind the title demo). The Rooftop's bell tower takes the same clock: 6:00 when the run opens, 7:00 at First Bell. It used to take the whole night's progress through setHour, which opened the Rooftop at 6:54 and crept six minutes in a run. test/wall-clocks.test.js pins the readings, the jump and the hidden-wall rule.

The failing fixture — world.js stepFaulty (2026-10-16). In the Second Floor, Cafeteria, Detention, Science Wing and Boiler Room, the real light nearest the map's start is failing. It holds for 6-14 s (the first stutter comes 3-8 s in), then stutters for under a second: two to four dim/bright segments of 0.17-0.33 s each, so no more than three swings a second, easing between levels and never below 30%. The stutter multiplies that light's intensity after the Domain and reclaim dimming (and is undone by the next frame's restore), fades a soft black disc over its floor pool (the kits batch their washes, so one pool cannot dim alone), and dims that fixture's mote halo and specks (Motes.setFaulty, an aAnchor attribute). A reclaimed room repairs it. None under reduced motion; on low quality the kit lights, and so the disc, are hidden. test/faulty-fixture.test.js pins the limits.

Moon beams — src/moon-beams.js. Moonlight as a volume above the moon patches the gym and the library already painted on their floors. Each window gets three sheared sheets (two on phones) running from the pane down into the room along the key light's direction, stacked across the window's height so the beam has a body from the gameplay camera: bright at the glass, fading toward the floor, soft at the sides, two mullion shadows carried down the beam, and a slow drift of value noise through it. One additive draw per room; the gym's six clerestory windows on the north wall (the seventh is the scoreboard) and the library's reading-hall north windows and study-bay east windows, placed from the same numbers the architecture uses. Gotcha: pow(1 - v, 1.5) with a perspective-interpolated v that overshoots 1 by an epsilon at the far edge is NaN, and an additive blend turns NaN into an opaque black quad — it showed only while the camera was moving. The uv is clamped first. Since 2026-10-16 the Second Floor (the window gallery's north windows), the Cafeteria (the window bay's three west windows), Detention (the west windows behind the blinds) and the Science Wing (every other window) have beams too, over the moon patches those kits already laid. windowBeams(windows, {y, h, reach, skew}) turns a kit's window records (outward wall normal) into beam specs that start just outside the pane and land on the patch. Strength 0.28/0.2 (desktop/phone); the cafeteria's tall panes 0.22/0.17 and Detention's slatted light 0.22/0.16.

The hour's grade — src/post.js grade(cool, warm). A colour temperature in the compositor after the tone map: deep night cools the frame and lifts its shadows toward blue; only the dawn warms it. Driven from _nightSky(p): neutral at 9 PM, cool from the second hour, fullest around 1–3 AM, easing back before the last hour, and the warm term is dawnMix alone — so a mid-night hour can never read as morning (the canon guard). Cool tops out at 0.55 so the rooms' own palettes (the gym's warm wood, the library's brass) stay theirs. Black stays black (2026-10-10). The blue lift used to be a flat add after ACES, which at full cool put sRGB (14,17,32) under every void: a navy floor on an OLED phone (the 10-07 phone capture measured (10,9,20) after the vignette) that also squeezed the darkest tones into fewer codes. The lift now fades in through a luminance toe, lift * cool * L / (L + 0.01) on the cooled colour's linear luminance: zero at black, half strength at L 0.01, nearly full by the mid-tones. At full cool, black is (0,0,0), a linear 0.006 grey is (21,22,28) instead of (26,28,39), 0.012 is (31,32,39) instead of (34,36,46), and everything from 0.08 up sits within one sRGB level of the old look, so the approved moonlit cast on floors, walls and bodies is unchanged; only the voids and the deepest shadows drop out. Red codes across linear 0–0.02 go from 29 to 42. The block sits behind if (gradeCool > 0.0), a uniform branch, so wherever cool is zero (the first four fifths of the 9 PM hour, and the tenth room once the dawn takes over) the compositor skips it and is cheaper than before; elsewhere it costs about four scalar ALU ops a pixel over the old multiply-add, with no texture fetch, pass, target or program variant. test/post-grade.test.js holds the numbers (its CPU model reads the constants out of the shader) and the no-new-draw guard; test/render-lighting.browser.mjs reads black and a dark grey back off the GPU at full cool. Reverting is the one lift line.

Rise on spawn — _syncEnemies, curse-shimmer.js. A body comes up through the floor over 0.4 s instead of scaling in: its matrix sits below the floor by (1 - pop)² of the body's height (the pool measures its geometry once) and the floor's depth clips what is still under it, while a per-instance aRise (local units, uploaded with the matrices) lets the shimmer shader run the floor line hot in the eye colour as the body emerges. Bosses keep their pop. The dissolve's clone of the body geometry drops the attribute.

Domain haze — post.js hazeRing, domain-fx.js, _syncHaze. While a Domain field stands (Iron Cell, Silk, Grid, Storm), its edge wobbles the air: the field's centre and radius are projected to an axis-aligned screen ellipse every frame, and the compositor displaces the scene sample radially in a band around it with three angular frequencies drifting at different rates. The DomainFx sync publishes haze = { x, z, r, amount } (opening × fade); the view projects it. Off on phones with the rest of the impact pass.

Boss entrance — boss3d.js claim(…, entrance). A modeled boss assembles out of embers: the death burn (boss-dissolve.js) run backwards over its first 1.5 s, progress (1 − entrance)^1.5 in ember red with the emissive lifted while it forms; the frame takes a wave, a split, a ring and a burst the moment it stands whole (a WeakSet on the view remembers which bosses have arrived). The pool-fallback body keeps its pop.

Boss presence — view.bossThreat, world.setThreat (2026-10-16). While any boss is alive (not in the title demo) the room answers it: a threat level eases to 1 over about 2 s and back to 0 over about 2.5 s after the last boss dies. At full threat the practical lights sink to 80% and lean 25% toward the boss's eye colour (recorded and restored each frame with the Domain dimming, so kit-animated lights such as the Boiler's firelight keep working), and the fog thickens 15% with a dark cast of that colour. Strongest in the dark, lamp-lit rooms; gentle in the Pool and the Yard. View only.

Hit tint — sim._damage payload, _syncEnemies. The flash a body takes is the colour of what landed: the hit event carries the enemy id, the owner (who), the source and the weapon's colour (wc, set around _primaryHit and the blast loop through a transient _hitWeapon), and the view keeps a small map of id → tint. Yui's needles flash the crowd blue, Ren's palm red, a blast in the weapon's own colour, a shikigami's bite violet; unknown sources keep the plain reddish-white. The tinted flash replaces the body colour for the frame rather than adding to it — a hue only survives the tone map if the channels stay far apart.

Recoil lean — _syncEnemies (2026-10-10). A knocked pooled body tips away from the blow along the sim's own knockback (e.kx, e.kz), which the sim already divides by mass, so weight shows for free: Ren's palm (knock 5) rocks a grub ~15°, a husk ~10°, a brute ~6°, a warden ~5°; a single shot (knock 0.45) tips a grub about 2°. The angle is min(0.32, 0.15·(√|k| − √0.2)) rad, zero at and below |k| = 0.2, so it fades out continuously instead of popping off, scaled by 1 − rise for a body still coming up through the floor. It is a world-space tilt premultiplied onto the body's yaw/pose, so it pivots at the feet: the footprint and contact shadow stay on the hitbox, and the eyes and outline (which share the body's instance matrix) lean with it. Knockback decays at exp(−6.5·dt) (half-life ≈0.11 s); the √ halves that rate, so the lean relaxes with a half-life of about 0.2 s and is flat again ~0.5 s after a palm hit. The sim does not step through hit-stop, so hurt, slam and clutch freezes hold the recoil. A body killed mid-lean dissolves mid-lean (the dissolve copies the last drawn matrix). View-only: e.kx/e.kz are read, never written. Off for bosses (they own their models) and under prefers-reduced-motion. Cost: one squared length per body; a sqrt pair, a sin/cos pair and a quaternion multiply only for bodies above the 0.2 floor — no draws, uniforms or uploads (instance matrices already upload every frame). test/enemy-recoil.test.js.

The shower — world.js, yard-graphics.js setShower. The yard's open ground (asphalt, court, concrete — the wettable standard materials) remembers its dry roughness, colour and reflection strength — read LAZILY, the first time the ground gets wet, and put back exactly when it dries. The first cut read the baseline at build time; the ground photo loads asynchronously and applyGround then sets the material's colour to the night grade and its roughness map, so the baseline was the dark pre-texture tint and writing it back every frame turned the whole yard near-black (Chris: "you can barely see the ground", the puddles "just texture"). Found by bisecting the day's commits in a worktree with a fixed capture: court luminance 52 → 22 at that commit, 52 again with the lazy baseline. The wet roughness is a multiplier on the authored map, not a replacement. On the world's own clock, a little over a minute into the room the drizzle builds to a downpour for about a minute (rain brighter, faster, longer streaks), the ground goes toward glass (roughness → 0.14, reflection ×1.5, a touch darker) at a fast rate and dries back at a slow one, so the court stays wet a while after the rain stops and the lamps reflect in it. world.setWeather({ shower }) forces it for captures; world.wetness reads it. Every 240 s of view time. A special grade brings the storm (2026-10-16): while one is alive the view forces the downpour on (and with it the lightning past 0.6), then hands the shower back to its clock; the change is edge-triggered and reset with each new world, never behind the title demo (view._specialStorm).

Window lightning — src/window-lightning.js (2026-10-17). Indoors the same storm shows through the windows. Every windowed room tags its moonlight (material.userData.moonlight: the floor patches under the panes in the Gym, Library, Second Floor, Cafeteria, Detention and Science Wing, and every moon beam), never its lamp washes (the Science Wing's cool pools are a clone of its moon material, so the tag goes on after the clone). While a special grade lives in such a room, view._lightning throws bolts at the Yard's rate (about one every 11 s, the same double flicker): world.lightning(L) lifts the patches' opacity by up to 2.2x and the beams' strength by 2.6x, the key and hemisphere lights rise (x2.2 and x1.45 at the strike), and the screen tints softly (.16, then .1: under the strong-flash line); the sky is left be. At L = 0 every value is its own again, exactly. A bolt keeps the kind it struck as (_boltIndoors), so a special grade dying mid-bolt never turns the rest of it outdoors, and a new room starts with none half-flashed. The Pool and Boiler Room have no windows and stay dark. The Rooftop's rain never stops, so a special grade there gets the Yard's outdoor bolts (the full flash, sky and fog, scatter and key light), though it has no shower clock (roofStorm). Gated, like the Yard's, on the Screen Shake setting and never behind the title. test/window-lightning.test.js, and the room check in test/environment-kits.test.js.

Rain that lands — yard-graphics.js (2026-10-10). The six ambient drop rings land in the water in the near half of the frame (|dx| ≤ 15, −13 ≤ dz ≤ 6 from the camera target) instead of anywhere in the yard. Measured offline on the real mask along a running path: about 6 of 6 on screen and 3.8 within 12 u of the hero, up from 1.6 and 0.56. Sites are the wet mask sampled once on a 0.625 u grid (≥ 0.15), re-filtered into a typed array when the target has moved 2 u. Drops use their own seeded stream (rippleRand) and fall back to the old pool pick only if three tries miss water. The downpour plays them 2.3× faster (SHOWER_RING_RATE) and 0.1 stronger. Every ring now has a thin crest rim (RING_RIM 0.42) and bends the reflection about 3× harder (RING_WOBBLE): drops, footsteps in the character's colour, and Domain or slam waves. The water shader skips idle slots and any slot farther than max(5r, 0.0274) uv. Past that point every term is under exp(−5) of its peak, so there is no visible edge. A wet fragment now runs the full ring maths for about 0.5–0.7 of the 10 slots instead of all 10. Phone ALU on the water goes down, with no new draws, uniforms or textures. View only: no sim state, no sim rng. test/yard-graphics.test.js.

Wet cells — yard-graphics.js wetCellGeometry (2026-10-10). The Yard's water used to be one 80 × 80 plane. That held for both the planar Reflector and the clearcoat MeshPhysical fallback used without float render targets. Every ground pixel on screen ran the water shader only to fetch the mask and discard, though about 15% of the mask is wet. Both layers now share one mesh, built from the mask at load. It is a 64 × 64 grid of 1.25 u cells that keeps every cell with a texel ≥ 4/255 within 0.625 u of it, emitted as one strip per horizontal run. Bilinear reach is 0.08–0.16 u, and the gameplay camera magnifies the mask even at DPR 1, so the margin is about 4× what it needs to be. Positions are the Reflector's local XY and the uvs are PlaneGeometry's, so the shaders, the alphaMap and the uv uniforms are unchanged. Each run's edges are split wherever an adjoining row's run starts or ends, so there are no T-junctions, which would crack or double-blend pixels. Measured offline on the rasterized mask, the mesh covers 36.7% of the plane on phones (256 mask) and 38.5% on desktop. It is about 550 triangles in the same single draw and builds once in 2–4 ms. Water fragment work therefore falls by roughly 60% with no per-frame cost. The reflection capture, its cadence, the clip plane and hideBehindMirror are untouched, because the Reflector's camera reads only its matrix. If canvas readback returns a blank or full mask (blocked or faked), the full plane stays. yardWetCells.enabled = false brings it back at room build for browser A/B checks, and surface.waterCoverage reports the fraction. A far showroom shot that minifies the mask could lose a sub-pixel fringe past 0.625 u; raise margin if one ever shows. View only. test/yard-graphics.test.js.

Anamorphic streak — post.js STREAK. One more quarter-resolution pass off the finished bloom: a wide horizontal smear (fifteen taps) added into the composite with a cool tint, so lamp heads, casts and the hot lips throw a cinematic flare. Off on phones (streakStrength 0 skips the pass); the warm-up compiles it against its real target.

Low health — post.js lowHealth. Under 32% hp (full at 8%) the frame's edges drain of colour and a red vignette beats — a lub-dub of two gaussians on a clock that runs faster the lower hp gets — while the centre stays honest so the fight is still readable. Eased in over ~0.4 s from the live hp in sync; never on the title demo, cleared on death.

Lightning — view._lightning. During the yard's downpour (world.shower > 0.6), roughly one bolt every ten seconds: the key light spikes ×4.5 and the hemisphere ×2.6 for a couple of frames, the sky and fog lerp toward a pale blue, the screen flashes, and a second flicker follows 0.12 s later — the wet court glints because the wet grade is already on the ground. View-side Math.random, honours the camera-shake toggle, never on the demo.

Camera punch. view.punch(a) pushes the camera in by up to 7% and eases back over ~0.3 s: the Domain cast (1) and a boss arrival (0.8). The earlier chain-driven crowd glow was replaced on September 21 by per-enemy attack cues (see Curse shimmer above); uCurseVein now only gates the detail by graphics quality.

Dawn bloom — post.js dawnBloom(d). Driven by dawnMix alone (the tenth room's last minute, or the win): the bloom opens up (strength +30%, threshold down 0.07), the streak goes gold, and the composite washes the top of the frame with the sun below the edge. A mid-night hour never raises dawnMix, so the canon guard holds by construction.

Dust kick — the dash case. The floor remembers the push-off: a dust-coloured smoke ring on the ground at the origin and a dark scuff strip along the first stretch of the dash, both gone in half a second.

Skyline parallax and cloud drift — world.js, yard-sky.js. The yard's lit-box skyline sits in its own group that rides a third of the way with the camera target, so it reads as blocks away rather than just past the fence; the sky dome's cloud field takes a uTime uniform and crawls slowly.

Depth: contact shadows, depth of field, the room on the boss (2026-09-19). The scene target now resolves its depth into a DepthTexture (three blits the multisampled depth; desktop only, the phone target keeps its renderbuffer). Two reads of it in the composite:

The phone post chain is trimmed (2026-10-10). Phones already held every desktop-only composite input at zero, but the program still carried the code and bound seven samplers each frame, five of them three's empty placeholder. The picture on a phone is unchanged; only the work behind it shrank. Three changes, all in post.js, with desktop untouched:

Light scatter, afterimage, ink, the floor mirror, the damage ghost (2026-09-19). Chris: "make it like the most advanced threejs and webgpu system anyone's ever seen." Straight answer recorded here: the renderer is WebGL through three 0.185 with GLSL everywhere (the post stack, onBeforeCompile injections on the toon bodies, every kit's custom material). three's WebGPU renderer needs its node-based materials and cannot share a scene with WebGLRenderer, so a move is a rewrite of every shader in the game — weeks, not a push, and not before launch. What landed instead are the techniques people read as high end, each at vsync in headed Chrome:

Threat-local combat clarity (2026-09-23). src/combat-clarity.js reads at most 240 hostile bullets and forecasts their closest approach to the hero over half a second, including relative movement, collision radii, lifetime and Silk's quarter-speed flight. Held Conductor shots, friendly shots, expired shots and distant/receding near misses do not drive it. The strongest nearby threat wins; bullet counts cannot accumulate brightness changes. Pressure eases in/out on simulation time and freezes when gameplay pauses. It changes no simulation state, RNG, collision, saves or balance.

View._syncCombatClarity projects a soft 3.5-world-unit circle around the hero's upper body, following terrain height and camera zoom with a round screen-space footprint on portrait and landscape displays. Inside it, the existing Post composite reduces bloom by at most 35%, and streaks, scatter, light echoes and shock/haze/chromatic distortion by at most 75%, weighted by threat pressure. Scene geometry, attack markers, hero colors, scene lighting, grading, dawn and damage flashes retain their normal treatment. This reduces post-process glare. The September 28 extension below also fades selected friendly artwork before composition. Outside the circle, that artwork is unchanged.

This composite treatment adds no render pass, target, texture sample, shader variant or asset. The composite still warms before combat. Low graphics bypass it; phone rendering uses only its existing supported effects. Demo/menu/room, quality, character and result transitions clear old pressure; After Hours can use it while combat is running. Resize clears the old projected patch and the next live frame projects it again. Echo-only expiry must not clear the independently active clarity uniform. Focused controller/View/Post tests and test/combat-clarity.browser.mjs cover these contracts.

Selective friendly-effect clarity (2026-09-28). FriendlyClarity shares the same projected pressure pocket with sprite effects, friendly glow, domain floor/ ribbons/motes, hero impact stamps, shikigami accents and awakened techniques. It reduces their alpha by at most 78% locally in their existing draw. Hostile projectiles and maintained attack geometry are never patched; mixed sprite pages carry a per-instance protected flag for hostile windup rings. Pixel tests verify unchanged warning and distant-art colors on landscape and portrait. Low graphics also receive this particle protection without enabling Post. The helper adds no textures, targets or draws, and installs shader patches before safe-screen warmup. Existing combat-pressure logic and pause/menu reset rules remain authoritative.

Reactive room atmosphere and impact aftermath (2026-09-28). RoomAtmosphere adds two fixed instanced draws: floor wisps/source steam and small debris. Ten presets use Yard leaves, Gym chalk, Library paper, corridor drafts, cafeteria napkins, Pool mist, Detention slips, Boiler steam/cinders, Lab vapor and Rooftop windblown paper. Boiler source plumes originate beside existing equipment. A preset may name its own steam sources (steamFrom) and plume gain (steamGain): the cafeteria's steam rises beside the serving line (case) and the prep island (table) at gain 3.4. Before 2026-10-16 its steam .17 matched no prop the room has, so it had no plumes. Placement uses the authored floor and rotated obstacle footprints, with elevated-floor checks; no map or navigation edits.

Heavy attacks and domains lift nearby existing debris, then let it settle; there are no physics bodies, new combat spawns or scenery destruction. A clear hero pocket expands under incoming threat pressure, while ambient alpha falls. Maximum desktop counts are 28 wisps and 66 scraps; phones use about 57% and low quality trims to 34% of that allocation. These use no new textures, lights or passes. Drift stays inside prevalidated floor patches; only large impact excursions recheck floor, and paused frames skip debris matrix work.

CombatAftermath adds short-lived dark fractures, stitches, scorch branches and brush marks after heavy impacts. One instanced draw, at most 24 desktop/ 12 phone marks, a 0.28s event throttle and 3.4s lifetime. Marks cannot bridge voids, steps or solid props; low quality disables them. They do not resemble closed danger circles. Terminal/hidden/new-run/room transitions clear them, including same-sim After Hours. All three new effect modules warm before play, freeze with gameplay and release their resources with the view. Their main-scene drawing adds at most four instanced draws, with no new asset downloads. Steam envelopes clamp interpolated coordinates and fractional-power inputs: MSAA edge samples must not inject NaNs into HDR bloom. The HDR browser regression checks finite half-float pixels and newly black output pixels, not only GL errors.

Hero edge lighting (2026-09-23). src/hero-lighting.js adds a narrow, directional cel highlight inside the existing opaque hero Toon materials. The light follows the room's rim direction/color with a small character tint; bright skin and white artwork receive less lift than dark fabric. The original four-band ramp, textures, emissive hurt response, black ink hulls, geometry and animations remain intact. No new meshes, lights, texture samples or render passes. This is separate from the unapproved full graphics-ladder style experiments; their ramp/specular/colored-ink treatments stay on the bench.

Classic, hybrid modeled-head and retained full-chibi factories install the shader before crew preparation warms it. Appending a modeled head shares the rig's uniforms without repatching its body. View.render refreshes the room light even on held frames; its world direction is transformed by the actual draw camera in the shader, including reflection cameras. Low quality disables the uniform immediately; toggling quality does not recompile. Standalone rig previews default to zero strength until explicitly enabled. The material treatment owns no GPU resources and retires with its rig.

Grounded contact shadows (2026-09-23). src/contact-shadows.js feathers the alpha of the existing hero and crowd shadow discs from their circle UVs. The core retains its original opacity; the perimeter eases to transparent. There are no shadow textures, extra meshes, lights, render targets or passes. Depth testing, fog, existing footprints and resource ownership remain intact. This local grounding remains enabled on phones and low graphics, independently of the desktop compositor's contact AO. It does not darken the room globally.

Procedural/hybrid hero shadows counteract the root's animation bob, remaining at terrain height plus .012 instead of dipping beneath the floor at idle. Full-chibi/imported rigs already bob or animate inside a grounded outer root; their shadow position stays unchanged. Shadows never enter the hero mesh list used by hit flicker or dash ghosts.

The crowd retains its single 700-slot instanced shadow mesh. Rendered hover height broadens the footprint by at most 40% and reduces opacity; enemy rise and boss assembly gather the contact on arrival, including boss model fallback. One fixed float attribute per slot adds 2,800 bytes of storage, with only the live prefix updated. No simulation, collision radius, movement or spawn rule changes. Round-clear holds the same last opacity buffer while shrinking its captured floor transforms; the next crowd sync rewrites the live prefix.

Haze breath, per-room look, ember trails (2026-09-19). The Domain haze ring swells and relaxes on two slow periods and its band opens with the breath, so a standing field feels alive. Each room sets its own grain and vignette through post.look from View.ROOM_LOOK on setMap, applied before the early return for an already-built room (2026-10-16: a fresh session's Yard, built for the title before the first run, used to keep the defaults and no ink) — the boiler room (0.9 / 0.64) and detention grittiest and tightest, the gym (0.32 / 0.4) cleanest; phones keep grain off. This shader vignette is the only vignette (2026-10-10): the day-one #overlay-fx DOM layer in index.html (a CSS radial vignette plus 1px white scanlines every 3 CSS px at 3.5%) is gone. It stacked a second corner darkening (about 18% at the screen corners) on top of post.look, banded every third pixel row about 7 luma brighter in phone captures (lifting true black), and cost the phone compositor one full-viewport alpha-blended layer at native resolution (about 2.96M device pixels a frame on a 2532x1170 iPhone, plus its backing store). Low graphics, where Post is off, now has no vignette, which is acceptable on the performance tier. To restore corner weight, raise View.ROOM_LOOK vignette values (a uniform, free); do not bring back a DOM layer. test/overlay-retired.test.js holds the line. The gameplay feedback is different (2026-10-16): with Post off, the hurt flash and the low-health heartbeat used to vanish entirely. Post.render now draws one blended full-screen triangle over the plain render (LOW_FX_FRAG, sharing the composite's flash, flashColor, lowHp and pulse uniforms) only while either is live: the red edge pulse and the flash, without the colour drain, which needs a scene sample. It is compiled with the low-graphics warm-up. A graded body sheds a spark now and then as it stands or moves (every other pool slot, 12% a frame for grade 1 in ember, 22% for grade 2 in violet, drifting up on negative gravity through the existing shard pool), so the harder bodies read from across the court even in a crowd; measured with 100 graded bodies on the board, frame at vsync.

Captures (headed Chrome, ../_hallpass-archive/experiments/fx-2026-09-19/): the seven rooms before, the Domain wave and the hurt split mid-flight, a graded row live and at four points of the burn, the motes in four rooms, the beams in the gym and the library, the grade at 9 PM and 1 AM, the rise at four ages, the haze over a Silk field, the court dry and wet, the boss assembling, the hit tints on a row, the streak on and off, the low-health state over a charged crowd, a bolt over the wet court, the punch and its rest frame, the dash dust, the dawn bloom, the parallax pair, the ember trails, the boiler room's look.

Sustained mobile rendering (2026-09-12)

Defeated and prewarmed boss bodies detach from the scene between uses, so hidden skeletons do not accumulate per-frame transform work. Boss3D retains one idle model on phones or two on desktop, in addition to active/exorcising bodies and the requested next boss. Eviction disposes model-owned GPU resources; unused animation-file bodies and replaced PBR materials are also released. The shared toon gradient and the next boss's prewarm remain intact.

Shard/glow pools submit only live instances and upload only that prefix. Empty pools and unused flipbook pages skip recurring buffer uploads. Final reclamation uses a static tint path once complete, preserving its distant edge while dropping the dissolve's ongoing glow/soot calculations.

As of 2026-09-23, base and HD SpriteFx pages also densely pack their live GPU instances. Stable CPU handles, ownership, animation clocks and blending order remain unchanged; a private render index lets stop() hide the right instance immediately between update and render. Matrices, frames, opacity and cached linear tint move together. Only the live prefix uploads, so one long-lived effect in the final pool slot no longer submits every empty slot before it. Controlled sparse cases reduce 170 desktop / 110 phone instances to one, with 84 bytes of instance attributes instead of 14,280 / 9,240. Those are submission/upload counts, not measured device frame-rate gains. The later friendly-clarity flag adds four bytes per live sprite: the current layout uploads 88 bytes each (full capacities 14,960 desktop / 9,680 phone).

Flat rings, warning strips, boss sweep strips, palm sectors and the optional aim cone use transparent double-sided single-pass materials. Both faces remain visible, but Three no longer submits a separate back-face pass for each flat mesh. This changes neither telegraph footprints nor FX timing.

One ring draw, self-softening band (2026-10-10, src/fx.js). fx.ring() no longer owns a mesh per ring. The 28 ring slots are one additive InstancedMesh (fx.ringMesh), and update() packs only the live rings into its prefix, as the shard and glow pools do. Any number of live rings is one draw (a Domain cast was four ring draws, a death three, blast-weapon fights several), and the 28 per-ring materials with their uniform uploads are gone. With no live ring the pool is empty and EmptyPoolSkip skips it; it stays visible from construction, so the loading gate's warm draw compiles its program and a run's first ring cannot hitch. Easing, reverse flicker, fade, lifetime, recycling (the most-progressed ring when all 28 are busy), terrain height, colour and the 0.86-1.0 footprint are unchanged; slots keep .on, so view.js and the performance probe still count live rings. Opacity is folded into the instance colour: with additive blending into the linear scene target that is the same pixel as the old per-ring opacity. On the FX-off path (no post, drawn straight to the sRGB canvas) a ring mid-fade is somewhat brighter than before. The bounding sphere is refit to the live rings each update so transparent sorting keys on them as each ring mesh did; the mesh is never frustum culled. Mirrors: hideBehindMirror tests the union of live instances, so the ring draw is hidden for a capture only when every live ring is under the mirror plane. The band shape is a static per-row vertex colour, RING_PROFILE = [0, 1, 1.1, 1.25, 0] from the inner rim outward (waveRingGeometry, four radial rows). Both rims reach zero, so the band softens its own edges on phones, which render without MSAA, and stops stair-stepping as it expands. The interior never drops below the old flat band, so hostile cue rings (sweep wind, appear, windups) are at least as bright in their body; the outer row is a 1.25x leading lip. The band averages about 84% of its old light, all of the loss in the soft rims. If a telegraph reads weaker in play, raise the middle values; [1, 1] restores the old flat band exactly (a test pins it). Cost: 384 triangles per live ring instead of 96 (desktop's 64 segments: 512), the same fill, and at most 28 × 76 bytes uploaded per frame, only while rings are live. The flat fx.ringGeo stays for the cage, boss and loot rings that view.js builds from it.

As of 2026-10-06, the nine primary-projectile, hostile-bullet/halo and orbiting charm meshes also upload only their live matrix/color prefixes. Each update replaces pending ranges, so a pool that was hidden while shrinking or growing cannot upload stale ranges when it returns. Empty pools do not dirty buffers; capacity, transforms, colors, ordering and simulation remain unchanged. Yui's loaded needle sprite uses one double-sided pass, including in mirrors; its solid fallback and the three-dimensional cards/orbs retain their materials.

The twelve boss-drop weapon silhouettes in WeaponView also upload only the live prefix of their matrix, shape, phase, progress and tint buffers. A warmed occupied pool transfers 88 bytes per live projectile instead of all 261 reserved slots (22,968 bytes). Pending ranges are replaced on each sync; empty pools do not dirty attributes. The one draw, capacity, shader artwork and gameplay stay unchanged. Initial GPU allocation remains full capacity.

Fixed furniture and obstacle parts cache their local transform matrices after construction and reparenting. They no longer recompose the same position, rotation and scale on each scene/reflection traversal. Their editor pivots and world-matrix propagation remain live, so moving a prop or an ancestor still moves every part. Animated room elements, lighting, texture scrolling and asynchronous decals keep their existing update paths. This saves CPU transform work without reducing geometry, lighting quality or reflection coverage.

The same local-matrix cache covers the enhanced campus and fixed room-shell groups in the nine non-Rooftop environments. Cutaway review views still change visibility, materials still animate, and moving dust/mist remains in separate lighting groups. Rooftop architecture is deliberately excluded because its bell-tower hands rotate with the hour. Legacy room dressing, ground meshes, lighting and ancestor world-matrix propagation keep their existing behavior.

Gym and Library furniture preserve native vertex indices when merging parts by material. Rounded parts receive sequential indices only when sharing a bucket with indexed parts; entirely flat buckets retain their original layout. This reduces duplicate geometry buffers while keeping triangle order, hard normals, UV seams, individual prop transforms and camera/shadow culling.

Each pooled enemy's body, outline and eyes share one instance-matrix buffer. Normal crowd synchronization writes and uploads their transforms once; colors, geometry, materials and draw order remain separate. All three meshes have the same pool lifetime and are disposed together. Death dissolves copy the last transform into their own buffers, while the round-clear sweep retains its independent snapshot. Reflections and boss captures reuse the shared live transforms too.

Boss projectile pools hide whenever their final instance count is zero. This skips renderer setup for unused attack shapes and artwork without changing active shots, fallback routing or native draw counts. The existing loading frame temporarily reveals attached empty pools to prepare their buffers and vertex bindings with the real world fog and render target; no opaque dummy projectile is drawn. Room/run loading, low/HDR quality switches and context restoration rearm this discovery, which restores prior visibility even if rendering throws. Detached artwork retains the safe-screen upload/compile/transparent-prime gate before activation; that prime temporarily overrides hidden visibility too.

As of 2026-10-10 the static key-light shadow is 1024x1024 on every device; phones previously used 512. The map is still drawn only once per room build, quality change or review shot (key.shadow.autoUpdate = false), and its size is a uniform rather than a shader define. Phones therefore render the same programs and the same five PCF taps per shadowed fragment. Texels are now 0.06-0.09 world units across the 64-92 unit footprints, so lamp poles, rails and chair legs keep their shadows. The one-time depth fill draws the same casters, and GPU memory rises by about 6 MB (1024 depth plus colour targets). Bias, normal bias, PCF radius and camera bounds are unchanged; these values were already proven on desktop. Low effects still disables the shadow. This matters most on phones, which have no screen-space contact AO. test/world-lifecycle.test.js checks the size, static lifecycle and footprint for all ten rooms on both phone and desktop.

The frame-rate governor uses uncapped wall time during active gameplay and ignores menus, picks and resume gaps. Sustained pressure first reduces scene DPR to 1.25 then 1; if a phone remains slow, bloom uses one blur pair instead of two, removing two quarter-resolution passes from the compositor. This reuses the same targets/shaders, retains glow and leaves UI resolution and gameplay unchanged. It does not oscillate quality or reload during a run.

Trial and revert (2026-10-10). Fewer pixels only help a phone whose GPU is the bottleneck. Low Power Mode caps WebKit's rAF at 30 Hz, and a CPU-bound phone stays slow whatever the ladder does. Both used to ride the ladder to the bottom in about 11 s and keep DPR 1 with one blur pair for the session. Now every step (performance.js adaptQuality) is a trial. The mean frame time over the slow stretch before the step is compared with 3 s measured after it, following a 0.5 s settle. Frames longer than max(50 ms, twice the running average) are clipped, so a stall cannot pass for a gain. Steps taken since the last one that paid are provisional, and all of them stay once their combined gain reaches 5%. If the ladder bottoms out before that, the view returns to the last state that paid (view.restoreRenderCost), qualityReverts counts it and the ladder locks for the session. A 30 Hz phone therefore tries 1.25, 1 and one blur pair over about 15 s, then goes back to 1.6 with full bloom. Judging the steps together rather than one at a time keeps a phone on a step that pays even when the step before it landed on the same vsync interval (33 ms at 1.25, 17 ms at 1). A trial pauses through menus and picks. A new run or school room undoes provisional steps without judging them (settleQuality), since another room's frames are not a fair comparison. Changing the FX setting (resetQuality) restores full resolution and both blur pairs and clears the lock; before this, the blur pair was never restored. qualityDrops now counts only steps actually taken; it used to count every attempt at the bottom, about 16 a minute on a 30 Hz phone. Per-frame cost is a few scalar operations and no allocation; a step allocates one small object, and a revert costs at most one extra resize per session. The risk is a phone that recovers and then throttles: once locked it gets no help until the FX setting is changed. test/performance.test.js drives the governor against the real View with capped, CPU-bound, pixel-bound, vsync-quantized and mixed frame-time models. That cannot establish device behaviour. On an iPhone in Low Power Mode, the Performance test's windows should show maxDpr back at 1.6 after the trial.

Developer-enabled Options → Developer → Performance Test records a real-device run locally and exports frame intervals, CPU submission time, resolution, bloom passes and geometry/texture/program/boss-cache counts. CPU submission is not GPU time. test/performance.browser.mjs provides isolated browser checks; desktop Chrome phone emulation cannot establish iPhone Safari frame rates or thermal behavior. Its SOAK=1 mode bundles current source in memory and repeats fixed boss/FX scenes; PERFORMANCE_SOURCE_BUNDLE replays a saved baseline and PERFORMANCE_OUTPUT directs reports into the external archive. This is an accelerated renderer lifecycle check, not a real ten-minute game or balance measurement. No simulation, spawn counts, rewards or tuning change here.

Combat light and surface response (2026-09-12)

src/combat-lighting.js makes attacks illuminate the actual toon characters, floors and furnishings. The scene owns a fixed three shadowless PointLights on desktop or two on phones: two/one brief impact lights plus one travelling weapon light. Strong techniques take priority over throttled primary hits. Player weapons use their authored palette; boss arrivals/projectiles use the boss palette. The travelling light holds a live projectile reference and fades before selecting a replacement, with bounded scans of the rendered projectile pools. Boss deaths pulse on the exorcism's sever cue. After Hours keeps the clear flag (won) while gameplay continues: its travelling light still follows/acquires live sources, including restored saves. A normal stopped clear or death fades the light out as before.

src/impact-resonance.js adds short, broken energy filaments across existing opaque room surfaces after domains, heavy blasts/slams and exorcisms. It composes after DomainRoom and RoomReclaim material hooks, including instanced props, without extra geometry, textures or render passes. Two desktop/one phone pulse slots expire within a second; a major event can interrupt a weaker blast's cooldown. Water and transparent/custom materials retain their own shaders. Elevated Rooftop origins use the terrain height.

Both effects freeze during pauses and upgrade drafts. The stopped exorcism uses its presentation clock only while the controller allows it. New runs, room/character changes and After Hours clear the sources; event dispatch passes the current simulation before the first View sync. Low quality hides the light rig and disables filaments. Reduced motion disables filaments, softens pulse intensity/attack and limits frequent flashes. Lights keep a stable shader configuration during combat; only explicit quality changes hide them. This adds light/shader arithmetic even at zero intensity, so equal draw/resource counts do not establish equal physical-device frame rates.

Coverage: test/combat-lighting.test.js, test/impact-resonance.test.js and test/combat-lighting.browser.mjs. The browser fixture compares matched WebGL frames across character palettes, room materials and phone settings, including actual pause, first-event dispatch, weapon effects and exorcism.

Boss flame trails clamp the interpolated UV taper before fractional powers and discard transparent fragments. Multisampled edge pixels can otherwise produce invalid HDR values that bloom spreads into black rectangles. test/boss-projectile-alpha.browser.mjs checks that all nine boss trails only brighten the backdrop across direct, HDR/MSAA and mobile rendering.

Boss trail phase belongs to each projectile object in a view-owned WeakMap, not its current position in the simulation's bullet array. Removing an earlier shot or compacting/reordering that array cannot jump a surviving shot's embers or halo. Authored wakes and the procedural fallback share this identity, including reduced motion and ordinary visual clears on hero tags. Expired shots are not retained; no seed/state is written into saves, and simulation RNG, trajectories, damage and render-pool sizes are unchanged.

Round-clear presentation (2026-09-10)

The bell keeps the last rendered crowd briefly in place, then a seal in the student's color sweeps outward: curses lift and shrink into pooled sparks as the wave reaches them. The student settles out of locomotion/dash/hurt into an idle stance, turns toward the viewer, raises a fist with the seal's final spark burst, and gets a gentle 18% camera push before the Cleared card. This 2.25-second finish starts after any final boss exorcism; the win voice follows on its presentation clock, never a stray wall-clock timeout. Reduced motion uses a 1.25-second seal and uniform clear, without camera zoom, lifted bodies or release particles. Only the Rooftop's First Bell starts sunrise; earlier hour clears stay in their night palette.

src/round-clear.js snapshots existing instance transforms into reusable buffers, with four lightweight seal draws and at most 28 enemy particle releases (16 on mobile). No enemy models are cloned or loaded at the bell. The sim, reward calculation and commit are unchanged; the animation reads a separate stopped-player pose and never clears the sim's saved velocity. New runs, character resets and Push On release the presentation and restore normal camera framing. Coverage: test/round-clear.test.js, exorcism flow tests, and test/round-clear.browser.mjs on desktop and phone layouts.

Final room reclamation (2026-09-12): a Final also sends an amber/ivory dissolve front across the actual floors, furniture and walls. Practical fixtures settle at their authored brightness, beacons stop flickering and bounded spirit sparks lift from fixture positions. Quiz/Midterm retain their ordinary finish. The cleanse shares the existing 2.25-second finish after boss exorcism, with no extra wait. Reduced motion uses the existing 1.25-second finish with a uniform surface fade and no traveling front or spirit releases. Earlier rooms stay at night; Roof keeps its authored First Bell lamp fade.

src/room-reclaim.js composes with DomainRoom's material hooks and changes uniforms rather than loading assets or adding room geometry at clear time. Fixture releases use existing particle pools, capped at ten desktop/six phone anchors. A restored Final checkpoint silently restores the settled room; new runs, room changes and After Hours reset it without accumulating light intensity or replaying the finish. Furniture has no possessed animation to settle, and most clocks are static art; bespoke object/clock choreography is future work. The Roof clock continues its existing time-of-night behavior. Coverage: test/room-reclaim.test.js, test/room-reclaim.browser.mjs and the controller regressions in test/school-game.test.js.

Grime removal (2026-09-23): Final reclamation also erases the existing burnmark, sludge, spill, dust and foodmess decals as the architectural front reaches them. An explicit decal registration path handles their asynchronous texture loads, including a restored room that has already been cleared. The dissolve is per world-space pixel: several stains sharing one material can clear at different times. It preserves the texture's original alpha and opacity before the sweep, and leaves cracks, floor markings, papers, props, metalwork and puddles intact. Reduced motion fades the grime uniformly. Reset/After Hours restores the same authored stains. No additional geometry, lights, textures, draw passes or changes to simulation/rewards.

The legacy room-flavour decal sheet has four rows, not three. Its twelve named exports use explicit cell selections with clear gutters, excluding grid lines and neighboring images. A white-chalk key preserves thin math and tally strokes that the general magenta key erased. Rebuild only this batch through the bake_decals_e helper in scripts/bake-art.py; other art and the sound library do not need regeneration.

Boss exorcisms (2026-09-11)

Each defeated boss performs a 1.8-second exorcism in the arena, with a steady gameplay camera and no boss-kill hit-stop or forced slow motion. Its existing 3D body burns away in place, including its ink outline, without stretching or substituting a ghost/mask. At 0.55 seconds a layered flame-and-lightning rupture erupts: a custom Higgsfield energy flipbook, licensed Envato impact/vortex arcs, and 3D comet trails. Paper, metal, embers or spirit ribbons scatter in that boss's palette and pull inward. At 1.35 seconds they seal, accompanied by the last of three synthesized sound cues. The boss music then returns to the night bed only if no other boss remains alive. The seal also clears boss-music state during the round-ending presentation. Push On and After Hours clear both game/audio boss flags before restarting music, so an evicted level bed can reload without replaying the previous boss cue. The existing level track and its saved playhead are retained. The burst fits beneath the HUD at its starting camera position; the boss-kill recoil retains its real radius with quieter rings.

A real awarded weapon grows from the remains from 0.8–1.35 seconds. The view never invents a reward or changes its outcome, collection rules or economy. Nearby weapon inspection and automatic upgrade screens wait until the full sequence finishes. Explicit Upgrade presses still take priority. Pause and Options hold the exorcism clock so its final cue cannot be lost behind a menu. The final boss's hour/First Bell cue, dawn lighting and results wait for the exorcism; a player's death retains its normal priority. Immediate level mode still stops the simulation by its existing rules while the presentation finishes before opening the draft. Default bank mode keeps combat moving.

src/boss-exorcism.js uses three reusable effect slots, shared geometry and instanced debris (42 desktop / 24 phone; low quality 20 / 14; reduced motion 12), plus 108 desktop / 56 phone spark paths with four tail samples each. Reduced motion uses 18 sparks without tails, less expansion and softer layers; low quality reduces spark paths to 60%. The four flipbook layers share one pooled atlas draw and the exorcism clock, so every layer freezes with a menu. assets/fx/exorcism.json records provenance and cuts. The 384px desktop cells occupy a 3072px page (1.84 MB download / 36 MiB GPU); phone cells are 192px on a 1536px page (587 KB / 9 MiB). python scripts/bake-exorcism.py regenerates these on demand from the ignored source and existing licensed HD accents. Luminance keying, feathered edges and transparent gutters prevent black boxes and bleed. Textures upload and materials compile for the active render target at startup. src/boss-dissolve.js installs the body/outline shader before boss prewarming; death only advances uniforms. Boss3D.holdForExorcism lends an already displayed body, returns it at the sever, and clears the dissolve for reuse. No kill-time model/video requests. Reset/disposal releases outstanding bodies and FX handles. The simulation, RNG, boss schedule, damage, XP and loot tables are unchanged.

Janitor / Pop Quiz signature pilot (2026-09-23): when both of a boss's painted pages are ready, its death replaces the four shared flipbook layers. The Janitor opens a green water crown, scatters tapered droplets and drains them into the reward. Pop Quiz opens an ivory/red paper arc, tumbles folded sheets and contracts into an ink seal. The borrowed body, audio family and 0.55 / 1.35 / 1.8-second release/seal/completion timings stay unchanged. Readiness is latched at death: missing or late artwork keeps the complete original performance, never a mid-death swap. Other bosses keep that original recipe. These are engine-animated still paintings, not generated video.

src/boss-signature-fx.js owns four shared instanced pages, three simultaneous records and at most two painted layers per boss. Subdivided surfaces bow in depth and erode from their edges; ground art respects world depth. The same exorcism clock drives everything, including stopped-Sim round endings and pause/manual drafts. Reduced motion removes surface warping and rotation, shrinks the art and lowers its opacity. The pilot reduces its spark paths to 45% of the original budget. Debris reuses the existing instanced draw, with its two new geometry buffers prepared behind the run loading card.

Four 512px RGBA pages total 251,232 bytes / 4 MiB GPU; phone 256px pages total 81,238 bytes / 1 MiB GPU, without mipmaps. Loading callbacks only decode; View's safe-screen queue uploads, compiles and primes each page before activation. scripts/bake-boss-signature-art.mjs removes the black matte, preserves authored RGB and adds clear gutters; assets/fx/engine/boss-signatures.json records source jobs and selections. Reset/eviction/disposal retires painted layers along with the owning boss record. No new combat network requests, video decoder, simulation rules or rewards.

Coverage: boss-exorcism visual/audio/flow unit tests, retained-body lifecycle tests in test/boss-warmup.test.js, and the actual-game WebGL captures in test/boss-exorcism.browser.mjs (artifacts in ../_hallpass-archive/). The pilot also has signature lifecycle/alpha tests and the controlled real WebGL comparison in test/boss-signatures.browser.mjs.

Atlas frames are 128px (64 mobile). Past ~9 world units use geometry (fx.ring/burst/glow), not sp.play — a stretched frame reads as a smear. Player-triggered FX must never occlude the fight; enemy-triggered FX may.

Boss facing (src/boss3d.js, fixed 2026-09-03). The sim's yaw is atan2(dx, dz), which is the same convention as three.js rotation.y for a body that faces +Z at rest. The instanced primitives (eyes on +Z) and the Meshy sculpts both apply e.yaw directly; YAW_OFFSET is a per-model trim, zero for the current library. The previous -e.yaw + π/2 was a formula for a yaw measured from +X and pointed every sculpt along (dz, dx): right only on the x = z diagonal, sideways along either axis. If a new sculpt arrives facing some other way, trim its YAW_OFFSET entry — never mirror the formula.

Screen peaks — post.js shockwave, flash (2026-10-17). Measured first with scripts/fx-census.mjs (the bot fighting, drawing off): a Yard Final (150 s, 360 kills) and a stressed Second Floor (a boss every 20 s, Domains) showed big moments mostly well apart (2 strong moments within 0.4 s of another under stress; dashes and the Yard's double lightning are the other close pairs, by design) and at most two strong flashes in any second. The one real stack: several level-ups in one frame each started a shockwave at the same spot, and the composite adds every live wave's push, so the warp tripled. Now a wave asked for at the same spot within 60 ms joins the wave already there (strongest request wins), and when all three slots are live the weakest wave makes way while a newcomer weaker than every live wave is dropped, so a small wave never cuts a Domain's or a boss's short. A flash ceiling backs the measured headroom: a flash rising past FLASH_STRONG (.2) is a strong flash, at most FLASH_MAX_PER_SECOND (3) in any second; a fourth stays a tint. test/peak-guards.test.js.

Bosses watch you — src/boss-look.js, Boss3D._watch (2026-10-17). Every rigged boss (the Meshy library shares neck and Head) turns its head toward the player on top of whatever clip plays: 40% of the turn in the neck, 60% in the head, at most 60 degrees either side of the body's facing and about 31 degrees down. The turn is a world-space rotation applied after the clip and the body's transform, so it works whatever the bones' rest axes. The gaze eases in world space at 7/s (14/s during a committed strike) and is then measured against the body, so a body swinging round at the sim's 10/s never carries the eyes past the player. A frame without a mixer step restores the clip's pose first, so the turn never compounds; a recycled body forgets it. Past ~130 degrees the target is behind and the head comes back to centre. The searchlight bosses (Hall Monitor, Parent) only look once they have caught you, because their searchlight is their gaze (MODELS.*.look); in its school encounter Parent-Teacher Night's eyes are separate markers, so there it always watches you. The clip's pose is put back on neck, Head and Spine01 before every mixer step (_restoreRig): a mixer writes a bone only when its value changed, so under a frozen clip (a planted attack's timeScale 0, a finished one-shot held on its last frame) the turn would otherwise compound and the head spin (found in review; measured: half a turn a second without, 0.002 rad with). The view hands the player's position to boss3d.lookTarget each frame. test/boss-look.test.js.

Bosses take the hit — src/boss-flinch.js, Boss3D._flinch (2026-10-17). Every blow on a rigged boss rocks its upper body away from the blow (the hit's knockback direction) on a damped spring at Spine01, on top of whatever clip plays and before the head look. A blow adds FLINCH.kick (1.6 rad/s) times the square root of damage over 15 (clamped to 0.6-1.8), times 1.8 for a crit: Ren's palm leans a boss about 5 degrees, a heavy crit about 11, and nothing piles up past 17 (FLINCH.max, .3 rad). It peaks in a tenth of a second and settles in about a third with a small rebound (omega 14, damping .42); blows from opposite sides cancel; a committed strike (a one-shot attack clip) gives half as much. The hurt clip still marks a big hit (a crit or 4% of max HP, once per 3 s); the flinch gives every hit its direction. The lean is a world-space tilt of the bone, so it works whatever the rig's rest axes; a frame without a mixer step restores the clip's pose first, so it never compounds; a recycled body forgets it. The view hands every boss hit event to boss3d.flinch. test/boss-flinch.test.js.

Off-screen boss — ui.js _bossEdge (2026-10-17). A living boss you cannot see gets a red dart at the screen edge pointing to it, its name (without "THE") beneath, pulsing; once it walks into view its label and bar take over and the dart hides. The point is the boss's screen direction from the centre carried out to a rectangle inset BOSS_EDGE (62 px from the sides, 40 from top and bottom); behind the camera plane the projection mirrors, so the direction flips. CSS clamps the dart clear of the notch, the top HUD (--hud-clear-bottom) and the bottom safe area, and the boss-intro hide covers it like the labels. It is drawn as a notched dart (clip-path), not a border triangle: 15 by 18 px is nearly equilateral, and turned toward a corner the old triangle read as pointing down. No layout reads (the view's size is known). While the boss is winding up or striking (BOSS_STRIKING: windup, charge, active, sweep, slam, cast; school encounters set the same states) the dart hurries: it brightens to orange-red and pulses three times faster (0.36 s), a warning from out of view; the class is written only when it changes. test/boss-edge.test.js.

Detention's clips (src/boss3d.js MODELS.detention, 2026-10-15). He keeps his Meshy body and walk. Four more clips are Tripo preset motion retargeted onto that skeleton (scripts/tripo-boss.mjs game, src/rig-retarget.js; docs/PIPELINE-3D.md "Tripo API direct"). All of it is view-only and reads sim state without writing it:

Every rigged boss shares the hurt dip and the fall (2026-10-15). The Janitor, Hall Monitor, Substitute, Group Project, Parent-Teacher Night and the Vice Principal carry Detention's hurt and fall, retargeted onto each boss's own skeleton (scripts/tripo-boss.mjs share). The same HURT and FALL settings apply. The dip never starts during a one-shot attack clip (the Janitor's swing, the Substitute's and the Vice Principal's strikes, Detention's throw). Pop Quiz and Exam Season are unrigged floaters and keep their procedural hover.

Damage numbers — triage (2026-10-10)

fx.number draws from a fixed pool of DOM labels: 34 on desktop, 24 on phones. The pool used to drop any number that arrived while every slot was busy, and plain filler hits filled it, so seeded headless nights lost about one crit label in nine (Yui fresh, seed 777: 598 crits, 75 pickup labels and a hurt number dropped in 720 s). Each label now carries a rank, taken from the exact class string view.js passes: plain and big hits are 0, crit 1, pickup labels (heal, mana, frag) 2, hurt 3.

On the same Yui night at phone settings, crit drops fall to 3 and pickup and hurt drops to 0. The live labels peak at 24 instead of 34 and average 11.4 instead of 13.9, which cuts the per-frame transform and opacity writes in projectNumbers. The eviction scan covers at most the 24 or 34 slots and runs only when a crit, pickup or hurt label finds the pool full. Both caps are constants in the Fx constructor. Covered by test/damage-number-priority.test.js.

Faceted shards, glow cores, ember-spark height (2026-10-10)

Shards (kill, hit, blast and chill debris, the weapon chips and the ember trails) come from shardGeometry() in src/fx.js. Each of the tetrahedron's four faces has a fixed shade, SHARD_FACES = 1.25 / 1.0 / 0.8 / 0.6. three's color_vertex multiplies that shade into the per-instance tint, so a tumbling chip flashes a bright face as it spins instead of reading as a flat speck. The mean is 0.91, so overall brightness stays close to the old flat chip. Phones build the chip at radius 0.17 instead of 0.11, which is 1.55 times larger. At 0.11 a shard drew only a few CSS px on an 844x390 landscape screen. The shade is a static attribute uploaded once. The extra phone area is opaque and depth-tested: even a full 260-shard pool adds only about 1% of a phone frame in opaque pixels, and the instance count does not change.

Glow core. The glow material (hit flashes, muzzles, kill blooms, pickups, chain arcs, trails) goes through applyGlowCore(). After color_fragment, the soft disc's own alpha (a^6) mixes the colour toward its brightest channel at GLOW_CORE = 0.5, which is the same strength as the inked sprites' uWhite. The result is a white-hot centre that reaches half strength a tenth of the way to the rim and about 1% at the disc's 0.55 shoulder. The core never adds energy past that brightest channel, and it fades with the glow. Draw calls, instance uploads, blending and program count are unchanged. The fragment gains about five ALU ops on a quad that is limited by additive fill. The hook must be set in the Fx constructor: FriendlyClarity.attach in view.js chains onto it, and the cache key becomes fx-glow-core-v1|friendly-clarity-v1:0. If owner-coloured hits look washed out, lower GLOW_CORE.

Height belongs to Fx. Fx.update adds heightAt to every shard and glow, so callers pass a height above the floor. The graded ember spark used to add heightAt as well, and on the Roof's Bell Walk (deck height 3) its sparks floated 3 units above the bodies. test/fx-particles.test.js covers the facets, the shader chain, and the single lift. It also fails if any fx.shard/burst/glow/chain line in view.js mentions heightAt.

Audio — src/audio.js

The September 15 event/asset coverage audit and remaining voice-production work are recorded in Audio Cue Review. Its BEFORE matrix describes revision 14710cb; implementation and verification are tracked separately so historical missing hooks do not become current rules.

Sourcing inventory (2026-09-15): Audio Cue Sheet and the public sound library separate runtime status from production work: existing cues needing new textures, unused takes needing selection, and proposed missing sounds. assets/library/cue-sheet.json is the editable audit snapshot. Its versioned previews and historical reference exports are collaborator assets; the game and native payload do not load them. render-audio-cue-sheet.mjs updates the text guide only, on demand, and never generates audio. Future recorded replacements must preserve the playback/priority contracts below.

Anime auditions (2026-09-16): The library offers three source directions for the Janitor entrance, each hero's primary weapon and proposed non-vocal cues: Blade & Breath, Cursed Resonance and Kinetic Energy. Room ambience has three location alternatives. The 81 previews are in assets/library/auditions/anime-20260916/, with source prompts and sanitized generation/mix provenance. They are loaded only on playback in the library, never by the game. cue.auditions is separate from the current cue.preview; generating a candidate does not change runtime status. A browser-local shortlist uses hallpass_audio_auditions_v1, never the game save, and exports exact take IDs, hashes and listener notes as JSON. Native asset discovery explicitly excludes assets/library: the runtime SFX manifest contains provenance paths there, which are source credits, not loads. Selecting a take does not install it. Trim selected weapon sources for their actual firing cadence and audition against music before integration. Janitor's current crashing entrance is marked for replacement, awaiting that selection.

Bounded combat cues (2026-09-15): src/combat-audio.js supplies short procedural fallback recipes through Sound.combatCue(kind, { id, pan, gain }), using the existing noise buffer and SFX/master volume. Six mono recordings now replace the four shikigami arrivals, Janitor arrival and shared loot reveal. assets/sfx/combat/manifest.json owns files and mix gains. They preload once through the serialized audio decode queue (about 2.1 MiB at 48 kHz); event playback never fetches, decodes, or waits. Missing assets retain the procedural fallback. Each recorded cue uses one source and obeys the same budget/cancellation rules. Four shikigami and nine boss entrances have distinct recipes, with midrange detail as well as low impact. Warnings share a cooldown; ordinary accents have per-family limits. Game selects the nearest actual warning event in a batch, breaks ties by event order and derives stereo position from the current camera, not raw world X. Panning is clamped to ±0.8 and falls back to centered output when unavailable; visual telegraphs remain necessary and sufficient for muted/mono play.

Boss loot uses one chime at the exorcism seal, including a final-bell kill. A pooled local light pillar, compact level-ray artwork, halo and sparks mark the prize. Cards/weapons grow from the death effect; a large heart waits 1.8s before magnetism, and that hold survives snapshots. Checkpoints release it. Pause freezes the reveal; reduced motion omits the pillar and flying particles. There is no new camera shake, fullscreen flash, timer or gameplay reload.

The new layer counts at most 16 source voices, including delayed notes and sources retiring after cancellation. Minor cues may occupy at most eight, reserving capacity for warnings, boss entrances and shikigami/KO events; at most two warning groups coexist. Sources and their filter/gain nodes disconnect on native ended, and a group's shared panner/bus disconnect after its last source. Pause, time-choice menus, mute/zero SFX, title and explicit stop cancel new cue tails with a short fade and a 15ms stop, including notes scheduled in the future. They remain counted until ended and are never replayed on resume. This cap is separate from the existing boss-score cap and does not claim to limit all legacy SFX/VO/music to 16 sources.

Legacy crits now have a 65ms gate, large kills 85ms, and each pickup family 65ms (hearts 160ms), so a crowd clear does not create a voice per reward/hit. The existing normal-hit and small-kill limits remain. No hit-stop or combat balance change belongs to these sound limits. Verification status and the final event coverage are maintained in the Audio Cue Review above.

Crowd kills and stereo lanes (2026-10-10): the 40ms gate let one small kill through per frame, so clearing twelve curses sounded like killing one. Game._dispatchEvents now counts a batch's small kills (not dawn, boss, r > 0.9 or crew-strike kills) in its pre-scan and calls Sound.kill(false, { count, pan }) once, at the first one. With m = min(1, log2(count) / 3) (1 → 0, 2 → .33, 4 → .67, 8+ → 1) the noise runs longer, louder (+60% at 8+) and darker, the triangle drops a quarter in pitch and runs longer, and from three kills a short 80→38Hz sine thump joins. A single kill sounds exactly as before. The gate is unchanged, so small kills sound no more often than they did. Big kills keep their recipe and their per-event call. Hits keep one gated call per event. Each kind (normal, crit) reuses the pan of its first hit in the batch, because only that hit can pass the gate. The game never calls s.hit/s.kill for crew-strike batches, as before.

Game._eventPan takes view.audioPan (camera-relative, ±0.8) and scales it by distance over 8m, so kills at the player's feet stay centred. Small kills use the mean pan of the batch. Sound.init builds three persistent lanes once: left and right are a 1.3x gain into a StereoPannerNode at ∓0.4 feeding sfxBus, and the centre lane is sfxBus itself. A mono voice through a panner at 0 would lose 3dB, which this avoids. _lane(pan) maps |pan| ≤ 0.2 to the centre and anything wider to a side. Side voices hold total power within about 1dB of centre (near side +1, far side −4.6dB). Without createStereoPanner (iOS < 14.1), every lane is sfxBus, and sound.lanes = null turns positioning off. Cost: four idle nodes per session, which propagate silence. There are no extra nodes per voice. The only added voice is the 2-node thump on batches of three or more small kills. Only the hit/kill callers in the event dispatch pass a pan. Strike, cellcrush and the other legacy SFX stay centred.

The authored catalog is assets/music/catalog.json; its source/edit recipe is scripts/music-arrangements.json. Read the current section of Music Engine before changing either. Eight night beds include all seven Between Bells sources plus Night Drive; Gangsta is menu-only. WOW, Boss 2–8 and nine short publisher cues rotate across boss encounters, with one generic fallback song. First passes preserve roughly two to three minutes of each song before returning to a prepared bed; selected body lengths are approximately 38–77 seconds (menu: 25 seconds).

The baker blends the incoming preroll into the outgoing tail. The first entry stays natural and the repeat retains its exact frame count. No guessed encoder padding is trimmed in the runtime. A cyclic tail guard supports MP3 encoding. One 256kbps stereo version serves every device. Revisioned URLs and matching loop-tuner overrides prevent cached old timings from altering new arrangements.

A shuffled bag visits every bed before repeating. Playback resumes its saved position after bosses, including first-pass progress. MusicPlayhead integrates turntable speed ramps so returning from a level-up menu cannot move the reported position away from what actually played. Audition always starts at the top; FIRST HANDOFF and LOOP JOIN seek to their respective transitions.

Decoded PCM is the memory budget. Music is lazy: title only at boot, chosen bed at roster, short boss cue as a boss approaches. Single beds cost 44–67 MiB at 48k, and regular boss cues about 4.1 MiB. The two-entry music cache scans past protected playing entries; a fading source retains its buffer until its audio- clock stop, then disconnects. audioReport() includes music, ambience, SFX and VO; do not confuse compressed MP3 size or single-track size with total memory.

Pause remains music-only: no added drone or bowls, and no music ducking. School ambience can duck separately. The LOOP tuner stays available while music plays; its stored overrides are valid only for the matching asset revision. watchAudio() distinguishes audio-clock stalls from musical changes. Guarded fetch-then-play handoffs keep the current source audible while a destination loads, discard stale completions, and recover from missing optional stage cues. setIntensity() still affects the procedural fallback, not the authored songs.

Music duck (2026-10-10). duck(sec, depth) lowers the music under voiced lines and big moments through one duckGain between musicBus and the compressor, so songs, the boss-score layer and the title track all sit under it while SFX, VO and ambience do not. It is separate from musicBus.gain, which keeps volume, pause, mute and fades. Depths: voiced lines 0.55 (about -5 dB, for buf.duration + 0.5 capped at 2.4s; dash/hurt pass duck=false, and a line skipped by the one-talker rule never ducks), domain 0.4 (-8 dB, 1.4s), bell and win 0.5 (-6 dB), Threadbreaker and seal break 0.7 (-3 dB, ~0.2s). Attack time constant is 30ms; release returns to unity over about 0.75s. A lighter or shorter duck never lightens or shortens a running one. Before this, duck() only set duckUntil, which only the procedural fallback read, so streamed songs never ducked; the fallback's per-note 0.35 factor was removed because the bus duck now covers it too. Cost: one GainNode, plus a few automation events per call. No per-frame work.

Reactive boss score — all nine bosses. At 40% HP, each boss adds its own small percussion/impact palette, synchronized to measured sample positions in the currently audible song. The v2 assets/music/boss-score.json covers all 17 rotating cues plus the generic fallback; songs and loop points are unchanged. Boss identity chooses texture, while track revision/hash and exact loop bounds guard timing. A missing song can use the fallback's score; custom loop overrides that differ from the authored bounds disable the layer. Eight shared mono clips add about 389 KB download / 1.5 MiB decoded at 48 kHz through the existing serialized preload queue, with no additional full-song cache entry.

The last living boss triggers one short defeat ending, even if multiple bosses fall together; defeating only one overlapping boss does not end the encounter's music. The ending may finish over the returning level bed. Gameplay never waits for a beat. Pause, parked platter, mute, audition, title and explicit music stop cancel effects; normal setBoss(false) and track handoffs preserve the ending. Scheduled accents follow current boss focus and source, with at most 12 active/ retiring voices. Playback-rate changes are included in clip retirement times. The Janitor/WOW pilot's original clips and event timing remain intact; expansion is authorized but listener approval and optional boss voice casting are pending.

Music faults and the WATCHDOG (2026-09-01)

Two bugs Chris hit, with one root cause: _playTrack silently no-ops when the track is not decoded, and nothing ever re-checked.

_ensureAndPlay(name, fade, wantBoss) is now the single entry point that survives an undecoded track: it plays whatever is audible as a stopgap, awaits ensureMusic, and re-checks conditions at landing (the moment may have passed — another boss, the run ended, muted). setBoss(true) uses it and only evicts the bed after the cue is actually on air, so the stopgap is never deleted out from under itself.

The watchdog (Chris's suggestion) runs inside watchAudio() every ~2s: compare what is on air against _idealTrack(bossMode) and correct it. It deliberately compares against _idealTrack, not _trackFor, whose whole job is to hand back a fallback — comparing against that would call the bug "correct". Every music fault so far has been a transition that silently did nothing; rather than chase each cause, this notices the wrong state.

Must stay true: _idealTrack answers "what SHOULD be playing", _trackFor answers "what CAN I play this instant". Keep them separate.

Verified live: bed evicted then retried → silence, then re-fetch lands on the bed with a live source; boss cue undecoded → bed as stopgap, swaps to the cue on decode, bed evicted only after; and a forced wrong state corrects itself inside two watchdog ticks.

First-run and return flow

The first deliberate run opens a frozen FIRST LESSON before Sim exists: move, aim/fire and Flash Step, followed by one explicit ENTER THE HOUR action. Reading instructions can therefore spend neither the run clock nor HP. Once seen, save.hints.controls suppresses it permanently.

Every non-daily start stores a validated save.lastRun setup. With no live autosave, the title exposes RUN AGAIN for a one-tap return to that student, party, room, period and tier; a resumable run always takes priority. Resume copy uses the snapshot's exact mm:ss, room, hour and period rather than placeholder minutes.

Clear counters are deliberately separate. runsWon counts successful period runs and keeps deprecated wins synchronized for older clients/cloud rows. nightsCleared and firstBellBy advance only on the Rooftop, the sole 6–7 AM hour. clearedBy remains the per-student room-FINAL record used for the early second-seat unlock.

Tap-to-select roster and visible preparation (2026-09-07)

The roster has no left-hand register or Join buttons. The top bar says CLICK WHO TO TAKE; tapping a portrait selects that student, raises and brightens their card, and marks their place in the team. The first selected student is captain. Tapping again removes them; the next member becomes captain. Selection is explicit, with no automatic bench fill. A full team asks the player to remove someone before adding another. Solo/daily selection can replace its one student and also waits for the pinned Next/Start Daily action, so preparation can be reviewed before proceeding.

Landscape displays all four portraits in one centered row, sized from the space between a fixed header and footer. Portrait phones use a two-by-two grid. The next action and optional paid club-seat action remain visible; neither depends on scrolling past the cards. CSS uses dynamic viewport height and safe-area insets, retaining 44px interactive targets on short screens.

Selection-card simplification (2026-09-15): portraits keep the student's name and two compact equipment icons: the loaded elective and their shikigami. Mastery/rank bars, build totals, deck counts, and repeated slot labels are off the card. Equipped electives use their existing technique artwork; summons use the same character-specific silhouettes as the combat HUD. Empty dashed slots retain a plus cue. Tooltips and accessible names identify each slot and its equipped/empty state, and tapping it opens the matching upgrade category. A small top-right info icon opens the character's full stats, upgrades and mastery; Club Deck remains in the footer, with its existing crew selector. Selection/captain marks and locked-student prices remain visible. Successful purchases, loads and clears update the icons without changing the chosen crew. All icon controls retain at least 44px targets. Progression, prices, seat limits, card ownership and combat rules are unchanged.

Menu art direction (2026-09-06): assets/menu.css gives each major decision its own composition: selectable portrait posters with visible preparation, illustrated technique modules, a held-arena pause screen, student dossiers beside roster portraits, and incident/clearance reports. The interface uses a dark cyberpunk palette: near-black surfaces, cyan technical accents, luminous character colors, and restrained scanlines. Menus use the supplied Broaven normal/oblique WOFF2 faces; the Averey Hall Pass wordmark and Ghostin Jingle club lettering stay distinct. Gameplay HUD lettering is unchanged. Main-menu and result actions retain slanted buttons, hard shadows and vivid accent fills. Options uses open category tabs and controls with explicit green ON / red OFF states. Character details reuse each roster card's portrait without loading a domain film, and retain the 30/70 art-to-upgrades landscape split. Portrait phones place the artwork beside the calling-card text above the scrolling upgrade body and pinned actions. The roster next action stays pinned below the centered portraits. The original art is retained; scripts/bake-menu-art.mjs rebuilds 1080×1440 portraits and 1920×1080 cutins from the existing masters. The production build copies the menu stylesheet, and the local single-file build embeds it. No combat HUD, controls, particle effects, simulation or reward rules change with this presentation layer.

Round-clear decision (2026-09-15): Room clears and school checkpoints share a compact portrait card: clear title, Coin haul, a short continuation/risk line, Keep Going, and Return To Club. Keep Going invokes the existing Push On / next-room route action, or After Hours when no push is available; it never silently starts a fresh build. A completed school/partial route only offers Return To Club. The ordinary-room return now opens the clubhouse roster, matching school extraction, rather than the stage picker. Normal banking, boosts, found-card receipts, route validation and autosaves are unchanged.

Run Details is a small link beneath the card, with a ground-drop count when relevant. It replaces the decision screen with the full stats, star grade, assignment rewards, found-card receipts and secondary actions. School details include crew HP/weapons, drop inspection and Suspend Run; room details retain Share and any offered New Run. Closing it (including Escape/controller Back) restores the same card and focuses its details link. Merely opening or closing the report never commits rewards or advances the simulation. Both views dim the frozen scene; details scroll separately on small displays. The main card keeps its two actions visible without scrolling on tested desktop, tablet and phone viewports down to 320×568 / 568×320. Death/revive and the school route map keep their existing layouts.

Capitalization review (2026-09-09): Menus, buttons, technique names, character cards and HUD labels use mixed case rather than CSS-forced capitals. Authored interface labels are mixed case too, including updated/disabled states. Broaven remains in use for this comparison. Technique titles scale to their card width (18–30px) so long names fit without mid-word breaks. Proper character and technique names retain their authored spelling; HP, XP, CE, SFX and input abbreviations remain recognizable. The Hall Pass wordmark keeps its branding. The menu browser audit checks every technique/evolution title for text escaping its card, in addition to normal menu navigation and action reachability.

Build clarity (2026-09-28): The level-up draft attaches a small recipe link to a technique only when its next rank advances an unfinished evolution. It shows the nearest relevant recipe and ranks still needed, or "Completes recipe" for the final prerequisite. This is a preview, not an automatic evolution: the player still chooses its golden card in a later eligible draft. The inspector shows the behavior change, each required technique and its current/projected progress. Already learned evolutions and unavailable Second Heart recipes (school runs or an existing revive) are omitted. These rank-based evolutions are separate from the two-card primary awakenings above.

The recipe link is separate from the selectable card. Inspection freezes the same draft, isolates keyboard/gamepad focus and blocks number-key picks until closed. Back returns focus without rerolling or spending a rank. The helper in src/evolution-progress.js reads existing definitions and live ranks; it does not change draw weights, RNG, simulation, saves or combat versions.

Boss weapon comparisons warn when binding will make the recipient's equipped primary awakening inactive. Both combat pickup and school-checkpoint inspection use that student's actual run hand and saved combat version, including benched students and cooling-down awakenings. Legacy versions 0–2, incomplete recipes, KO members and boss-to-boss swaps do not report a newly lost awakening. The warning stays beside the binding actions; equipped cards and their other bonuses are retained.

Chibi student motion

Player hybrids (2026-09-07): Ren, Yui, Kaito and Sora use the original buildPlayerRig procedural bodies and syncPlayerMotion animations. player-head-rig.js replaces only the head/hair children with each student's static modeled head, seated in the shirt collar on the existing animated head pivot. No imported animation or skeletal solver runs on this path. The head shares toon lighting, hurt flashes, invulnerability visibility and frozen-pose dash ghosts with the body. Failed loads retain the complete original rig; ?rig=classic keeps its original head as well. Stale loads cannot replace a newer character/run. scripts/bake-player-head.mjs <id> writes assets/players/<id>-head.glb on demand. Ren retains his approved extracted head and closed neck underside. Yui, Kaito and Sora use isolated Tripo H3.1 heads from new volumetric references matched to Ren's bold eyes, brows and sculpted facial proportions (prompts in docs/HEAD-REFERENCE-PROMPTS.md). Their entire head meshes are retained and normalized around a measured neck origin; Sora's twin tails remain intact below that origin. Only the selected student's head asset loads. Generation provenance is stored beside the shipping heads. The full-body experiments below remain in source but are no longer the cast's active gameplay path.

Ordinary curse models (2026-09-11): All eight regular enemies use their original pre-refresh geometry from 36d02fc, including the simple luminous eyes and single-color toon shading. enemy-originals.js owns these shapes; actors.js adds the original ink hulls. Gameplay pools use three instanced draws per species and the shared static outline material. The study-specific vertex colors, articulation shaders and phase buffers stay out of gameplay. Existing whole-body motion, spawn/charge/hurt cues, elite and chill tinting, and travel-facing wisps/moths remain. AI, hitboxes, spawn counts, rewards and sim RNG are unchanged, as are boss models and their fallback geometry. The newer Updated, Clean and Bold designs remain in the showroom with their articulation and comparison controls. Original is labeled Current game there. Tests fingerprint the original body/eye/outline geometry and check the game materials. node test/enemy-models.browser.mjs checks real WebGL compilation, stable instance buffers and 320/240-enemy desktop/phone layouts, including low quality/reduced motion. Its timings are desktop Chrome measurements, not physical-phone FPS claims.

Procedural character outfits (2026-09-07): player-body.js builds each outfit from cross-sections, beveled panels, piping and pleated cloth shells. Costume geometry and material colors follow assets/portraits/<id>.webp and the full-body sheets assets-src/incoming/models/<id>-v-front.png, with the chibi sheets guiding proportions. Sora's cream V-neck layer also follows her portrait and approved v2 model. Spell colors do not recolor the clothes. Ren wears a burgundy/gold blazer, open white shirt with no tie, hand wraps, dark trousers and white sneakers. Yui wears a navy sailor collar and pleated skirt, cyan piping, tights, black gloves and loafers. Kaito wears a dark raised standing-collar jacket with purple piping, trousers and muted leather loafers. Sora wears a navy/gold blazer, cream layer, yellow tie, pleated skirt, bare legs above equal navy knee socks, and brown loafers. Original hip/shoulder/head pivots and motion remain the animation contract. Skirts and their waist are attached to a pelvis pivot independent of torso lean, with hollow flared shells that clear the stride. Outfits use at most 24 shaded meshes and 6,500 triangles before outlines; details share materials per pivot. Every part participates in hurt flashes, dash ghosts and disposal. Outline hulls inherit their shaded mesh's visibility and rotation. Head GLBs remain unchanged; bodies require no downloaded models or textures.

Run axis — player-motion.js (2026-10-17). The shipped students are this procedural body with a modeled head (the "hybrid" rig, all four, because each has a PLAYER_HEADS entry); the chibi path and ren-direction.js below are not what players see. The body used to turn whole toward the aim, so a sidestep or a backpedal swung the legs along the aim while the body slid across: skating. Now the aim facing eases as before, and on top of it the pelvis (the rig's root: legs, skirt, contact shadow) turns toward the axis the student runs along, up to RUN_TWIST.hip (45 degrees) either way, while the torso turns back by as much, so chest, arms, head and Kaito's orb stay on the aim and the shots still leave from where the student faces. Running more than 105 degrees off the aim is a backpedal: the pelvis takes the run's back axis. It switches back inside 75 degrees, so a sidestep wobbling around 90 never flips the hips. A dash and Ren's Threadbreaker link pose own the body, so the twist lets go there, and standing still settles it to exactly zero. test/run-axis.test.js.

Strikes — player-motion.js strikeFor (2026-10-17). The shipped body never moved for its own attacks (Ren's chibi palm layer is in the unused path). Now the view hands the student's weapon events to strikeFor and the body answers with a short envelope over the stride pose (STRIKES): Ren's palm (cone, not card sweeps) thrusts alternating hands to the centre line in 0.065 s, holds a beat and recovers by 0.26 s (inside the 0.38 s between palms), the chest turning into the blow and the other hand drawing back to guard; Sora winds the volley back and flings it overhand; Kaito pushes the orb out with both hands spread; Yui's raised needle hand kicks up with each shot. Each strike ends at exactly the pose of a student who never attacked; the torso lean it adds is taken back out before the next frame's damped lean, so it never lingers or compounds on held frames. A dash or Ren's link pose owns the body. test/strikes.test.js.

Ren chibi motion (2026-09-07): ren-motion.js keeps Idle/Jog running under a separate alternating-arm palm layer. The source attack's guard and contact poses form a 0.36s strike/recovery, inside Ren's normal attack interval; busy strikes ignore visual retriggers while every simulation attack and its FX still fire normally. Jog cadence follows ground speed at 2.8 world units per cycle, with a small footfall bounce and bounded torso lean. The retargeted idle's pelvis scale is normalized to avoid resizing between idle and jog. Procedural bone offsets restore their clean base each frame, including paused/dash frames, so constant tracks cannot accumulate lean.

ren-direction.js layers independent lower-body, torso and head control onto the existing skinned mesh. The authored jog supplies cadence and foot lift; world-space two-bone IK redirects its ankle paths into forward, backward, sideways and diagonal movement. Knees keep a forward bend, foot targets stay on their own side, and joint turns ease with a speed limit through reversals. The pelvis can turn up to 24 degrees into a strafe while the spine cancels that turn to retain aim. The head eases toward aim up to 37 degrees ahead of the body's turn. Every overridden bone restores its clean clip pose before the next update. This is directional gait adaptation, not terrain foot planting; the original mesh, attack timing, effects and simulation remain unchanged.

Voice pipeline

The cast is four ElevenLabs LIBRARY voices, locked 2026-09-22 after the audition rounds: Ren is Harry, Yui is Zoe, Kaito is Austin (Chris switched him off Chadwitch on the 22nd), Sora is Tilly. They live in scripts/voice-cast.json, and scripts/cast-vo.py renders the whole book from it — 168 takes, nine roles per student — to masters in assets-src/incoming/vo-cast/{id}/ and publishes the game copies to assets/vo/{id}_{role}[n].mp3. scripts/cue-sheet-vo-sync.py then re-derives the vo-live-* cues so the audit cannot drift from the game.

What ships is the picks list, since 2026-10-02. Chris re-cast the book on Eleven v4 (scripts/vo-v4-audition.py renders every book line on eleven_v4 with the same voices, directions and trims) and chose per clip on the clip sheet. scripts/vo-picks.json lists, per student and role, the takes that play, in order: book:N (the v3 master), v4:N (the v4 render of book line N) or heritage:NAME. cast-vo.py --publish copies exactly those to assets/vo/ (renumbered contiguously, stale files removed), --takes counts them for src/audio.js, and --check measures them; a role with no picks falls back to the book's order. The first picks shipped 160 takes: 119 v4, 21 v3 and 20 heritage, every rotation 1–8 deep. Two kept v4 death reads were fitted to the 1.9s budget in their masters (Ren "Tch... figures.", Sora "No... no no...").

The ten roles, in the order a night uses them: entry, dash, hurt, tag, heal, domain, bosskill, bell, win, death. entry fires 1.1s after the arrival card so the club walking in is not fighting the sting for the same moment. bell and win share one cue slot and are chosen at the bell: First Bell (the whole night) earns the win read, an hour or a period cleared gets a bell call-out and the night goes on. bosskill deliberately skips the kill that ends the night, because that one already has the win read 0.95s behind it.

Length is a feature (Chris, 2026-09-22). A line that fires while the player is running has to land like a snap: half a second, a second at the outside for a tag-in, and never two sentences joined together. Only the lines that play over a card rather than over the fight — entry, bell, win — may breathe. TARGETS in scripts/cast-vo.py is that rule as numbers, --check writes the measurements to assets/library/vo-qc.json, and test/vo-book.test.js fails the build on a take that runs over its budget. The first pass of this book missed it by a mile: Kaito's one-syllable dash grunt shipped at 2.72s, 1.6s of it his out-breath, because the tail trim threshold sat below the level of that breath. Set the tail threshold per role (TRIM), not once for everything.

Two ways a take can be broken that duration alone cannot see, both of which have now happened: a trailing breath that makes a 0.4s grunt a 2.7s cue, and an afade=t=out:st=0 that holds the faded level for the rest of the file and ships 20ms of audio at the right length and the right peak. --check measures the envelope, not the header, and the QC manifest carries a voiced fraction per take for that reason.

Randomisation. Sound.vo() rolls between the base read and whichever alternates are decoded, so a line never repeats back to back. Take counts are per STUDENT per role and are generated with the book into assets/library/vo-takes.json, which src/audio.js imports: they are not uniform, because the kept pre-audition takes land in some rotations and not others (Ren has ten domain shouts and four wins). test/vo-book.test.js fails if the book, the manifest and assets/vo disagree, because nothing at runtime complains — vo() no-ops on a missing buffer, so a drifted name is just a student who went quiet, and a take past the top of vo()'s scan ships, loads and is never chosen.

One talker at a time (2026-10-10). vo() keeps the line on air in _voNow and ranks every role by VO_PRIORITY in src/audio.js: death, win and bell 4; bosskill, domain and entry 3; tag 2; heal and hurt 1 (also any unknown role); dash 0. A line that starts while another is still speaking plays only if it outranks it. Then the one on air is faded out (time constant 15ms) and stopped 80ms later, so the cut has no click. A line of equal or lower rank is skipped: vo() returns false, makes no nodes and does not duck the music. A line counts as on air until 80ms before its end, so the next one may start over its tail. The student's rotation is still requested for a skipped line, and a line whose buffer is missing never cuts one that can play. game.js's dash (2.6s) and hurt (5s) limiters still reset on a skipped grunt, so the next one waits its usual window. Before this, 11-16% of lines started over another one: the heal line over itself when two hearts came together, a heal over a domain shout, a grunt under the shout. On bot nights (each student, seeds 11, 22 and 33, 600s, game.js's VO rules, take lengths read from the MP3 frames) that is 36-56 overlaps per student. Afterwards there are none, at the cost of 23-27 skipped lines per student (half of them a second heal) and 6-26 cuts, mostly a domain cutting a grunt. A finished VO source now disconnects itself and its gain in onended, as tone() and noise() already did; they used to stay connected to sfxBus. Cost: one small object per spoken line, a few per minute, and no per-frame work. test/vo-rotation.test.js pins the rules.

Takes from before the audition (Chris, 2026-09-22). Forty of the takes that shipped before the cast book were kept and now play in the same rotations as the audition ones, mostly the Seedance/Higgsfield domain shouts. They are copied byte for byte from assets-src/incoming/vo-heritage/ — no re-trim, no re-fit, no second encode — and they are NOT held to TARGETS, because a budget is for a line being written, not for one already chosen. scripts/vo_heritage.py records each one's pipeline and any that have been cut; the QC manifest marks them heritage so the budget assertion skips them while the envelope check still applies, since a hollow file is a bug whatever made it.

Memory. The book is 357s of audio and decoded PCM is ~192KB per second of mono 48k, so decoding all of it costs 69MB. Boot takes the 36 base reads (16MB) and ensureVoAlts() fetches one student's rotation (~13MB) the first time vo() names them, evicting the previous student's. A run therefore costs ~29MB however long the party is. Same arithmetic that moved boss.mp3 out of the boot manifest.

Line indices are positional and become filenames: append new lines, never reorder, or an approved take is renamed. Renders are not repeatable, so cast-vo.py never re-renders a master that exists — delete it to re-roll.

The instant clones in scripts/clone-vo.py were the placeholder that got the game talking, and the audition pool under assets/clone/ stays as history. Clone source quality was capped by material anyway: Ren 51 clips/31.8s, Yui 20.9s, Sora 22.6s, Kaito 17.4s, and under ~30s an instant clone drifts between renders. scripts/clone-page.py still regenerates {id}-clone.html from it.

Delivery — the app payload (2026-09-19)

Chris: every Capacitor deploy pushed the whole 135 MB dist-app, most of it music. Measured: music 81 MB (18 tracks at 256 kbps MP3), boss models 16 MB, everything else 38 MB. Streaming the music from the site was proposed and declined — the app must play offline from first launch — so two things landed instead:

Still on the table: phone-only atlas variants in the app copy.

Shipping updates to installed apps

Native Capacitor apps: game fixes ship via npm run ota (build dist-app/, upload its package version to Capgo's production channel). The v8 updater uses onlyDownload; a downloaded bundle waits for a later app boot and the title screen. Resuming/backgrounding, gameplay, pause, level-up and boss-drop screens never activate it. src/native.js starts the native-only update service, which acknowledges boot once with notifyAppReady() and holds title input during the switch. Failed boots retain Capgo's rollback protection. Existing shells need one native release containing the SDK before OTA works. Native/SDK/plist changes need a new native build and App Review; routine compatible game fixes use OTA within store rules. See docs/STORE-PLUMBING.md → Live updates (Capgo).

Web and home-screen installs: the version check below is separate; the Capgo service is removed from their bundle at build time.

An iOS home-screen app has no browser chrome to reload from, and iOS brings it back from a SUSPENDED SNAPSHOT rather than re-fetching — so a player can sit on an old build for days no matter what the cache headers say. (They are already right: Netlify serves max-age=0, must-revalidate with ETags.)

build.mjs writes dist/version.json holding the same buildId that is baked into the bundle as __BUILD__. game._watchForUpdate() re-checks it on visibilitychange and pageshow (which covers bfcache restores) and reloads when they differ, but applies the update only at the title screen. A response finishing during loading, card/weapon choice, a checkpoint, route selection, options or any future non-title state remains pending. The player gets one notice; the actual showTitle() transition applies the update.

Title OPTIONS → CLUB also carries a manual FORCE REFRESH (game.forceRefresh()), available without developer mode — the automatic check cannot help an already-installed app that predates it, and on a home screen there is no address bar to fall back on. It deletes every Cache Storage entry, unregisters any service worker, re-fetches index.html and bundle.js with cache: 'reload' so the HTTP cache cannot serve a stale copy from the same URL, then lands on ?fresh=<timestamp> — a URL the cache has never seen. It must never touch localStorage: the save, settings and any resumable run live there. The button says so on screen, and that promise is tested.

Must stay true: never reload mid-run. _applyUpdate() only reloads from title; every other state toasts once and waits for showTitle(). Autosave would survive a reload, but a game that restarts itself while you are playing is not something we ship. Dev builds (__BUILD__ === 'dev') never nag.

Menu motion (2026-09-19; refined 2026-09-21)

Chris: take the menus, loading screens and interstitials "to the next level". Surveyed every screen at 1280 first: screens popped in and were removed instantly on close, the title had no wordmark over the live demo, the loading card was a heading and a 4px bar, results were static. Landed, all CSS plus a few lines of ui.js, every piece skipped under prefers-reduced-motion: reduce (the audits run reduced, so their layout measurements are untouched):

Captures in ../_hallpass-archive/experiments/fx-2026-09-19/ (n-*, n2-*, n3-*, n4-*).

Screen chrome

Menu mastheads (_screenHead → .screenhead) and their separator lines span the viewport. Contents share equal left/right gutters, including the larger phone safe-area inset. Their measured height reserves scroll space so focused controls remain visible. Utility menus keep a top-right close action; the technique draft keeps its level stamp. Pause puts PAUSED in this top masthead and overlays the Hall Pass wordmark on the lower kanji. Its centered actions lead with Resume, followed by a compact Options / Your Hand row when a hand is available, then optional Suspend Run and the available exit actions. Resume also responds to Escape/gamepad back. The exit (Quit To Title, or Abandon Run in a school run) ends the run for good, so its first press only arms it ("Confirm Quit / Run Is Lost" on warning stripes) and a press at least 350ms later quits; it disarms after 2.5s or when focus moves away. On landscape phones it sits 14px clear of the buttons above it. Pause actions use raised, slanted button faces with hard shadows, a bright Resume action, and a visible pressed state, matching the title menu. Menus honor safe-area insets, scroll when content exceeds the viewport, retain 44px minimum action targets and keyboard/gamepad focus navigation, and respect reduced motion. Combat controls remain outside the .screen styling scope.

Options screen

Player and developer split (2026-09-15): ordinary Options has four tabs: Controls, Audio, Display, and Club. Controls holds How to Play, input preferences and Bank Level-Ups. Audio holds Sound, Music and SFX volumes. Display holds visual preferences, touch Tilt Parallax, Crew Intros and Opening Film. Club holds Field Report, Club Card, Gameplay Analytics and Credits when wired, plus Force Refresh in title Options. Native Patron appears only when that service is available. On landscape phones (max-height 500px, min-width 600px) the switches pair up two to a row in 44px rows, and a setting with a note keeps it beneath itself in an .optcell (Bank Level-Ups, Gameplay Analytics, Force Refresh), so no category hides a setting below the fold. Club Card does not autofocus its paste field on touch, which would raise the keyboard over Link and Close.

Test tools are hidden by default. Ctrl+Shift+D toggles them for this app session from title, Options or pause; it does nothing during gameplay or while editing text or using a sheet. A touch browser can start with ?devtools=1. The gate is not a saved preference and does not change gameplay settings by itself. Enabled Options appends Developer (Graduate, Cards, Performance Test, build ID, Sound Library), Music, and Loop when its audio hook is available. Force Refresh is a player-accessible exception in Club, exposed only from title Options, never pause or an active run. It preserves progress and saved runs, and rechecks the current state before reloading after async cache work. Disabling the gate returns a hidden selected category to a player category; title and pause continue to share the same options surface.

?perf=1 remains the explicit recorder shortcut. For phone profiling, open ?devtools=1&perf=1 before a run so the recorder can be reopened through Options → Developer during a pause. These entry points never justify a reload mid-run; stop and export before closing the session.

Buttons, not links (Chris, 2026-09-01). Everything on the pager that does a thing is a real .btn: FIELD REPORT, CLUB CARD, FORCE REFRESH, SOUND LIBRARY (and CREDITS when wired). Short notes remain where needed to explain a setting or its consequences. CLUB CARD opens a modal (_cardModal, .cardmodal veil inside .screen, z-index 34): the code (tap to copy), the sync status, and a LINK field — the old inline card box is gone. TONIGHT'S RUN left the title for the STATS career pane (on.daily on showStats) while Chris decides whether the daily stays; the title keeps only the cycling assignment slot.

Options has directly selectable category tabs above a category title centered between previous/next arrows. Swipe and left/right keys reach the same remembered category. Outer panels, decorative chapter numbers, handbook copy and the bottom Back button are removed. The corner close action and Escape/gamepad back return to the previous screen. Both options entry points use the same 38% dim layer as time stop, with no blur or opaque panel. The runtime builds categories from available services; native store products retain their own PATRON page. Individual actions, device tabs and volume sliders retain raised button/control surfaces with full borders and hard shadows. ON/OFF settings use a recessed switch with explicit state text and a left/right thumb; the whole row remains the activation target. This button treatment is shared by title and pause options while the surrounding category layout stays open.

Must stay true:

Site pages

scripts/site-index.py regenerates /assets.html (master index) and /video.html, reading every count from disk so they cannot drift. /showroom.html serves a second bundle of the REAL renderer — it is badged INTERNAL and must not be linked from anything public.

The cast showroom's enemy selector compares Original / Updated / Clean / Bold for all eight regular enemies. ?enemy=grub and ?enemy=husk open the first two directly. The Designs selector shows all four, selected pairs, or one model; &designs=clean, &designs=bold, and &designs=new link to the new directions. enemy-originals.js supplies the restored gameplay cast and Original choice; enemy-models.js preserves the Updated design as a showroom study. showroom-enemy-studies.js adds two review-only designs per enemy: Clean favors smooth forms and simple glowing eyes; Bold explores different silhouettes with the same restrained faces. All share scale, lighting, orientation and bob phase; the three newer designs retain their vertex articulation. They remain three meshes each with no additional textures. Full cast restores the palette studies. Zoom in/out and Reset zoom work for both cast and environment views; wheel, two-finger pinch, and +/−/0 keys use the same bounded 50–300% camera zoom. Comparison framing leaves room for controls above and fits a row on wide screens or a two-by-two grid in portrait. The Updated, Clean and Bold studies never enter the gameplay bundle or change saved choices. node test/showroom.browser.mjs checks all eight sets, design selection and links, and desktop/portrait/landscape controls in real WebGL, including native touch pinch gestures.

/character-showcase.html is a separate internal Sora asset review, reached from SORA STUDY in the cast showroom. character-model-preview.js owns GLB normalization (2.5 units tall, feet at zero), game/source/toon material comparison, real embedded animation selection and full resource disposal. Idle, walk, primary and special presets are available only when matching clips exist; unnamed clips remain selectable by their original names. Superseded asynchronous loads are disposed instead of appearing after a replacement. The page supports local GLB inspection without uploading it, alongside the generated front/rear/profile references, clearly marked as the original silver-hair/asymmetric-sock v1 concepts. The default sora-v2.glb has pale blonde hair, matching fitted socks, repaired facial shading and three verified clips. Load failures retain the reference art. Game treatment uses matte cloth/skin while keeping the painted face maps; provider materials remain available unchanged for comparison. This page never loads or alters the sim. See docs/CHARACTER-ASSETS.md and the asset provenance JSON for the generation recipe and rig/runtime limitations. Gameplay uses the original procedural chibi rig for every student, including Sora. The detailed Sora v2 GLB stays in the isolated showcase for possible cinematics; its experimental game adapter is retained but not imported by View. /?try=sora&map=yard&nointro=1 remains a human-controlled chibi Sora trial without changing unlock saves. Original stride, bob, lean and attack movement are preserved. The v1 model has a genuine in-place walk and a cast retargeted onto that same 24-bone skeleton. The offline retarget utility verifies per-joint deformation against the source; the strict direct-copy validator remains separate and rejects the independent provider rigs. V2 preserves the full cast as Special and adds a 0.26s PrimaryAttack with a release marker at 0.08s. hallpassPrimary animation extras identify its upper-body layer and hand socket. The study's Walk + repeat cards uses a 0.34s cadence, splits the walk by the primary's exact channel paths, and keeps the unaffected root/leg walk phases running. Matching walk channels and explicit upper-body metadata are required for this mode. The primary returns to the current walk or rest; the special plays once and holds its final pose. Golden cards are separate cosmetic props spawned from the animated hand at the release marker. The page's pause, rest, replacement and disposal controls also manage those props. This remains an asset review, and does not replace the playable Sora or her projectile logic.

Clear the board (a boss holds the bell)

Rule: the round's bell rings on time, but the round is not cleared while a boss is alive. Config: HELD_BELL. Sim: the bell gate in step(), the held guard in _spawns(), the CE branch in _damage(), the HP floor in _hurt().

Canon: a round is one hour of the night (9–10 PM is round one), so the bell that ends it is not daybreak — nothing burns the boss for you. Before this a boss alive at the bell was simply outrun for its last minute; now the bell waits, and the scene changes so the wait is a duel:

State: bellHeld, heldBy, heldT (snapshot-safe). Event: bellHeld {by:'boss', name} once. The bell still rings on time (sound.bell(), the bell without the victory resolve) and a toast — THE BELL — STILL STANDS. CLEAR THE BOARD — says why the round has not cleared. The win sound and banner wait for the dawn event (internal name; it fires at every round's bell), whose subtitle then reads BOARD CLEARED.

Measured 2026-09-01 (standard-16): never fires at yard/gym FINAL — the 600s boss is dead before 720s at both skills, so the headline numbers are digit-identical to ce-split-after. Fires at competent MIDTERM yard 4/16 (heldT p50 26s, max 38s, all won) and gym 1/16. It lands on exactly the player who used to outrun the boss — a human slower than skill 0.8.

Telegraphs — the warden's shot and the held bell (2026-09-17)

Board #37 and #42, Chris's pre-launch pick. Two threats had no picture.

The ranged windup (warden, spitter). They fire from 7–11 units, past the ~9-unit sprite-legibility line, on a night board where the warden's body is near-black. The windup event now carries dx/dz/len/w (the lane toward the player, no rng), the view lays a geometry fx.strip for the windup whatever the sprite state (the same read a brute's charge has), and CombatLighting throws a pulse for the whole windup (it threw none). Boss windups have no direction and keep their ring.

September 21: windup lighting bypasses the small friendly-shot throttle and uses priority 4. It can replace a friendly Domain burst in the phone's one burst slot; routine primary fire cannot replace the warning. A later event of equal priority can still take that slot. Light counts are unchanged.

The Held Bell. The bell has rung and a Special Grade still stands. The toast is now a danger banner ("THE BELL IS HELD — STILL STANDS"), the boss bar reads "· BELL HELD" in amber, the boss ring runs amber and fast, and the boss burns brighter until it falls: pool bodies through the tint channel, modeled bosses through their per-instance emissive (boss3d.held, set by the view each frame). Not a sunrise — the canon guard in config.js HELD_BELL stands: nothing burns the boss for you, it just looks like the one thing on the board that still has to fall.

Study Hall mercy — the first night, winnable (2026-09-17)

Chris: "I want it winnable, but without adding another level." The lab's answer to WHY novices lose was never a boss: I-025 measured 88% of a novice's damage as husk/grub contact on a smooth curve crossing a finite HP pool at ~3:35, and every discrete lever failed (I-024). So the mercy works the channel that kills them and nothing else:

Measured (sweep preset mercy, sim 48cb8c3, the night a new player plays: QUIZ pushed on to FINAL; novice skill 0.25 at n=64, competent 0.8 at n=64):

rung dmg taken valve until novice clears novice FINAL clear competent clears
0 (none) 1.00 300s 13/64 (20%) 32% 61/64
1 0.85 420s 18/64 (28%) 35% 63/64
2 0.72 540s 36/64 (56%) 64% 63/64
3 0.60 720s 55/64 (86%) 95% 63/64

So a new player's first night is a 1-in-5; after one death 1-in-4; after two, better than even; after three, they will almost certainly graduate — and a competent player is unmoved at every rung (63 of 64 either way). Median novice death moves later, not earlier (451s → 499s → 613s), never back onto the ~215s wall (0 of the deaths land in 180–260s at any rung). Near-misses stay at zero for novices at every rung — the mercy buys survival, not drama; that is the next thing to look at, not a reason to hold this.

Clutch — the night flinches (2026-09-17)

Chris: "now do the near-misses, give novices some drama." The lab's near-miss is HP under 15% that climbs back over 35% (PROTOCOL heuristic 2), and novices never had one: their night is smooth attrition, so once low they stayed low and died. CLUTCH in config.js:

Measured (sweep preset mercy, sim 4905334, QUIZ pushed on to FINAL, n=64 per cell; "≥1" = share of runs with at least one near-miss):

rung novice clears novice near-miss p50 / mean novice ≥1 / ≥2 clutches per run competent clears / near-miss mean
0 23/64 (36%) 1 / 1.27 86% / 30% 1.11 63/64 / 0.11
1 33/64 (52%) 1 / 1.28 81% / 34% 1.14 64/64 / 0.06
2 38/64 (59%) 1 / 0.97 69% / 17% 0.86 64/64 / 0.03
3 50/64 (78%) 1 / 0.72 53% / 14% 0.69 64/64 / 0.03

Before the clutch the novice near-miss median was 0 at every rung. The flinch alone also lifted the novice first night from 20% to 36%, so MERCY was re-laddered one notch softer (0.9/380, 0.8/480, 0.72/540) to keep the ladder a ladder; the mercy table above this section is the pre-clutch measurement and stands as the record of that decision. A competent player clutches about once in ten nights and is otherwise unmoved.

The free pull and the club's one question (2026-09-17)

The free pull. One card on the house each night, claimed at the Deck (the club). freePullRoll(dailyInfo().seed, clubCard) fixes the roll per night per club card, so a retry, a reload or a second device deals the same card; the receipt id is the day (free-YYYY-MM-DD), so applyPull grants it once however many times it is claimed. save.freePullDay is the last claimed night (merges toward the later date). The title's Deck button says "Free Pull Tonight" while one is waiting; the Deck masthead shows the button; the reveal is the normal pull reveal. card_pull events carry free: true, cost: 0. The push (docs/STORE-PLUMBING.md §3) is what tells a player it is there; a tapped message with additionalData.open = "deck" opens the Deck from the title (game.openFrom), never over a run.

The club's one question. Back at the Deck after a run, sometimes, one tap about one thing from that run — the last boss met, the room, or the period — with three answers (Too easy / Just right / Too hard) and a Skip. Rules: never twice a night (save.surveyDay), never when Gameplay Analytics is off (the answer would have nowhere to go), one coin flip per run so the club is not a form. Events: survey_shown, survey_answer (subject_kind, subject_id, question, answer, plus the run's run_won, run_seconds, run_level, room, period, tier, mercy, student) and survey_skipped. The facts come from _lastRunFacts, set in _commitRun.

The store, the ads, the push (native shells only)

Three services sit behind one rule: every call is behind isNative(), so the web build at hallpass.cc behaves exactly as it did before they existed. Ids live in src/services.js and each service stays dark until its placeholder is replaced. The dashboard checklist is docs/STORE-PLUMBING.md.

THE CHARTER'S FAVOR (src/ads.js, game._reviveWithAd). The death screen offers REVIVE — WATCH AN AD when a rewarded ad is loaded, the run is not Tonight's Run, and the favor has not been used this night. The reward resolves only if the ad actually completed; a dismissed ad says so and the screen stays. On revive the sim's revive('charter') brings the student back at half health with the Second Heart shove (sim.revive is that evolution's branch factored out; it consumes no rng), the commit that death made is reopened, and the pre-death fragments are pending again — a run that came back has forfeited nothing yet. Kills and mastery stay counted: since this change the commit banks mastery as a delta per student (committed.mastery), which also closes a latent double-count when After Hours re-committed a run.

Rooms pay more as the night goes on. Each room's bonusMult (1 on the yard, 1.65 on the roof; Night II and III add more) used to multiply only the bell bonus. It now rides every fragment gem as well, with a fractional carry so the count stays whole — so a later hour is worth more both during and at the end.

Three bets around death and the hand (INSURANCE, FAVOR, REROLL in config; all tunables). Insurance is a Second Heart bought BEFORE the run on that room's blueprint sheet, in fragments or with a rewarded ad: die once, keep everything. It is bought per room and priced by the hour (insuranceCost: base 40 plus 20 per hour, so the yard is 40 and the roof 220), because a guaranteed revive on a hard room is worth most of the haul — the bot's pool clears went from a coin flip to near certain with it. It is the evolution's own flag set at construction, so a saved run replays identically, and the Second Heart card is not dealt while the flag is up. The favor is offered AT death: back at half health, but the bell moves clockPenalty seconds further away (the sim's night grows; nightK is left alone so surges, density and boss hp keep their schedule) and fragLoss of the unbanked haul is dropped. Refusing forfeits all of it, and the death screen shows the exact numbers. Rerolls deal a fresh level-up hand from the same rng stream (sim.reroll() → _drawHand()): freePerDay on the house per local day, then fragments or an ad. Tonight's Run allows the free rerolls and nothing paid — a ladder stays a ladder.

Top of the hour (game._betweenHours). One interstitial rides the NEXT button between hours: never after First Bell, never for a supporter, never on a fresh save's first win, and never twice inside SERVICES.admob.interstitialEvery.

PATRON (src/iap.js). RevenueCat, keyed by the club card so purchases follow the save. Two products and no subscription: supporter (one-time — ads off, a badge) and timed boost_7d / boost_30d consumables that multiply what play already earns. The entitlement mirrors into save.supporter, which is what the rest of the game reads, so a synced supporter resolves on the web too.

Tonight's Run at 9 PM (src/push.js). OneSignal, identity = the club card. Permission is asked once, after the first cleared hour, never at boot. Tags: student, hour_reached, nights_cleared, supporter, tz_offset, revived. The send is a scheduled dashboard message with timezone delivery.

Card numbers live in config (2026-09-16). CARD_STATS (each card's multipliers and flat bonuses, keyed by id) and CARD_RULES (every trigger card's damage, radius, cooldown, refund — boss signatures included) moved from src/cards.js into src/config.js, which re-exports nothing but is imported by cards.js; cards.js re-exports CARD_RULES so no importer changed. Reason: the tuning loop may only write config.js, and "is the carried build too strong as the player pushes on" is a tuning question. Identities, kinds, rarities and trigger behaviour stay in cards.js.

Push-on power curve (lab, 2026-09-16). scripts/playtest-runner.mjs 4.1.0 records stages[] per run (one per period or room played, with minHpFrac, nearMisses, hits, level, dmgPerSec, cleared) and the summary's stage* arrays plus powerCurve (each stage's median damage rate over the first stage's). npm run sweep -- --preset=pushon is the standing matrix; PROTOCOL heuristic 10 says what "too overpowered" means in those numbers. insight.mjs --graduate --rooms=… --cards=… --boss-cards drives a chained run with the build, cards and boss-card pocket carried, exactly as game.pushOn does.

Analytics. PostHog (src/analytics.js), on the web as well as in the app — the only service here that is not native-only. The SDK is imported lazily inside init() so it is off the boot path and the node harnesses can import the game without a DOM; events fired before it resolves sit in a bounded queue. Autocapture and session replay are off: the DOM is a HUD over a canvas, so autocapture would record button noise and replay would film an unrecorded WebGL rectangle. Pageviews are ON (one per boot) because they are what PostHog's Web Analytics counts traffic, referrers and UTM campaigns from, and Core Web Vitals are captured as $web_vitals. In-run performance is the game's own measurement: game._perfFrames collects live-play frame times under the same gate adaptQuality uses, and run_ended carries them as perf_fps / perf_p95_ms / perf_stalls_50 / perf_quality_drops / perf_quality_reverts (steps taken, and ladders undone because they bought no frames) with a device_model, so frame rate charts per phone. The manual recorder sends the same summary as perf_recording when a recording stops; the full report stays on the device. Identity is the club card, the same id RevenueCat and OneSignal use. Options → Club → Gameplay Analytics (settings.analytics) is the player's switch, and off means the SDK is never loaded at all.

RevenueCat Ads. RevenueCat's ad tracking is native-only (iOS/Android SDKs, not Capacitor), so plugins/hallpass-ad-tracker bridges it: the AdMob plugin emits loaded/failed/shown and the paid event with impression-level revenue, Ads._report forwards each with its placement and a per-load impression id, and the native side calls RevenueCat's AdTracker.

No ATT prompt (2026-09-10). iOS ad startup is disabled, including Google UMP: its remotely configured IDFA message can trigger ATT independently of our code. Both ad placements stay unavailable on iOS; purchases still work. The explicit tracking-authorization call is removed on every platform. Android initializes ads only after UMP returns canRequestAds: true, including any required regional form. Failure or an unknown decision keeps ads off. Every rewarded/interstitial load and reload uses npa: true (non-personalized). This does not remove regional consent requirements or imply zero ad data use. Re-enabling iOS ads requires disabling the AdMob IDFA message and verifying the native no-tracking configuration on fresh installs and previously authorized devices. This release changes JavaScript only and can reach Capgo-enabled installs on a later cold boot at title; it does not interrupt runs.

Optional chibi surface study (2026-09-06)

/chibi-study.html compares original and polished procedural Sora under identical lighting and cameras, with walk, idle, dash pose, rotation and distance controls. Both use the extracted player-motion.js function used by View; bob, stride, pivots and proportions remain unchanged. chibi-polish.js adds beveled blocks, a curved canvas face map, shaped blonde locks, cuffs, lapels and small uniform details. It owns its added geometry/materials and disposes its face texture. /?try=sora&map=yard&nointro=1&chibi=polished opts into this surface study in actual gameplay. Ordinary Sora and the other students keep their original rig. The detailed GLB remains separate for possible cinematics. No simulation or balance changes are involved. This is an unshipped local art review.

Crowded-scene rendering and iPhone measurements (2026-09-07)

Quality changes now resize the drawing buffer even when viewport dimensions are unchanged. After 3.5 seconds of sustained sub-45 fps gameplay, resolution steps down to at most 1.25 pixels per CSS pixel, then 1.0 if necessary. Bloom, HD particle assets and authored effects stay enabled; the previous automatic Glow FX shutdown is removed. The detector uses real frame time, excludes menus/level picks/resume gaps, and resets its observation window after each step. Resolution stays reduced for the session, including across rotation; explicitly changing Glow FX resets the cap. Manual Glow FX off still works.

Viewport changes set the renderer's logical size and effective pixel ratio together, resetting the canvas once instead of twice. Repeated measurements and device-DPR changes that leave the capped resolution unchanged do no GPU resize work; the latest device DPR is still retained for later quality changes. CSS layout, camera framing, post-processing sizes and resolution limits stay the same. Hall Pass uses its own Post targets and Three's default output buffer.

Pickup draws skip spheres fully outside the camera view, with conservative bounds enclosing both sprite glow and reflected positions on Yard water, including raised-floor pickups. All pickups remain in the simulation. Enemy, shadow and pickup instance buffers upload only the occupied prefix. Enemy counts, AI, collisions, rewards, particle counts and effect timing are unchanged.

Developer-enabled Options → Developer → Performance Test (also ?perf=1) records visible gameplay frame times, CPU submission times, scene counts, build/runtime and resolution data. Measurements are opt-in, bounded to 72,000 frames and exported manually; there are no automatic uploads or save mutations. See the physical iPhone test procedure. Native packaging discovers runtime asset directories from the production bundle/HTML/CSS so the installed app carries the same heads, HD FX, audio and menu art as the web.

The title-screen gameplay preview keeps the fighting character and their light visible, including when an asynchronously loaded detailed rig replaces the procedural fallback. Attract mode changes camera behavior, not body visibility.

Desktop Options centers its controls vertically below the full-width masthead. Long categories remain scrollable from the top; touch layout is unchanged.

Grift is the supporting UI family for descriptions, explanations, hints, metadata, form fields and compact HUD labels. Broaven remains the menu display family for section titles and primary buttons; the title wordmark keeps its own fonts. Six supplied WOFF2 faces provide normal weights 400–800 and regular italic.

Skill selection cards are single whole-card targets, with no nested Learn/Evolve button. Click, tap, Enter, Space, number shortcuts and gamepad confirmation all select the card; focus is outlined around the card itself.

Main-menu actions form one vertical stack: Continue Run (only with a saved run), New Run, Stats, Options. Continue Run has no subtitle and is the red primary action when available; otherwise New Run is red. Run Again is never offered.

Startup keeps the canvas hidden against black until Game chooses the first screen. The intro has an immediately opaque black background and keeps the yard hidden while video loads, plays and fades; skipping or finishing reveals the title demo.

The opening video/title screen carries the large two-line Hall / Pass wordmark, with the kanji behind it; the former After School Exorcism Club headline is removed. The following main menu shows its vertical action stack without the logo. The opening wordmark scales to viewport width and height.

Ren's base slash falls back to a short character-colored geometric sweep while the FX atlas is unavailable or its sprite pool is full. A successful animated slash replaces the fallback; the optional aim-cone setting still overlays it.

Mouse capture is restricted to live gameplay. Late pointer-lock completion is released on reward, upgrade and end screens, and a rejected raw-movement request cannot retry capture after a menu opens. Escape during gameplay still pauses.

Domains take priority over automatic level-up time stops for the cast and all lingering fields. XP earned during a domain is banked even in instant mode; automatic bank opening waits until the domain finishes. A domain button press also wins over automatic banking in the same frame. Manually opening the bank still deliberately stops time during a domain.

Boss music now rotates through the experimental Boss WOW arrangement first, then Boss 2 through Boss 8, then the nine original boss cues, using encounter order and preloading before arrival. The experiment preserves its opening and repeats a prepared 20.15s phrase. It is available in developer-enabled Options → Music and loop-lab.html?track=boss-wow.

Web diagnostics use Sentry for uncaught errors, rejected promises, caught frame errors and WebGL context loss, with bounded reports and gameplay-only context. The public project DSN enables tracking on production web hosts only; see docs/ERROR-TRACKING.md. APK updates require an explicit major-release request.

Mana pickups and boss projectile readability (2026-09-10)

Ordinary kills now roll a 12% chance for 8 CE, up from 5% for 8 CE. Expected pickup income rises from 0.4 to 0.96 CE per eligible kill; XP awards, passive regeneration and special costs are unchanged. Purple orbiting orbs represent mana, with purple pickup effects and a correct +8 Mana label. Ordinary mana orbs use a 1.5-world-unit sprite (previously 1.15) to stand out among XP. The boss Energy Cache uses the same purple visual family.

XP values up to 2 are blue and values above 2 are gold. Blue flames and hearts (including boss hearts) are 15% smaller as a readability trial. Gold flames and boss level rewards keep their previous scale; size never varies within an XP color. Both XP pools accommodate the full bounded pickup population so changing tiers cannot hide a pile. Pickup attraction/collision is unchanged.

Boss bullets carry a saved presentation identity. Hall Monitor fires rigid amber verdict lances with tight sparks; Pop Quiz throws scorched folded-paper blades shedding ash; Substitute uses split ivory talismans with violet ghost-fire wakes. Group Project's fractured coral seals shed ceramic chips. Parent-Teacher Night fires rose/ivory gaze rings with paired streaks; the Vice Principal throws chipped obsidian blades with crimson pressure trails. Every shot has a bright collision core. Unavailable artwork retains the original flame and five motes. Janitor (sweeps), Exam Season (zones) and Detention (cages) do not fire bullets in their current kits; their themed fallback styles do not add new attacks. Seven shared instanced pools cap rendering at 240 projectiles / 1,200 motes. Fire and halos render once per plane, with no per-projectile lights or particle emitters, and only live instance ranges are uploaded. Collision radii, speed, damage and firing patterns are unchanged. Older saves without the visual identity use a default boss style.

Authored projectile wakes (2026-09-23): All six projectile bosses each use one transparent painted texture, shared by a bounded instanced page: 160 shots per identity on desktop, 96 on phones. These replace their flame/halo layers and reduce their motes from five to two. A faceted ivory/amber lance and an ivory folded-paper kite with scorched edges replace the first two placeholder bodies. The Substitute has two folded ivory/violet strips with an open central slit; Group Project has six bevelled coral segments around the visible contact core. Parent-Teacher Night's two continuous almond arches retain an open center; their inner lip stays level with the ridge so far facets cannot paint across the near rim when rendered with the transparent wake. The Vice Principal has a broad asymmetric slab with chipped crimson bevels. Project, Parent and Principal textures contain only the trailing artwork, with feathered crop edges; their 3D heads provide one impact silhouette. Each head shares its artwork mesh and draw. The opaque contact core stays unchanged. The nose is anchored to the core, the artwork trails behind travel, and its leading edge fades to avoid implying a larger collision area. Desktop pages are 512px (186,256 bytes combined); phone pages are 256px (69,892 bytes). A shared shader uses each boss's own glint color. No full-screen pass, video decoding or per-shot allocation. Pages download once and remain detached until the existing safe-screen upload/compile/invisible-draw gate activates them. Late, unavailable or saturated artwork retains the original flame/halo; all 240 dangerous shots retain their solid cores. Pause freezes the visual time; reduced motion holds the shimmer, body spin and motes still relative to each moving projectile. Empty scenes, new-run/room resets and disposal clear all pages. Exports use real alpha and empty gutters; scripts/bake-boss-projectile-art.mjs rebuilds them from ignored masters (--next exports Substitute/Group Project; --final exports Parent/Principal). No changes to simulation or difficulty.

Notice placement (2026-09-10)

Ordinary announcements and toasts are horizontally centered on every viewport, 12px below the player's XP track (below the adjacent Upgrade control on narrow phones to avoid covering it). Layout observation follows HUD wrapping and viewport changes without per-frame measurements. Concurrent toasts stack below the announcement; menu confirmations retain centered top placement. Safe-area width limits and click-through input remain intact. Boss film reveals retain their existing full-width layout.

HALL PASS by Very Bad Games · Solo Developer Chris Sands · All documentation