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:
- Yard.
- Gym and Library, in either order.
- Halls.
- Cafeteria and Pool, in either order.
- Detention.
- Boiler Room and Science Wing, in either order.
- 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):
- The HUD clock (
nightClockin ui.js) sweeps ONE hour, fromhourOfMap; the night bar's ticks are quarter hours; the sunrise marker exists only on the finale. After Hours reads{next hour} +mm:ss— overtime into the next hour, not a forced dawn. - The sky (
view.sync) crosses only this hour's slice of the full night gradient — a 2 AM room stays 2 AM dark; first light and the golden ramp live inside the LAST hour's slice only. - First light (2026-10-16):
dawnMixramps only in the run the game marksrunFinale(passed to the view withsetNightHour), fromsunriseAt / durationof the way through that run (630 s of a Final, 157.5 s of a Quiz) to the bell. The HUD sun mark sits at that point on the bar and now lights there too, for every period. Before this, the view compared 630/720 with the whole night's progress, which put first light into the 5 AM room's last quarter and started the Rooftop's sunrise at its first minute. - Wins: mid-night hours chime
"{next hour} — HOUR CLEARED"; only the roof gets FIRST BELL / NIGHT SURVIVED and the dawn art on the win screen. Same gate on the pre-end warning and the share card. - Helpers:
hourOfMap(id),hourClockLabel(i),isFinalHour(id)in config.js.hourClockLabel(10)is 7:00 AM by construction. - Save migration: the school opens front-to-back; a save that reached hour N under ANY older ordering keeps every hour up to N open (roof veterans from the 5-stage era get the whole night — they earned the depth).
- Replays, periods, dailies, tiers are explicitly practice — canon is the straight run, and the game does not apologize for retries.
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.
src/cards.jsowns the catalog (fifteen pullable cards: ten general stat cards, three general trigger cards, one Ren-only and one Yui-only; plus nine universal boss signatures, one for each boss identity), the club deck rules —STARTER_COLLECTION(seven commons every club owns), copies (a card can be owned more than once: a general card up toMAX_COPIES= 4, one per student, a student's own card once;ownedlists a card once per copy,copiesOfcounts them,mergeOwnedkeeps the larger count per card across saves),pullPool(first the set — the pullable catalog minus what is owned, never a duplicate — then, once that set is complete, copies of every pullable card under its cap; empty only when those cards are at their caps),drawPull(a pure weighted draw,RARITY_WEIGHT6/3/1, and a pity clock: thePITY_EVERYth dry pull is an uncommon or better when one is left),applyPull(the collection after a pull, one more copy, idempotent on a receipt id so a retry or a cloud merge never grants twice),normalizeCollection— and the loadout rules:MIN_SLOTS3 toMAX_SLOTS8,SLOT_RULES(slot 4 at mastery rank 1 for ◆400 … slot 8 at rank 4 for ◆5,000; prototype prices),validateLoadout,loadoutFor(the saved deck, in its order, minus anything the student cannot field tonight or of which every copy is in a teammate's hand, trimmed to the slots; no saved deck yet gives the best usable unclaimed cards by bias; an emptied deck stays empty). One card, one holder: each copy is a physical thing and sits in one student's deck at a time;holdersOflists every hand on a card in roster order,claimedFornames the cards a student cannot take because the club has no spare copy, andclaimDeckis the decks after one student commits theirs: the others keep a card only while the club has copies enough, in roster order. A deck the run fills for the first time is saved as that student's before the next student's first deck is filled. Final run hands are read after those reservations, so the crew never carries the same single copy and the club matches the run. The dealt-deck experiment (STARTER_DECKS,drawHand, blind five / keep five of seven) stays for the lab runner only.Effects: stat cards fold into the student's build at construction through
applyCardStats; replacing one with a found signature removes its contribution during the run. Trigger cards ride the live party entry and fire at one hook each — Passing Period (tag-in iframe), Second Bell (every third special free), Spirit Dividend (a killing bite refunds energy, capped per summon); each fires acardevent the game toasts. Special readiness, cost and charge use the same next-cast cost as the cast itself: Second Bell and a fresh tag-in can show a full ready ring at low energy. Exhaustion still blocks the cast. Live hands cross snapshot, restore, Push On and school-room doors on the party.originalCardsretains the starting hand for ownership/risk;cardsis the current hand andcardSlotsis its normal slot capacity.The run (
game.startRun): nothing to choose. Each student's deck isloadoutFor(pid, _cardLoadout(pid))and rides ashands, with purchased capacities passed ashandSlots; the builds are the normal perk sheet, so Training and the deck both apply.Where it lives: a hand is a student's (Chris, 2026-10-13): it opens from that student's profile ("Hand N/M ›" in its Loadout head; the Crew card reads "Hand N/M" over "Skills & training ›", two whole lines) or the roster's Club Deck shortcut (whoever is picked first); the Clubhouse's Deck tab opens the last student viewed (desktop and portrait; a landscape phone's rail shows only Crew / Store). The Clubhouse opens on Crew (
showClubhouse's default: the last section, never Deck) without an intermediate landing page and remembers its last tab/student for this session; the deck's ✕ on a landscape phone goes back where the deck was opened from (deckBack). Roster detours retain the selected crew order and Room/School Run setup on return.Crew: a visible four-position strip distinguishes permanent Crew slots from individual Card slots. The second permanent position is earned by clearing any room's Final; positions three/four cost 2,500/7,500 Coins. School Run's early second position is a labelled temporary bonus, never mistaken for a purchased position. Locked roster positions open their requirement/purchase in Crew. Purchases explicitly confirm the intended position and reject stale confirmations. Recruiting and each student's existing skills/training sheet are also reachable here. On landscape phones (max-height 500px, min-width 600px) the slots are one strip of 44px chips under the four students, who take the height left over, so the purchase never sits below the fold (
MOBILE PASS · r5-clubinmenu.css). The Deck / Crew / Store tabs are 44px tall there too. On a landscape phone every Clubhouse page's ✕ floats at the top right (the deck's too; the Store runs to the safe edge under it, only its first heading keeping clear; Crew's four students, which don't scroll, keep a 56px lane for it; a Crew card's "Hand N/M" (status, no chevron: the card itself opens the profile, where Hand is) over "Skills & training ›", both in the student's colour; phone labels keep a 12px floor (13px for the profile's tabs); every ✕ drawn alike: 750 22px, a #2b4357 frame, a 6px ring of the page's colour), and a student's sheet closes with the same ✕ (its foot Close lifted out,.cdclose; the foot row folds away when Close was all it held), its "Hand N/M ›" filled cyan. Crew / Store's rail speaks the deck rail's language (boxed tabs at 14px, the chosen one filled cyan; the free-pull dot inside the Store tab).Store: card pulls, free pulls and native Supporter/Coin boosts have one discoverable home ("A student takes them into their hand from their profile"). The deck links to Store for new cards; native Options Patron remains available. All routes use the same purchase handlers, receipt/progression rules and wallet; no pricing or simulation changes. On landscape phones the native shelf's heading shows above the fold under the pull tiles and its buttons sit two to a row at 44px.
Deck rail on landscape phones (2026-10-12, Chris: the cards take the screen).
fit()sets.deck-railon a landscape phone. The deck is one student's page (Chris, 2026-10-13: "still a lot of UI on the right and left, getting in the way"; a hand is reached "from their player profile, not switch[ed] between them inside the deck builder"). A hand opens from its student's sheet (_charDetail: "Hand N/M ›" in the Loadout head, from the Clubhouse Crew or the roster) or the roster's Club Deck. On the rail the deck has no student switcher and no Clubhouse section tabs (.deck-crewand.clubhouse-tabshidden); its ✕ floats at the top right, owning no column (over the next card's faded edge; each row ends with a 52-56px spacer so its last tile scrolls clear of it), and goes back where the deck was opened from (clubhouse.deckBack: that profile, the roster, else the Crew tab; off the rail it leaves the Clubhouse as before). At the left one 44px column: Spare / "'s hand" (words bottom to top, rotate: 180deg, nevertransform, which button states overwrite; the chosen half a deep filled cyan; counts upright at 13px; the hand's count cyan while slots are open; the unchosen words #b8c6d4), the coins at its foot (◆ over the count over "Coins", all upright). The stand-alone Build Deck (not reached in the game; the menu tests'deck) keeps its pull tiles in a column before it: outlined and of equal height, the word bottom to top with its price upright at the word's end, "Pull" / "◆ 120", "Pull 3" / "◆ 330"; only a free pull is filled; a tap arms a tile gold, "Tap again", and a second within 2.5s pulls, while keys, pads and scripted clicks (event.detail === 0) pull at once. The Clubhouse's Deck tab opens on the hand when the student has nothing spare. The rows. Spare and Hand are each one swiped row of cards sized by the pane's height, capped so ~36px of a third card always shows; both share padding, gap and peek, start at the same x when they scroll (every Spare row isflex-start, so the toggle never moves a card sideways), and both run to the safe edge (MENU_PROBE_INSETS=47,47,21withMENU_PROBE_SELECTORS/MENU_PROBE_CLICKmeasures the boxes); the replacement bar keeps 52px clear of the ✕. The hand snaps to card edges, its end spacer sized as it scrolls (--end-pad) so the fully scrolled row starts on a card's edge (a card cut at the left there left a hole and a ghost of its frame), and ends with one column as wide as the room left beside the ✕ (--spare-w, set by fanEdge: the row's width less 56px for the ✕, less two whole cards, or one on an SE; 96px at least, about half a card at most; a card-wide column on an SE read as a placeholder), so the scrolled end shows two whole cards and it on an iPhone 15 or Max, clear of the ✕, of "+ Add card · N open" over the locked slot (a lock the rank doesn't open yet reads "Mastery rank N" in gold and isaria-disabled, not disabled, so a tap says the rank it needs; its number and rank read at full opacity). No column stands at the right edge: rounds 12-27's fixed tail column, its "N more ›" and its thumbnails went on 2026-10-13. Spare / Hand never resizes the cards. While more of a row lies past its edge (.has-more) its last 48px fade (and, while scrolled from the start,.has-less, the whole sliver of the card cut at the left,--less-wset as it scrolls, so no fragment sticks to the rail; a card that starts at the edge keeps its face), plus a soft patch in the top-right corner (a 210x112 radial mask; the ✕ wears a 6px ring of the page's colour) so the floating ✕ never slices a title, the next card's sliver saying there is more. The rail face (row cards; the phone zoom, reveal and Protect sheet wear the same face): no requirement row, flavour, footer labels, serial or pips; the kind label sits in the rule box's trigger line ("ALWAYS · TECHNIQUE", 11px+); the rule prints at 13px+, a number never parted from its unit ("1.2 s", "3 seconds": a no-break space at render, the card data untouched); the footer strip holds one pill, the slot number first:3 · at risk(quiet, the words coral, no ring) for a slot neither safe nor offered ("after 2" read as "after 2 runs"; the slot's zoom says once what at risk means, "lost if Ren falls", and the Protect sheet the order, "slot 3 comes next"), a plain1 · 🛡 Safe(gold words, no pill: a state, the card's quiet 1px gold edge (a glowing ring read as "selected") already says it), the filled gold2 · at risk · 🛡 Protect ◆cost(the slot offered Protect says it is at risk too) (a spend, filled as every coin spend is) (tap: a confirm sheet with the card, the price, the balance after),+ Hand,⇄ Swap in(36px, drawn as tall as they act),Buy back ◆240 · N runs left(a lost card also wears a "Lost & Found" ribbon) or whose card it is. A recipe card wears✦ <Awakening>as a tag on the card at the art's top-left (the trigger line keeps its kind word; at the art's foot the tag's tap slop met the card's centre, where a tap opens the card; its offsets are in--tc, since the tag's own 12px font would turneminto 12px units), in violet (gold stays for Safe and Protect), filled with a violet glow when ready,✦ n/2 <Awakening>when not (its label says it all; tap: the recipe; its words ellipsize in an inner span so the tag's 12px tap slop is not clipped by the tag); the chip under the hand shows only off the rail. Colour by verb: cyan moves cards in the hand (Add to hand, Swap, + Hand, Swap in, + Add card), gold spends coins (Pull, Protect, Buy back, Free pull) and marks Safe; red is real money only. Spare order: usable cards, the Lost & Found (on the rail it leads the row, its buy-back clock running), other students' cards, then one card-sized tile for every blank ("16 left to find" on one line, then where they come from); when nothing can join the hand that tile leads the row instead (card-sized), headed "No spare card for" ("Nothing else spare for " beside a Lost & Found card; in a row that fits, it takes the room left, 420px at most), with one real action: Free pull ("on the house", gold, plus "More in Store ›"), Pull a card ◆120, or Store › (the Lost & Found card beside it carries its own buy-back). The switch counts the spare usable cards. While a Lost & Found card waits in the Spare row the Spare tab wears a coral "1 lost" under its count, and while the hand has open slots the hand tab a cyan "2 open" in plain 12px words ("+2" read as a bonus). Row cards' rule boxes start on one line ( flex-start), whatever their rule's length. Past the Spare (or swap) row's edge the next card shows under the same 48px fade; that row has no column of its own at the right either. The zooms set their actions in a 270px column beside the card (on wider phones card, a 96px gap for the steppers and actions centre as one group, the stepper gutter counted; on narrower ones a zoom with no steppers, Lost or reveal, centres card and actions 32px apart), give the card's art the rule panel's spare room (62em), keep a lost card's sticker and dimming, and share one headline style (display 18px: cyan "Slot N · in your hand", orange "Lost by" with its clock in the side column, gold "New card"). A lost card's zoom gives each action a full-width row: Buy back to hand (◆240 · slot N) when its student has an open slot, Buy back to spare (◆240, outlined), Close; its clock line says "N runs left to buy it back; after that it is gone for good". A hand card's zoom: titled, on the rail, "REN'S HAND" small over "Slot 2 · at risk" (and, at risk, "lost if Ren falls" under it) (one line; the one-line "Slot 2 · at risk · Ren's hand" wrapped with an orphan); ‹ › hug the card's edges at mid-height (inside .pullreveal-cards;fit()counts only cards there when it re-sizes a zoom) and step to the neighbouring hand card; "Move to N" (a shield marks a safe slot; the second line says "with", or in a warning coral (not Safe's gold) " loses Safe" / "this card loses Safe" when the move crosses the safe line; only real slots); Swap (or, in the Clubhouse with nothing spare, "Get a card › · no spare to swap", which opens this deck's Spare view); Put back (to spare); Protect (set a little apart, filled gold: it spends, as the card's chip and the confirm do); Close (on a landscape phone the zooms' Close is the same 44px ✕ at the top right as every Clubhouse page, .zoom-close, appended to the scrim itself so it lands in the screen's corner (inside the action column it resolved against that column, 60-170px off), its name kept "Close" byaria-label(the drawn ✕ would otherwise join it); a backdrop tap still finds it). The sheets are opaque; a tap on their backdrop presses only an exit (Cancel, Close, Done, Keep as spare), never a spend. The Protect sheet (Cancel then Protect off the rail, iOS order; on the rail Protect stacked over Cancel, as the zooms stack their actions) says which slots are safe already, and is laid out as the zooms are: no boxed panel, the card at the zoom's size (bigTc(1), printed as the zoom prints it: kind named, rule 15px+, the trigger line kept inside the card), a gold display title, and the words, price and buttons one centred block (12px apart) beside the card. Gold marks the spending buttons only (Protect, Buy back). Swap is a mode: the top-right ✕ is its Cancel (labelled "Cancel the swap" while swapping, gold-ringed, still 44px, a small "Cancel" caption under it; round 29's "✕ Cancel" pill covered the third title; round 30's second Cancel at the column's foot was one action with two controls, gone again in round 31); the left column widens to 94px (80px on an SE, so its second candidate clears the ✕), the one place the row moves as the mode opens, and stands exactly as tall as the cards, level with them (height: calc(var(--tct) * 105)). It holds the outgoing card's art at its card's proportions (94x132, framed in its kind's colour, the face in frame; stretched to the column's height it cut the subject off at the neck), its words reading across beside a gold bar on a visible surface ("Pick a card to swap in" first, in cyan; then the slot in the hand row's words, "Slot N · at risk" in coral or "Slot N · Safe" in gold; the card's name is on its art; then the rule (no break inside "Slot N" or "at risk"; an SE stacks the two); the art on the cards' top line, the tag under it only as tall as its words (run down to the cards' foot, it read as an empty framed box), the art at its card's proportions, its face in frame) (Back cancels the swap first), and the row holds only the cards that can swap in; a candidate's zoom names what goes out, rule and all, and a swap ends on the hand with a toast ("in · back to spare"). A single pull's reveal stacks Add to hand (naming whose slot it lands in), Keep as spare and, last and set apart because it spends, Pull again (◆120 and the balance, Build Deck). Tests: test/deck-phone.browser.mjs(swipes the rail's rows sideways),test/card-ui.browser.mjs(prices and reach on the rail),test/awakening-ui.browser.mjs(the rail's recipe tags),test/menu-ui.browser.mjs(club-deck*,deck-swap,club-crew,club-storestates;MENU_AUDITfor the judge captures, which judge a target by the part its scrollers leave visible).Build Deck (
ui.showDeck, from Clubhouse, the roster footer's Club Deck): one screen for the whole club. Desktop and tablet layout: the masthead carries the title, the crew (a portrait button per unlocked student, pressed for whose deck this is, each counting its hand), protection and the coins. Clubhouse shopping lives in Store (◆120 per card, ◆330 for three). Under the navigation are the row and the hand. The row is the club's cards floating in space, side by side and facing you, one long carousel that scrolls sideways: one entry per free copy of every card in the set — face up when the club owns it (two spare copies are two cards in the row), a dashed blank with only its number when it does not, dimmed and tagged when this student cannot field it — with a tag under each (Take, Not Pulled, what it needs). A card in Yui's hand is seen on Yui's tab, not in the row, unless the club has another copy. Walk the row with the arrows (a page at a time, hidden when everything fits), the wheel, a swipe, or the arrow keys (the menu's own spatial focus walks card to card and scrolls the focused one into view); the edges fade under the arrows. Hovering a card tilts it toward the pointer. A tap opens a card big — three quarters of the window tall; a blank comes up as a blank; a card that is in the hand pulses a glow in its colour, because it is live — with Take (or Put Back for a hand card) and Close under it. The hand is a fan below — the front of the hand is the left, index 0 of the saved deck — with its spare slots outlined after the cards and the next slot locked at the end (a lock, its number, and its price once the mastery rank is earned or the rank it needs; tapping it opens it throughgame.openSlot). Desktop: drag any card you can see down into the hand to take it — into a gap to put it there, onto a card to swap (the hand card goes back to the row and the new one takes its place; the target lifts and pulses gold and the caption says so; a full hand swaps with the nearest card) — drag a hand card up to put it back, drag within the fan to reorder (the fan reflows live, a card-sized ghost follows the pointer). A drag let go on the row changes nothing. The row keeps its scroll position through every change.Phone card management: touch devices whose shorter
screenedge is below 768 CSS pixels use Collection and Hand tabs instead of the combined carousel and fan. Browsers cannot reliably measure physical inches; this screen-size proxy stays stable through rotation and browser chrome changes. Collection opens first. The crew and coins stay visible, and Hand shows its filled/available count. Each pane is a native vertical card grid; Hand numbers its slots and labels protected positions Safe. Every pulled card in Collection prints its name under the thumbnail, as the hand does; a free card carries no "Take" line, and one this student cannot field says why under its name (Needs A Special, Name Only). Tapping a collection card opens its details. Add to hand fills a free slot and opens Hand. If the hand is full, choose an outgoing card and confirm Replace; the incoming card takes that position and the outgoing card returns to Collection. Cancel leaves the hand unchanged. A hand card's details offer Put Back, Move earlier and Move later; protection belongs to numbered positions, so Safe follows the slot when cards move. Empty slots open Collection. Swipes only scroll: they never drag, reorder or remove a card. Pull and triple-pull live in Collection's footer; Protect Slot lives in Hand's footer while an offer remains. The next locked slot remains a purchasable tile in Hand; its slot and its price (or the rank it needs) print at reading size, and a tap asks Open Slot / Cancel with the price before it spends. Each purchase's price stays visible beside its action in the relevant pane. Switching crew or rotating retains the selected pane. Choosing a card to Replace hides the Protect footer to give the hand room. In landscape the Hand is one row sized to its pane so every name shows. A landscape inspection also repeats the card's rule and requirement at reading size beside the card, and a single pulled card stands beside Add To Hand / Done the same way; a triple pull keeps 170px under its cards for the actions.Shared behavior: the deck saves on every change through
claimDeck; rearranging is free. Opening the screen saves a never-saved deck the way the run would fill it, so the row shows what is really free. Pull debits coins, draws throughgame.pullCards, writes a receipt and flips the new card face up, big, over everything (.pullreveal, above the fan; the screen behind is inert until Done); Add To Hand puts eligible new card types at the back of the hand when slots are free; copies already in this student's hand do not consume the reveal's addition allowance. Triple reveals scale to narrow portrait screens and can scroll vertically, keeping every card and Done reachable. Canceled drags change nothing; closing a card viewer restores its opening focus. Once the pullable set is complete (pullSetComplete), the button reads Copies and the reveal says Another Copy. Boss trophies never block this transition or appear in paid pulls;setCompletestill includes them when checking the complete collection. An empty pull pool has no more shop copies to offer. Pause › Hand shows the current hand, with accepted boss cards marked Found · Unbanked. On landscape phones (max-height 500px, min-width 600px) each card's name sits under it above its tag,_fitHandsizes the row from the slots' measured chrome so five cards fit across an iPhone SE, and a tapped card stands beside its tag and trigger, its rule and its requirement at reading size (any tap still closes it). On desktop and tablet, sizes come fromfit()in the screen (--tctfor the row,--tcffor the fan), not from media queries, because the inline--tcon a card outranks any stylesheet rule; the screen's centring spacers are zeroed on this screen so the row takes the whole height. The hand keeps a fixed share (45% of the height, 150–340px); the row takes everything else and its cards stand three quarters of it, centred — the card scale is 75% of the row height less the tag and padding, capped only by width — so the club's cards scale with the window while the hand stays put. The generic.screen .btn:hover/.focusrules inindex.htmlresettransform, so the fan's transform rules carry.screen .deck-fan .btn.fan-cardspecificity and the row's cards pintransform: noneon the button (the tilt lives on the card inside); a new card-shaped button needs the same guard.Save:
cards { owned, base, pity, log, lost }(normalized on every load;basepreserves unreceipted legacy copies,ownedis a derived cache, the log deduplicates by receipt id, pity takes the max),decksBy(whole per-student decks, this device wins a conflict),slotsBy(max across saves). Art ships asassets/cards/<id>.webp(scripts/bake-cards.py), inlined into the single-file build like the other art folders.Signature paintings (2026-09-23): All nine boss signatures have their own action scenes. The first three use a jade mop crescent, an amber spotlight pinning a marked curse, and a crimson pen tearing through paper. Stand-In uses the Substitute's violet arrival shockwave; Final Warning a golden clock-hand finishing strike; Stay After the furnace golem's converging chains; Shared Credit two linked cyan hits; Watchful Eye a two-mask aperture breaking hostile shots; Office Hours stolen violet energy at the Vice Principal's doorway. Full art is 800×600, with 560×420 deck thumbnails.
src/card-art.jsis the shared presentation registry for the deck, viewer, hand, exchange, receipts, notices and ground-card atlas. All nine retain their boss portraits as fallbacks.bossArtstill identifies the boss and its reveal style; rules, ownership, rarity and rewards are unchanged. Missing paintings retry the matching boss portrait once, then retain the existing no-art/foil fallback. Paintings center-crop; portrait fallback keeps its prior framing. Detached thumbnails decode before insertion, including successful retries. Single-file thumbnail requests reuse embedded full art when the thumbnail is absent. Ground-card images preserve aspect at decode and center-cover the existing 256×144 tiles, without stretching 4:3 art. The shared 1024×864 atlas, pickup pool and safe-screen activation contract stay unchanged; pickup time adds no new decode or upload. Bake selected hyphenated masters withscripts/bake-cards.py --ids clean-sweep marked-late answer-key; the legacy bulk bake still ignores hyphenated old copies.Measured so far (I-205, the dealt-deck experiment): a five-card hand landed in the same band as the maxed perk sheet on ren@yard MIDTERM. The club deck has not been measured yet; the runner's
cardsoption still drives the dealt-deck arms for comparison.
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.
- The stake. A room run puts the hand on the table with the haul the
moment the player takes Push On at a bell; the next bell banks both. A
school run puts it on the table at the first push or door and it rides
until the crew is back at the club (Return To Club clears it; a suspend
keeps it in the save). What is on the table is
stakeFor(deck, { owned, locks }): everything past the locked front of the hand, minus the floor — the club's last copy of a starter card is never lost (isKeeper). Nothing at stake means no stake at all.save.stakeis{ pushes, by: { charId: [ids] } }; the clear card's risk line beside Keep Going counts owned cards and carried found cards at risk. School Run Details separates the owned stake from found rewards, and Pause › Hand tags each card Safe or At Stake. The resume wrapper also stores versionedcardRiskwith the exact stake and push count alongside the Sim snapshot. Resume validates those cards against the saved crew and preserves an explicitly safe hand. Legacy wrappers use their existing permanent-save stake when available; they do not acquire a new retroactive stake. - Replacement and protected slots. New risk is calculated from the
starting hand's slot positions, restricted to original owned cards still
equipped when the player pushes. A card replaced before its first push
does not acquire a new stake. Any existing stake is retained even after
that card is replaced; swapping cannot erase a risk already taken. Resume
validates this retained stake against
originalCards, not the changed live hand. A found copy never enters the owned-card stake or gains Safe Slot protection, including when placed in a protected position. Its separate unbanked receipt identifies it even if the club already owns another copy of that card identity. - The loss. A death, or a quit, with a stake live rolls every staked
card against
LOSS_CHANCE[pushes](0 / 0.3 / 0.6 / 0.8 by how deep the run pushed; the last holds) and at mostLOSS_MAX_PER_DEATH(2) go, the unluckiest rolls first. The rolls come from the run's seed and the second of the death (lossRolls), so a replay loses the same cards and nothing can be argued.loseCardtakes one copy off the collection, out of that hand only, and records the loss (cards.lost) so the receipts cannot resurrect it on a reload. The actual deduction checks the remaining copies again, so two teammates losing the same starter cannot take the last copy or create a phantom recovery offer. A forfeit at boot — a crash's leftovers — rolls nothing. The death screen shows what the night took and where it waits. - The Lost & Found. A lost card waits
LOST_FOUND_RUNS(3) runs; every run started ages it (ageLostFound) and persists that change. What runs out is recorded as expired, so an older cloud save cannot restore it. Each offer names a particular loss, including two copies lost at the same timestamp. In Build Deck it rides at the front of the row, dimmed, tagged with its price and the runs left; a tap holds it up with Buy Back (◆240,buyBack→game.buyBackCard). Recovery records a durable resolution; resolved offers and recoveries that would exceed the copy cap grant nothing and cost nothing. - Locks.
locksBy[charId]protects the front of the hand: slot 1 first, each bought once behind a mastery rank (LOCK_RULES: rank 1 ◆600, rank 2 ◆1,200, rank 3 ◆2,400, then rank 4 at ◆4,000 to ◆10,000 for slots 4–8; prototype prices), never more than the slots,game.openLock. A protected card is never at stake; the fan wears a gold Safe chip on each, and the masthead's Protect Slot N button buys the next. Rearranging is still free, so the front of the hand is where a favourite goes. A whole hand on lockdown is the late-game god Chris described. - Save & merge.
locksBytakes the larger count per student; the stake is this device's. Loss records have stablelossIdvalues and optional recovered/expired resolutions; recoveries recordresolvedAt. Purchases, losses and recoveries replay in stable order from a fixed legacy baseline, enforcing the copy cap and starter floor at each operation. Local operation times advance beyond all observed operations, even within one millisecond. An impossible loss gets a derivedapplied: falseand cannot offer a recovery; two offline saves cannot each lose the same sole spare starter and sell both back. Lost & Found joins by loss identity, keeps the fewest runs remaining and excludes resolved or unapplied entries. Pending offer metadata stays on the loss ledger, including hidden rejected losses, and ages there; a later merge cannot reset its recovery window. Acquisition receipts and loss identities are retained rather than dropped from a rolling history; legacy records migrate deterministically. A stale save cannot revive a loss or sell its recovery twice. - The economy diagnostic.
scripts/card-economy.mjsreads graduate runner JSON and computes the expected capped number of independent card losses at each recorded push depth:E[min(Binomial(eligible, chance), 2)]. Five eligible cards at 60% each lose 1.90272 cards per death, before averaging over all completed runs.--handand--locksdescribe an assumed pooled crew stake; protected starter copies must be excluded. This is arithmetic over recorded outcomes, not a simulation of changing decks, buy-backs, card power or collection saturation. Capped, running or incomplete records are reported separately and suppress cell projections. The earlier ~0.3 losses/run claim and ◆300–400 pricing recommendation are withdrawn: that calculation rolled only once per death and treated unfinished runs as deaths. Pull prices remain ◆120 / ◆330 for three; a price decision needs a new study underplaytest/PROTOCOL.md.
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.
- Ground choice:
sim.cardDropsholds{ id, cardId, x, z, preferredOwnerId, dismissed }. The kill owner is only the preferred recipient, with a living active fallback. The card is placed separately from the ordinary boss pile; approaching it after the exorcism opens the choice. Domains, a pending level choice and a live Flash Step block the combat picker. Cleared results and school checkpoints also offer card inspection, including a last-frame boss drop. - Presentation:
src/card-pickups.jsrenders nine pooled, beveled foil cards using existing boss and collection artwork in one 1024×864 atlas (3.38 MiB GPU), so an owned card left behind has its actual art too. Decoding and texture activation happen during safe loading; the combat sync creates no GPU resources. Pickups wait for the exorcism to finish. The choice dims and freezes the arena. Like Build Deck, its top pane shows the ground card and its bottom pane shows the selected student's hand, with crew portraits and an explicit open slot. Take/Swap commits the choice; touch, keyboard and gamepad share it. Small hands scroll horizontally. - Equip or leave: choose any eligible living teammate, then fill a free normal slot or explicitly replace a chosen card. No duplicate card identity can occupy the same student's hand. Leaving keeps the card on the floor; walking beyond the re-arm distance allows another approach. Checkpoint inspection can revisit left cards. Unclaimed ground cards do not cross a school-room door; their boss identity remains spent.
- Replacement: a displaced owned card loses its run effect but is not
deleted from the collection or saved club deck. It replaces the incoming
card on the ground, at the same position, and can be recovered or given to
another eligible teammate. The screen stays open and updates both panes
after each swap; Leave resumes without immediately reopening the pickup.
A displaced found card moves its original receipt back onto the ground;
recovering it restores that same receipt, never another acquisition. The live
build is adjusted for the recipient, including active/benched stats and
the shared Room Run or individual School Run health pool. Earned upgrades
remain; replacement does not heal, refill energy/domain/dash charges,
reset cooldowns or revive a student. Starting hands remain in
originalCards, and prior owned-card stakes survive replacement. Transferred owned cards retain their original student's slot protection and stake viacardOrigins, so crew swaps cannot protect a previously unprotected card. - Receipt ledger, not an extra hand:
sim.cardPocketnow contains only accepted{ id, cardId, ownerId }receipts. It grants no effect and no extra capacity. Accepted hands and receipts cross Push On, After Hours, school-room doors and suspension. A holder's knockout disables their effects but does not remove the accepted receipt from the surviving crew. - Bank or lose: only accepted cards still in their recipient's live hand become permanent when returning to the Club, leaving cleared results for roster/stages, or choosing a safe New Run. A bell alone does not bank them. Failure or abandonment loses unbanked cards without a Lost & Found offer; the existing rewarded revive retains the hand and receipts. A surviving school crew can bank a kept card held by a KO teammate. Unbanked cards have no Safe Slot protection. Once banked, they follow ordinary deck, copy, protection and card-loss rules and remain excluded from paid pulls.
- Receipts and resume: Game mints a 32-character attempt ID outside the
Sim RNG; each reward has a stable
<attempt>:<serial>receipt.applyBossCardpreserves pull pity and records even a capped acquisition, so retries and stale cloud copies cannot reclaim it after a later loss. Wallet and_localRuncheckpoint share one local write; the older resume key is only a compatibility mirror and_localRunis not cloud progress. Accepted choices save the hand, removed bonuses and receipt together; storage failure restores the prior choice. Cleared results restore without paying or counting the win twice. Owned ground cards usesource: 'hand',originalOwnerIdand an<attempt>:hand:<serial>identity from the separatecardExchangeSerial; these cannot enter the boss-reward ledger. Ground cards and ownership provenance survive suspension; room doors still leave ground cards behind. No exchange consumes combat RNG. - Legacy pilot migration:
cardLoadoutVersion: 1marks the slot-based contract. A prior Clean Sweep pocket receipt fills a free normal slot if its living owner does not already carry Clean Sweep. Otherwise it becomes a ground choice at the player, with the same receipt and spent boss identity; migration never silently evicts an existing hand card. That pending card must be accepted to activate or bank. New snapshots retain health-upgrade order for later replacement; older health adjustments use known Binding Vow ranks without replaying or refunding earned upgrades.
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):
- Graduate path, Ren skill 0.25: after bell 1 went 4 hits / 0.86 → 5 / 0.83, after bell 2 8 / 0.83 → 8 / 0.76, the late Final 16 / 0.12 → 15 / 0.01; wins 8/16 → 7/16. Yui 0.25: after bell 2 0 / 1.00 → 3 / 0.94; wins 8/16 → 7/16. Skill 0.8: unchanged (16/16, 15/16).
- School run yard → gym → library (
--rooms, added to the runner for this), Ren 0.25: the library's first minutes went from 0 hits to 7 and its second push to 22 hits / 0.42; gym after bell 2 0 / 1.00 → 10 / 0.88. At 0.8 the gym's post-bell windows are still 0 hits: a competent build at Final strength out-damages any body the ladder can put in front of it, so the residual calm there is a damage-output question (the carried build), not an enemy-hp one. Wins at 0.8 went 16/16 → 13/16 with the deaths in the library's late stretch; the first cut withfloor0.3 made that stretch lethal (9/16), hence 0.15.
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.
- The gate (
game.startRun,resumeRun,enterSchoolRoom): when the view hasprepareRun, the call shows the loading card (ui.showLoadingon the arrival splash, with a step line and a bar), with plain status labels: Loading scene / characters / textures / lights. The loading copy is vertically centered inside its banner independently of the arrival animation's translate offset; wrapped room names expand the banner on narrow screens instead of clipping the text or progress bar. It sets stateloading— the loop draws nothing in that state — and runsview.prepareRun({ map, party, step }): two animation frames so the loading card actually paints before the promise continuation builds,setMap(the synchronous build), the party's rigs built ahead (preloadChibiRig: bytes fetched once per session, a full independent rig parsed and built, handed over atsetCharacterso no parse lands mid-run), companions, a wait for the art still arriving (explicit FX manifest/load promises plus a probe onTHREE.DefaultLoadingManager, which cannot see ordinaryfetchrequests), thenwarmRoom(unique material textures, shader-uniform atlases and detached party rigs uploaded in slices of at most six uploads or 4ms, unchanged texture/source versions skipped).Post.compileissues the world against its actual HDR render target, plus bright/blur/composite passes against their own targets. Detached rigs use the room's lights. A poll onprogram.isReadylets the GPU link in parallel before one warm draw. Cached companions also compile their opaque and fading material variants against those same lights/target, so the first shikigami expiry cannot discover a new shader. Original material state is restored afterward. Then the call re-enters itself with_preparedset and continues. A view without the hook (the tests' doubles) starts at once. The whole gate gives up at its timeout, so a slow network can only delay a run, never hold it. Generation guards discard stale progress and completion after a canceled/replaced gate. Starting another room also cancels older shader warmup; returning to title releases prepared rigs. A failed character preload removes only its own prepared promise. If a canceled or already-claimed parse fails after a replacement starts, the replacement stays available forsetCharacteror cancellation disposal; the stale failure cannot force a duplicate parse or orphan the new rig. - The hold (
_openRun): once the sim, the rig, the HUD and the companions exist, the run opens — the arrival card, staterun, the mouse — only afterview.warmRun: wait for the prepared character head to attach, upload its new textures, issue the assembled frame and post programs, poll the links, then draw once behind the card. Compiling the correct render-target variants removes the former second discovery draw. The shared gate dismisses its loading splash before invoking the arrival or resume callback, including warm-up failure and views without that hook. Stale completions cannot dismiss a newer loading screen. This also covers continued runs and new runs with arrival intros disabled. The engine's owncompileAsyncis not used: its poll threw on a material without a program and never resolved. - Initial gate measurements (before the optimization below): the run's first four seconds were
clean everywhere — gym cold 1 frame over 50ms (worst 50), library cold
0, a resumed night 1 (66ms), a repeat gym 0 (worst 18ms) — against 6
over 50ms with a 550ms worst on the old yard start and a 5.5–6.3s
first frame indoors. What remained was inside the gate: a cold indoor
room takes ~10s on this machine, ~6s of it the first full draw, which
creates and links about thirty programs
compilecannot reach (shadow depth variants, the post pipeline, sprites) and waits on them in the same frame. A repeat entry is ~1.8s, a resumed night ~2.8s. - The menus (same day). Measured the same way, every transition:
the one that hurt was returning to the title.
showTitlerestarted the demo behind it every time — a thousand synchronous sim steps, a new primitive rig, and a fresh head model fetched and parsed for a random student (600KB, 50–150ms of tasks per return). Now the demo is kept across menu visits (a new one only at boot, after a run, or when the last ended). Its warm-up now runs with a 3ms CPU budget and a 150-step ceiling inside_stepDemo, retaining the same total steps and RNG order; its board is smaller (140 enemies on desktop, 100 on mobile), and opaque pre-run menus and the intro do not step or draw the hidden board. The title still animates. On a new demo, its controls paint first whilewarmRunprepares the pipeline; normal drawing begins after preparation. The constructor's existing Yard is reused, not disposed and built twice. Pause and level-up keep their frozen gameplay backgrounds and existing effect policy. Menu art is decoded off the thread before it is painted (Image.decodefor the roster portraits and stage thumbs), the card row's art isloading="lazy"so fifteen cards decode only what is on screen, and the portraits are 720×960 (originals in the archive underassets-originals/). The initial reuse pass measured back-to-title at a few milliseconds with nothing fetched (was a 51ms and a 113ms task), the boot title has no long task (was 334ms), the title's idle worst frame is ~34ms (was 67–72), and only two first-open stalls remain on this machine, the roster's and the deck's first paint, both image work. - Card menus: title fitting batches all geometry reads before font-size writes. Closing inspection preserves the existing hand/collection nodes, decoded art, scroll and focus unless the player changes the deck.
- Boot scheduling: optional analytics and queued feedback wait until after paint/idle; cloud save and native/crash setup retain their order. Resize events coalesce before drawing, including iOS delayed remeasurements and otherwise unannounced viewport/DPR changes. No graphics quality, gameplay or update policy changes.
- Late effects: base, HD and exorcism atlases distinguish downloaded
(
loaded) from usable (ready). A late page stays detached, with no HD route published. Loading/title/menu/pause screens prepare it in slices: upload, compile for the actual target, await shader linking, prime instance buffers with a transparent draw, then yield before activation.run,winninganddyingprohibit this work; every asynchronous continuation rechecks that guard and the room generation. Timeout/network failures keep the existing fallback until the next safe screen. Image-load callbacks and first-critical-hit sync must never issue renderer compilation or uploads. Opaque menus also service pending preparation even though they do not draw the hidden world. A canceled older worker cannot consume a newer room's gate. - Pooled orb ownership: base-orb sprite handles belong to the View, never to simulation shots. Crew switches clear the effect pool while existing shots can survive. Each handle must still be active and follow its private anchor before it can be moved or stopped; a cleared/recycled handle is reacquired, with a safe retry when the pool is full. Removed shots release only their own handles. The domain-ready loop likewise rejects stale slots.
- Combat HUD and diagnostics: unchanged HP/clock/domain/weapon text keeps its existing DOM nodes. Kill-chain pulses use cancelable transform animations without a forced layout read; notices finish geometry reads before writing changed CSS variables. The blackbox keeps exact current phase in memory, but routine persistent phase/history writes are each limited to once per 500ms. Important transitions still force immediate persistence; resuming rearms the recorder. Crash recovery reports the last sampled phase rather than claiming to identify the exact instruction that froze.
- Compact run saves (2026-10-06):
Sim.snapshot()writes each enemy once. The spatial grid stores only occupied bucket indices and ordered enemy IDs; query scratch arrays are omitted. Zero entries preserve the previous grid's dead occupancy, which immediate Flash Transfer input counts. Restore binds new IDs or legacy embedded buckets to the actual restored enemies, preserving pre-movement bucket order and leaving post-grid summons unindexed until the next simulation step. Saves without grid metadata rebuild it. The snapshot envelope remains v2; RNG, five-second autosaves, wallet durability and combat rules are unchanged.test/snapshot-caches.test.jscovers immediate attacks, legacy v1/v2 saves, dead occupancy and subsequent deterministic continuation. - Repeatable profiling:
test/loading-performance.browser.mjsbuilds an isolated production bundle, blocks external side effects, then measures intro, title, roster, deck/inspection, map and Yard/Gym/Library/revisit gates.LOADING_SOURCE,LOADING_HTML,LOADING_CSScan freeze a baseline;LOADING_REPEATS,LOADING_PHONE,LOADING_CPUandLOADING_OUTPUTcontrol sampling. Desktop Chromium phone layouts are not physical Safari measurements. Reports belong in the external archive; latest results and remaining synchronous construction costs are recorded in the session log. - Sustained combat profiling:
test/combat-performance.browser.mjsmeasures natural Chrome frames, with audio, run autosaves and blackbox enabled. UseCOMBAT_OUTPUToutside the repo,COMBAT_KIT=full,COMBAT_PARTY=ren,yui,kaito,soraandCOMBAT_SECONDS=600for a longer equipped-crew run. Five-second windows include frame tails, method/storage costs, events and renderer/audio resource counts.COMBAT_SOURCEfreezes a control;COMBAT_LATE_FX_MS=8000reproduces late manifest delivery. Optional tracing starts before measurement, avoiding profiler-start suspension in gameplay results. The recorder stops at the requested deadline and rejects insufficient active gameplay. Synthetic bot/keyboard input is labelled; these isolated routed profiles have HTTP caching disabled, not a warm-cache or physical-device performance guarantee. The automatic loot driver binds weapon offers and uses Leave on card offers to keep the configured hand.
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:
hitStop— a hard freeze of a few frames on impact.timeScale/slowScale— slow-motion, eased, used by boss spawns, domain casts, death.platter— the level-up turntable: winds to a dead stop over 0.5s, spins back up over 0.45s, smoothstepped.
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:
- Ren / Iron Cell: oblique prison-bar shadows and red light.
- Yui / Silk Cathedral: broad cyan cathedral-glass panes with straight facets and a gentle shimmer. The floor frame stays fixed; no overlapping rosettes, radial ribs or waving threads cover the ground.
- Kaito / Blackout Grid: dark violet surfaces etched with circuit traces,
junctions and traveling electrical packets. Practical Point/Spot lights dim
to 12% at full strength; ambient, key and rim lighting remain for visibility.
Since 2026-10-16 the fake light those fixtures throw goes with them: floor
washes the kits tag
practicalWash(Halls, Cafeteria, Detention, Science Wing, Library, the Yard's lamp pools) drop to 15%, restored and re-dimmed each frame like the lights, and the mote halos and dust fade by the same 85%. Moonlight is never tagged, so window patches and beams stay; the Science Wing's cool fixtures got their own wash material for this. - Sora / Paper Storm: sparse, stationary gold paper-seal impressions with lit folds and ink marks. Airborne charms carry the movement; the floor has generous space between stamps and no spiralling wind pattern. The low airborne helix is also hidden because it reads as floor spirals from the gameplay camera; floating paper charms retain their existing choreography.
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:
- A tag costs
TAG_COOLDOWN(7s) AND appliesTAG_DOMAIN_LOCK(2s) on arrival. Bench domain charge is FROZEN, so without the lock you could rotate into a full bar and cast for free — that is the whole reason the lock exists. - A swap is refused while
domainFxis live. Swapping out of your own cast would orphan the effect. _onTag'sdrainEventsis for the level-up modal ONLY. That path does not run the event loop, so clearing the queue is how the swap event is consumed; doing it mid-run would throw away kills and pickups from the same frame.- The button hides entirely for a solo run (
body.hasclub). - Phone and tablet use the SAME CLOCK POSITIONS so muscle memory carries: DOMAIN at 12, STEP at 10:30, ELEC at 9 around the aim stick, TAG at 12 above the move stick. Only the radii differ (the tablet stick is r62 vs r50). Verified by measuring the angle of each button about its stick: both devices read 0 / 315 / 270 / 0.
- The tag cooldown is a CONIC RING on the button (
--tagpin degrees, set in updateHud), matching the elective badge so the game's two cooldowns read the same way. No CSS transition on it — it is driven every frame and a transition would make it lag the sim it reports. - techniques/upgrades stay run-global and character-agnostic. Do not couple an upgrade to a character.
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:
- Shared CE pool. It drinks from the same meter as Flash Step and
the elective (
SHIKIGAMI_SKILL.ce, 55). There is no second resource to manage — carrying both slots means casting each one less often, which is the intended cost. - An extreme cooldown.
SHIKIGAMI_SKILL.cdis 75s. Unlike an elective (where the meter IS the cooldown) the animal cannot be re-called for a long time, so calling it is a decision about the night, not a rotation slot.cdis the first lever to reach for,cesecond.
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.
- Ownership:
save.electivesBy[charId](array). Loaded:save.electiveBy[charId]. - Tuition escalates on that student's own count, so everyone's first elective is list price and a full timetable is a campaign you run four times.
game.buyElective(charId, id, then)— the purchase names the student.- The sim takes
electives: {charId: id}; the singleelectiveopt still means "this one student" and is what the sim tests and the bot build. - Party entries carry
electiveas a plain string, so snapshots still round-trip through the generic scalar copy loop.
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:
- Backdraft (45 energy): brace for 0.6s with 70% damage reduction, then punch a forward cone for 65 base damage. A hit during the brace empowers the punch to 1.6x. The punch still fires if nobody hits Ren.
- Binding Stitch (45): pin the nearest six visible targets in a forward cone for 2s, dealing 15 damage followed by a 48-damage snap. Bosses move at 80% speed instead of being rooted.
- Dead Zone (50): a stationary 2.4s void ahead of Kaito slows ordinary curses to 45% speed (bosses to 80%) and consumes ordinary hostile bullets. Its burst deals 48 base damage plus 7 per absorbed shot, capped at eight. Heavy bullets, held elite shots, beams and ground hazards are not absorbed.
- Curtain Call (40): slip up to 4.2 units in the movement direction (aim when stationary), respecting walls and props. Leave a paper double for 1.6s, then a 34-damage cutting-paper burst. Paper Decoy remains the longer stationary alternative. Neither double redirects bosses.
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.
- Character: four permanent perk cards (Honed Output, Long Legs, Wide Pockets, Night Owl), starting health, trained movement speed and base energy recovery. Earned Mastery remains a separate expandable four-rank track.
- Flash Step: its own progression page with Stride, Tempo, Echo Step and
Flash Step+. A base-technique node connects the four independent branches;
the inspector shows each rank's effect. There are no new prerequisites.
Stride/Tempo/Echo keep
save.stepBy[id]/buyStep; Flash Step+ keepssave.perksBy[id].flash/buyPerk. Charges and recharge reflect training. - Shikigami: one companion bond, with duration, base damage, energy and cooldown in the inspector. One unlock permanently enables the summon in its own slot alongside the equipped special; it has no invented ranks.
- Specials: two hero-specific technique cards, one equipped slot. Locked, owned and equipped states are distinct. Unlocking also equips; owned swaps and unequips are free. The next unlock price still doubles per owned special.
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:
.chardetailisposition: fixed, not absolute. It lives inside.screensoclose()still tears it down on navigation, but.screenreserves a stable scrollbar gutter, soinset: 0there measured 1265x785 inside a 1280x800 window — the film stopped short of the edge and the "half" panel was half of the wrong number.- Skill cards use
.cdskill, not the roster's.cardlayout. Their square proportions and artwork must not inherit horizontal roster sizing. - A purchase redraws the body only, never the sheet. Restore focus to the action, or to Done when the action becomes disabled. The sheet owns keyboard/gamepad focus until Close/Escape returns it.
- On landscape phones the body is budgeted to fit above Pick/Close.
.cdbodyis the only scroller and shows no scroll cue, so at max-height 460px the Character tab's Mastery row and the Flash Step tiles' stars sit above the footer: tighter gaps, a one-line base-technique node, the Character note hidden, and footer padding ofmax(10px, --sab)rather than the two added. The Shikigami tab's lone tile joins the 4-column grid there instead of growing to 215px. Opening Mastery scrolls the body so the perks show. Rules live in theMOBILE PASS · rosterblock ofmenu.css.
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:
- The creature is baked onto the summon at cast time, not looked up per
step. It belongs to whoever CALLED it, so tagging out mid-hunt must not morph
the animal on the field into the next student's.
sk.whocarries the owner and the view rebuilds the mesh only when that changes. buildHound(spec)preserves the shared game/showroom API and delegates tosrc/shikigami-model.js: a sculpted black hound, a two-legged white heron with folded wings, an armored horned thunder beast, and a paper fox with five folded tails. Each has an articulated gait, head and tail/wing poses for idle, movement and lunging. Root placement remains simulation driven. No stretched lunge proxy or external model fetch is needed.
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:
- Bulwark (Husk/Brute, sometimes Grub): locks a 120° frontal shield for 2.4 seconds, taking 60% less directional weapon damage from that side. Its shield opens for 1.4 seconds. Flanks/rear, specials, absolute damage and domain attacks bypass the shield.
- Pouncer (Wisp/Lurker/Moth, sometimes Grub): marks a locked direction for 0.85 seconds, dashes at 10 units/second for up to 0.65 seconds, then recovers for one second. Scenery stops the dash. The windup cannot deal contact damage; the existing collision, chill and Silk slow rules still apply.
- Conductor (Spitter/Warden): gathers at most six existing ordinary hostile bullets within six units and line of sight. They orbit harmlessly during a 0.95-second warning, then the same bullets release in a locked fan. It never captures player weapons or boss shots and creates no extra bullets. Without available bullets it uses its original ranged behavior. Killing it or casting a domain destroys its held ammunition.
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:
- The club breathes on the wave: bench CE regen ×
benchLullMultwhile a pressure lull is open. Cast domain → wave breaks → tag on the beat. - Fresh entrance: for
freshWindowseconds after tag-in the elective costsfreshCostMultof its bite (0 = free), and it is ONE cast, not a spam window (freshTzeroes on use). The entrance is the elective moment — each student's own shikigami makes a different entrance. - The tag button shows the bench's lungs: cooldown ring while cooling,
then the ring is the next member's CE;
flush(≥90%) glows and breathes.
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:
- The wave breaks when the domain RESOLVES, not at cast. A recoil at cast time shoves enemies out of the domain's own kill field (Blackout's strikes, Silk's slow), nerfing the mechanic it serves. Spawning freezes at cast; the lull countdown and the shove wait for the after-effect gate to clear.
- Spawns are deferred, never deleted (non-negotiable #3: spawns are the XP economy). The bank must drain; watch it in any tuning run.
cooldown(30s) is the economy valve. Late game casts a domain every ~30s; without the gate, lulls chained, the bank never drained, and the night spawned 45% fewer enemies per minute (657-702 kills/min -> 297-405 at t>=480). With it: room% (beats with <3 enemies within 8u) 26-29% -> 45-49% late, field size back to baseline, kill pace ~-20%.
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:
recoverFracis the load-bearing bound. v1 banked the FULL hit and converged on damage immunity while firing: 16/16 wins at every skill, near-miss zero. Rate constants set how fast the refund arrives;recoverFracsets how much.- The valve/rally RELAY at 300s is one shared edge on purpose. Moving one moves both.
- The cast lull and the damp are the anti-perpetual-domain devices. Shipped FINAL numbers: novice 1/16 with deaths reaching p75 10:09, competent EXACTLY flat at 5/16 vs the taper baseline.
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):
- Push —
game.persist()→cloud.schedulePush(save), debounced 8s so mid-run persist spam collapses to one write of the latest state. - Boot —
game.cloudBoot()(called from main.js): if a card is held, pull a frozen boot candidate and apply it only at title, merging against current receipt progress while preserving local settings/equipment. A late response cannot replace the save captured by a live menu or run. Coins retain spending/earnings since the boot snapshot; linking a different Club Card invalidates the candidate. This does not replace the cross-device maximum-wallet policy with a durable transaction ledger. If NO card is held but the save shows real progress (runs > 0 || fragments > 0), boot also schedules a push — a veteran from before this feature gets their card just by opening the game, while a bounce visitor's default save never mints a junk row. - Link — CLUB page input →
cloud.linkCard: pull that card's save, merge local progress in, adopt the card, push the merger back.
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:
- Axis mapping follows the screen angle. beta/gamma are in the DEVICE
frame; the game is landscape, so
_readrotates them byscreen.orientation.angle(falling back towindow.orientation, because iPhone Safari only grew the former in 16.4). Verified: a screen-right tilt moves the camera 100% along its own right axis, a forward tilt 96% along forward. - Auto-level. People hold a phone at any angle and settle into new ones mid-run, so the effect answers CHANGES in tilt, not the absolute pose: the resting pose drifts toward wherever the phone is (~6s time constant) and the camera follows the difference. Verified: a held 14° tilt eases 0.48→0.43→0.30→0.16→0.04 over 16s, and tilting back reads as the opposite.
- Clamped and small.
RANGE14° gives the full nudge,view.tiltAmt1.2 units along right and 0.7× that along forward — about 3° of camera swing at 24 units of standoff. 40° of tilt moves the camera 1.18 units, not 3.4. - Filtered. A 10Hz low-pass keeps sensor jitter off the camera.
- Opt-out, and off for reduced-motion.
settings.tilt(default on) shows as TILT PARALLAX in both option layouts on touch only. The gate isui.isTouch || input.isTouch || mobile—ui.isTouchis the codebase's own definition of touch mode and is what?touch=1sets, which is how the pane tests it.prefers-reduced-motionforces it off. Turning it off eases home over ~1s; it never snaps.
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.
- Secondary skill controls use flat white inline SVGs matching the shikigami: a lightning bolt for Circuit Break, a portal/arrow for Flash Transfer, and a paper effigy for Paper Decoy; a fist, needle, inward void and slipping double identify the four signatures. Glyphs change with the equipped skill; charge updates reuse them. They dim while charging, brighten when ready, and keep the active countdown ring. Existing card/store illustrations remain full-colour; new signatures share their vector emblem across loadout/HUD.
- Ready states must stay tellable apart without reading. The domain sweeps a red-to-white rim; the elective blooms from BEHIND its icon. A ring and a glow, not two rings.
- The TAG button shows the PORTRAIT of whoever is next, because a face answers "who am I about to become" faster than any symbol.
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.
- Boss arrival: an edge-to-edge cinematic strip shows a large crop of the
authored video and boss name for 1.3 seconds, including its shutter exit.
The opaque dark base and matching gradients remove the video/translucency
seam. Its copy clears the player health and Upgrade controls, above the
player's centre. Peripheral controls remain visible over the film; world
boss labels return at their current HP as it clears.
The upcoming film is loaded and primed on one muted frame twenty simulation
seconds in advance, then paused until arrival. At most two films/stills are
retained; replacement/menu/expiry pauses playback and restores labels.
Text never waits for media. Blocked autoplay retains the large still, and
video reveals only on
playing. Reduced motion and single-file builds use the still. The Janitor's text sits right to preserve his authored left pose. - Waves: WAVE INCOMING plus the authored name (such as WISP TIDE) lands as a cyan typographic impact for 850 ms. It has no full-width backdrop.
- Banked levels: a gold LEVEL UP impact shows the new level and current available upgrade count for 1.05 seconds. The Upgrade button also pulses; its existing count remains after the impact ends. Input hints follow the current device. Immediate level-up selection uses the same three-card draft with the lighter scrim and fully held arena described under THE BANK.
- TOP OF THE HOUR: one line, 850 ms, no secondary instruction. First Bell and room/hour chronology are unchanged.
- Ordinary toasts: a compact dark/gold strip with bounded wrapping. Both visual fade and removal use the caller's duration (previously the CSS faded every toast at 1.4 seconds, including messages requested for longer).
- A toast owns the lane (2026-10-17): a toast stacked below a banner
landed just over the student (desktop, 30-50 px above the head in a
measured Detention frame) or on them (a landscape phone under 500 px tall).
While a toast shows it holds the lane for the LEVEL UP only
(
NoticeLane.hold, by key): one showing ends at once and a new one waits, then goes up if still fresh (pending notices expire after two seconds; the Upgrade button keeps the count; a notice that waited behind the cut level, such as an EXORCISED, goes up at once). Every other banner keeps the stack: an EXORCISED (a boss kill's reward toast follows it at once), a held bell, a wave or an elite carry news a toast must never erase (a first version held them too and was caught in review).test/notices.test.js.
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:
Contact shadows: four OPPOSITE tap pairs on a world-sized circle (0.42 units, projected through
projScale = 1/(2 tan(fov/2))), with a range cut past 1.6–3 units so far silhouettes do not halo. The crowd's feet and the crew's shoes get a seam of shadow on the floor.post.contactShadows(0.6)on by default on desktop; measured +0.2 ms at p90 in the gym with 30 bodies.The pairs are the whole trick, and the first version did not have them (fixed 2026-09-19). It took eight independent taps and asked whether each neighbour sat closer. That question cannot tell "something is standing here" from "this surface leans away from the camera": on a ground plane seen from a tilted camera roughly half the taps are nearer for free, the threshold saturates, and the entire floor shades as though occluded. The yard lost its court to it, crushed to near black, and every room's largest flat surface took the same hit. Summing an opposite pair cancels the planar component exactly (
dzA = -dzBon any plane, at any angle), so a floor contributes nothing and only a genuine step pushes the sum positive. If this effect ever needs re-tuning, raiseaorather than widening the radius: a wider circle starts sampling real geometry and the plane cancellation is what keeps the floor honest.Depth of field: while a menu dims the live scene (title, pause, level-up) the scene crossfades to a quarter-res double-blurred copy outside a 7-unit band around the crew (
view.menuDimsets it; the focus distance is re-asserted every frame from the camera). Three extra quarter-res passes only while live; pause at vsync.The room on the boss: a 64px cube capture from the first active boss's chest, one face every other frame (a whole capture in twelve — all six in one frame was a visible hitch), the bosses hidden from it; every toon body samples it as a fresnel-weighted reflection at 0.28 through the dissolve hook (the ink hull, a basic material, only burns). Desktop only, off on low quality and in the title demo. Capture correction (2026-09-21): manual face rendering must initialize
CubeCamera.coordinateSystemand callupdateCoordinateSystem(); otherwise all six cameras face -Z. Hold the capture origin for the six-face cycle and keep the reflection amount at zero until every face has rendered. Reset readiness when the owner/room changes or captures are disabled. Exclude both active bosses and bodies borrowed by the exorcism, since both sample this texture. Restore the previous target, cube face, mip level and XR state. Planar isolation (2026-10-06): Yard puddles, the Gym floor mirror and Roof rainwater reuse their cached main-view reflection during cube captures. Their capture hooks exit before advancing cadence or changing the texture matrix whenever the current target is a cube. This prevents nested scene draws and cube-face resets while retaining normal main-view resolution, update cadence and water shading. Phones keep their existing planar budget; boss cubemaps remain desktop-only. Planar transform reuse (2026-10-07): the outer render updates scene transforms before invoking those mirror hooks. Each synchronous planar capture reuses that update by temporarily disabling only the scene's automatic world-matrix traversal, then restoring its prior setting infinally. Reflection cameras still update. Main/cube passes, animation, movable room/prop roots and capture frequency remain unchanged. Render callbacks must not mutate scene transforms between the outer update and capture; current callbacks change visibility or material uniforms only. Reflection cull (2026-10-10): each capture also hides, for the capture only, every mesh whose world bounds lie entirely behind the mirror plane (src/reflection-cull.jshideBehindMirror). The Reflector's oblique clip plane discards those anyway; skipping them saves their draws. Parents, skinned meshes, non-meshes and instanced pools over 512 are never hidden. Measured in combat: Roof 234→210 draws/frame desktop, 180→169 phone; Yard and Gym smaller. Reflection textures and final frames stayed bit-identical (the Gym's full frame is not run-to-run deterministic even without it). Single-pass flat transparents (2026-10-10):warmRunmarks a transparent DoubleSide materialforceSinglePasswhen every mesh using it is flat. three otherwise draws it twice and flipssidewith needsUpdate, re-selecting the shader program on every draw. A flat mesh shows one face, so the picture is unchanged; the Yard loses seven draws and most of its per-frame program checks.reflectionCull.enabled/singlePassexist only so browser A/B checks can turn each off. Empty pool skip (2026-10-10,src/empty-pools.js): instanced pools sized for combat mostly hold zero instances, yet three still selects their program, refreshes uniforms and binds state before declining the draw, in the main pass and every planar capture (the Yard processed 74 empty pools per idle main pass). After a gate's final warm draw (warmRun/warmRoom) View armsEmptyPoolSkipwith every InstancedMesh that exists then; eachrender()clears the layer mask of armed pools whose count is zero and restores it infinally. It never touchesvisible(owners manage it), skips pools with their own onBeforeRender, and stays off during boss projectile warmup. The first warm draw is what creates a pool's vertex state, sosetMap,setQualityand context restoration disarm it until the next gate has drawn every pool once; pools created later warm themselves. WebGL call counts are unchanged (three already issued no GL draw for them); the saving is JavaScript render work. The warm-up test lists the DOF copy pass; the depth of field and the contact shadows are gated onfancy, so the slow-phone fallback drops them with the rest.
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:
- A phone composite variant.
compMatgetsdefines: { MOBILE: 1 }on phones.#ifndef MOBILEremoves the haze, the chromatic split (phones take the plain single fetch), the depth block (contact shadows and DOF), ink, the echo, streak and scatter adds, and grain, plus their uniforms and samplers. The phone program bindstSceneandtBloomonly. Everything a phone shows sits outside every guard: the shockwave loop and its lip (the phone big-moment waves ride them), combat clarity, speed lines, low health, menu dim, flash, dawn, grade, vignette, ACES and sRGB. The desktop program is the old one statement for statement. If an effect is ever turned on for phones, move its block out of the guard first, or it silently does nothing. - Depth discard. After the scene pass,
render()rebindssceneRTand callsgl.invalidateFramebuffer(FRAMEBUFFER, [DEPTH_ATTACHMENT]). A tiled phone GPU can then skip writing the depth buffer back to memory, about 3.4 MB a frame at 1363×629. Phones have no depth texture and the next scene pass clears depth, so nothing reads it. Three does this itself only in the Oculus browser. WebGL1 contexts and desktop skip it. Any future phone feature that reads scene depth after the pass must remove it. - No dead targets or programs.
rtC(scatter) andrtD/rtE(DOF) arenullon phones, andcompile()warms only the bright, blur and composite passes there. Phones used to compile the streak, scatter and copy programs and allocatertC/rtDon the warm-up bind (about 0.85 MB of half-float at that size) that no phone frame ever drew. Render guards the scatter and DOF branches on the target, and the streak branch on!mobile. Per-frame cost is neutral or lower: the draws stay 7 (5 afterreduceWork), the phone fragment program is a strict subset of the old one, and the only addition is one invalidate call per frame. Nothing here has been measured on a device; a driver that ignores the hint loses nothing.test/post-mobile-trim.test.jsholds all three: the variant's samplers and kept effects, every used uniform declared in each variant, the invalidate's place in the frame, and the phone warm list.
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:
- Light scatter (
post.jsSCATTER): a 24-tap radial blur of the finished bloom toward a screen point, one more quarter-res pass, added in the composite with a tint. A pulse (decays ~0.8 s) from the Domain cast and a boss arrival, projected from the world point (view.scatterAt); a hold re-asserted per frame for the sunrise (from above the top edge, warm) and lightning (cold). - Afterimage (
post.jsecho, revised 2026-09-21): short luminous trails accumulate the bloom into two quarter-resolution HDR targets. Subtract the current bloom before adding the trail, so stationary lights retain their brightness. The composite stays on the canvas; one extra quarter-res pass runs during a burst. The old full-frame feedback duplicated floors, court markings and earlier casts. History now expires between bursts and resets on room selection (including the same room), menus, resize and graphics changes. Both targets and the shader warm before combat; no first-cast allocation. Domain/dash bursts decay over ~0.4 s and retained light decays with elapsed time. Off on phones, low quality and reduced motion. - Ink (composite): a Sobel on the scene's luminance darkens edges a
hair so the frame reads as drawn —
ROOM_LOOK.inkper room, boiler and detention heaviest, gym lightest, never on phones. As of 2026-09-21, ink applies to the scene before bloom/scatter/streak/echo. Hot emissive pixels are protected; drawn surfaces keep their outlines while weapon glows avoid dark rims. No additional scene/depth samples or render passes. - Floor mirror (
src/floor-mirror.js): a Reflector under the gym's varnish renders the mirrored live scene every other frame at 512² and adds it back through a five-tap soften, warm, at 0.22, dimming toward the walls; motes hidden from the capture. Desktop only, off on the low tier. Honest note: from the gameplay camera's steep angle a planar reflection sits mostly under the body that casts it, so the payoff is lamp glints and cast light on the wood more than mirrored crew; the measured cost was nil, so it stays. - Damage ghost (HUD): the boss bar keeps a pale bar that trails the
red fill 260 ms behind after a hit, the fighting-game read.
Since 2026-10-17 the student's own health bar has one too (pale pink;
ui._hpGhost, Web Animations): on a real hit (the same student's HP falls) the fill drops at once and the ghost holds at the health before the hit for 260 ms, then follows over 520 ms; a hit inside the hold keeps the first level, so a combo shows its whole chunk. Rises (regen, heals) never touch it (it sits under the fill; a CSS transition restarted by every regen tick froze it, caught in review). A tag, a new run or a max-HP change is not a hit: the ghost just takes the new level, as it does under reduced motion. No layout reads.test/hud-performance.test.js. Capturesg7-*in the archive folder.
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:
- Alternate walk (
walk2). Each time a walk spell ends (4-8 s), there is a 30% chance the next one is Tripo's walk, then it goes back to his own. The two gaits crossfade over 0.45 s. The choice is view randomness (Math.random); the sim never sees it. - Cage throw (
attack). The sim setse.cd = 6.5on the frame the cage is thrown, andmodeplays the throw whilee.cd > 4.3: a 3 s wind-up, swing and recovery, played across 2.2 s. - Hurt dip. A crit (the sim's 0.18 s flash) or a single drop of 4% of max HP fades a 0.7-weight hurt pose in over 0.1 s, holds it 0.35 s and fades it out over 0.3 s, at most once every 3 s and never during the throw. The pose blends with whatever is playing, so he buckles rather than crouches.
- Fall.
holdForExorcismstarts the fall on the borrowed body and plays its first 1.4 s across the burn up to the sever (0.55 s). The exorcism timings themselves are unchanged.
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.
- Filler (rank 0) may hold at most
minorCapslots: 8 on phones, 22 on desktop. Past that it is skipped. Filler never evicts anything. - Any higher rank takes a free slot, or, when the pool is full, replaces the live label with the lowest rank at or below its own, oldest (least life left) first. A crit therefore pushes out a fading filler number, or an older crit, but never a pickup label. A hurt number can always appear.
minorLivecounts the live filler labels. It goes down when one expires inupdate()or is evicted, andclear()resets it to zero.
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.
- Retry landed in silence.
setBoss(true)deletes the night bed to free ~50MB. Die during a boss fight — which is when you usually die — and the retry asks for a bed that is no longer resident, so_playTrackreturns without playing and the run is silent for good. - A boss walked in over the bed.
_trackFor(true)deliberately falls back to the bed when the cue has not decoded (better than a silent boss fight), and the boss-ON path never retried. The boss-OFF path had been made promise-shaped in an earlier fix; the ON path was left behind, and that asymmetry IS the bug.
_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:
Delta OTA.
scripts/ota.mjsuploads with--delta: the Capgo CLI sends the bundle per file and a device downloads only files whose hash changed, so the music rides along once and never again. The first delta after a zip-only bundle is still a full download on devices that only know the zip. The updater (v8.51) supports the manifest path; the CLI refuses delta over 10,000 files (dist-apphas about 370).Packed boss models.
scripts/pack-models.mjsruns gltfpack over the uncompressed set (now kept in../_hallpass-archive/assets-originals/models/, which is whatbake-models.mjsedits) intoassets/models: KHR_mesh_quantization plus EXT_meshopt_compression with node names, materials and extras kept so clips still resolve their bones by name. 15.9 MB → 5.4 MB.boss3d._loader()hands GLTFLoader the MeshoptDecoder (an inline wasm). Verified in headed Chrome: all nine arts loadreadywith their clips (walk, swing, run, attack; quiz and exam are static by design) and the Janitor walks in skinned at 41 k triangles. The chibi and head rigs inassets/playersare untouched.Music re-baked at 128 kbps (Chris: "reencode first"). Through the pipeline, never a hand re-encode:
bake-music.pynow ships each track at the LOWEST of 128k / 192k / 256k that passes its own wrap and entry guards (same LAME encoder, same Xing gapless header, same decoded-alignment assertion), with the nine short publisher loops pinned at 256k. Three boss songs needed 192k (the wrap click grew past the splice guard at 128k: boss-wow, boss-new-2, boss-new-4); the other eighteen took 128k with wrap metrics within a few hundredths of a dB of the 256k set. 79.7 MB → 44.5 MB.bake-boss-score.py --relockthen carried every track's measured event positions (the pilot's 150 included) and refreshed only the song hash locks, asserting the loop bounds had not moved — a packet-size change does not move the waveform, and the music bake proves lag 0. Verified: 1229 node tests, the Chrome music test (54 decoder cases, three wraps each at 44.1 and 48 kHz) and the Chrome boss-score test. Not verified: a listening pass at 128k on a phone speaker and headphones — Chris.
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):
Chapter cuts. The September 21 refinement replaces the full-frame cyan wipe and modal ink flood with a 280 ms scarlet/ivory strike along the masthead separator. The title settles in 200 ms; crew and technique panels follow at 28 ms intervals. All move through transform/opacity; controls stay available throughout.
_openadds.enteringonly when the screen name changes and input is not keyboard/gamepad. Same-screen repaints stay still, reduced motion skips the cut, and closing is instant. The existing softinkTickaccompanies the cut. No cover is left over combat on resume; masthead dimensions and gutters are unchanged.Title presence. The compact wordmark is back at the top of the menu (
titleBlockwith.title-mark), ink-revealed; this reverses the "film owns the identity" call and the audit now asserts exactly one. The compositor takes amenuDim(post.menuDim, set by the game: title 0.45, pause 0.55, level-up 0.4, released on the first run frame) — darker, cooler, vignette closed in — so the buttons own the frame. Title, pause and results buttons get a short edge accent on fine-pointer hover. Pause retains its existing bevel/skew press feedback.The loading room card.
showLoadingfinds the map by name and lays the stage thumbnail blurred behind a full-frame card with a slow drift, the hour (fromNIGHT.startHour+ room index) in the kicker, the copy centred on a cyan rule with a frosted panel, the bar with a travelling shine. The splash'stop: 50%from the boss-splash rule had to be cleared once the copy wentposition: relative.Results that tally.
_tallycounts every number on the death card and the round-clear card up from zero in sequence (620 ms each, 90 ms stagger, cubic ease-out), keeping the value's dressing (x42,+120,08:00stays). The stamp slams in with a rotation, the portrait drifts, and the death modal desaturates the frozen arena behind it with a backdrop filter.Level-up decision strike. A fine-pointer card lean is restrained to about 2 degrees each way. Picking strikes a light blade across the icon, gives it a small impact and dims the other cards over 180 ms; descriptions retain their contrast, with no whole-card brightness filter. Keyboard, gamepad and reduced-motion picks apply immediately. A
pickedflag is set before every callback; double activation applies once.close()cancels the timer and the callback checks screen ownership, preventing a discarded draft from selecting after a navigation or reroll.Card acquisition signatures.
src/card-reveal.jsmaps the actual card to a palette and authored SVG mark. Ren's exclusive cards strike red; Yui's draw cyan threads. General cards use their kind, not the student browsing the deck. Each of the nine boss signatures recalls its attack colour and motif (sweep, checks, paper, doubles, seal, bars, links, eye, verdict). This is presentation only: no new cards, pull weights or RNG. Pulls flip in 540 ms, staggered by 90 ms for the existing three-card pull. Marks last 420/520/620 ms for common/rare/boss, with a 55 ms echo. Names and rules remain clear: linework and foil are masked to the illustration. A themed pointer-following foil replaces the rainbow/gold burst. Pointer coordinates (--px/--py) stay local to the hovered card and reset on leave; touch/reduced motion skip pointer effects. Keyboard/gamepad and reduced- motion pulls show the front immediately. Closing before the scheduled flip suppresses its chime. Effects use finite transform/opacity animations, with no particle loop, timers, textures or renderer passes. Boss world drops play once on entry to the ground/hand exchange; teammate changes, same-screen repaints, save retries and cards left from a hand stay quiet. Existing purchase, equip and exchange actions stay immediate.Printed backdrops. A stationary, faint halftone fades into the opaque menus. Replaces the three drifting colour pools. Ghost words and roster portraits stay still instead of following the mouse across the screen.
Menu sound. Procedural like
click/deny, so nothing new to download:inkTick()a soft paper accent on chapter entry,reveal()a three-note chime timed to the flip's second half (first card only),tally()a rising nine-tick whir under the count-up. All optional (this.sound?.x?.()) so the audits' sound doubles stay tiny.Coin bump. The deck masthead's counter repaints through one function; when the text changes it scales up with a gold glow.
Blueprint draw-in. On the stage map's first open per session every stroked element of the survey gets
pathLength="1"and a stagger, and inks itself over 1.1 s (stroke-dashoffset1 → 0), labels fading in after; thedrawingclass is dropped at 2.4 s so later pans cost nothing.Sliders that fill to their value (
--v) with a glowing thumb; stats that tally (.statbox .svjoins_tally).The crew on the loading card.
showLoadingtakes the party and lays a row of portrait chips (face, name, the student's colour) under the step line; they slide in, staggered, when the bar reaches the last step, so the arrival is the crew walking in. Every gate passes its party (new run, door, resume,_openRun).Pause.
showPausesetsbody.paused(cleared inclose()): HUD rows fade over 160 ms. The frozen arena stays at its original scale; there is no seven-second camera drift or moving frame on resume.The purchase flourish. A bought skill or an unlocked student lights its button — a gold sweep and an expanding ring, with a chime (
sound.buy). Purchases repaint their screen, so the flourish is claimed by the rebuilt button through a one-shot_justBoughtmarker rather than applied to the button that was clicked.
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:
- PAUSE opens the same Options categories as the title, including the controls guide. Closing pause options returns to PAUSE without resuming the run.
- Arrow keys are ignored while an
INPUThas focus, or paging would steal the volume sliders' own arrows. - The keydown listener lives on
documentONLY and is removed in_open(). Binding it to the pager as well made every press page twice, and leaving it bound pages a screen that is no longer on screen. - Short landscape and narrow portrait screens may scroll; no action may become
unreachable or sit under the masthead.
npm run test:menuschecks actual action geometry, focus isolation, selection payloads and setting input behavior.
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:
- The night thins to
spawnMult(0.25) of the live rate; no new bosses, no surges. Not zero — under the kills-earn-CE economy a silent board is a CE drought (Chris caught this in the naive version). - The boss bleeds cursed energy. Damage to it pays
ceFromBossDmg(0.02 per point) instead ofENERGY.perDamage(0.001): the duel fuels itself. - It ends when one of you falls. The weapon is free and bosses do not heal, so a survivor always finishes it; there is no timer.
- No HP floor. The domain hold (
heldBy === 'domain') floors HP at 1 because that win is already earned. The boss hold (heldBy === 'boss') does not: you can lose at the threshold. - Short nights pull the first boss forward (
BOSS_LEAD, 60s): the quiz Janitor arrives at 2:00, not 2:30, so the duel is a finale rather than an ambush at the buzzer. Full nights keep the 150s cadence. - Short-night bosses carry less hp (
bossHpShortFloor0.4: hp × (0.4 + 0.6·nightK), so a quiz boss is 0.55 of a full night's, a midterm's 0.7). Measured before: the competent bot took a median 82s, worst 254s, to finish the quiz Janitor — a slog, and the runner's 90s overtime counted three of sixteen as losses.
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 — 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 — 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:
MERCYinconfig.js: rungs of{ dmgTakenMult, panicHealUntil }.dmgTakenMultscales what lands after the guard (Sim._hurt);panicHealUntilwidens the recovery valve's time gate (same roll count — no rng). Enemy hp, speed and counts are untouched (board #51: adding enemy hp-mass backfires).- The rung is
mercyRung(save): zero for anyone who has cleared any room's FINAL; otherwise one rung per death (save.mercyDeaths, counted in_onDeath, never on a daily). It ridesmods, so a resumed run keeps it and the lab can measure it (--mercy=N, sweep presetmercy). - No tier, no label, no toast. Dailies and Night II+ never carry it.
run_started/run_endedcarrymercyso PostHog can split first-night outcomes by rung.
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:
- The first hit in a period that leaves a solo student under
hpFrac(15%) — or would kill them — is survived at 1 hp. The crowd withinknockRis shoved off (a zero-damage_damagewithknock, bosses excepted), the student is untouchable foriframes(1.4s) and climbs back tohealTo(42% of max) overhealOver(1.6s), linearly, instep(). That climb IS the near-miss by definition. - Once per period:
clutchLeftisperPeriodat construction and re-armed ingraduate(); a further room is a new sim and brings its own. - A School Run crew does not clutch: its drama is the KO handoff in
_die(). The tests that deal deliberate lethal hits disarm it explicitly (sim.clutchLeft = 0). - No rng.
clutchLeft / clutchHeal / clutchT / clutchesUsedare scalars and ride the snapshot;result().clutchesand the lab's run recordclutchescount uses;run_endedcarriesclutches. - The moment:
clutchevent → slow-motion, a hard hit-stop, the banner "CLUTCH — NOT TONIGHT", two rings, a burst, a glow, a shake, and a priority-4 light pulse.
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.