Play Instigator

Blog

Dev progress on the left. Personal thoughts on the right.

Dev Log

AI-generated · automated

I'm a solo dev working part-time. No time for proper devlogs, so I automated them. Every week, a local AI reads my raw session notes and writes a summary. No editorial polish — just proof the game is alive.

Musings

Thoughts on making this game, running a studio alone, and whatever else comes up.

Hey everyone! So, we’ve been busy making Ritual & Ruin even better for you all. Let’s dive into what’s new in the game!

First off, we tackled some big bugs that were crashing the demo from lobby to match and fixed it for good! The controls panel is back to normal, so you can click away without a hitch. Plus, there's something exciting about the visuals – the blood particles are now a vivid cherry red instead of lime green. We also tweaked the altar mechanics; now it takes twice as much blood to fill it up and feeding it gives less health in return. This should make the ritual economy more challenging and engaging. Lastly, we fixed that pesky "Go to Lobby" button on the end screen so you can hop back into action smoothly.

Next up, we took a swing at making the lobby panel look crisp while keeping everything else pixelated for that classic CRT feel. We used some fancy stencil masks to exempt the panel from getting squished by pixels. This means when you’re adjusting your controls, it’ll be super clear and not pixelated! Plus, we’ve added a safety net to make sure font crashes don’t sneak through again. If anything goes wrong with the fonts during build, the game will halt – no surprises there.

These changes are all about making the game more stable and visually appealing while adding a bit of challenge to the gameplay. We can't wait for you to try out these updates!

Raw session notes

Demo Stabilization — Permanent Font Fix, Panel Revert, Altar Tune, End-Screen Lobby (Build #026)

Implemented: 2026-06-21

Type: Fix + Tune

Player impact: The demo is playable end-to-end again: no lobby→match crash, the controls panel

is back (visible/clickable), blood is red, the altar is harder (2× blood to fill, feeding gives ¼ the

health), and the end screen's "Go to Lobby" button works.

What changed (all in alpha build #026)

1. Lobby→Match crash — PERMANENT fix. The TMP font ShareTechMono SDF.asset kept getting

re-emptied (0 glyphs) on every build — the build honoured m_ClearDynamicDataOnBuild: 1, which is

wrong for a m_AtlasPopulationMode: 1 (Static) font. Set m_ClearDynamicDataOnBuild: 0 + restored the

60 glyphs (from e383c1e). Verified the font stays at 60 glyphs after the build (was dropping to 0

after #021–#024, making the crash a coin-flip). Now deterministic.

2. Rebind panel — reverted to original. The earlier "render crisp via RenderTexture + WorldUI layer

+ RenderObjects" approach (Option B) rendered the panel too small, in the wrong place, and not

clickable. Fully reverted (git checkout 484698e for both lobby scenes + PC_Renderer + TagManager;

deleted RebindPanel_RT/_Mat/_RTSettings; restored orphan RebindUI_RT). Back to the original

world-space WorldSpacePanel.asset panel (clickable; pixelated by CRT — crisp legibility deferred).

Kept the ScreenFXTool Dev - CRT On/Off + Dev - Screenshot (full post) menus.

3. Blood color — cherry red. (See Problems/Active/2026-06-20_lime-green-blood.md.) Restored

BloodEmitter.prefab shape + particle colors from green to cherry red.

4. Altar fill ×2. Altar.prefab maxBloodCapacity: 100 → 200 (bloodPerParticle stays 1, so ~200

particles to fill vs ~100). Prefab override is master over the code default (50).

5. Feed reward ×0.25. Feeding the altar *gives* the feeder HP/evolution (it never cost health —

reward-only via ProgressionManagerUnifiedBar.AddBarUnits). MatchScene override

rewardPerParticle: 0.5 → 0.125 (a quarter). Makes feeding far less generous (harder ritual economy).

6. End-screen "Go to Lobby" fixed. EndScreenController.OnLobbyClicked hard-loaded "LobbyScene",

which isn't in the demo build (only DemoLobbyScene ships under DEMO_BUILD) → silent LoadScene

failure. Now #if DEMO_BUILD"DemoLobbyScene", else "LobbyScene" (mirrors MainMenuController).

Files modified

  • Assets/ProtoV2/Fonts/ShareTechMono SDF.assetm_ClearDynamicDataOnBuild: 0 + 60 glyphs restored
  • Assets/ProtoV2/Scenes/{LobbyScene,DemoLobbyScene}.unity, Assets/Settings/PC_Renderer.asset, ProjectSettings/TagManager.asset — Option-B revert
  • Assets/ProtoV2/Prefabs/FloorSystem/Altar.prefabmaxBloodCapacity: 200
  • Assets/ProtoV2/Scenes/MatchScene.unityrewardPerParticle: 0.125
  • Assets/ProtoV2/Scripts/UI/EndScreenController.cs — DEMO_BUILD lobby routing
  • Assets/ProtoV2/Scripts/Editor/ScreenFXTool.cs — kept dev CRT/screenshot menus

Verification

  • All edits grep-verified on disk; reimport clean (only pre-existing Obi/Feel warnings, 0 errors).
  • Alpha build #026 SUCCEEDED — 0 errors, 250 MB, Builds/RitualAndRuin-v0.1.0-alpha-026-20260621/,

font 60 glyphs confirmed *after* build.

  • User to smoke-test #026: lobby→match (no crash), panel clickable, blood red, altar harder, end-screen

Go-to-Lobby works.

Follow-ups

  • Crisp panel legibility still open (deferred; needs on-device verification or the "enlarge text" route).
  • Optional: TMP fallback font + a BuildSystem preflight that aborts on a 0-glyph font (defense-in-depth).

Implementation Overview

Top-Floor Scroll-Away Cleanup and Dead-Player Physics & Death Pose Enhancements (Implemented on 2026-06-20)

Type: Fix + Feature Areas Affected: Floor System, Player (DeathHandler), JellyfishVisuals

Impact on Players

Improved Visual Cleanliness: When the top floor cycles out at a three-floor cap, visual indicators such as orange-red hole/emitter rings and per-player crosshairs no longer remain frozen over disintegrating floors. Enhanced Physics for Dead Players: Dead players now fall with collapsing worlds rather than freezing mid-air. They pass through both living and dead characters, allowing Obi blood to flow over them without obstruction.

Distinct Death Poses: A dead jellyfish relaxes visibly; its bell sags, and tentacles drape limply across the floor.

Problem Details and Solutions

Lingering Scroll-Away Indicators (Fix)

Issue: Visual indicators keyed to floors did not animate with Floor.RetractAndDrop, causing them to freeze in place during a cycle until the floor was destroyed or scroll completed.

Solution: Components Affected: GapHighlighter, EmitterIndicatorController, PlayerCrosshairController Implemented subscriptions to FloorManager.OnScrollAlarmStarted. Removed visuals of departing top floors at retract start. Tore down all edge quads on re-register for GapHighlighter to prevent "ghost" rings from lingering.

Verification: Indicators were successfully removed, showing a transition from 304 indicator quads to zero during the floor's retracting phase while it was still visible.

Dead-Player Physics & Death Pose (Fix + Feature)

Issue: Dead players remained static mid-air due to kinematic settings and did not interact appropriately with living characters or environmental elements like Obi blood.

Solutions: Physics Adjustments in DeathHandler: Removed the setting of rb.isKinematic = true to allow corpses to be affected by gravity. Applied Physics.IgnoreCollision per-collider for all compound colliders, allowing dead players to pass through each other and living characters. Set ObiCollider.Filter = 0 on corpses to enable Obi blood to flow over them without obstruction.

Visual Adjustments in JellyfishVisuals: Implemented a death pose where the jellyfish's tentacles drape limply, influenced by gravity and floor contact. Ensured the bell's base anchors lerp down for natural posture.

Verification: Real deaths were simulated to confirm: Physics adjustments resulted in corpses falling correctly with their floors. Collision settings allowed dead players to pass through one another. Blood ignored corpses, behaving as expected. The limp pose was distinctly visible and aesthetically appropriate.

Files Modified

GapHighlighter.cs: Alarm-time edge destruction and teardown on re-registration. EmitterIndicatorController.cs: Alarm-time ClearQuads() invocation. PlayerCrosshairController.cs: Alarm-time removal of departing floor crosshairs. DeathHandler.cs: Adjustments for kinematic freeze, collision settings, ObiCollider filter, and death pose initiation. JellyfishVisuals.cs: Implementation of the limp-drape UpdateDeath.

Commit History

Changes were part of commit 4a42fa3, which finalized Obi pass-through adjustments and lean pose implementation.

Related Documentation

Problem documentation for scroll-away cleanup and dead-player poses are archived under respective entries. Decision logs provide additional context for design choices made during this update.

Conclusion

The enhancements addressed critical visual and gameplay issues, ensuring a smoother and more immersive player experience. Verification in play mode confirmed the effectiveness of these changes, with any potential issues deferred to further testing phases if necessary.

Raw session notes

2026-06-17 — Accent-colour in-engine showcase captures + per-decision docs

What changed

Built an in-engine "showcase" capture pass so each non-environment accent can be decided from real arena screenshots (not swatches), on top of the locked M64 Tile Jade Bright environment. Four separate decision docs, one per accent:

  • Options/Color Palettes/Accent Decision - Player Teams.md
  • Options/Color Palettes/Accent Decision - Altar Body.md
  • Options/Color Palettes/Accent Decision - Gap Rings.md
  • Options/Color Palettes/Accent Decision - Blood.md

Code

  • Assets/ProtoV2/Scripts/PaletteHiResCapture.cs
  • New menu Ritual & Ruin/Debug/Capture Accent OptionsCaptureAccentOptions(): captures every AccOpt_* palette (except _Blood_) with the locked depth tuning on (step 0.30, hue +0.06), and zoom-frames per groupFrameGroup()/PlaceCam() disable ScrollingFloorCamera and slide the orthographic camera along its forward axis to recenter on the players (Teams/Gaps) or nearest totem (Altar). Altar held at 0% fill so the trunk shows its body colour.
  • New menu Capture Blood OptionsCaptureBloodOptions(): forces the blood emitters into continuous auto-dispense (reflection on debugAutoDispense/debugDispenseOn/Off), pools ~2.5s, zooms onto an emitter, then recolours the live pour per option (SetBloodColor recolours solver particles → one pour serves all shots). Applies the M64-based AccOpt_Blood_* palette first so blood pools against jade, not the scene default.
  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs — added capture-only lock accessors TeamLocksValue / GapLocksValue / AltarLocksValue so the rig can temporarily unlock teams/gaps/altar (they're locked on the MatchScene applier to protect identity mid-match); restored after capture.
  • Tools/gen_accent_palettes.py (new) — mints AccOpt__ palettes = M64 base with one accent group swapped (GUID-preserving, skipped by the print/explore capture sets). Tools/compose_accent_sheets.py (new) — labelled 2×2 comparison grids.

Options captured

  • Teams: A Splatoon (current) / B Neon Drifter / C Cool-Shifted / D Candy Carnival.
  • Altar body (altar.baseColor, fill stays Ember Coral #F0683A per decision): A Charred Bark (current) / B Pale Bone / C Aged Bronze / D Mossy Stone.
  • Gap rings: A Amber (current) / B Toxic Lime / C Void Cyan / D Molten Red.
  • Blood: A Arterial (current) / B Dark Oxblood / C Bright Carmine.

Why / findings

  • Applying a palette at runtime only re-broadcasts the floor; teams/gaps/altar are locked on the MatchScene applier, so the swaps were no-ops until the rig temporarily unlocked them.
  • At the game's wide fixed camera, only teams read; everything else needed the zoom rig.
  • Altar fill colour is low-impact — it only tints small runes on the totem; the trunk body colour (altar.baseColor, visible at rest) is the meaningful altar pick. Decision: keep fill = Ember Coral, choose the body colour instead.
  • Blood is a single-colour pick; shades read subtly on the thin pour stream.

Verification

  • Clean compile (no console errors) after each script change.
  • Ran all three menus in Play Mode (3 floors, holes open): CaptureAccentOptions wrote 12 PNGs, CaptureBloodOptions wrote 3. Eyeballed every option — teams recolour distinctly, altar A vs B/C/D clearly differ, gap rings show coloured (cyan/lime/red/amber), blood pours against jade. Comparison sheets embedded in the four decision docs.

Follow-ups

  • Awaiting the user's pick per decision (one letter each) → bake into M64 via gen_media_palettes.py.
  • Hazard removed from the design (2026-06-17) — delegated to a background agent to scrub from systems + docs and record a Decision Log entry.
  • AccOpt_* palettes are temporary exploration assets (can be deleted once picks are baked).

Altar Per-Feed Return Motes + Completion Camera Punch & Rumble

> 2026-06-17 · Closes the two remaining items on the altar problem ([[2026-05-18_altar-identity-and-completion-feel]]): todo 4 (per-feed "feeding gives back" motes) and todo 5 leftover (completion camera-reaction + controller rumble). Built as two parallel workers (swarm), each owning a non-overlapping file set, then integrated + verified. Builds on the signed-off completion blaze ([[2026-06-17_AltarBlazeSignOff]]).

What shipped

Todo 4 — per-feed return motes (`AltarFeedMote.cs`, NEW)

On every valid blood feed, a small glowing sparkle STREAK flies from the altar canopy to the creature that fed it — the explicit "the altar takes; feeding gives back" cue. Reuses the completion drain streak design (white head → magic-cyan tail TrailRenderer, outward-burst → homing-arc velocity integrator), tuned daintier/smaller/faster.

  • Hooks AltarParticleConsumer.OnParticleConsumed (a C# event Action fired once per particle in FixedUpdate). Obi pours many particles/sec, so it coalesces: tallies consumed particles per teamIndex and flushes ≤_maxMotesPerFlush (3) motes on a _emitInterval (0.15s) timer, gated by _particlesPerMote (3) so a dribble doesn't spam. No per-particle allocation.
  • Recipient = the feeding team's nearest living creature. BloodAttributionTracker stores team as 0/1; PlayerSetup.TeamIndex == PlayerIndex/2 — so p.TeamIndex == teamIndex resolves it. MultiplayerManager.ActivePlayers holds only living players (corpses removed by DeathHandler), so a match is alive. Falls back to nearest active player when team is -1/absent; skips if no recipient.
  • Streak material = FlameParticle_Additive.mat (serialized _streakMaterial); falls back to a runtime Sprites/Default material if unset (TrailRenderer mesh path → no URP-particle magenta).
  • SetRecipientOverride(Transform) (mirrors AltarFlameController.SetSparkTargetOverride) + DebugEmitMote() (#if UNITY_EDITOR) added so the capture harness can exercise the motes (see Verification).

Todo 5 leftover — completion camera punch + rumble (`AltarCompletionPunch.cs`, NEW)

On OnRitualComplete (the BOOM): a strong one-off camera shake and a controller rumble across all players.

  • Camera: new public ScrollingFloorCamera.Shake(intensity, duration) (the existing shake was private/scroll-only — refactored ScreenShakeCoroutine to take params; scroll path unchanged). Completion punch ~0.3 intensity / 0.4s, honoring GameSettings.CameraShake.
  • Rumble (greenfield — no haptics existed in the project): UnityEngine.InputSystem.Gamepad.SetMotorSpeeds on every active player's gamepad (low 0.6 / high 0.85, ease-out over _rumbleDuration 0.3s), winner (ProgressionManager.GetAltarRewardRecipient) gets a 1.25× boost. Guaranteed motor-zero + ResetHaptics() on OnAltarReset/OnDisable so a controller never sticks. Keyboard players (no gamepad) skipped. Gated by new GameSettings.ControllerRumble.
  • New setting GameSettings.ControllerRumble (default ON, key Settings_ControllerRumble) — mirrors CameraShake exactly; toggle added to the GAMEPLAY settings tab below Camera Shake.

Files

  • NEW Assets/ProtoV2/Scripts/BloodSystem/AltarFeedMote.cs
  • NEW Assets/ProtoV2/Scripts/BloodSystem/AltarCompletionPunch.cs
  • EDIT CameraSystem/ScrollingFloorCamera.cs — public parameterized Shake.
  • EDIT Settings/GameSettings.csControllerRumble setting.
  • EDIT Settings/SettingsMenuBuilder.cs — Controller Rumble toggle.
  • EDIT Cinematics/AltarCinematicDirector.cs (editor-only) — sets AltarFeedMote recipient override + drives DebugEmitMote() during the fill window.
  • PREFAB Prefabs/FloorSystem/Altar.prefab — added AltarFeedMote + AltarCompletionPunch to the root; AltarFeedMote._streakMaterialFlameParticle_Additive.mat.

Verification

  • Compiles clean, 0 errors (the only console errors are pre-existing MatchScene boot noise: PlayerInput control-scheme + UIScanlineOverlay _MainTex — neither from this work).
  • Per-feed motes — visually verified via the cinematic capture (clean pass, fill window frames ~90–132): thin white→cyan sparkle streaks burst outward from the canopy and curve down toward the creature while the runes rise — the reused streak look, daintier. NOTE: the capture harness fills via AddBlood (no real particles → OnParticleConsumed never fires), so the director drives DebugEmitMote() synthetically during fill to make the motes visible; the real per-feed event path is straightforward and was reviewed but is exercised only by real gameplay (live pour).
  • Completion blaze — no regression: detonation frame (~455) still bursts the white drain streaks correctly with the two new components attached.
  • Fresh MP4s stitched: Recordings/altar_completion_{shipped,clean}.mp4 (2026-06-17 22:03).
  • NOT verifiable in capture (need a live playtest): the completion camera shake (the harness renders its own cinematic camera, not the gameplay ScrollingFloorCamera that gets shaken) and the controller rumble (physical). Both are compile-clean + code-path-reviewed; they need an eyes-on/hands-on pour to confirm feel.

Follow-ups

  • Live playtest to confirm camera-shake strength + rumble feel; tune _completionShakeIntensity/Duration, _rumble* in the Inspector if needed.
  • Per-feed mote density/size tuning is Inspector-exposed (_emitInterval, _particlesPerMote, _maxMotesPerFlush, _trailStartWidth, burst/homing) — adjust against a real pour.

---

2026-06-18 — Blood stays Opaque (decision) + Emitter "summoning ring" redesign (mockup rig)

Continuation of the M64 accent-selection pass. Two items closed/advanced.

Blood render — DECIDED: stay Opaque

  • Decision: keep the Obi Opaque fluid for blood; accept the current brighter-red look for the demo. Transparent fluid rendering is materially heavier on GPU (extra surface/refraction passes) and the demo doesn't need accurate dark blood.
  • Earlier finding stands: per-particle SetBloodColor doesn't reach the reconstructed surface in Opaque mode, so colour D Black Cherry #5E0303 renders near the same as A. bloodTint stays baked at #5E0303 (harmless under Opaque).
  • Assets/ProtoV2/FluidPassBlood.asset was NOT modified — stays shipped Opaque (smoothness 0.9 / thickness 1 / IOR 1.33 / materialType 1).
  • I had begun a Transparent-mode tuning capture (CaptureBloodRenderOptions) before the user's clarification; fully removed (method + menu item) — no trace left, FluidPassBlood untouched on disk.
  • Doc: Options/Color Palettes/Accent Decision - Blood.md → status decided. Future (post-demo, low pri): darken via the Opaque look, not Transparent.

Emitter indicator — colour decided + shape redesign

  • Colour: idle = White-Mint #DEFAEF (cool, separates the "stand here" signal from the crowded warm zone). Active state (brighter pulse) TBD once shape is locked. BloodEmitterIndicator already has idle (countdownColor) + active (burstFlashColor) states.
  • Shape: dropping the flat ring + Unity cylinder glow for an extruded ring with varying rim height (summoning-circle aesthetic). Research (game VFX) → the extruded ring is the "perimeter energy-wall" layer; uneven rim via scrolling alpha-mask (cheap) or vertex displacement (true crown); additive glow + single HDR tint keeps it palette-driven.
  • New: Assets/ProtoV2/Scripts/BloodSystem/SummoningRingMockup.cs — procedural annular-wall mesh generator, 4 silhouette profiles (Even / SineCrown / FlameSpikes / TallEven), additive URP/Unlit glow tinted via _BaseColor.
  • New menu: Ritual & Ruin/Debug/Capture Summoning Ring Mockups in PaletteHiResCapture.cs — spawns each profile on the floor under emitter[0], close tilted-iso frame, White-Mint, one PNG each → Captures_Explore/; destroys mockups after.
  • Helper setters added earlier this arc: EmitterIndicatorController.SetIndicatorColor, BloodEmitterIndicator.SetIndicatorColors.
  • Doc: Accent Decision - Emitter Indicator.md → status redesign.

Files

  • Assets/ProtoV2/Scripts/PaletteHiResCapture.cs — added CaptureSummoningRingMockups + menu; removed the Transparent blood method/menu.
  • Assets/ProtoV2/Scripts/BloodSystem/SummoningRingMockup.cs — NEW.
  • Assets/ProtoV2/Scripts/FloorSystem/EmitterIndicatorController.cs, Assets/ProtoV2/Scripts/BloodSystem/BloodEmitterIndicator.cs — colour setters (earlier in arc).

Verification

  • Pending — Unity MCP bridge was offline this session, no C# language server available, so the new capture + mesh code is NOT yet compiled/run. Compile-check + run owed next time Unity is reachable: Play → Tools/Force Floor Step ×2 → Capture Summoning Ring Mockups, then review the 4 PNGs.

Follow-ups

1. Run the mockup capture; pick a silhouette + tune height/thickness/peaks.

2. Add energy treatment (fade/fresnel; optional rotating rune ring + rising motes).

3. Wire chosen ring into BloodEmitterIndicator (replace cylinder glow); idle White-Mint / brighter+taller active.

4. Add emitterIndicator to the palette schema (ColorPaletteSO + PaletteApplier + generators); bake White-Mint into M64.

---

2026-06-18 — Jun 21 Demo: Standalone Build Verification (two booth-blocking bugs fixed)

Problem: Problems/Active/2026-05-18_jun21-demo-onboarding.md — verify the booth build is shippable on a real standalone Windows build (Tasks 1–6 in that doc's Handover).

MCP-driven verification pass against E:\Unity\Projects\PrototypeV2. Found and fixed two bugs that would each have broken the booth demo, then re-verified end-to-end.

Bug A — standalone build shipped the wrong lobby scene (would crash on Start)

BuildSystem.RunBuild passes its own hardcoded AllScenes array to BuildPipeline.BuildPlayer, which overrides EditorBuildSettings. That array listed MainMenu, LobbyScene, MatchScene — it did not include DemoLobbyScene. So:

  • Adding DemoLobbyScene to Build Settings via DemoSetupWizard had no effect on the actual build (BuildSystem ignores EditorBuildSettings).
  • With DEMO_BUILD on, MainMenuController.OnStartClicked calls SceneManager.LoadScene("DemoLobbyScene") — which would throw at runtime in the standalone ("scene not in build settings"). The build compiles green, then dies the instant a player presses Start.

Fix (Assets/ProtoV2/Scripts/Editor/BuildSystem.cs, AllScenes): branch the scene list at compile time, mirroring the pattern MainMenuController already uses.

Demo build ships MainMenu + DemoLobbyScene + MatchScene (LobbyScene is never loaded under DEMO_BUILD); full-game build unchanged.

Verified: built RitualAndRuin-v0.1.0-alpha-012, then grepped the player's globalgamemanagers (baked build-settings scene list): DemoLobbyScene present, standalone LobbyScene absent. Launched the exe — Player.log shows Scene loaded: MainMenuScene loaded: DemoLobbyScene with 4 players spawned, no missing-scene error. Before the fix this transition would have been fatal.

Bug B — lobby controls infographic rendered as a black box

In InputRebindPanel_Demo.uxml, the infographic VisualElement sits inside infographic-container (class players-container, which is flex-direction: row in InputRebindPanel.uss) with the container's inline align-items: center. The infographic element had width: 100% + flex-grow: 1 but no explicit height. In a row flex parent, the cross-axis is vertical, and align-items: center sizes the child to its content height — which for an empty image element is 0. The element collapsed to zero height; all that showed was its background-color: #000000 fallback. Booth players would have seen a black panel instead of the controls diagram.

A url-resolution red herring was ruled out first (the bare url("project://database/.../controls_infographic.png") form is the same one the working corner-mark images in the USS use, and it resolves fine).

Fix (Assets/ProtoV2/UI/InputRebindPanel_Demo.uxml): added height: 100% to the infographic element so it fills the container.

Verified: Editor Play Mode on DemoLobbyScene (DebugBuildCapture auto-screenshot) — before: black box; after: full green controller schematic with all callouts (TRANSFORM/LT, PAUSE, MOVE, FLY/A, POUR) visible and legible. Rebuilt as RitualAndRuin-v0.1.0-alpha-013 (the fixed UXML is now baked in; #012 still has the black box — discard it).

Handover task results

| Task | Result |

|---|---|

| 1 — DEMO_BUILD define enabled (Standalone) | ✅ Already enabled (console-confirmed). |

| 2 — standalone Windows build | ✅ #013 Succeeded, 0 errors, 249 MB. |

| 3 — DEMO_BUILD flow survives the build | ✅ Runtime log: MainMenu → DemoLobbyScene (not ExperimentBriefScene); MainMenu screenshot shows no stream banner/video/split-camera (StreamLayoutManager self-destructed). |

| 4 — infographic visible in lobby | ✅ Fixed (Bug B) + verified by screenshot. |

| 5 — controller hot-swap in the build | ⏳ Needs a human — physical gamepad plug/unplug on the booth machine. Not coverable via MCP. |

| 6 — booth-distance print legibility | ✅ controls_infographic_print.png (3840px): primary labels high-contrast/legible; only the parenthetical sub-text ("(FLY / CUP)", "(WHEN NOT CUP)") is small — acceptable, secondary clarifiers. |

Gotchas (for future MCP-driven builds)

  • BuildSystem ignores EditorBuildSettings — it hardcodes its scene list. Any scene that must ship has to be in BuildSystem.AllScenes, not just the Build Settings window.
  • A "Save modified scene?" modal hangs MCP builds. The first #012 attempt sat wedged ~16 min (idle CPU, Responding=true, no Bee/Tundra activity, MCP ping unanswered) because the active dirty MatchScene triggered a save prompt that blocks the main thread. Save/close all dirty scenes before invoking a build via execute_menu_item. Diagnostic signature of a modal-blocked Editor: process Responding but near-zero CPU and no build-backend processes.
  • UI Toolkit zero-height background-image: a VisualElement with only flex-grow/width inside a flex-direction: row + align-items: center parent collapses to height 0 and shows only its background-color. Needs explicit height.

Follow-ups

  • Task 5 hardware hot-swap + a final booth-distance glance at the live lobby on the booth machine (bundle them).
  • RitualAndRuin-v0.1.0-alpha-012 has the black-box lobby — ship #013 (or later).

---

Session 2 — quit crash + late-connect controller pairing (build #014)

Two more bugs reported after the #013 hand-off, both root-caused from the standalone crash-session Player.log (Crashes/Crash_2026-06-18_143745643/).

Bug C — quitting from the main menu crashes

TERMINATE SESSIONMainMenuController.OnQuitClickedApplication.Quit() → native crash. Stack:

Obi's native ObiNativeList (signed-distance-field compute buffers, carried by the lobby's auto-spawned player prefabs) is finalized by the GC during Unity's managed shutdown and tries to destroy a GraphicsBuffer after the graphics device is gone → access violation. Pre-existing (crash dumps date back to March); the demo just routes every booth session through this quit path. Can't fix in Obi (project rule #4).

Fix (MainMenuController.OnQuitClicked, standalone branch only): PlayerPrefs.Save() then System.Diagnostics.Process.GetCurrentProcess().Kill() — hard-terminate so the crashing finalizer never runs. Nothing at the menu needs the normal quit sequence (settings persist on change). Editor branch (EditorApplication.isPlaying = false) untouched.

Bug D — only P1 pairs when a controller connects after the lobby loads

All 4 players auto-spawn in WaitingForController mode (slots 0-3). When a pad connects, InputRebindUIController.OnInputDeviceChangeAssignGamepadToSlotTryPairNewGamepadPlayerSetup.OnGamepadConnectedPairWithGamepad. The log shows the exact failure:

PairWithGamepad did InputUser.PerformPairingWithDevice + user.ActivateControlScheme("Gamepad"). ActivateControlScheme(string) throws when the user has no associated actions — true for P2-P4 (only P1's user keeps its action association). The surrounding catch swallowed it, so P2-P4 never paired and DemoControllerStatus read them red. (Pre-connected pads happen to pair at PlayerInput instantiation before this path, so all 4 worked then.)

Fix (PlayerSetup): new ActivateGamepadScheme(user, gamepad) helper used by both PairWithGamepad and ReapplyDevicePairing. Tries user.ActivateControlScheme("Gamepad") first (the proven P1 path — leaves Mouse/Keyboard pairing intact, important for UI cursor routing; that's why the original avoided SwitchCurrentControlScheme wholesale). On failure, recovers with playerInput.SwitchCurrentControlScheme("Gamepad", gamepad), which associates the actions, activates the scheme, and pairs the pad; ReleaseMouseToUI() runs after. Keeps the working path for P1, rescues P2-P4.

Status

  • Both compile clean (0 errors); built RitualAndRuin-v0.1.0-alpha-014. Boot-tested (no exceptions; reaches MainMenu).
  • Quit fix (Bug C) confirmed by reasoning + clean build; final hardware confirm folds into the booth pass.
  • ⚠️ Bug D fix below was WRONG — superseded by Session 3. The SwitchCurrentControlScheme recovery also throws "Invalid user". Do not ship #014 for the controller fix.

---

Session 3 — late-connect pairing, *actually* root-caused and fixed (build #015)

The Session-2 Bug D fix didn't work (user confirmed: late connect still broken). Stopped iterating on the user's hardware and built a virtual-gamepad reproduction harness (temporary DemoGamepadAutoTest.cs + InputSystem.AddDevice() on a timer, Editor Play Mode on DemoLobbyScene, dumping each player's InputUser state). That gave the definitive root cause.

Real root cause

Baseline dump (no pads, right after spawn):

Only player 1 gets a valid InputUser at spawn — PlayerInput auto-claims Keyboard&Mouse for it. Players 2-4 log *"Cannot find matching control scheme … all control schemes already paired to matching devices"* (keyboard taken by P1, no pad present) and are left with user.valid == false and zero paired devices. On such a user, *both* InputUser.PerformPairingWithDevice(pad, user) *and* PlayerInput.SwitchCurrentControlScheme(...) throw "Invalid user" — so every late-connect pairing for P2-P4 failed and the indicator stayed red. (P1 always worked because its user was valid.)

An existing PlayerInput has no public API to be handed a fresh user — the only thing that creates one is its own enable-time device assignment.

Fix (`PlayerSetup.ActivateGamepadScheme`, used by `PairWithGamepad` + `ReapplyDevicePairing`)

Branch on playerInput.user.valid:

  • Valid (P1): existing path — PerformPairingWithDevice + user.ActivateControlScheme("Gamepad"), leaving Mouse/Keyboard paired for UI cursor routing.
  • Invalid (P2-P4): toggle playerInput.enabled off→on. This re-runs PlayerInput's enable-time assignment; with the gamepad now present it claims the Gamepad scheme, gets a fresh valid user, and pairs the (only unpaired) pad.

Verified in the harness (all in Editor, no hardware)

Add 4 pads one at a time → all four indicators GREEN, each userValid=True scheme=Gamepad, each paired to the correct slot's pad (P2→105, P3→106, P4→107). Remove pads → each flips back to red (WaitingForController) with userValid staying True (so reconnect re-pairs cleanly). Pre-existing P1 path unchanged.

Status / follow-ups

  • Built RitualAndRuin-v0.1.0-alpha-015 (0 errors). Temp test harness removed.
  • ✅ User-confirmed working on hardware (quit clean, late-connect pairing + movement, hot-swap). Problem closed → Problems/Archive/. Ship #015 (supersedes #012-#014).
  • Hardware confirm (booth pass): (1) quit from menu → clean exit, no crash dialog; (2) lobby with no pads, then plug pads → P2/P3/P4 go green *and actually move their creature* (the harness proves pairing + scheme; only physical input confirms the re-enabled PlayerInput still drives MultiplayerCharacterInput); unplug → red, replug → green.
  • Tooling notes: MCP execute_menu_item is unreliable for custom top-level menus — a self-installing [RuntimeInitializeOnLoadMethod] runtime harness is far more reliable. Deleting a .cs via raw filesystem leaves a phantom CS-error in an unrelated file until a refresh_unity scope=all force reimport.

---

2026-06-19 — Automated Demo-Build Smoke Test Harness (Layer 1 shipped + 3 real build bugs caught)

Problem: Problems/Active/2026-06-18_automated-demo-build-testing.md — give Claude / a scheduled job a repeatable, machine-readable pass/fail signal that the demo "still boots, runs a match, and finishes without throwing", runnable against the shipped .exe by exit code with zero MCP dependency.

Implemented Layer 1 (the self-contained command-line smoke test + build variant + screenshot extension). Layers 2 (in-Editor MCP loop) and 3 (asmdef split + PlayMode tests) handled as noted below. Three executor agents built the three files in parallel; verified end-to-end against two real standalone builds.

What changed

| File | Change |

|---|---|

| Assets/ProtoV2/Scripts/Debug/SmokeTestDirector.cs (new) | Runtime (NOT editor-gated) smoke bootstrap. Arms on -smoke CLI arg (case-insensitive) or SMOKE_TEST define. [RuntimeInitializeOnLoadMethod(BeforeSceneLoad)] subscribes Application.logMessageReceived (counts Exception/Error only) then spawns a DontDestroyOnLoad ~SmokeTestDirector. Coroutine: load MatchScene → settle 2s → ensure players (only SpawnAllPlayers() if PlayerCount==0) → set every MultiplayerCharacterInput.inputEnabled=false → drive all SimpleCharacterController1.SetInput() for 15s with a per-creature phase-offset rotating vector → screenshots (early+late) → pixel-stat readback → write smoke_result.jsonApplication.Quit(0/1). 45s realtime watchdog guarantees a failing exit on hang. |

| Assets/ProtoV2/Scripts/Editor/BuildSystem.cs | Added Build Smoke (MatchScene Only) (pri 30) + Build Smoke + Run (pri 31) under Tools/Ritual & Ruin/. RunSmokeBuild sets the SMOKE_TEST define on NamedBuildTarget.Standalone in a try, calls RunBuild(allScenes:false, devBuild:true, run:…), restores prior defines in finally. |

| Assets/ProtoV2/Scripts/Debug/DebugBuildCapture.cs | Existing 5s-after-load shot unchanged. MatchScene now also gets a mid shot (_mid.png, +5s) and late shot (_late.png, +8s), plus a log-only mid luminance stat (skipped in -batchmode — see Gotcha). |

| ProjectSettings/GraphicsSettings.asset | Added ProtoV2/BoundaryWallToon (guid 03493d1d…) to m_AlwaysIncludedShaders. |

| Assets/ProtoV2/Shaders/UIScanlineOverlay.shader | Added an unused _MainTex ("(unused)", 2D) property. |

smoke_result.json contract

{ pass: bool, errorCount: int, errors: string[] (≤20, JSON-escaped), durationSeconds, scene, meanLuminance, brightPixelRatio, timestampUtc }, written next to the exe (Path.GetDirectoryName(Application.dataPath)). pass = errorCount==0 && luminanceOK && !timedOut.

3 real build bugs the harness caught on its first run (build #016 → exit 1, errorCount 5)

The first smoke run failed and surfaced genuine shipped-build errors (the system working as intended):

1. [BoundaryWalls] ProtoV2/BoundaryWallToon shader failed to load — fell back to Unlit — classic shader stripping: the shader is only referenced via Shader.Find() (no material asset in a built scene/Resources), so the build strips it; side walls render opaque/untextured. Fix = Always Included Shaders.

2 & 3. Material 'UIScanlineOverlay_Runtime' … doesn't have a texture property '_MainTex' (×2)RawImage internally pushes _MainTex onto its material; the UIScanlineOverlay shader declared zero texture properties. Cosmetic console noise (scanlines render fine) but counts as an error. Fix = declare an unused _MainTex.

(The other 2 errors in run #016 were self-inflicted — see Gotcha — and were fixed in the harness itself.)

Verification (two real standalone builds, run by exit code)

| Build | Command | Exit | smoke_result.json |

|---|---|---|---|

| #016 (pre-fix) | RitualAndRuin.exe -smoke -batchmode -screen-fullscreen 0 | 1 | pass:false, errorCount:5 (3 real + 2 self-inflicted), meanLuminance:0.0000 |

| #017 (post-fix) | same | 0 | pass:true, errorCount:0, errors:[], meanLuminance:0.4183, brightPixelRatio:0.2506, scene:MatchScene, duration:18.6s |

Build warnings dropped 78 → 21 between #016 and #017 (shader fix). smoke_result.json + 4 screenshots (MatchScene.png, MatchScene_mid.png, smoke_early.png, smoke_late.png) land next to the exe on each run. Both exit-code directions proven. All five source files compile clean (0 errors) via refresh_unity.

Layer 2 (in-Editor MCP loop) exercised: manage_scene MatchScene already active → clear console → manage_editor play → wait 12s → read_consolemanage_editor stop. Mechanically works; surfaces 3 Editor-only Cannot find matching control scheme input-pairing errors during 4-player auto-spawn (the same multi-PlayerInput / limited-device quirk from the demo P2–P4 InputUser work) — these do not occur in the standalone build (errorCount 0 there). Consistent with the doc's framing of the in-Editor lane as developer-in-the-loop, not unattended CI.

Layer 3 (asmdef split + PlayMode tests): deliberately not done — the doc flags it as a broad architectural change (every script leaves Assembly-CSharp) requiring its own scoped task + approval. Flagged as follow-up.

Gotchas

  • ReadPixels from the system/back buffer throws "… not inside drawing frame" under -batchmode and logs it as an engine error — which the smoke run's own error counter then counted, failing itself (2 of run #016's 5 errors), and the bad readback returned black → false black-screen luminance fail. Fix: SmokeTestDirector now renders a camera into an explicit RenderTexture and reads *that* back (same approach as AltarCinematicDirector), which is drawing-frame-independent; DebugBuildCapture's log-only readback is gated behind !Application.isBatchMode. After the fix, meanLuminance reads a real 0.4183 in batchmode.
  • execute_menu_item for a build "disconnects while awaiting command_result" — that's normal: BuildPipeline.BuildPlayer blocks Unity's main thread (and the MCP socket) for the whole ~25 min build. Detect start/finish by polling the filesystem (Builds/…-NNN-…/, BuildCounter.txt, then RitualAndRuin.exe + BuildManifest.txt), not by the tool return. A disconnect while awaiting command_result means the build *started*; an immediate socket close with an un-incremented counter means it did not (retry after refresh_unity wait_for_ready).
  • The build exe is RitualAndRuin.exe, not PrototypeV2.exe (project-folder name).
  • Smoke build = MatchScene-only + SMOKE_TEST define ⇒ auto-arms without needing the -smoke arg; passing -smoke anyway is harmless.

Follow-ups

  • Screenshots in -batchmode are all identical size (~48 KB)ScreenCapture.CaptureScreenshot likely grabs a single back-buffer state under batchmode. The authoritative visual signal is the camera→RT meanLuminance/brightPixelRatio (which work). If real per-moment PNGs matter for CI, route screenshots through the same camera→RT path.
  • Layer 3 — asmdef split + formal PlayMode tests (separate approved task).
  • Editor-only input-pairing errors during 4-player auto-spawn make the in-Editor lane noisy; if it's to be a real check, suppress/whitelist that specific InputSystem message or spawn fewer players for the editor smoke.
  • Consider an optional known-benign-message allowlist for the smoke pass/fail if future builds carry accepted-noise errors (kept out for now — would risk masking regressions).

---

2026-06-19 — Decay Spark Emitter (first-ritual decay legibility cue)

Motivation

Implementation pass on [[2026-05-18_first-ritual-death-tuning|Decay Legibility (first-ritual deaths)]]. The most common new-player question is *"why is my health decreasing?"* — the bar is visible but the *cause* of decay never teaches itself. Refill direction already shipped (AltarFeedMote, 2026-06-17, altar→creature). Missing half: the power-loss cue — the creature visibly bleeding its own power OUTWARD into a hostile environment (intrinsic burnout, "a fish in air"), NOT a drain toward the altar.

Research + direction-selection happened this session (see the problem doc's 2026-06-19 dev-log entry for the 6 directions surveyed and references). Locked decisions:

  • Colour = "degraded same-power": sparks are the altar's cyan power-colour but dimmed/desaturated, guttering to gray as they escape; refill (AltarFeedMote) stays pure/bright cyan. One resource — loss reads as *spent/fading*, gain as *fresh*. Lore-aligned (altar replenishes the same power you burn).
  • Primary effect = sparks + dissipating particles (Direction B, recoloured cyan). No flow toward the altar.
  • Deferred to post-demo: withering-body base (C), edge-disintegration-on-death (E), audio undertone.
  • Scope = visual only for the Jun 21 demo.

Approach landed

  • One new player-prefab component driving a Unity ParticleSystem. Body-anchored, omnidirectional outward emission that fades to nothing — the LOSS signature (*diverging + fading = loss; converging + brightening = gain*).
  • Emission scales UP per evolution tier (so higher tiers visibly exude more — teaches "evolved burns power faster" for free) and UP as fill drops (more frantic near death), with labored "gasp" bursts at critical fill.
  • Reuses the proven PlayerBlobShadow scene-root pattern to dodge the player root's localScale = 0.25 (see gotcha below), and the existing FlameParticle_Additive material (soft round additive texture) like AltarFeedMote does.

Changes

New: `Assets/ProtoV2/Scripts/DecaySparkEmitter.cs`

  • [DisallowMultipleComponent], requires a sibling UnifiedBar (GetComponent in Awake; disables itself with a warning if absent).
  • BuildSystem() spawns a ~DecaySparks_{name} GameObject at scene root (NOT parented — same reason as PlayerBlobShadow: the player root's localScale = 0.25 would shrink a parented particle system and squash its world-space output). LateUpdate tracks the creature body centre (transform.position + up * _bodyYOffset) each frame.
  • ParticleSystem config (all in code): simulationSpace = World (sparks linger where shed, not a comet trail), Sphere shape radiusThickness = 1 for omnidirectional outward emission, colorOverLifetime gradient degraded-cyan → cooling-gray → faded, sizeOverLifetime shrink-to-zero (disintegration), noise module for turbulence, negative gravityModifier for a slight upward drift.
  • Per-frame drivers from UnifiedBar: rateOverTime = _baseRate × _tierEmissionMultipliers[tier] × lerp(fullMul, emptyMul, 1 − fill); noise.strength = lerp(full, empty, 1 − fill); below _criticalThreshold (0.2) emits periodic Emit() gasp bursts. Emission gated entirely on UnifiedBar.IsDecayActive (zero leak during countdown / after death).
  • _sparkMaterial SerializeField (assigned FlameParticle_Additive.mat); BuildFallbackMaterial() builds a runtime additive URP-particle material if the slot is empty (avoids the URP-particle magenta fallback — but note the fallback has no texture, so particles render as hard quads; the wired material is required for the soft-spark look).
  • Lifecycle hooks (OnEnable/OnDisable/OnDestroy) manage the orphaned scene-root object.

`Assets/ProtoV2/Scripts/UnifiedBar.cs`

  • Added two read-only getters so cues can scale without the bar gaining VFX responsibilities: public bool IsDecayActive => _isDecayActive; and public int EvolutionTier => evolutionTier;. (GetFillPercent() was already public.)

`Assets/ProtoV2/Prefabs/PlayerPrefab.prefab`

  • Added DecaySparkEmitter with tuned defaults (_baseRate 6, _startSize 0.11, _startSpeed 0.7, _lifetime 0.9, _tierEmissionMultipliers {1, 2.2, 4}, _fullFillRateMultiplier 0.35_emptyFillRateMultiplier 1.0, degraded-cyan→gray colour stops).
  • _sparkMaterial wired to FlameParticle_Additive.mat (guid c74ca14eaea1f0448b866996b9561099). Gotcha: the Unity MCP (manage_prefabs.modify_contents / manage_components.set_property) silently failed to bind the Material object reference — reported success / "already up to date" but left _sparkMaterial: {fileID: 0}. Verified by grepping the prefab YAML (GUID absent), then wrote the reference into the prefab file directly. If MCP material binds keep no-op'ing, edit the prefab YAML directly and confirm with a grep.

Verification

  • Clean compile of the new/edited scripts (read_console → 0 errors mentioning DecaySparkEmitter / UnifiedBar).
  • Ran a live match (4 players spawned). Inspected Player_1 components resource: DecaySparkEmitter present; UnifiedBar.IsDecayActive = true, EvolutionTier = 0 (new getters reading correctly); _isDecayActive = true; no runtime exceptions. So Awake/BuildSystem/LateUpdate execute without error on a real spawned player.
  • NOT visually confirmed. This MCP build does not expose manage_camera, so no screenshot was possible — the actual spark *look* at gameplay zoom (density/size/colour read) still needs an in-editor playtest by the user.
  • find_gameobjects by_component gave false negatives this session (returned 0 for DecaySparkEmitter even though it was confirmed on Player_1 via the components resource) — don't trust its negatives; use the gameobject/components resource for ground truth.

Blocking issue (unrelated, pre-existing WIP)

Assets/ProtoV2/Scripts/BloodSystem/SummoningRingMockup.cs:241CS0136: a mesh local declared in a nested scope collides with an enclosing mesh. This file was already modified in the working tree at session start (separate WIP, not part of this task) and compiled earlier this session. One error fails all of Assembly-CSharp, so the project won't enter play mode until it's resolved. Left untouched (concurrent WIP); flagged to the user.

Tuning knobs (all on the component, no recompile)

_baseRate, _startSize, _startSpeed, _lifetime, _tierEmissionMultipliers, _fullFillRateMultiplier/_emptyFillRateMultiplier, _noiseStrength*, _criticalThreshold/_gaspInterval/_gaspCount, and the three _lossColor* stops. Live prefab decay rate is 0.5 u/sec base (slow), so the leak is intentionally sparse at full health and ramps as the bar drops.

Follow-ups

  • User play-test for the visual read; tune the knobs above to taste.
  • Post-demo: withering-body base (C) and edge-disintegration-on-death (E) — both deferred. Audio undertone (Task 2) still deferred.
  • If this bakes in, consider a Confirmed/tech/ note on the decay-cue pair (AltarFeedMote refill ↔ DecaySparkEmitter loss).

---

Update 2026-06-20 — full look redesign + port + in-game tuning (SHIPPED for Jun 21)

The original "degraded-cyan additive blob" cue was redesigned end-to-end with the user via an iterative editor showcase rig, then ported onto the real emitter and tuned in real gameplay. Accepted by the user for the demo.

Final look (locked)

  • Solid round-textured SLIT ribbons — per-particle Trails (ribbon follows the noise-curved path → bends with flow), pointed at both ends (no head dot), alpha-blended solid (not additive glow). Materials built in code (shared static): a round-disc alpha material for the trail + a transparent material for the hidden head.
  • Shell-OUTSIDE-body emission — sphere shell at _shellRadius with radiusThickness 0 + randomDirectionAmount 0 → sparks are born just outside the body and flow purely outward, never into the 3D shape. Wander comes from a noise strength that ramps up over life (calm near body → wandering further out).
  • Width timing — grow in to full by _growEnd, HOLD full, then shrink to a speck only after _shrinkStart (shrink completes before the alpha fade; alpha holds until the latest shrink so fade never precedes shrink). trails.sizeAffectsWidth = true so width tracks the shrinking size.
  • Per-particle randomization — ±_random (0.4 = ±40%) on width-timing, min/max width, and duration (random-between-two-curves); ±20% on speed. Organic, no two identical.
  • Slight upward liftgravityModifier -0.06 on top of the radial spread.
  • Colour = white-hot → red edge_lossColorStart HDR white (1.6) → _lossColorEnd red (1.4, 0.4, 0.3). This was the legibility fix: solid cyan washed out / blended with the new teal floor; solid alpha doesn't bloom, so HDR white was needed to read at gameplay zoom (it blooms; the red rides the dying/trailing edge).
  • Origin_bodyYOffset 1.0 (raised off the bottom onto the creature's bulk) + _shellScaleY 1.4 (vertical shell stretch so emission spans the body height, not a flat ring).

Emission ← live decay rate

  • Added UnifiedBar.CurrentDecayRate getter (exposes baseDecayRate × tierMultiplier × time-ramp).
  • rateOverTime = lerp(_minSparks, _maxSparks, InverseLerp(_decayRateLow, _decayRateHigh, decayRate)). Final: 3 → 22 sparks/sec over decay rate 0.5 → 2.5. Slow decay = few, fast = many; higher evolution tiers decay faster → auto tier-scaling (no separate tier multiplier needed).
  • Dropped the old fill-based emission ramp AND the critical-fill "gasp" bursts — emission is now purely decay-rate driven, per the user.

Files

  • Assets/ProtoV2/Scripts/DecaySparkEmitter.cs — rewritten (recipe above; scene-root world-space, tracks body each LateUpdate).
  • Assets/ProtoV2/Scripts/UnifiedBar.csCurrentDecayRate getter (alongside the earlier IsDecayActive / EvolutionTier).
  • Assets/ProtoV2/Prefabs/PlayerPrefab.prefabDecaySparkEmitter values set explicitly in YAML (see gotcha) — key: _bodyYOffset 1, _shellRadius 0.4, _shellScaleY 1.4, _width 0.16, _minWidth 0.3/_maxWidth 0.6, _growEnd 0.25/_shrinkStart 0.72, _random 0.4, _minSparks 3/_maxSparks 22, colours white→red.
  • Assets/ProtoV2/Scripts/DecaySparkShowcase.cseditor-only tuning rig (#if UNITY_EDITOR, menu Ritual & Ruin/Debug/Record Decay Spark Variations). Spawns a close-up stand-in body + records ~3 s comparison clips per variation to Recordings/; orchestrator stitches to MP4 with ffmpeg. Kept for the deferred velocity-inheritance work. (Preview MP4s left under Recordings/ — not in Assets/, so they don't affect the build.)

Gotchas (load-bearing)

  • Solid alpha doesn't bloom → on a bright/competing floor it needs an HDR colour to read. Cyan-on-teal was invisible at gameplay zoom; HDR white fixed it. Audit colour vs floor hue for any small solid-particle cue.
  • Prefab vs script-default serialization fight: stripping the component's serialized fields made Unity reserialize the *then-current* defaults back into the asset, which then overrode later script-default edits. The reliable fix was to write the final tuning values explicitly into the prefab YAML (prefab always wins, no recompile-timing dependency). The MCP manage_components/manage_prefabs also silently failed to bind some values — YAML edit + grep-to-confirm is the trustworthy path.
  • manage_camera screenshots aren't exposed in this MCP build; used the existing Ritual & Ruin/Debug/Capture Game View menu (GameViewCapture.mcp-screenshots/latest.png) for in-editor verification.

Verification

  • Real gameplay (4 spawned players, decay active): CurrentDecayRate reads correctly and drives emission; live ParticleSystem readout matches the recipe (shell-outward, gravity −0.06, ±40%/±20% randomization, shrink-before-fade, per-particle slit trails); no console errors. White reads clearly on the teal floor where cyan did not. User confirmed look + density + raised centre.

Follow-up

  • Deferred polish problem created: sparks should inherit player velocity so they trail off a moving creature — Problems/Active/2026-06-20_decay-spark-velocity-inheritance.md.

---

2026-06-19 — Emitter "summoning ring" indicator: organic crown + 3 states, wired in

Redesigned the blood-emitter ground indicator from a flat ring + cylinder glow into an animated "summoning circle" crown, and wired all three states into the live component.

What changed

  • Assets/ProtoV2/Scripts/BloodSystem/SummoningRingMockup.cs (new, then iterated): procedural extruded-ring mesh generator.
  • Organic crown: mixed sine frequencies (Organic(variant), 4 integer harmonics), min/max-normalised so valleys dip to ~0 between peaks; time phase travels per-harmonic → the crown "dances".
  • Flare: flare splays the crest outward proportional to height → inverted truncated cone (active state).
  • Recharge (DepletedStyle.RechargeSweep): crown collapses to a low sputtering ring; triangles split into TWO submeshes (charged arc / depleted arc) so a white fill traces anti-clockwise over red as charge 0→1. Rendered alpha-blended (MakeGlowMaterial(col, additive:false)) — additive washed red→tan over the teal floor. Idle/active stay additive glow.
  • FillMesh(pr, mesh) reuses one Mesh for per-frame animation; BuildMesh allocates (mockup use).
  • Assets/ProtoV2/Scripts/BloodSystem/BloodEmitterIndicator.cs (rewritten): legacy LineRenderer ring + cylinder glow REMOVED. Now owns a MeshFilter/MeshRenderer + one reused Mesh, rebuilt each frame from SummoningRingMockup.FillMesh. States:
  • Idle ("stand here"): calm White-Mint #DEFAEF crown, slow dance, brightness breathing.
  • Active (OnDispensingChanged): taller (activePeakHeight) + flared outward + brighter, faster waves.
  • Recharge: red sputter + anti-clockwise white sweep driven by _charge; on full → brief ready-flash → idle.
  • Public API: SetCharge(0..1), Deplete(), SetIndicatorColors(idle, active). _debugAutoRecharge cycles the state for testing (no gameplay supply system exists yet).
  • Assets/ProtoV2/Scripts/PaletteHiResCapture.cs: CaptureSummoningRingMockups menu drove the mockup iterations (silhouette → organic dance → idle/active → recharge sequence). Earlier Transparent blood-render method removed (see 2026-06-18 log).

Why

Art direction: replace the flat indicator with a ritual "summoning circle" — extruded ring, varying rim height, animated. Active = energizing (taller/flared/brighter/faster). Third state needed for "emitter out of blood, recharging": chosen design = sharp collapse → red + sputter → white anti-clockwise refill → ready-flash. Red is unambiguous now that the hazard palette slot was removed.

Verification

  • Compiles clean (no CS errors in console after refresh_unity force).
  • In-engine (MatchScene, Play + Force Floor Step ×2): the new crown renders at each emitter (beis=3), old cylinder/line gone. Idle confirmed live; active + recharge validated via the mockup captures (Captures_Explore/_compare_SummonRing*.png, _compare_SummonRing_recharge.png) — same FillMesh path.

Follow-ups

1. Add emitterIndicator to ColorPaletteSO + PaletteApplier (+ generators) so palettes drive the colour; bake White-Mint into M64. (Component default is already White-Mint, so M64 matches today.) — Dropped in Session 2 (palette archived).

2. Gameplay "out of blood" trigger to call Deplete()/SetCharge() — needs an emitter supply/reservoir system (none in BloodEmitter yet). — Done in Session 2 (Obi particle budget).

3. Look polish: faint additive disc-base on the crown; confirm anti-clockwise sweep direction in motion; verify active state against a real standing player; tune brightness vs ScreenFX bloom.

Session 2 — recharge wired to Obi particle budget + videos + palette archived

Recharge driven by the real particle cap. Obi allocates a fixed pool per emitter: ObiActor.particleCount (capacity, from the blueprint) and activeParticleCount (alive). ObiEmitter stops emitting once activeParticleCount >= particleCount and resumes only as particles die (lifespan ~4s / GroundParticleKiller ~3.5s) — exactly the "out of blood → recharge" mechanic.

  • BloodEmitter now exposes ParticleCapacity, ActiveParticleCount, AvailableBudgetNormalized (1=pool empty/ready, 0=pool full/out of blood), IsOutOfBlood (active ≥ 97% capacity).
  • BloodEmitterIndicator (_useEmitterBudget, default on): latches into recharge when IsOutOfBlood, drives charge = AvailableBudgetNormalized, exits + ready-flash when budget recovers past rechargeReadyThreshold (0.9). _debugAutoRecharge keeps the simulated timed cycle for video/testing.

Videos. Added Capture Summoning Ring Videos menu (CaptureSummoningRingVideos): records deterministic 72-frame sequences of idle / active / recharge (advancing time + charge), suppresses live indicators via new BloodEmitterIndicator.SetSuppressed so they don't overlap the mockup, → ffmpeg to Captures_Explore/Videos/SummoningRing_{idle,active,recharge}.mp4. (First pass overlapped the live idle crown; fixed with suppression.)

Palette archived. Per direction: color composition is settled, so no palette-schema work — chosen colours are applied directly (M64 bake + component defaults). Marked Options/Color Palettes/_Palette Candidates.md archived; exploration tooling left dormant.

Files: BloodEmitter.cs (+budget API), BloodEmitterIndicator.cs (budget-driven recharge + SetSuppressed), PaletteHiResCapture.cs (CaptureSummoningRingVideos).

Verification: compiles clean; videos captured + encoded; recharge frame 0 = clean low red ring (no overlap), idle = single clean crown. Budget-driven recharge not yet observed saturating in normal play (depends on capacity vs emission/kill rate — tuning follow-up).

Session 3 — tan-floor fix + continuous budget gauge

Tan floor fixed. Symptom: project rendered warm/tan, not the chosen jade. Root cause: PaletteApplier has no runtime apply (no Awake/Start) — colours only reach the project when Apply() writes the shared material assets + scene in-editor (via OnValidate/SetPalette). The MatchScene applier's _activePalette was Combo4_SpecimenSample (warm), so that was baked in; M64 was never persistently applied (capture tools SetPalette then restore). Fix: new edit-mode menu Ritual & Ruin/Bake Chosen Palette (M64) To Scene (PaletteHiResCapture.BakeChosenPaletteToScene) sets _activePalette=Media64 + FloorDepthLighten=0.30, calls Apply, then AssetDatabase.SaveAssets + EditorSceneManager.SaveScene. Ran on MatchScene → verified jade in play. Shared floor/pillar materials are global (one bake fixes all scenes); re-bake after any palette change.

Ring → continuous fuel gauge (replaces the toggle). Since the pool depletes by particle count, the ring now varies continuously instead of toggling: reworked SummoningRingMockup.RechargeSweep so charged segments = full white-mint crown (additive glow) and depleted segments = low red sputter (alpha), boundary sweeping with charge. BloodEmitterIndicator rewritten to a single charge-driven path (no _depleted latch, no BindMaterials swap): fixed 2-submesh materials (_matWhite additive + _matRed alpha), charge = AvailableBudgetNormalized (or _debugAutoRecharge 1→0→1, or SetCharge), ready-flash past 0.97, dispensing boosts crown height/flare/brightness/speed. Verified live (full white crown at full budget on the jade env). Videos re-captured: Captures_Explore/Videos/SummoningRing_{idle,active,recharge}.mp4 + _compare_SummonRing_budget.png (0%→35%→70%→100%).

Files (Session 3): PaletteHiResCapture.cs (bake menu), SummoningRingMockup.cs (continuous RechargeSweep heights + white-additive/red-alpha materials), BloodEmitterIndicator.cs (continuous gauge rewrite).

Follow-up: tune pool capacity vs emission/kill so the gauge visibly drains during play.

Session 4 — gauge as LERP (not timer-sweep) + beat pulse

Feedback: the anti-clockwise sweep was *timer* semantics; a continuous gauge should LERP. Also wanted the ring to fluctuate more — "light leaking out, like music beating."

  • Lerp + crossfade: dropped the directional Charged() boundary. RechargeSweep now lerps the whole-ring height Lerp(lowSputter, fullCrown, charge) and crossfades colour via two submeshes both drawing the full ring — material 0 white-mint additive (alpha ∝ charge), material 1 red alpha (alpha ∝ 1−charge). Uniform gauge, no boundary. Charged/antiClockwise removed.
  • Beat: added beat/beatDepth to the mockup (crown amplitude swells with a caller-supplied beat) and bumped harmonic speeds. BloodEmitterIndicator drives a real-time beat envelope (beatHz 1.3, beatSharpness 2) that swells crown height + flares the white glow alpha (beatBrightness), and sets the two crossfade material alphas per frame. idle/active waveSpeed 0.35/0.7.
  • Videos re-captured (now pulse with the beat): Captures_Explore/Videos/SummoningRing_{idle,active,recharge}.mp4. Verified frames: crown swells+glows on-beat, settles between beats; recharge lerps red-collapse → full beating crown.
  • Files: SummoningRingMockup.cs, BloodEmitterIndicator.cs, PaletteHiResCapture.cs (video beat + per-frame material crossfade).

Session 5 — consistency, finer detail, idle↔active lerp, glow

Feedback round on the look:

  • Consistent motion: removed the per-harmonic amplitude wobble in RawWave (it made the ring drift between steady and choppy). Now constant amplitudes + alternating travel directions → steady, non-spinning shimmer.
  • No large waves: dropped the low harmonics; Organic freqs are now higher/coprime (e.g. 8/13/21/34) with flatter amps → fine even spikes, no big slow lobes.
  • Idle lower: idlePeakHeight 0.42 → 0.26.
  • Active spikier + higher floor (from Session 4): activeSpike 3.2 (shapePow), activeMinHeight 0.30.
  • Idle↔active is now LERPED: BloodEmitterIndicator smooths _activeAmount toward dispensing (0→1 over activeBlendTime 0.3s) and lerps peakHeight/minHeight/shapePow/flare/colour/waveSpeed by it — no more hard toggle.
  • Capacity lerp during active: the charge crossfade already composes with the active look (active crown at charge 1, collapses toward red as charge→0). Confirmed.
  • Glow: crown colour pushed HDR (glowIntensity 1.8, beat-modulated) so URP bloom halos it.
  • Videos now 4 clips: SummoningRing_{idle,active,transition,capacity}.mp4 (transition = idle→active lerp; capacity = active with charge sweeping low→high).
  • Files: SummoningRingMockup.cs (RawWave, freqs), BloodEmitterIndicator.cs (activeAmount lerp, idle peak, glow, per-state min/spike), PaletteHiResCapture.cs (4 clips).
  • Known capture-framing nit: the chosen emitter sits in a pillar column, so a pillar bisects the video frame (cosmetic, not part of the effect).

Session 6 — 3-point feel (low sputter / idle / active), calmer idle, 50% opacity

  • Three points along the gauge: the charge axis now lerps a FIXED short erratic red sputter (empty/low capacity) → the idle crown → (via _activeAmount) the active crown. The sputter floor in ComputeHeights is now independent of crown height (0.02 + 0.13·fastFlickerHash) so "low" always reads as the original short red sputter regardless of idle/active params.
  • Calmer idle: added a detail param (0..1) to SummoningRingMockup that fades out the upper harmonics in RawWave at low detail. BloodEmitterIndicator lerps idleDetail 0.25 → activeDetail 1.0 with _activeAmount, so idle is gentle (mostly the 8/13 harmonics) and active is busy/spiky. Idle shapePow 1.6→1.4.
  • 50% opacity: idle/active/empty colours set to alpha 0.5; static EmptyRed 0.5.
  • Videos (4): idle (calm), active (spiky), transition (idle→active lerp), capacity (charge low→high = red sputter → calm crown). This capture landed on a clear chunk (no pillar).
  • Files: SummoningRingMockup.cs (detail, fixed sputter, EmptyRed alpha), BloodEmitterIndicator.cs (detail lerp, alpha 0.5, idleSpike 1.4), PaletteHiResCapture.cs (clip detail/opacity).

Session 7 — −30% amplitude, slower (chosen tempo), calmer idle

  • −30% amplitude: new heightScale param on SummoningRingMockup (scales all heights + flare); BloodEmitterIndicator.amplitudeScale = 0.7.
  • Slower waves + speed pick: added Capture Summoning Ring Speed Variations menu (4 short idle clips A_slow/B_slower/C_vslow/D_crawl, all at 0.7 amplitude). AD chose C (very slow). Baked: idleWaveSpeed 0.38, beatHz 0.29; activeWaveSpeed 0.85 (kept energetic).
  • Calmer idle (fewer high freqs): RawWave harmonic attenuation made progressive/aggressive — Lerp(pow(0.1,k), 1, detail) (k=0 always full, higher k fades hard at low detail); idleDetail 0.25 → 0.08, so idle is dominated by the lowest harmonic = a few gentle smooth lobes.
  • Videos re-captured at final tuning: SummoningRing_{idle,active,transition,capacity}.mp4 + speed options RingSpeed_{A_slow,B_slower,C_vslow,D_crawl}.mp4.
  • Files: SummoningRingMockup.cs (heightScale, attenuation curve), BloodEmitterIndicator.cs (amplitudeScale, speeds, idleDetail), PaletteHiResCapture.cs (variations menu + clip sync).

Session 8 — all 3 states finalized via AD variation rounds

Iterated idle / active / low-capacity with the AD using new capture menus (Capture Summoning Ring Idle Variations, … State Variations); variation videos in Captures_Explore/Videos/StateVariations_v*. Pillars hidden during state captures (renderer name contains "pillar").

Final look (baked into BloodEmitterIndicator): BaseFreq {3,6,11,19}, amplitudeScale 0.45, crown opacity 0.5, glow 1.8.

  • Idle — 3 gentle waves (idleDetail 0.08 → lowest harmonic), peak 0.26, spike 1.4, waveSpeed 0.38, beatHz 0.29.
  • Active — "C subtle": wavy-regional crown + intermittent super spikes (activeSuperSpike 0.30, new SuperSpike()), peak 0.95, minHeight 0.30, spike 2.5, flare 0.50, detail 1, waveSpeed 0.85; lerped from idle via _activeAmount.
  • Low capacity — opaque dark muted red (alpha 0.85); FlintSputter() reworked to a dim ring + RARE snappy sporadic bursts of 1–4 discrete sharp spikes (count = spark magnitude) at irregular fixed spots, on a faster dedicated _sputterClock (sputterSpeed 5.3, sputterSparsity 22, sputterSpike 0.28).

New generator params (SummoningRingMockup): detail, heightScale, superSpike, sputterPow/Spike/FineFreq/Time + SuperSpike()/reworked FlintSputter().

Files: SummoningRingMockup.cs, BloodEmitterIndicator.cs, PaletteHiResCapture.cs (state/idle variation menus, pillar hide, folder output).

Verification: compiles clean each round; states confirmed in-engine via the variation captures (AD picked each). Live indicator runs the final values.

Session 9 — low-capacity FINAL: flat red ring (sparks rejected)

Iterated the empty/low-capacity sputter hard: discrete 1–4 spike flint, then 5 exploration methods (SputterMethod 1 needles / 2 cluster / 3 crackle / 4 twinkle / 5 arc, captured to Captures_Explore/Videos/Flint_Methods/, individual single-segment needles via pr.segments=180). AD's final call: make it flat — just a dark-red ring, no spikes. Set BloodEmitterIndicator.sputterSpike = 0 (FlintSputter returns its flat baseline; the charge crossfade colours the flat annulus dark red at charge 0). Spark code (FlintSputter/SputterMethod + the sputter* params) left dormant/inert at spike 0, not deleted (in case we revisit). Empty state now = opaque dark muted red flat ring.

Files: BloodEmitterIndicator.cs (sputterSpike 0), SummoningRingMockup.cs (+SputterMethod, kept dormant), PaletteHiResCapture.cs (flint-method capture menu).

Note: Unity MCP disconnected at end of session — change is a deterministic one-value edit; applies on next recompile, no capture needed to confirm "flat red ring".

---

2026-06-19 — Lobby UI fixes: infographic cutoff, CRT UI legibility, lobby environment sync

Resolved three Active problem docs in one Unity-MCP session (driven against the live Editor on MatchScene/MainMenu/LobbyScene/DemoLobbyScene). Verified visually via the Ritual & Ruin/Debug/Capture Game View menu (play-mode camera → .mcp-screenshots/latest.png, read back as image).

1. Controls infographic cut off by ground — [[2026-06-18_demo-lobby-infographic-cutoff]]

  • Root cause: InputRebinder in DemoLobbyScene is a Unity 6 world-space UIDocument (WorldSpaceRebindUI; transform drives size — no runtime repositioning). Its lower edge dipped into/behind the Ground cube (scaled (60,1,30) at y=-1 → top surface y=-0.5, front edge z≈12.09, coplanar with the panel at z=12.1), so the bottom of the CRT panel (POUR / FLY row + bottom border) was occluded.
  • Fix: raised + shrank the panel so it clears the ground. InputRebinder transform position.y 7.32 → 9.5, scale 10.4946 → 8.4 (x,y; z left 2.9336). Rotation (13° back tilt) unchanged.
  • Verified: before/after captures — full controller diagram + all five labels + bottom border now sit above the ground horizon, booth-readable.
  • Note: LobbyScene's rebind panel is the wider "INPUT CALIBRATION" 4-column layout and was not ground-clipped — no change needed there.

2. UI pixelation under the CRT pass — [[2026-06-18_ui-pixelation-crt-legibility]]

  • Root cause (confirmed): the ScreenEffect FullScreenPassRendererFeature on PC_Renderer (injectionPoint 600 = AfterRenderingPostProcessing, fetchColorBuffer 1, PixelCRT _Pixelate=540) downsamples the camera color buffer. Any canvas rendered through the camera (ScreenSpaceCamera / WorldSpace) is caught by it; ScreenSpaceOverlay canvases composite *after* all camera passes at native resolution and escape it.
  • Offending canvases (both ScreenSpaceCamera, renderMode 1):
  • MainMenuMainMenuUI (only canvas in the scene) — fixed → Overlay (0).
  • MatchSceneMatchSceneUI (in-match HUD) — fixed → Overlay (0) (sortingOrder 10, still below the UIScanlineOverlay at 5000 so scanline flavour stays on top).
  • Brief scenes (ExperimentBriefScene, ObjectiveBriefScene) already used Overlay — no change.
  • Why Overlay is safe here: these are screen-space menus/HUD with no requirement to depth-sort with world geometry; CanvasScaler stays Scale-With-Screen-Size. The game world keeps the full CRT/pixel look; only the UI text becomes crisp.
  • Verified: MainMenu + in-match HUD render crisp and correctly positioned in captures.

3. Lobby scene environment sync — [[2026-06-18_lobby-scene-environment-sync]]

  • Scoped to the applicable gap. The lobby is a flat-ground waiting area, not the floor-loop arena, so the doc's "full arena port" doesn't map 1:1. Floor/pillar/wall colours already live in shared materials (Ground uses the same ChunkFloorTile.mat as the arena) that MatchScene's PaletteApplier writes into — already synced project-wide. The real per-scene gaps were lighting and bloom volumes.
  • Applied to BOTH LobbyScene and DemoLobbyScene (exact values copied from the live MatchScene Lighting/ rig — note MatchScene's active palette is currently Combo4_SpecimenSample, NOT the doc's stale M64_TileJadeBright):
  • Disabled the legacy single Directional Light.
  • New Lighting/ parent with 7 lights: KeyDirectional (Directional, rot 45/330/0, (1,0.957,0.808), int 1.0, soft shadows), FillDirectional (Dir, 330/150/0, (1,0.784,0.847), 0.45, no shadow), AltarAccent (Point, y 1.5, (1,0.267,0.659), 2.3, range 6), BackRim (Dir, 345/180/0, (1,0.863,0.878), 0.5), PlayerRim (Dir, 35/200/0, (1,0.95,0.85), 1.0), AltarAccent_Mid (Point, y -8, (1,0.5,0.3), 1.5, range 12), AltarAccent_Bottom (Point, y -17, (1,0.5,0.3), 1.2, range 12).
  • Bloom volumes: repurposed the existing Global VolumeVolume_EnvironmentBloom (Settings/Volumes/EnvironmentBloomProfile.asset, priority 0, global) and added Volume_UIBloom (Settings/Volumes/UIPhosphorBloomProfile.asset, priority 10, global). (The old lobby volume pointed at the generic SampleSceneProfile.)
  • Verified: captures show both lobbies now carry the warm multi-directional MatchScene mood instead of the flat single-light look.

Gotcha (MCP-for-Unity light creation)

manage_gameobject create with component_properties:{Light:{type:2,...}} only applied type partially and dropped color/intensity/range/shadows — and the point lights silently came out as Directional. Caught it on read-back (AltarAccent reading type:1 = a pink directional flood). Fix pattern: set Light fields with a follow-up manage_components set_property (incl. type explicitly) and always read the component back to confirm type. Both scenes corrected.

Files touched (scene assets, via MCP)

  • Assets/ProtoV2/Scenes/MainMenu.unity — MainMenuUI canvas → Overlay.
  • Assets/ProtoV2/Scenes/MatchScene.unity — MatchSceneUI canvas → Overlay.
  • Assets/ProtoV2/Scenes/DemoLobbyScene.unity — infographic transform; lighting rig; bloom volumes; legacy light disabled.
  • Assets/ProtoV2/Scenes/LobbyScene.unity — lighting rig; bloom volumes; legacy light disabled.

Follow-ups

  • Final booth confirmation on hardware (CRT slider at ship value) recommended, esp. in-match HUD legibility and the infographic position at booth viewing distance.
  • 0 Unity console errors after each scene's play-mode pass (only the pre-existing benign "Cannot find matching control scheme for PlayerPrefab(Clone)" warning).

---

Emitter and altar no longer share the same cell

Implemented: 2026-06-20

Type: Fix

Area: Floor System / blood emitter placement

Player impact

A blood emitter can no longer sit directly above an altar. Previously the hidden ceiling floor (the blood source, which drips straight down) could pick an emitter cell on top of an altar one floor below — at 1 floor that force-filled an *active* altar with no player pour (short-circuiting the ritual), and at 2–3 floors the emitter's summoning ring projected onto a visible altar tree, reading as a placement bug. Now every emitter has clear floor beneath it.

Root cause

The blood-dripping emitter is always the single persistent EmitterOnly ceiling floor, one floorVerticalSpacing above the top visible play floor. Its emitter cells (Floor.EmitterPositions, consumed by BloodEmitterPool.ActivateOn) were only ever filtered against the floor-below's *pillar* cells (GetBelowPillarCells) — never its *altar* cells. The 2026-05-22 mutual-exclusion pass covered pillar↔emitter and pillar↔altar across floors, but not emitter↔altar.

Two placement paths left the conflict open, each at a different moment:

1. Initial layout / ramp — the ceiling is generated *first* (FloorManager.GenerateInitialFloors), so its emitter cells are fixed before the play floor below places altars. Altar placement (PlaceObjects with reserved) excluded only the play floor's *own* emitter cells, not the ceiling-above's.

2. Steady-state cycle — the persistent ceiling re-rolls its emitter cells every cycle (FloorManager.CycleSequenceRepickEmitterPositions), *after* the new Top floor's altars already exist, with no altar exclusion.

What changed

Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs:

  • GenerateFloor — altar placement now unions the floor-above's emitter cells into reserved (new helper GetAboveEmitterCells). Covers the initial layout + expand ramp, since the floor above is always generated first, so its emitter set is final.
  • RepickEmitterPositions — now excludes the floor-below's altar cells (new helper GetBelowAltarCells) in addition to below-pillar cells. Covers the cycle re-roll. Both helpers mirror the existing GetBelowPillarCells pattern and resolve adjacent floors via FloorManager.GetFloorAbove/GetFloorBelow.

Assets/ProtoV2/Scripts/FloorSystem/FloorDebugTools.cs (VerifyInvariants):

  • Added Rule 4: error if an emitter sits directly above an altar on the floor below. PASS summary updated. Runs on F8 / verifyInvariantsOnStep after each debug-triggered step.

The two exclusions are symmetric and each fire at the only moment their counterpart object already exists, so initial + ramp + cycle are all covered with no chicken-and-egg ordering problem.

Verification

  • Unity script compile: clean, zero compiler errors (refresh_unity force compile → editor idle → read_console errors-only empty).
  • Play-Mode soak PASS (auto-step autoStepIntervalSeconds=3.5, Run-In-Background on so the loop ticked headless): drove the full 1→2→3 ramp + 9 steady-state cycles (Top #1 → #9 — every ceiling emitter re-roll exercised). 10 / 10 [InvariantCheck] runs logged PASS, all including Rule 4 ("no emitter over a below-floor altar"). Zero placement violations. Only unrelated console noise: the pre-existing "Cannot find matching control scheme" controller-pairing warning. Debug stepper disarmed (autoStepIntervalSeconds → 0) afterward.

Follow-ups

  • None functional. Optional: revisit whether play-floor EmitterPositions (never used to drip blood — only the ceiling drips) are still worth picking at all, or could be deferred to promotion.

Related

  • Problem doc: Problems/Archive/2026-06-20_emitter-altar-same-cell.md → resolved (simple/fix).
  • [[2026-05-22_PillarHoleEmitterAltarExclusion]] — introduced the cross-floor exclusion + VerifyInvariants this fix extends with Rule 4.
  • [[2026-05-29_OrphanedPillars_FutureGapGuard]] — sibling cross-floor placement guard with the same generation-order reasoning.
  • [[2026-06-19_EmitterSummoningRingIndicator]] — the summoning ring that made the emitter-over-altar overlap visible.

---

Only the ceiling floor picks emitter positions

Implemented: 2026-06-20

Type: Refactor

Area: Floor System / blood emitter placement

Player impact

None directly — internal code hygiene. Removes phantom emitter cells from play floors so floor

generation no longer reserves layout around emitters that never appear. (Side benefit: editor

gizmos + LogFloorState no longer draw misleading red emitter markers on play floors.)

Root cause

FloorGenerator.GenerateFloor picked EmitterPositions for every floor, but only the single

persistent EmitterOnly ceiling floor ever uses them — BloodEmitterPool only

ActivateOn(_emitterFloor), and EmitterIndicatorController reads

CurrentEmitterFloor.EmitterPositions for the where-blood-falls rings. The "pick on every floor …

for when it rotates into the EmitterOnly role" rationale was a vestige of the old model where the

departing top floor was *promoted* into the emitter. The current persistent-ceiling cycle

(FloorManager.CycleSequence) instead glides the one ceiling down and re-rolls it via

RepickEmitterPositions, then destroys the old top floor — so a play floor never becomes the

emitter, and its picked positions only ever constrained its own altar/pillar/path layout around

emitters that never spawn.

What changed

Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs:

  • GenerateFloor now gates the emitter-position pick on emitterOnly: the ceiling picks as before

(PickCellPositions excluding GetBelowPillarCells); play floors get an empty list.

  • Refreshed the now-stale "pick on every floor" comment and the emittersPerFloorMin/Max Inspector

tooltips to say "EmitterOnly ceiling floor … play floors pick none."

Audited every EmitterPositions reader first (grep). The ceiling's positions still drive emission

(BloodEmitterPool) and the indicator rings (EmitterIndicatorControllerCurrentEmitterFloor),

both untouched. Every play-floor reader — the reserved altar set, PlacePillars exclusion,

ValidateAndFixPaths mustReach, EnsureMinimumGap, FloorDebugTools gizmos/VerifyInvariants

is a same-floor constraint that is correct (and slightly less arbitrary) with an empty list.

GetAboveEmitterCells still resolves the ceiling's real emitters for the top play floor, so the

emitter↔altar exclusion from [[2026-06-20_EmitterAltarSameCell]] is unaffected. EmitterPositions

is null-safe (getter/setter coerce to empty), so empty lists raise no NREs.

Verification

  • Unity script compile: clean, zero compiler errors.
  • Play-Mode soak PASS (auto-step autoStepIntervalSeconds=3.5): generation logs confirm the

intended split — Floor 0 (ceiling, emitterOnly) emitters=3; every play floor emitters=0.

Ramp 1→2→3 + 8 cycles (Top #1→#8), 9/9 [InvariantCheck] PASS, zero violations, no NREs.

Only unrelated console noise: the pre-existing controller-pairing warning. Stepper disarmed after.

Follow-ups

  • None. If the promote-the-top-floor cycle model is ever revived, re-pick at promotion (the existing

RepickEmitterPositions already does exactly that).

Related

  • Problem doc: Problems/Archive/2026-06-20_emitter-positions-ceiling-only.md → resolved (simple/system).
  • [[2026-06-20_EmitterAltarSameCell]] — same-day fix this cleans up after; its GetAboveEmitterCells

exclusion now only ever sees the ceiling's real emitters.

  • [[2026-05-21_HazardRemoval_ProgressiveFloorCount]] — introduced the persistent-ceiling cycle model

that made play-floor emitter positions vestigial.

---

2026-06-20 — Smoke Test upgraded to full demo nav-flow autoplay (MainMenu → DemoLobby → Match)

Problem: Problems/Active/2026-06-18_automated-demo-build-testing.md (Layer 1, shipped 2026-06-19). The smoke test proved the *match* boots and runs, but force-jumped straight to MatchScene — it never exercised MainMenu, the lobby, or the scene transitions. A regression in the menu/lobby/transition path (broken Start button, stalled fade, lobby that never advances) would pass the old smoke test untouched.

Upgraded SmokeTestDirector to walk the real demo navigation path unattended via the in-game UI hooks, and verified against a fresh all-scenes demo build (#018). Decision (asked + confirmed): full nav-flow coverage, demo build (DEMO_BUILD).

What changed

| File | Change |

|---|---|

| Assets/ProtoV2/Scripts/Debug/SmokeTestDirector.cs | RunSmokeTest() no longer force-loads MatchScene. New staged driver: Stage 1 if boot scene is MainMenu → settle → FindFirstObjectByType().OnStartClicked() (DEMO_BUILD routes Start → DemoLobbyScene) → WaitForScene("DemoLobbyScene"). Stage 2 if active scene is DemoLobbyScene → settle (auto-spawn) → belt-and-suspenders SpawnAllPlayers() if PlayerCount==0FindFirstObjectByType().LoadMatchScene()WaitForScene("MatchScene"). Then the existing match logic (settle → input-bridge disable → 15s scripted SetInput wander → screenshots → camera→RT luminance → Finish) runs unchanged. New helper WaitForScene(target, timeout, fallback): per-frame poll, records each newly-active scene into _scenesVisited, and on a 12s stall force-loads the target (sets _usedSceneFallback, LogWarning) — a second stall LogErrors (counts as failure). New JSON fields scenesVisited[] + usedSceneFallback. HardTimeoutSeconds 45 → 90 (3 scene loads now). Boot-straight-into-MatchScene path (SMOKE_TEST-define / MatchScene-only build) still degrades to a no-op and works. |

| Assets/ProtoV2/Scripts/Editor/BuildSystem.cs | Added Tools/Ritual & Ruin/Build Demo Test (All Scenes, Dev) (pri 12) → RunBuild(allScenes:true, devBuild:true, run:false). No define toggling — DEMO_BUILD is already a persistent Standalone define, and the AllScenes #if DEMO_BUILD branch (DemoLobbyScene) lives in *editor* code, so it must be baked at editor-compile time, not toggled per-build. |

Why the design is hang-proof

  • SceneTransition.Fade (used by both OnStartClicked and LoadMatchScene) is a frame-driven Update() state machine (zero coroutines, Time.unscaledDeltaTime) that fires its callback at the blackout midpoint. The director sets Application.runInBackground = true, so the player loop ticks under windowed -batchmode and the real transitions complete — no input synthesis, no force-load needed in the happy path.
  • Every transition has a 12s WaitForScene fallback that force-loads the target; the 90s hard watchdog is the final backstop. Worst case the run still terminates with a verdict.

Verification — build #018 (all scenes, DEMO_BUILD, dev), run by exit code

RitualAndRuin.exe -smoke -batchmode -screen-fullscreen 0exit 0.

scenesVisited proves all three scenes were walked; usedSceneFallback:false proves the real in-game transitions fired (no force-loading); errorCount:0 covers exceptions across the whole flow, not just the match; meanLuminance:0.4891 clears the black-screen guard. Build #018 itself: 314 MB, 0 errors, 121 warnings, Result: Succeeded. Both edited files compile clean (0 errors) via refresh_unity + read_console.

Gotchas (carry-over + new)

  • AllScenes #if DEMO_BUILD is editor code — a runtime PlayerSettings.SetScriptingDefineSymbols toggle (the trick RunSmokeBuild uses for SMOKE_TEST, which is runtime-only) does not flip it. DEMO_BUILD must be a persistent Standalone define for the all-scenes build to ship DemoLobbyScene. It already is (ProjectSettings.asset Standalone defines).
  • Flag vs define: this build was made *without* SMOKE_TEST and armed via the -smoke CLI flag — so the same #018 artifact is both a playable demo (no flag) and smoke-testable (with flag). The MatchScene-only Build Smoke variant still uses the define (auto-arms).
  • Build/run mechanics unchanged from 2026-06-19: execute_menu_item disconnects during the ~25 min build (normal; counter increment = build started); poll the filesystem for BuildManifest.txt. Exe is RitualAndRuin.exe. Launch the GUI-subsystem exe via Start-Process -Wait -PassThru to capture ExitCode (a bare & won't wait).

Follow-ups

  • usedSceneFallback is currently informational (doesn't fail the run). If we want a stalled real-transition to be a hard failure, gate pass on it — kept soft for now to avoid first-run flakiness.
  • Layer 3 (asmdef split + formal PlayMode tests) still deferred — Problems/Active/2026-06-19_asmdef-split-playmode-tests.md.

---

Lobby→Match Crash — Gutted TMP Font Restored

Implemented: 2026-06-20

Type: Fix

Player impact: The game no longer crashes when leaving the lobby for the match — players can

actually reach gameplay again. (Alpha 19 & 20 were unplayable past the lobby.)

What changed

Restored Assets/ProtoV2/Fonts/ShareTechMono SDF.asset from the last-known-good commit e383c1e:

Glyph table 0 → 60, character table 0 → 60. The .meta/GUID is unchanged, so only the binary was

restored. Shipped in alpha build #021.

Why

The font's m_GlyphTable and m_CharacterTable had been emptied to [] in the 22:22 auto-checkpoint

56e193e (a TMP regenerate/clear caught mid-operation), while the asset stayed

m_AtlasPopulationMode: 1 (Static). Timeline: e383c1e (17:22) had 60 glyphs → debug-018 (22:05)

worked; 56e193e (22:22) had 0 glyphs → alpha-019 (22:34) and alpha-020 (22:56) crashed. Since

TMP has no fallback font configured (TMP Settings.asset m_fallbackFontAssets: [], default = stock

LiberationSans, a different GUID), the first MatchScene UI text render against the empty font faulted

during mesh generation → hard crash on MatchScene load. ShareTechMono is applied across MatchScene UI

(OnboardingController, MatchCountdown, MatchTimerUI, MatchManager) and all six phosphor TMP materials.

The Obi "corpse death" commit (4a42fa3) was initially suspected but is exonerated — it was

present in the working debug-018 build too, so it cannot be the regression.

Verification

  • Working-tree glyph count after restore: grep -c "m_GlyphRect:"60 (was 0); char table → 60.
  • Forced asset reimport: console clean, no font errors.
  • Alpha build #021 SUCCEEDEDResult: Succeeded, Errors: 0, 249 MB →

Builds/RitualAndRuin-v0.1.0-alpha-021-20260620/.

  • Final confirmation: run #021 and walk lobby → match; the transition should no longer crash.

Files modified

  • Assets/ProtoV2/Fonts/ShareTechMono SDF.asset — restored 60 glyphs/chars from e383c1e.

Follow-ups (hardening — recommended, not yet done)

  • Configure a TMP fallback font (TMP Settings.asset m_fallbackFontAssets) so an empty primary font

degrades to readable glyphs instead of crashing.

  • Add a build-time preflight in BuildSystem.cs that aborts if TypographyLibrary.shareTechMono has 0

glyphs (mirrors the existing DemoLobbyScene-omission preflight). Prevents re-shipping a gutted font.

  • Investigate what editor action empties a Static TMP font's tables on auto-checkpoint.

---

Top-floor scroll-away cleanup + dead-player physics & death pose

Implemented: 2026-06-20

Type: Fix + Feature

Area: Floor System (scroll cleanup) · Player / DeathHandler · JellyfishVisuals

Player impact

When the top floor cycles out at the 3-floor cap, its orange-red hole/emitter rings and the

per-player crosshairs no longer hang frozen over the disintegrating floor. Dead players now go

limp and fall away with the collapsing world (instead of freezing mid-air), pass through both

living and dead players, and let Obi blood flow straight over them — and a dead jellyfish reads

distinctly: the bell relaxes and the tentacles drape limp, splaying flat on the floor.

Two problems, shipped together

1. Lingering scroll-away indicators (fix)

Per-floor visual indicators are parented to / keyed to the floor and don't animate with

Floor.RetractAndDrop, so on a cap CYCLE they hung frozen over the departing top floor (gap rings

until the floor was destroyed ~5 s later; emitter rings + crosshairs until scroll-complete).

  • GapHighlighter, EmitterIndicatorController, PlayerCrosshairController now subscribe to

FloorManager.OnScrollAlarmStarted (fires only on a CYCLE, never an EXPAND) and remove the

departing top floor's visuals at retract start.

  • GapHighlighter also tears down all edge quads on re-register (was leaking orphan "ghost" rings

onto surviving floors each scroll).

  • PillarOcclusionController checked — fine (only swaps materials; pillars hidden by RetractAndDrop).

2. Dead-player physics + death pose (fix + feature)

  • Falls with the world: DeathHandler no longer sets rb.isKinematic = true (the controller runs

useGravity=false + manual gravity, so a kinematic corpse got no gravity at all). The body stays

dynamic → rests on its floor, falls when that floor retracts.

  • Passes through characters: IgnoreCollisionsWithOtherCharacters() runs per-collider

Physics.IgnoreCollision vs every other DeathHandler's colliders (full compound: root sphere +

3 bell meshes). Chosen over a layer scheme because the player's bell meshes share Default with

the floor and carry the renderers (relayering would risk camera-culling).

  • Obi blood passes through: sets each corpse ObiCollider.Filter = 0 (dormant) so blood +

attribution ignore the dead body — uses the Floor dormant-surface pattern (Filter, not

enabled=false) to keep the triangle-mesh handle in Obi's container and avoid the BIH-shift that

corrupted the bowl in the 2026-05-24 dual-mesh floor fix.

  • Limp death pose (JellyfishVisuals): a floor-collision tentacle drape — gravity + outward splay

+ per-tentacle randomness + a floor plane with friction, with the tentacle anchors lerping down to

the bell base so the tops don't poke out the top of the bell. Chosen over 3 rounds of captured

comparison videos (top-level style → ragdoll drape variant → anchor-drop amount): **LimpRagdoll +

SplayedFlat + DropBellBase**. Exploration scaffolding (5-style enum + selector) collapsed to the

single shipped drape after the pick.

Files modified

  • Assets/ProtoV2/Scripts/FloorSystem/GapHighlighter.cs — alarm-time edge destroy + teardown-on-re-register.
  • Assets/ProtoV2/Scripts/FloorSystem/EmitterIndicatorController.cs — alarm-time ClearQuads().
  • Assets/ProtoV2/Scripts/FloorSystem/PlayerCrosshairController.cs — alarm-time removal of the departing floor's crosshairs.
  • Assets/ProtoV2/Scripts/DeathHandler.cs — drop kinematic freeze; Physics.IgnoreCollision pass; ObiCollider.Filter = 0 pass; play the limp pose.
  • Assets/ProtoV2/Scripts/JellyfishVisuals.csEnterDeathState() + the limp-drape UpdateDeath (capture-only styles removed; net −155 lines).

Verification

All in-editor (play mode, real code paths; no physical gamepads):

  • Indicators: driven cap CYCLE → departing floor's 304 indicator quads → 0 at retract start, while the floor was still alive.
  • Corpse colliders: real death → 16/16 compound collider pairs vs another player ignored (incl. all 9 bell-mesh pairs); 3/3 corpse ObiColliders Filter=0.
  • Corpse fall: corpse rested stable on its floor (y 9.17 → 9.17) then fell when that floor's real RetractAndDrop ran (y 9.17 → −5.71).
  • Death pose: verified via a real death (UnifiedBar.TakeDamageDeathHandler.HandleDeath) — dead jellyfish reads distinctly vs living ones. Lean refactor compiles clean (logic-identical to the verified drape).

Commit 4a42fa3 (final Obi pass-through + lean pose); earlier parts auto-checkpointed. Verification treated as good for now — reopen if issues surface in actual demo-build testing.

Related

  • Problem docs: Problems/Archive/2026-06-20_top-floor-scroll-away-cleanup.md, Problems/Archive/2026-06-20_dead-player-squashed-pose.md.
  • Decision Log: Decision Log/Scroll-Away Corpse and Hole-Ring Cleanup Decision 2026-06-20.md.
  • Comparison videos: Games/Ritual & Ruin/Options/Death Animation/ (3 rounds: Death_*Ragdoll_*RagdollDrop_*).
  • [[2026-05-21_HazardRemoval_ProgressiveFloorCount]] — the scroll/cycle model this polishes.

Overview

The provided text outlines a series of sessions focused on refining and confirming color palettes for an environment, specifically targeting the appearance and usability of various surfaces and elements within a game or media project. The work is iterative, addressing feedback and making adjustments to hue, saturation, and brightness to achieve desired visual effects.

Key Sessions

Initial Palette Adjustments (Sessions 1-5) Depth Gradient: Implemented depth gradients using value, saturation, and hue shifts to create a "cool shadows / warm highlights" effect. Ceiling Desaturation Fix: Addressed an issue with the ceiling appearing too desaturated by capping upward depth levels and adjusting hue direction. Global Saturation Boost: Introduced a global saturation boost to enhance overall scene vibrancy, ensuring even the most desaturated surfaces (like the ceiling) appeared correctly.

Finalization of Environment Colors (Session 6) Accent Color Adjustments: Coordinated non-environment colors to ensure readability and harmony with the environment's cool jade tones. Player P1 Color Change: Shifted P1 from teal to vivid green for better contrast against other elements.

Technical Details

Hue, Saturation, Value (HSV) Adjustments: Hue shifts were used to make deeper surfaces cooler and higher ones warmer. Saturation adjustments included both a gradient effect based on depth and a global boost. Value lerp was employed to manage brightness across different depths.

Implementation Tools: The Unity engine was used for implementing changes, with scripts like PaletteApplier managing the color adjustments. Python scripts (gen_media_palettes.py) were utilized to regenerate palette assets while preserving metadata and GUIDs.

Verification and Testing

Capture Tool: A capture tool facilitated testing by rendering images of the environment under various settings, allowing for visual verification of changes. Feedback Loop: Iterative feedback was crucial, with adjustments made based on observations from captured images.

Open Considerations

Playtesting: Further playtesting is suggested to evaluate potential confusion between warm-colored elements (e.g., player zones and altar). Re-capturing Palettes: Once final decisions are confirmed, a complete re-capture of all light palettes with the locked depth defaults is planned.

Conclusion

The sessions demonstrate a meticulous approach to color design, balancing technical adjustments with artistic vision. The iterative process, supported by tools and feedback, ensures that the visual elements align with user expectations and project requirements.

Raw session notes

2026-06-08 — Light/Dark palette modes (M54–M61) + higher-floors-lighter depth gradient

Reframed the palette exploration as two modes and added an engine feature for floor depth shading.

1. Light-mode "in-between" palettes (M54–M59)

Feedback: explore variations between the saturated coloured tiles (M34 Teal, M35 Rust) and the clinical neutrals (M27 Ash, M28 Slate) — and make the holes more readable.

  • Tools/gen_media_palettes.py — added 6 light, softly-tinted floors, each keeping grid darker than surface (topGrout ≈ tile×0.6) and dark contrasting chunk-side faces (sideTile/sideGrout) so an open hole reads as a recessed shadowed shaft:
  • M54 Mist (light soft teal, between M34↔M28), M55 Blush (light warm clay, M35↔M28), M56 Linen (light sage-neutral, barely tinted, M34↔M27), M57 Wheat (light warm sand, M35↔M27), M58 Jade (mid teal — lighter/softer M34), M59 Adobe (mid terracotta — lighter/softer M35).
  • Verified in capture: each reads as a distinct light floor; holes read clearly as darker recesses against the light surface.

2. Dark-mode Phosphor Mint refined (M60–M61)

Feedback: M16 Phosphor Mint is the shortlisted DARK mode but its near-black surface makes holes read dark-on-dark and the floor read as void.

  • M60 PhosphorMintRead — brightens the chunk-SIDE faces to a dim mint (sideTile 124A38, sideGrout 1E7E5E) so hole walls glow; surface lifted just off black (topTile 0A1410). Closest to M16.
  • M61 PhosphorMintLift — same hole fix, surface lifted further to a dark teal (topTile 16261F) so the floor reads as a surface, not void. Fixes "the surface is very dark."
  • Verified: holes now show as recessed dark rectangles with dim-mint glowing walls (vs invisible in M16); M61's floor clearly reads as a surface.

3. Higher-floors-lighter depth gradient (feature)

New opt-in depth shading so depth reads by value.

  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs
  • Added [SerializeField, Range(0,0.5)] float _floorDepthLighten (default 0 = off) + public FloorDepthLighten / SetFloorDepthLighten(step).
  • BroadcastFloorToActiveChunks() now passes a per-role shift: Top = +step (toward white), Middle = 0, Bottom = −step (toward black). New ShiftFloorValue(FloorPalette, shift) + Shift(Color, shift) lerp every floor colour toward white/black, alpha preserved.
  • Operates only on the per-instance chunk broadcast (play-mode); the shared floor asset is untouched. Limitation: re-Apply needed after floor role cycling for the shift to follow new roles.
  • Assets/ProtoV2/Scripts/PaletteHiResCapture.cs — added a second menu Ritual & Ruin/Debug/Capture New Palette Batch (Explore) using the correct clean-frame ScreenCapture path (NOT the HDR offscreen path) → Options/Color Palettes/Captures_Explore/. Captures M54–M61 + a depth before/after pair on M56/M57 (*_depth00 vs *_depth18, step 0.18).

Verification

  • refresh_unity (force, scripts) → 0 compile errors (both edits).
  • Play → Tools/Force Floor Step ×2 → "Expanded to 3 visible floors" (holes open) → ran the Explore capture → console: "EXPLORE: wrote 12 PNGs … at 3840x2160 (superSize 2)."
  • 12 PNGs confirmed in Captures_Explore/; viewed M54 Mist, M59 Adobe, M56 depth00/depth18, M60, M61 — light palettes' holes read, mint holes legible, depth gradient visible (subtle at 0.18).

Follow-ups

  • Depth gradient at 0.18 is subtle — try ~0.30 for a stronger read; consider a non-linear curve (only deepen the bottom).
  • Light vs dark mode is now an explicit fork — pick a light favourite (M54–M59) and confirm M60 vs M61 for dark.
  • Depth shift currently lerps toward pure white/black; could instead scale value in HSV to preserve hue saturation better.

Session 2 — ceiling + camera-relative scroll-following gradient + pastel teals

Three asks: account for the ceiling, make the depth colours transition during scroll, and add brighter/pastel teals.

1. Ceiling accounted for

The EmitterOnly floor one spacing above Top does render (its underside is the visible ceiling; "hidden" in FloorManager = gameplay-hidden, mesh stays on). It stayed on the shared asset at base value → darker than the lightened top floor (gradient inverted at the very top). Fix: depth shading now includes CurrentEmitterFloor, instancing its materials (mr.material, play-mode only) so it shades without corrupting the shared asset.

2. Camera-relative depth gradient that follows the scroll (decision: option B2)

Old behaviour: depth shift was per-discrete-role, applied only at Apply() time → stale after a scroll (a floor changing role kept its old shade). Reworked PaletteApplier to shade by camera-relative height instead of role:

  • DepthShift(worldY, camY, step, spacing) = clamp((worldY−camY)/spacing, ±2)·step. At rest (cam ≈ middle floor) this reproduces ceiling +2 / top +1 / middle 0 / bottom −1, so static captures are unchanged.
  • Floors fixed in world space + camera scrolls ⇒ as camY changes, every surface's shade transitions continuously during scroll/expand/cycle — no role bookkeeping, no snap. Update() re-pushes each frame while FloorManager.IsScrolling (+ one frame after).
  • Extended to pillars (PillarOcclusionController.SetPillarColorByDepth(baseColor, Func) — per-pillar shift by go.transform.position.y, occlusion alpha preserved) and altar fills (per-altar shift in PushDepthToAltarFills). Floors/pillars/altars only; players/blood/UI untouched.
  • Safe vs the dissolve/occlusion system: that writes _BaseColor (opacity); the palette writes _TopTileColor/etc. — different properties, no conflict.
  • Files: PaletteApplier.cs (BroadcastFloorToActiveChunks rewrite + ShadeFloorByHeight/DepthShift/DepthCameraY/Update/PushDepthToPillars/PushDepthToAltarFills), PillarOcclusionController.cs (SetPillarColorByDepth).

3. Pastel teals (M62, M63)

gen_media_palettes.pyM62 Tile Jade Pastel (brighter/airier M58) and M63 Tile Mist Pastel (brighter M54, lightest teal). 63 palettes total; M1–M61 metas preserved.

Verification

  • refresh_unity (force) → 0 compile errors across all edits.
  • Static: re-ran Explore capture → 18 PNGs (M54–M63 plain + 8 _depth30). Viewed M62/M63 (good pastel teals, grid darker, holes read) and Jade _depth30 (ceiling now lighter).
  • Scroll proof: new menu Ritual & Ruin/Debug/Capture Depth Scroll Demo sets Jade + depth 0.30, fires a real RequestScroll() CYCLE (console: "Bottom cleared at cap — CYCLE … Cycle complete. Top=2, Mid=3, Bot=4"), captures ScrollDemo_0_before/_1_during/_2_during/_3_after. Before vs after confirms the risen floor re-shades lighter (new Top) and a fresh darker floor enters at the bottom — colours followed the scroll (old code left them stale). No runtime exceptions.

Follow-ups (open)

  • Pick the light favourite (incl. pastels M62/M63/M64) + depth step; confirm M60 vs M61 for dark.
  • Pillars span floors — currently one shift per pillar by pivot Y; a true along-pillar gradient would need vertex/shader work.
  • Toggling depth off mid-session leaves the instanced ceiling stale until next scroll/Apply (minor; capture tool restores fine).

Session 3 — saturation gradient + brighter Jade (M64)

  • M64 Tile Jade Bright — an even brighter pastel teal than M62 (airy pale mint; grid still darker, holes still read). 64 palettes total.
  • Saturation gradient on the depth shading: new PaletteApplier._floorDepthSaturate (Range 0–0.6, default 0.2). Previously the gradient was pure value (lerp toward white/black). Now Shift(), after the value lerp, converts to HSV and does s = clamp01(s − shift·satFactor) — so deeper/darker surfaces gain saturation and higher/lighter ones lose it ("deeper = richer, higher = paler"). satFactor is mirrored to a static s_depthSatFactor from the field before each depth push (floors / pillars / altar-fills) — keeps the static Shift() configurable without threading a param through every call site. Scales with the per-surface depth shift; 0 = lightness only.
  • Verified: 0 compile errors; re-captured 20 explore PNGs. M58 and M64 _depth30 clearly show lower floors as a richer/more-saturated teal vs the pale top — most visible on the bright M64 (near-white top, saturated-teal bottom).

Session 4 — hue gradient

  • Added PaletteApplier._floorDepthHueShift (Range −0.15..0.15, default 0.03, small). In Shift() after the value lerp + saturation: HSV h = Mathf.Repeat(h − shift·hueFactor, 1) so deeper surfaces lean cooler (teal→blue) and higher ones warmer — a subtle "cool shadows / warm highlights" depth tint. Mirrored to static s_depthHueFactor from the field before each depth push (same pattern as saturation). The depth gradient now stacks value + saturation + hue.
  • Verified: 0 compile errors; re-captured 20 explore PNGs. M58/M64 _depth30 show the subtle cooler-bottom / warmer-top tint on top of the lightness + saturation; effect is intentionally slight at 0.03.

Session 5 — ceiling desaturation cap + hue-direction A/B (2026-06-09)

Feedback: the ceiling reads "almost yellowish, way too desaturated"; and "show me M64 with the hue shift in the other direction."

  • Root cause (ceiling). The ceiling is the EmitterOnly floor one spacing above Top → +2 depth levels above the camera, the max of the ±2 clamp. At step 0.30 that's a +0.60 shift: the value lerp toward pure white at t=0.60 desaturates heavily, and the +0.03 hue rotates the residual pale teal toward yellow-green ⇒ washed-out yellow.
  • Fix. PaletteApplier._floorDepthCeilingCap (Range 1–2, default 1.25) caps the upward (lighter) clamp only; deeper/darker stays at −2. Ceiling shift drops +0.60→+0.375 (white-lerp t and sat-loss fall with it) so it stays the lightest surface without blowing out. Mirrored to static s_depthCeilingCap at all three depth-push sites (floors/pillars/altar-fills); DepthShift now clamps (worldY−camY)/spacing to [−2, cap].
  • Hue A/B tooling. Added public FloorDepthHueShift getter + SetFloorDepthHueShift() setter, and a new capture menu Ritual & Ruin/Debug/Capture M64 Hue Compare (CaptureHueCompare()) that shoots M64 at depth 0.30 + the ceiling cap, varying only hue, to distinct files. Captured both ±0.03 (_huePos/_hueNeg) and ±0.06 (_hue06pos/_hue06neg).
  • Files: Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs (new field + setter + static cap + 3 mirror sites + DepthShift clamp), Assets/ProtoV2/Scripts/PaletteHiResCapture.cs (menu + CaptureHueCompare).
  • Verified: refresh_unity (force) → 0 compile errors. Play → Force Floor Step ×2 → 3 floors/holes → ran the menu twice (0.03 then 0.06). Viewed all four: ceiling now reads as a proper teal surface (cap working); at ±0.06 the hue direction is clearly legible (positive = deepest floor blue-cyan; negative = deepest floor green/warm, top cool). All 4 embedded at the top of Media-Inspired Palette Options.md ("Depth hue-direction A/B + ceiling fix (M64, 2026-06-09)").
  • Decision (same session): hue +0.06 (positive, deeper→cooler) chosen. Plus feedback "ceiling slightly more saturated… everything more saturated by a bit" → added a global saturation boost.

Global saturation boost

  • PaletteApplier._floorDepthSaturateBase (Range 0–0.4, default 0.08): a flat saturation add applied to every depth-shaded surface regardless of depth, on top of the per-depth saturation gradient. Lifts the whole graded scene and rescues the ceiling (the most-desaturated surface). Shift() now: skips the value-lerp at shift 0 (so a global-only boost leaves lightness alone), and does s = clamp01(s + s_depthSatBase − shift·satFactor). BroadcastFloorToChunks now runs ShiftFloorValue when depthShift != 0 OR s_depthSatBase != 0 so the shift≈0 middle floor still gets the boost. Mirrored to static s_depthSatBase at all three push sites.
  • Defaults set + persisted: hue _floorDepthHueShift = 0.06, _floorDepthSaturateBase = 0.08, _floorDepthCeilingCap = 1.25 written to the MatchScene ColorPaletteRoot PaletteApplier (instanceID 61308) via manage_components and the scene saved (so the real game, not just the capture tool, uses them).
  • Capture tool: CaptureHueCompare repurposed → menu Ritual & Ruin/Debug/Capture M64 Depth Final renders M64 at the chosen tuning to Media64_TileJadeBright_depth30_final.png.
  • Verified: 0 compile errors; captured _final. Viewed: scene reads distinctly more saturated, ceiling now a proper teal (not washed/yellow), deepest floor keeps the +0.06 blue-cyan lean. Embedded as "✅ CHOSEN" at the top of Media-Inspired Palette Options.md.
  • Follow-up: re-capture the full _depth30 set (M54–M59, M62–M64) with these defaults so the whole light batch matches.

Ceiling refinement — near-white mint, no yellow

Feedback: ceiling looked "dull yellowish," expected "near-white with a tint of mint," and wasn't clearly brighter than the top floor.

  • Cause 1 (not bright enough): the ceiling cap of 1.25 put the ceiling at +0.375 vs the top floor's +0.30 — nearly identical lerp toward white. Fix: raised _floorDepthCeilingCap to 2.0 (full; ceiling lerps ~60% toward white → clearly the brightest, near-white surface). (Tried 1.8 first, then pushed to 2.0 per feedback "push to 2".)
  • Cause 2 (yellow): with +0.06 hue, lightening surfaces rotate the *opposite* way from the cooling depths → toward yellow-green. Fix: Shift() now gates hue to the deepening side only (doHue = s_depthHueFactor != 0f && shift < 0f). Deep floors still cool toward blue; the top floor + ceiling keep the palette's base mint hue, so the bright ceiling reads near-white-with-mint, not yellow.
  • Verified: 0 compile errors; re-captured _final. Ceiling now reads as a bright pale mint-white (lightest surface, no yellow), gradient clean: ceiling → top → mid → bottom(blue). Defaults persisted on ColorPaletteRoot (hue 0.06 / satBase 0.08 / ceilingCap 2.0); scene saved. Doc "✅ CHOSEN" image + note updated.

Session 6 — accent colours confirmed (2026-06-17)

With the environment + depth gradient locked, picked the non-environment colours. Approach: coordinated full set, warm pops against the cool jade (per user choice).

  • The set was already coordinated for M64, so the only real change was a readability fix: P1 team #2E8C9C (teal) → #33B85A (vivid green) — teal P1 was blending into the teal floor. Kept P2 blue / P3 orange / P4 magenta, coral altar (#F0683A), amber gap-rings (#F0A838/#FFC04A), red blood (#B81818), teal walls/bg. HP + hazard stay locked (cross-palette anchors).
  • Workflow: edited Tools/gen_media_palettes.py (M64 teams.p1) → python Tools/gen_media_palettes.py (regenerates all 64 .asset bodies, .meta/GUIDs preserved — the generator always rewrites .asset content and only mints a meta when absent) → refresh_unity (assets).
  • Verified: captured Media64_TileJadeBright_depth30_final.png with players visible — green P1 reads distinctly vs floor and vs P2/P3/P4. Accent table + capture recorded in Media-Inspired Palette Options.md.
  • Open consideration (playtest): warm zone is crowded — altar coral / gaps amber / hazard+blood red / P3 orange all warm; move P3 cooler if player↔altar confusion appears.
  • Open follow-up: accent edits were M64-only; the other light palettes (M54–59, M62/M63) still carry their original accents. Re-gen the full _depth30 capture set with the locked depth defaults when ready.

To address and implement the improvements described in the session logs for creating a more realistic and visually appealing flame effect on a virtual tree, we need to focus on several key areas: leaf design, particle system adjustments, shader configurations, and overall tuning. Here's a breakdown of steps based on the sessions:

Leaf Design Bézier Outline: Implement Bézier curves for the maple leaves to achieve sharp tips and curved sides. This involves creating an outline using quadratic Beziers between defined anchor points (tips) with outward-bowed curvature. Mesh Quads: Convert leaf particles into mesh quads that face outward from the tree center rather than being camera-facing billboards. This requires: Replacing billboard particle systems with a quad mesh (LeafQuad) for each leaf. Adjusting orientation using LookRotation to ensure leaves radiate outward, incorporating random tilts and scales.

Remove Stems: Eliminate stem points from the leaf design, resulting in base closure without visible stems.

Particle System Adjustments Fire Effects: Transition glow effects into fiery VFX fire by using a new particle system (Fire) child. Key changes include: Use of noise turbulence for realistic flickering. A color gradient from white-hot to orange-red over time. Ensuring the fire remains additive to enhance visibility.

Leaf Count and Scale: Increase the number of leaves per tier to 60, 45, 28 while decreasing individual leaf scale (0.13–0.28) for a more dense look that better represents foliage.

Shader Configurations Material Adjustments: Ensure all flame-related materials are additive to avoid magenta rendering issues on URP particle systems: Set textures with white bodies, red edges visible only on the upper side using EdgeDistance and bias techniques. Shader Management: Regularly validate shader assets after changes to prevent corruption. Use fresh builds if necessary.

Canopy and Flame Tuning Canopy Shape and Size: Modify the canopy shape from a cone to a rounded dome, adjusting parameters like _canopyCenter and _canopyRadius for height and spread.

Stray Flames Reduction: Limit each leaf's fire emission to one small flame per leaf, reducing the particle count and tightening the emission angle to minimize stray effects.

Leaf Orientation: Adjust the orientation logic so leaves follow a radial growth direction with minor wobbling, enhancing realism by mimicking natural foliage branching.

Final Verification Compile and Test: Ensure all code compiles cleanly without errors. Run edit-mode captures and play mode tests to verify visual outcomes. Capture Re-runs: Use the AltarCanopyCaptureTool for capturing updated canopy layers, validating changes visually in both design and gameplay environments.

Additional Considerations Once the tree model is finalized, re-tune parameters such as _flamesPerTier, _tierRadius, and tier anchor heights to match the sculpted canopy. Adjust pour targeting ranges if necessary due to footprint size changes from previous iterations.

Address any lingering visual issues, like rune textures or bloom tint effects, based on final game visuals. This might include switching from green bloom (#00FF41) to a more appropriate red-glow for the flame effects.

Implementing these steps will create a more immersive and realistic burning leaf effect, enhancing both aesthetic appeal and gameplay experience in the virtual environment.

Raw session notes

Altar Tree — "Squashed and Fat" Scale Fix

> 2026-06-02

The integrated altar tree (AltarTreeV1.fbx, see [[2026-06-01_AltarTreeTotem_ModelIntegration|V1 Model Integration]]) rendered squashed and fat in Play mode — ~2× too wide, ~half height — despite the prefab itself measuring correct in isolation.

Root cause

Altars are Instantiated as children of a floor chunk (FloorGenerator.PlaceObjects, ~line 695: Instantiate(prefab, spawnPos, spawnRot, chunk.transform)). Chunks are scaled non-uniformlychunk.localScale = (chunkSize, chunkHeight, chunkSize) (FloorGenerator.cs:282), which is (2, 1, 2) in MatchScene. The altar root (localScale 1,1,1) therefore inherited a lossy scale of (2,1,2): double width on X/Z, half height on Y. The TreeVisual child's 90° X rotation under that non-uniform ancestor scale also sheared the mesh.

The old cylinder placeholder hid this for months — a squashed Unity cylinder still reads as a flat ritual disc. A tree does not.

Fix

FloorGenerator.PlaceObjects now neutralizes the parent's non-uniform scale on each placed instance, so props render at their authored proportions:

At chunk scale (2,1,2) this sets the altar root to (0.5, 1, 0.5) → world lossy (1,1,1). Generic: any non-cube prop placed via PlaceObjects is now correct. World position is unaffected (scale doesn't move the pivot). The CapsuleCollider/ObiCollider also become uniform (more correct than the previously-squashed capsule).

Verification (Play mode, live)

  • Recompile clean — 0 console errors on FloorGenerator.cs.
  • Spawned altar Floor_1/PooledChunk_Dynamic/Altar_12_5: localScale (0.5, 1.0, 0.5), lossyScale (1.0, 1.0, 1.0) — confirms the world scale is now uniform (was (2,1,2)).
  • No runtime exceptions from AltarFillVisual.Awake or floor generation. Pre-existing unrelated warnings only (input control-scheme pairing, UIScanlineOverlay _MainTex).

Session 2 — Fill material never applied + procedural runes

Live MCP inspection (Play mode) of a spawned altar's TreeVisual MeshRenderer showed all 16 submesh slots still held the original FBX materials (Material.001 (Instance)Material.015), not the AltarFill instance — and the console carried [AltarFillVisual] No target MeshRenderer assigned or found. So fill/runes/emission were never on the tree (raw FBX look only).

  • Root cause: AltarFillVisual.Awake resolved the trunk renderer with GetComponentInChildren() (active-only). The altar spawns under a pooled/floor hierarchy that isn't active-in-hierarchy at Awake, so the search returned null and the material replacement was skipped.
  • Fix (AltarFillVisual.cs): extracted renderer-resolve + material-build into EnsureMaterial() (idempotent, guarded by _mat). Called from Awake; uses GetComponentInChildren(true) (include inactive) and an explicit Unity-null check instead of the ?? operator; retries in Start() once the hierarchy is fully active. SetPaletteColors already updates the serialized fields, so a palette push before the material exists isn't lost.
  • Runes implemented (AltarFill.shader): _RuneTex was null → "white" → flat glow (no glyphs). Added a procedural rune generator (Hash21 + RuneCell: hash-chosen vertical/horizontal/diagonal carved strokes per world-space cell), triplanar-projected, multiplied by the optional _RuneTex (defaults white = procedural-only). Emission stays gated to the filled region, so runes glow bottom-up as blood fills.

Verification (Play mode, live)

  • Recompile clean — 0 console errors (script + shader, incl. procedural HLSL).
  • Re-inspected spawned altar TreeVisual: all 16 slots now AltarFill_Instance (Instance); no [AltarFillVisual] warning.
  • Visual glow/rune read still needs eyes-on (no screenshot capability) — but the shader path is proven applied and compiles; emission math gates correctly to the fill region.

Still-open altar wiring notes

  • PaletteApplier.ApplyPipeline altar-material swap uses GetComponent() on the altar root (now empty — renderer is on the TreeVisual child) → silently no-ops. Only matters if pipeline.altarMaterial is ever set; left as-is to avoid clobbering the fill material. Flagged.
  • _FillEmission (rune glow color) is not palette-driven — only _BaseColor/_FillColor are. Likely fine, but note if the altar palette should tint the glow.

Session 3 — Canopy flames (3-tier completion blaze) built

Implemented the altar canopy flames per [[Altar Flames Implementation Plan]]. Locked design: ignite on ritual completion only, 3 canopy tiers in a low→high stagger, palette-driven color, Shuriken variant first (VFX Graph variant deferred — controller already supports it via a selector).

New files

  • Scripts/BloodSystem/AltarFlameController.cs — on the altar root (RequireComponent AltarParticleConsumer). Subscribes OnRitualComplete → IgniteCanopy() (staggered tier cascade, _tierStagger 0.12s) and OnAltarReset → Extinguish(). Resolves anchors by hierarchy convention (FlameAnchors/Tier1|2|3 → children = emitter points) — no serialized arrays to wire headlessly. SetFlameColor for the palette. LiveFlameCount debug accessor.
  • Scripts/Editor/FlameVFXSetupWizard.csRitual & Ruin/Setup/Build Flame VFX Prefab: generates a soft radial sprite, an additive URP particle material, and Prefabs/FloorSystem/FlameTip_Particles.prefab (cone-up emitter, color/size-over-lifetime, billboard, + flicker point light).
  • Scripts/Editor/FlameDebugMenu.csRitual & Ruin/Debug/Ignite|Extinguish Altar Flames (play-mode test without filling an altar).

Edits

  • ColorSystem/ColorPaletteSO.cs — added HDR flame to AltarPalette (+ Default).
  • ColorSystem/PaletteApplier.csAltarLocks.flame; serialized _altarFlameTargets; PushAltarFlame (serialized + runtime FindObjectsByType) in ApplyAltar.
  • Prefabs/FloorSystem/Altar.prefab — added AltarFlameController + FlameAnchors/Tier1(y1.3)/Tier2(y1.8)/Tier3(y2.2) with 3/2/1 anchor points; wired _particlesPrefab. (Provisional anchor positions — re-tune with the final sculpt.)

Verification (Play mode, MCP, race-free via LiveFlameCount + diagnostics)

  • Debug-ignited a live altar: prefab resolves (FlameTip_Particles), Tier1 found (3 anchors), all spawn FlameTip_Particles(Clone); settles to 6 flames across tiers. Live clone confirmed parented at …/Tier1/Anchor_A/FlameTip_Particles(Clone) with ParticleSystem + Renderer + FlameLight child, canopy height, uniform scale. 0 errors.

Gotcha (load-bearing): a prefab GameObject reference created via SaveAsPrefabAsset uses the root GameObject's random fileID, NOT 100100000. Hand-editing the YAML ref with 100100000 resolved to a non-GameObject (empty name, Instantiate returned non-castable → silent no-spawn). Fixed by using the real root fileID (9101786405667738845). The MCP modify_contents could not set this object reference (both {guid} and {path} forms no-op'd), so the ref was set in YAML directly.

VFX Graph variant (also built). Installed com.unity.visualeffectgraph 17.2.0 (user-approved). Copied the package template 02_Simple_Loop.vfxAssets/ProtoV2/VFX/FlameTip_VFXGraph.vfx (authoring a graph from code isn't practical; the template is a working blockout to polish in the VFX editor). FlameVFXGraphSetupWizard (Ritual & Ruin/Setup/Build Flame VFX Graph Prefab) wraps it in FlameTip_VFXGraph.prefab (VisualEffect + flicker light). AltarFlameController now also tints VisualEffect instances via an exposed Color property (guarded by HasVector4/3), and has a _variant selector (Particles/VfxGraph). Wired _vfxGraphPrefab (real root fileID 243793337807439560, guid 4a6e57d3…). Verified: at _variant=VfxGraph, debug ignite spawned 6 FlameTip_VFXGraph(Clone) (VisualEffect+VFXRenderer+light) across the canopy tiers, 0 errors; default reverted to Particles. Note: the VFX import does heavy first-time shader compilation that stalls while the editor window is unfocused — focus Unity to let it finish.

Flame audio (wired). Added flameIgnite + flameLoop (+ volumes) to GameAudioData, under the Altar category — updated GameAudioDataEditor per the project rule: Categories Altar array, TotalClipFields 26→28, and FieldSearchPatterns (ignite: flame_ignite/ignite/whoosh; loop: flame_loop/fire_loop/crackle). AltarFlameController now takes [SerializeField] GameAudioData _audioData, adds its own runtime AudioSource (the root's existing one belongs to AltarAudioSource — verified 2 AudioSources coexist, no collision), and on IgniteCanopy plays the ignite one-shot + starts the loop; Extinguish stops it. All routed to _audioData.sfxGroup. Wired _audioData on the prefab to GameAudioData.asset (guid 578461c2…). Verified: refs resolve, AudioSource added, ignite runs with 0 errors (clips currently null → graceful no-op).

Still TODO: add the actual flame SFX files (ignite whoosh + fire-crackle loop) to Assets/ProtoV2/Audio/SFX/ and assign via the GameAudioData inspector (or auto-link — patterns added); flip _variant to A/B the two flame visuals and pick one; expose a Color in the VFX graph to make it palette-tintable; bloom-tint check (bright flames vs #00FF41); populate palette flame per ColorPaletteSO asset; re-tune anchors to the final sculpt.

Session 4 — Flame redesign (candle-burst), runes always-visible, blinking diagnosis

Runes now read on the dormant tree. They were emission-only in the *filled* region, so at fill 0 nothing showed. Reworked AltarFill.shader: runes are now carved into the albedo (albedo *= lerp(1, 0.55, rune)) so they're visible always, plus a new _AmbientRuneGlow (default 0.6) gives a faint constant glow that ramps to full _EmissionStrength in the filled region as blood rises. Bumped the altar's runeTiling 1→3 so the glyphs are dense enough to read on the ~1.5u trunk.

Flames redesigned to the "candles bursting alive" spec (was a steady confetti stream). FlameTip_Particles.prefab rebuilt as a single candle flame: ignition puff (emission burst at t0) + small flickering sustained flame + a FlameFlicker light component (bright ignition flash → decays to base → Perlin flicker) + a manual-emit Sparks child (stretched additive streaks, world-space, gravity). AltarFlameController:

  • Tiered ignition low→high (_tierStagger 0.4) with per-flame jitter within a tier (_intraTierJitter 0.2) so candles in a layer pop at slightly different moments.
  • Once all tiers lit, a spark loop (_sparkInterval 2.5, _sparkCount 14) LookAts each flame's Sparks emitter at the target and Emits.
  • Spark target = altar reward recipient. Originally a private nearest-player lookup (no range cap) — corrected to reuse the HP-award system: added ProgressionManager.GetAltarRewardRecipient(altarPos) (wraps the existing private GetClosestCreature, i.e. closest living player within maxProximityRange 10u — the exact creature that gets the proximityReward completion bonus via HandleAltarConsumed). AltarFlameController.FindNearestCreature now calls it (falls back to nearest active player only if no ProgressionManager). So sparks fly to whoever actually wins the ritual; if nobody's in range (no reward), no sparks. NB the per-particle reward is team-based (blood attribution) — a different recipient set; the sparks intentionally match the *completion proximity* bonus, which fires on the same OnRitualComplete beat as the flames.
  • Extinguish stops the spark loop too.

Verified (Play, MCP): with timing temporarily zeroed (altars cycle fast in debug, outliving a 1.8s staggered spawn), debug-ignite spawned 6 candle flames (each = candle PS + FlameLight/FlameFlicker + Sparks), the spark loop ran and aimed each Sparks emitter at the nearest player (Sparks child rotation (6.8,131.8,0) ≠ identity), 0 errors. Restored final tuning (stagger 0.4 / jitter 0.2 / spark 2.5). Note: the debug-ignite can't be visually captured on a stable altar because debug altars cycle faster than the staggered spawn — a real ritual completion on the active bottom altar is the true test.

"Why is the tree blinking / doing weird things?" — Diagnosis: the V1 art is a 16-cube blockout merged without a boolean union, so internal/coplanar faces coincide → Z-fighting, which flickers continuously as the floor scrolls (and the Pixel-CRT 540p/posterize ship look amplifies the shimmer on the thin scrolling edges). Not a code/shader issue — the fix is the final clean tree sculpt replacing the blockout (already TODO 1). No code change made for this.

Session 5 — Runes-as-fill-gauge correction (tree no longer turns red/blinks)

Session 4's "always-visible carved runes + _AmbientRuneGlow + red _FillColor albedo lerp" was wrong — it made the whole tree red and blinking (the countdown pulsed _FillColor, which the albedo used). Correct intent: the runes ARE the fill gauge, the tree keeps its own colour, and only the runes glow/pulse.

  • AltarFill.shader — albedo is now just _BaseColor (constant tree colour; no fill-colour lerp, no rune carving). Runes glow via emission only in the filled region: emission = _FillEmission * _EmissionStrength * rune * (1 - fillMask). So 0% → no runes, 50% → runes on bottom half, 100% → full, rising bottom→top with _FillLevel. Removed _AmbientRuneGlow. _FillColor is now unused by the shader (left as a harmless property so AltarFillVisual/PaletteApplier SetColor calls don't break).
  • AltarCountdownVisual.cs — pulses the rune glow now (_EmissionStrength boost + _FillEmission tint toward the pulse colour), not _FillColor. So during the countdown only the runes flare/blink; the tree colour is untouched. Completion flash spikes emission, then restores.
  • Debug: Ritual & Ruin/Debug/Set Altar Fill 0/50/100% sets _FillLevel on live altars to preview the rune fill without pouring blood.

Verified: compiles clean; live Set Altar Fill 50% ran with 0 errors. Visual read is the user's (runes bottom-half at 50%, tree colour constant).

Session 6 (2026-06-03) — Per-floor tree colour, one-shot sparks, fire visibility

User reported three things after eyeballing live: (1) the tree is a different colour on each floor, (2) the sparks flying to the creature loop forever (should fire once when the bar fills), (3) the fire barely shows.

  • (1) Per-floor tree colour → AltarFillVisual.SetAltarActive was recolouring the trunk. It swapped _BaseColor between baseColor (active floor, 0.08 grey) and inactiveColor (0.18 grey). The three stacked floors are in different active/inactive states, so their trunks rendered as different greys — the exact "why is each floor's tree a different colour" report, and a violation of the Session-5 "tree keeps its own colour" rule. Fix: SetAltarActive is now a no-op on colour (kept for the SendMessage contract); the activation beat lives only in the runes/fill gauge. Also EnsureMaterial now seeds _BaseColor with baseColor (was inactiveColor) so every trunk starts at the one intended colour. AltarFillVisual.cs.
  • (2) Sparks looped → AltarFlameController.SparkLoop ran while(_burning) every _sparkInterval. Replaced with EmitSparksOnce() called once when the canopy finishes igniting (the "ritual complete → HP awarded" beat). Removed _sparkRoutine + _sparkInterval field (and the stale _sparkInterval: 2.5 line in Altar.prefab). Target unchanged (ProgressionManager.GetAltarRewardRecipient). AltarFlameController.cs.
  • (3) Fire too weak → beefed the candle in FlameVFXSetupWizard: startSize 0.10–0.20→0.28–0.5, lifetime 0.45→0.7, speed 0.25→0.5, emission 14→32/s, ignition burst 8→22, maxParticles 40→120, rise velocity 0.5→1.1, light range 2.2→3.5 / intensity 1.6→3. FlameFlicker defaults base 1.6→3 / flash 4.5→7. NB the green #00FF41 Gameplay bloom tint is also washing the orange flame (see [[Bloom Tint Gotcha]]) — flagged, not yet changed (palette decision).

Verification (MCP, 2026-06-03): forced recompile → console 0 errors; re-ran Ritual & Ruin/Setup/Build Flame VFX Prefab[FlameVFXSetupWizard] Built …/FlameTip_Particles.prefab (new candle/light values are baked in — editing the wizard alone doesn't touch the existing prefab, so the rebuild is mandatory after any flame-tuning change). Still needs a real ritual completion on the active altar to confirm visually (one-shot sparks + brighter fire) — debug-ignite can't be captured (debug altars cycle faster than the staggered spawn).

Session 7 (2026-06-03) — Flame sequence redesign: countdown-paced leaf-canopy

User: "I don't see the flames." Clarified the intended design (now recorded canonically in [[Altar Flames Implementation Plan]]): altar fills → runes reach top → 3s countdown, one canopy layer ignites per second lowest→top → all flicker → explode into sparks that fly to the creature. And: "the flames are supposed to be like leaves on the tree" — many small scattered flames per layer = burning foliage, not torch tips.

Root cause of "no flames": ignition was wired to OnRitualComplete, which fires only at the END of the countdown (and the altar is isConsumed the same instant) — so the blaze never played out during the countdown where the user expected it.

AltarFlameController rewritten:

  • Ignition now hangs off OnMeterFull (countdown start). IgniteRoutine paces tiers to the live countdown: perTier = RitualCountdownTime / 3 (≈1 s) → lower at sec 1, middle at sec 2, top at sec 3. _fallbackTierStagger (1 s) covers debug-ignite with no live countdown.
  • Leaf scatter: each tier spawns _flamesPerTier ({9,7,5} lowest→top) small flame-leaves in a horizontal disk of _tierRadius ({0.75,0.55,0.35}) around the tier anchor, with _tierVerticalJitter wobble and per-leaf _intraTierJitter pop delay. Reads as foliage catching fire.
  • Detonate() on OnRitualComplete: timing-safety re-ignites any un-lit tier, flares every leaf (ps.Emit(18)), then EmitSparksOnce() streaks sparks into the reward creature.
  • URP light budget: only the FIRST leaf per tier keeps its point light (~3 canopy lights total) — 20+ point lights would blow the per-object additional-light cap and pop. Other leaves are pure additive puffs.
  • Extinguish now StopAllCoroutines() (leaf spawns are fire-and-forget coroutines) + resets _litTiers. Removed _tierStagger (field + stale prefab line).
  • Flame prefab re-tuned to leaf scale in FlameVFXSetupWizard: startSize 0.13–0.26 (was 0.28–0.5), lifetime 0.55, emission 18/s, burst 10, maxParticles 50, rise 0.8 — small per-leaf since ~21 scatter across the canopy.

Verification (MCP): forced recompile → 0 compile errors (only an unrelated Player_4 control-scheme runtime msg); re-ran Ritual & Ruin/Setup/Build Flame VFX Prefab[FlameVFXSetupWizard] Built …/FlameTip_Particles.prefab. Behaviour (layered leaf ignition + detonation) still needs a real ritual completion to confirm visually — debug-ignite has no live countdown so it uses the fallback pacing and won't detonate.

Flame-leaf shape + canopy capture tool

User wanted the flames "more solid (like a plane)" and "flame shaped … a fiery maple leaf" rather than circular specks. FlameVFXSetupWizard reworked: new procedural flame-leaf sprite (FlameLeaf_Shape.png — central flame tongue + 4 side lobes via Tongue() unions = flame-shaped maple leaf, solid core + thin feather) on an alpha-blended material (FlameLeaf_Alpha.mat, was additive — alpha holds the silhouette instead of washing to a glow blob). Candle PS retuned for solid leaves: startSize 0.22–0.42, lifetime 0.8, rate 7 + burst 4, maxParticles 24, ±0.22rad start rotation, alpha solid most of life, minimal size shrink, billboard (= camera-facing plane). Sparks keep their own additive soft sprite (FlameParticle_Additive.mat). Per-tier glow light unchanged.

Refinement (white core + bunches, 2026-06-04): (1) leaf sprite now bakes a white-hot opaque core → transparent fiery-orange edge gradient (EvalTongue accumulates a core = spine-closeness fading toward tip; RGB lerps edge→white by core, alpha 0.16 + 0.84*core). Candle startColor forced white so the gradient shows true; AltarFlameController.ApplyColorTo no longer tints the candle leaf (only the glow light + sparks are palette-tinted — leaf hue is baked). (2) Canopy scatter changed from a uniform disk to spaced bunches: BuildTierLeafOffsets (now a public static shared by the capture tool) places _bunchesPerTier {4,3,2} clumps on a jittered ring of _tierRadius, each a tight _bunchRadius 0.16 cluster of _leavesPerBunch 4 leaves — gaps between bunches read as foliage. Offsets applied via tier.TransformPoint so bunches rotate with the tree spin. Replaced _flamesPerTier.

Round 3D crown (2026-06-04): flat stacked disks "looked like randomly bunched leaves" — user wanted a roundish full-3D canopy. Replaced the per-tier disk/bunch scatter with a canopy ellipsoid (_canopyCenter (0,1.75,0), _canopyRadius (0.85,0.7,0.85)). Each tier = a vertical band of the crown (TierVMin/Max v=sin-latitude: bottom cap / equator / top cap), so low→high ignition fills a round canopy bottom-up. BuildTierCanopyOffsets (replaces BuildTierLeafOffsets) spreads _leavesPerTier {14,20,10} over each band's ellipsoid surface using a golden-angle longitude spiral (even, full 360°) + _shellJitter 0.14 radial depth. Leaves parent to FlameAnchors (not the tier transforms) via TransformPoint so the crown rides scroll+spin; Tier1/2/3 transforms now only count tiers. Capture tool mirrors the new helper. Removed _bunchesPerTier/_leavesPerBunch/_tierRadius/_bunchRadius/_tierVerticalJitter.

Rounded cone + two-layer flame (2026-06-04): (1) Sphere crown → rounded CONE: radius profile CanopyProfile(h)=(1-h)^0.55 (widest bottom tier, tapering up); tiers are height-bands (TierHMin/Max), leaves fill band VOLUME (golden-angle longitudes + biased radial fraction). _canopyRadius (1.0,0.8,1.0), _leavesPerTier {20,13,7} (bottom most). (2) Single-sprite flame → two layers so it's not "copy-pasted fire": a SOLID alpha maple-leaf CORE (FlameLeaf_Alpha.mat, white startColor, alpha=coverage, white-hot→warm RGB) + a soft ADDITIVE orange GLOW child (FlameGlow_Additive.mat) — additive = the requested screen behaviour (lone=transparent orange, overlap=white-hot). Per-leaf rotation/size variation. ApplyColorTo now tints Glow+Sparks+light (TintChildParticle), leaves core white. Capture sims root withChildren so the glow renders.

New AltarCanopyCaptureTool (Ritual & Ruin/Debug/Capture Canopy Layers): edit-mode, deterministic capture of the canopy at Layer 1 / 1+2 / 1+2+3 — instantiates the altar, applies AltarFill at full fill, scatters leaves per-tier like the controller, ParticleSystem.Simulates a still frame, renders a framed camera to PNG straight into the docs images/ folder. Embedded as a comparison table in [[Altar Flames Implementation Plan]]. Re-run after any flame/rune tuning to refresh.

Random tree spin

User wants each altar's tree seen from a different angle. New AltarRandomSpin component on the altar root: on Start, picks a random Y angle and RotateAround(rootPos, Vector3.up, angle)s both TreeVisual and FlameAnchors together about the altar's vertical centre, so canopy + flame-leaf layers stay aligned. Must not rotate the altar instance itself — its chunk parent is non-uniform scale (2,1,2), so a relative rotation would re-shear the mesh (the squash bug); spinning only the uniform-scaled children avoids it. Side benefits: world-space fill line unaffected (Y-rotation doesn't change Y-bounds), triplanar runes re-project per angle (extra variety), vertical colliders symmetric so gameplay unaffected. Added to Altar.prefab via MCP manage_prefabs modify_contents components_to_add. _randomize/_fixedAngle fields for locking a view while debugging. Compiles clean.

> Note: altar ritualCountdownTime is 5s in the prefab (not the spec's 3s); tier ignition paces to RitualCountdownTime/3 ≈1.67s/layer automatically. Set countdown to 3 for literal one-layer-per-second.

Curvy runes

User: runes "look like random slash marks." AltarFill.shader RuneCell rewritten from straight V/H/diagonal strokes → curvy calligraphic glyphs: a sine-displaced wavy stroke + a circular arc/loop per cell (hash-chosen orientation/params, angularly-clipped hooks, occasional closed ring). Shader recompiles clean (no shader errors). Visual read pending playtest; tune via runeTiling (3) / _RunePower.

Session 8 (2026-06-05) — Maple-leaf flame + the magenta saga

User wanted the flame leaf to actually look like a maple leaf (curved sides, sharp pointy/spiky tips), not a star/lotus/round blob. Researched how it's done: vector maple leaves = Bézier outline — corner anchors at the tips (sharp) + curved segments along the sides. Implemented in FlameVFXSetupWizard: MaplePts point set (lobe tips + side teeth) → BuildCurvedOutline joins them with outward-bowed quadratic Béziers → curved sides, sharp tips. (Trademark note: user opted to keep the maple point structure; it's stylised + curved, their call.) Texture white body, red only on UPPER edges (EdgeDistance chamfer rim × upperBias). Single-flame preview menu Capture Single Flame (core alone + core+glow on flat quads) added to iterate the shape in isolation.

The magenta saga (load-bearing): the canopy particles rendered solid magenta while the single-flame *mesh-quad* preview rendered fine. Root causes, in order of discovery: (1) Sprites/Default renders on a mesh but is magenta on URP particle systems (no URP particle pass); (2) alpha-blend URP Particles/Unlit also went magenta on the particle path; only ADDITIVE URP Particles/Unlit renders reliably → made all three flame materials additive (white leaf texture under additive still reads as a solid white-hot leaf). (3) Even then it stayed magenta because my repeated shader-swaps + WriteMaterialAsset CopyPropertiesFromMaterial had corrupted the .mat assets. Fix: delete all flame mats+pngs + FlameTip_Particles.prefab (+metas) and rebuild fresh → renders correctly (white-hot canopy + orange glow). Verified in edit-mode capture and play mode.

Prefab-relink gotcha: deleting the prefab .meta kept the GUID but SaveAsPrefabAsset gave the root a new random fileID → Altar.prefab _particlesPrefab pointed at a dead fileID. Re-pointed it to the new root (2569954455232514087).

Open: the canopy is quite WHITE/bright (additive white cores dominate) — can dial back via leaf startColor/texture brightness. Green #00FF41 bloom still tints flames in-game ([[Bloom Tint Gotcha]]).

Session 9 (2026-06-05) — Outward maple leaves + fiery VFX fire

User: leaves should face OUTWARD from the tree (not camera-billboards), no stem, varied position/surface-direction, smaller; and the glow should be a fiery VFX fire, not an orange blob.

  • Core LEAF → mesh QUAD (was a camera-facing billboard particle). New LeafQuad child = built-in Quad mesh + Sprites/Default leaf material. AltarFlameController.OrientLeafQuad (shared with the capture tool) rotates each quad to face outward from the canopy centre (LookRotation(outward, up)) with random tilt (±28°) + in-plane roll (±180°) + random scale (_leafScaleMin/Max 0.25/0.5). Bonus: a mesh quad with Sprites/Default renders crisply and completely dodges the URP-particle magenta issue (which only hits particle systems). _leavesPerTier bumped to {30,22,14} (smaller leaves need more).
  • Stemless leaf: removed the stem points from MaplePts; the base closes flat.
  • GLOW → fiery FIRE (Fire child PS): rising flames, white-hot→orange→red colorOverLifetime gradient, Noise turbulence for flicker, additive, flame-teardrop sprite. ApplyColorTo no longer tints the fire (own gradient); Detonate flares the Fire child (root has no PS now).
  • Prefab root fileID was preserved by SaveAsPrefabAsset this time (overwrite, not delete) → Altar.prefab _particlesPrefab link still valid, no relink needed.

Verified: compiles clean; canopy capture renders correctly in edit mode AND play mode — a burning maple-leaf crown (outward stemless white leaves scattered through real rising fire), no magenta.

Tuning follow-up (same session): user wanted MANY more, smaller leaves, and the fire looked separated from the leaves (it rose into an upper plume). Fixed: _leavesPerTier {30,22,14}→{60,45,28}, leaf scale 0.25–0.5→0.13–0.28; and shrank the per-leaf fire (size 0.07–0.17, lifetime 0.3–0.5, vel.y 0.8→0.35, rate 28→10, maxParticles 50→16) so each fire hugs its leaf rather than rising away. Now reads as a tree full of small burning maple leaves.

Tuning follow-up 2: leaves looked rigid (flat identical cards) + the crown was too cone-conforming. Fixed: (a) leaf is now a curved mesh (CreateCurvedLeafMesh → cupped + tip-curl, FlameLeaf_Curved.asset) + OrientLeafQuad per-leaf non-uniform scale & random-signed Z (skew/bend-flip); (b) canopy profile cone (1-h)^0.55rounded dome (1-h²)^0.55, _canopyRadius y 0.8→0.9; (c) looser scatter — _shellJitter 0.12→0.22, radial frac up to 1.15 (leaves spill past the surface), + h/theta/y jitter. Result: a rounder, organic, irregular burning-leaf crown.

Tuning follow-up 3 (2026-06-05): (a) stray flames — each leaf's Fire emitted a cloud (rate 10/max 16/burst 4) that drifted into scattered strays; cut to ONE small flame per leaf (rate 6, max 6, burst 1, tight cone 7°/0.02, vel.y 0.35→0.18, noise 0.22→0.1 → ~2–3 particles in a tight column). (b) bands too low/spread_canopyCenter.y 1.75→1.95 + _canopyRadius.y 0.9→0.58 so the crown sits up in the branches and the bands are closer together. (c) floaty orientationOrientLeafQuad rewritten: leaf apex (+Y) now follows the radial growth direction (outward, up-biased, small wobble) with a random face-twist around it, so leaves radiate outward like foliage branching from the centre instead of random tilt/roll. Verified via canopy capture.

Tuning follow-up 4 (2026-06-07→08): (a) radial depth + crown height — bigger fire and more in/out leaf-distance variation, then dialled back: radial frac settled at lerp(0.55,1.2) (was briefly lerp(0.3,1.35), too ragged); crown raised _canopyCenter.y 1.95→2.05 + _canopyRadius.y 0.58→0.72 so the top reaches higher over the branch tips. (b) smaller leaves / larger flames (2026-06-08)_leafScaleMin/Max 0.13/0.28→0.11/0.23 and fire startSize 0.17–0.28→0.22–0.36 so the fire dominates the tighter foliage (hotter, more burning-canopy read). Files: AltarFlameController.cs + mirrored AltarCanopyCaptureTool.cs constants + FlameVFXSetupWizard.cs fire size. Re-ran Build Flame VFX Prefab (fire size is baked) + Capture Canopy Layers; verified clean compile + capture PNG.

Follow-ups

  • Leaf scatter radii/counts are tuned to the cube blockout canopy — re-tune _flamesPerTier/_tierRadius/Tier-anchor heights to the final sculpt's actual canopy layers.
  • Auto-pour range (3u XZ in PourController) vs the now-correct ~0.7u footprint — the footprint shrank ~2× from the squashed state, so pour targeting may want a re-tune (carried from prior log).
  • Still pending from V1 integration: real blood-red rune texture, flame VFX at branch tips, final ritual sculpt to replace the cube blockout, red-glow vs #00FF41 bloom check (see [[Bloom Tint Gotcha]]).

The provided document outlines several updates, fixes, and implementations related to a game project. Here's a structured breakdown of the key points:

Orphaned Pillars Guard

Implemented: 2026-05-29 Type: Fix Area: Floor System / Pillar Placement

Summary: Pillars previously could appear as "sticks pointing at nothing" due to gaps in the floor above them. This issue was addressed by ensuring pillars do not overlap with future gap cells of the floor above.

Changes Made: Extended the condition for pillar placement to include checks against both current and future gaps (IsFutureGap(x, y)). Added a verification step (VerifyInvariants) to flag any potential latent orphan pillars during generation.

Verification: Clean compilation without errors. Pending playtest to ensure no orphaned pillars in new cycles.

Player Stuck on Retracting Top Floor

Implemented: 2026-05-29 Type: Fix Area: Floor System / Cycle Retraction + Surface Lifecycle

Summary: Players previously got stuck on a non-existent surface when the top floor retracted due to a stale name lookup for FloorObiSurface.

Changes Made: Updated references to disable both _solidSurfaceGO and _holedSurfaceGO using tracked surface references. Ensured proper cleanup of mesh resources in OnDestroy and DestroyFloor.

Verification: Successful compilation with zero errors. Playtest confirmed that players now correctly fall onto the floor below during retraction.

Player Impact on Retracting Top Floor

The fix ensures a smoother gameplay experience by preventing players from getting stuck when floors retract, maintaining immersion and game flow.

Related Work:

Orphaned Pillars Fix: Related to previous issues with pillar placement logic and the need for consistent floor design. Dual-Mesh Refactor: The refactor that led to stale references was addressed in this fix, ensuring all surface interactions are updated correctly.

Follow-ups:

For both fixes, follow-up actions include further testing and documentation updates to ensure comprehensive coverage of changes and their implications on gameplay. No immediate additional steps were identified beyond the ongoing verification processes.

Overall, these updates aim to enhance game stability and player experience by addressing specific technical issues within the floor system mechanics.

Raw session notes

Bowl Containment Fix — Dual-Mesh FloorObiSurface

Date: 2026-05-24

Type: Fix (regression)

Player impact: Cup form holds blood across the entire match again — the demo-blocker where the bowl went "convex" after the first floor cycle is gone. Blood also correctly falls through newly-opened floor holes.

What changed

FloorObiSurface is now two sibling GameObjects per non-emitter floor, both built at floor-generation time and registered in ObiColliderWorld from the start:

  • FloorObiSurface_Solid — mesh covers every non-real-gap cell. Active initially: MeshCollider.enabled = true, ObiCollider.Filter = -65535.
  • FloorObiSurface_Holed — same mesh with the floor's pre-randomized future-gap cells cut out. Dormant initially: MeshCollider.enabled = false, ObiCollider.Filter = 0.

When the floor leaves the Bottom role and opens its gaps (Floor.OpenGapsForMiddleRoleFloor.CommitGaps), the two flip in a single call (Floor.ActivateHoledSurface()): solid goes dormant, holed goes active. Pure flag toggle — no Mesh.Destroy, no MeshCollider.sharedMesh swap, no ObiCollider destroy, so neither triangle-mesh handle ever leaves Obi's ObiTriangleMeshContainer.

The Holed variant is only built for non-emitter floors (the emitter ceiling never transitions out of its role and so never opens gaps — it gets only the Solid surface).

FloorGenerator.RebuildObiSurfaceMesh is reduced to a no-op stub. The whole runtime-mesh-swap code path it represented is now bypassed by design.

Why

The bowl-loses-concavity regression was caused by FloorGenerator.RebuildObiSurfaceMesh swapping the MeshCollider.sharedMesh at runtime when a floor transitioned Bottom→Top. Obi's ObiMeshShapeTracker.UpdateIfNeeded detected the mismatch, called world.DestroyTriangleMesh(handle), which calls ObiTriangleMeshContainer.DestroyTriangleMesh — and that method RemoveRange's the destroyed mesh's BIH nodes / triangles / vertices out of the shared container, shifting the offsets of every handle whose firstTriangle was after the destroyed one's. In Obi 7.1.1's Compute backend, the resulting state visibly corrupted the player Bowl's triangle-mesh collision — a "phantom barrier" appeared in empty space above the bowl rim (blood bounced off as if hitting a dome), and the floor whose mesh had just been rebuilt also stopped passing blood through the new holes.

Three patches downstream of the swap were tried and ruled out: force-updating every other ObiCollider after the rebuild (shape.dataIndex was confirmed already auto-refreshed for dynamic colliders, so the patch was redundant); skipping the old Mesh.Destroy (no help — sharedMesh = alone still triggered the corruption); destroying + recreating the whole FloorObiSurface GameObject so DestroyCollider would set collidersToUpdateCount = colliderHandles.Count (no help). The corruption lives in the Compute backend's BIH/triangle buffer state — opaque to MCP inspection and not addressable via the public API.

An intermediate per-cell BoxCollider filler design also fixed the bowl bug but introduced cascading secondary problems (XZ-seam tunneling between filler box top and mesh top, O(N·M) perf collapse from per-filler GroundParticleKiller contact iterations, player snagging on filler bumps, PlayerBlobShadow Y-hopping). The dual-mesh redesign — user-suggested — bypasses all of these.

Files modified

  • Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs
  • BuildSolidChunkMesh(floor, bool skipFutureGaps = true) parameterized; Solid variant calls with false, Holed variant with true.
  • AddFloorObiSurface rebuilt to create both surfaces via CreateFloorObiSurfaceGO(...) helper.
  • CreateFloorObiSurfaceGO new — owns MeshCollider.enabled and ObiCollider.Filter initial values per the active flag.
  • RebuildObiSurfaceMesh reduced to a no-op stub (kept so any external caller compiles).
  • AddFloorFillers deleted (the intermediate design's per-cell box filler creator).
  • GenerateFloor no longer calls AddFloorFillers.
  • Assets/ProtoV2/Scripts/FloorSystem/Floor.cs
  • _solidSurfaceGO / _holedSurfaceGO fields replace the filler dictionary.
  • SetObiSurfaces(GameObject solid, GameObject holed) registers the pair.
  • ActivateHoledSurface() + SetSurfaceActive(go, active) flip both flags on both surfaces.
  • CommitGaps(cells) now sets the gap state + calls ActivateHoledSurface(). No more RebuildObiSurfaceMesh call, no per-cell filler destruction.
  • RegisterFiller / DestroyFillerForCell deleted; _fillers field deleted.
  • ComputeFutureGapCells + FutureGapCells + IsFutureGap retained (the Holed mesh's hole positions come from this set).

Verification

Live Play Mode, MatchScene, user-confirmed end-to-end:

  • Bowl holds blood pre-cycle (Carrier mode, stand under emitter).
  • Bowl holds blood post-cycle (after completing floor 1's altar → ExpandSequence 1→2).
  • Bowl tolerates player movement without ejecting blood.
  • Blood passes through the newly-opened holes in the old floor 1 after cycle.
  • Player walks across the floor smoothly — no catching on cell boundaries.
  • PlayerBlobShadow stays flat across all cells (no Y-hopping at seam boundaries).

Console clean (no compile errors; only the pre-existing unrelated kinematic body linear velocity warnings from Obi's rigidbody system).

Follow-ups (non-blocking)

  • Confirmed/tech/Core Systems Reference.md FloorObiSurface paragraph wants a one-liner mentioning the dual _Solid / _Holed siblings + toggle mechanism. The runtime behaviour described in Confirmed/design/Scrolling Floor System.md is unchanged.
  • GroundParticleKiller is added to both surfaces — the dormant one's OnCollision handler is effectively a no-op (its ObiCollider.Filter = 0 means no particles match, so no contacts are routed to it), but moving the GPK off the dormant surface entirely would be a tiny perf win.
  • Full pre-existing problem investigation + Dev Log session lives in Problems/Archive/2026-05-23_cup-blood-containment-breaks-after-floor1.md.

Ground particle decay — lost-contact grace window

Implemented: 2026-05-24

Type: Fix

Area: Blood system / Obi Fluid

Player impact

Blood lying on the floor now decays within the 3.5 s recoverable-then-decaying window again. Before this fix, particles on the floor lingered for tens of seconds until Obi's 60 s lifespan backstop killed them — the floor visibly stayed bloody between rituals and the recovery-window mechanic stopped reading as such.

What changed

Assets/ProtoV2/Scripts/BloodSystem/ObiParticleKillerBase.cs — added a serialized lostContactGraceSeconds field (default 0.5 s, [Min(0)]). The lost-contact check in UpdateParticleTimers now uses max(lostContactGraceSeconds, 2 * fixedUnscaledDeltaTime) instead of the previous hard-coded two-fixed-frame window.

resetTimerOnLostContact semantics are unchanged: a particle that genuinely leaves the surface (creature picks blood up, falls into a hole) still cancels the kill timer.

Why

Two-fixed-frames (~0.04 s at 50 Hz) was sized only for the Obi-vs-FixedUpdate execution-order race. Commit bab8450 (2026-05-21) added ObiCollider.ForceUpdate() + ObiColliderWorld.SetDirty() after every mesh swap in FloorGenerator.RebuildSurfaceMesh — necessary to stop blood from colliding with the old hole-less mesh, but now each floor scroll re-uploads the collider and contact briefly drops for resting particles. With resetTimerOnLostContact = true, that dropped the 3.5 s kill timer on every scroll. Bumping the grace window to 0.5 s comfortably absorbs the re-upload bounce while staying tight enough that a real pickup still cancels.

Note on the lifetime-vs-delete misread

When framing this, the working theory was "we switched the kill path from particle-delete to particle-lifetime." Half right:

  • Our code (ObiParticleKillerBase.TryKillParticle) still calls emitter.KillParticle(actorIndex) exactly as it always has. No semantic change on our side.
  • But Obi 7.1.1's ObiEmitter.KillParticle is itself implemented as solver.life[index] = 0 (Assets/Obi/Scripts/Fluid/Actors/ObiEmitter.cs:445). The upstream API has always been lifetime-based since the Obi 7.1.1 upgrade. That's why the lifespan: 60 field on BloodEmitter.prefab was acting as the actual end-of-life backstop and why the symptom looked like "very long" rather than "forever".

So: not a kill-path regression. The kill call was firing fine; the *timer that decides when to fire* was getting cancelled by mesh-swap contact drops.

Verification

In-Editor (MatchScene): blood landing on the floor disappears in ~3.5 s. Floor scrolls during a pour no longer cause ground particles to linger. User confirmed.

Files modified

  • Assets/ProtoV2/Scripts/BloodSystem/ObiParticleKillerBase.cslostContactGraceSeconds field + updated grace check in UpdateParticleTimers.

Follow-ups

  • None blocking. Total kill stays at 3.5 s (2 s recovery + 1.5 s decay) per GroundParticleKiller.Awake(); if you ever want exact 3.0 s, drop one of those constants in GroundParticleKiller.cs.

Related

  • Problem file (archived): Games/Ritual & Ruin/Problems/Archive/2026-05-23_ground-particle-decay-slow.md
  • Commit that exposed the regression: bab8450 (FloorGenerator.RebuildSurfaceMesh mesh-tracker fix, 2026-05-21).

---

2026-05-24 — Outlast Timer Reports Scaled In-Game Seconds

Motivation

When the first team dies, MatchManager enters the outlast phase and exponentially ramps Time.timeScale (default: doubles every real-world second). The surviving team is forced to die in ~5 real seconds no matter what they do. The HUD outlast counter and the EndScreen's "Team A outlasted Team B by Xs" line were both measured in unscaled real seconds, so the readout was always ≈ 5.0s regardless of how stubborn the surviving team actually was — making the post-match comparison metric useless.

Root cause

MatchManager.Update used a single elapsed = Time.unscaledTime − outlastStartTime for two different purposes:

1. The Time.timeScale exponential-ramp formula (legitimately wants real seconds so the ramp is wall-clock predictable).

2. The displayed outlastTimerText and the outlastSeconds value flowed to OnMatchEnd → EndScreen (should be scaled in-game seconds so the number reflects equivalent normal-speed survival).

The two outlast calculations in CheckTeamElimination (line 188) and CheckRemainingTeamAlive (line 219) also used Time.unscaledTime − outlastStartTime, so the value handed off to the EndScreen was always real seconds — capped at ~5s by the timeScale ramp.

Changes

`MatchManager.cs`

  • Added private float _outlastScaledStartTime; — snapshot of Time.time (timeScale-scaled) captured in RecordTeamEliminated alongside the existing real-time outlastStartTime.
  • Update: split elapsed into elapsedReal (drives the Time.timeScale = outlastStartTimeScale * Mathf.Pow(outlastGrowthPerSecond, elapsedReal) ramp — unchanged behaviour) and elapsedScaled (drives outlastTimerText.text).
  • Both outlast = Time.unscaledTime − outlastStartTime calculations in CheckTeamElimination and CheckRemainingTeamAlive switched to Time.time − _outlastScaledStartTime, so EndMatch(winningTeam, outlast) and therefore OnMatchEnd now report scaled seconds.
  • Updated the XML doc on OnMatchEnd to specify the unit is scaled in-game seconds.
  • Inline comments on the two outlastStartTime fields and the Update split explain *why* the two clocks are separate.

The draw threshold in CheckRemainingTeamAlive (outlast < 0.02f) is now interpreted in scaled seconds without any code change. At simultaneous elimination timeScale ≈ outlastStartTimeScale (1× by default), so scaled ≈ real and behaviour is identical in practice.

Considered & rejected

  • Real seconds + speed badge (e.g. "4.2s @ 18×") — exposes the mechanic but doesn't give a normalised comparison number across matches.
  • Display both numbers (large scaled, small real + peak speed) — more information but clutters the EndScreen's clinical typography style; can revisit later if forensic detail becomes useful.

Verification

  • Compile-clean via Unity refresh (no errors/warnings on MatchManager).
  • Player confirmed in real match: HUD outlast counter visibly accelerates as the speedup ramps; EndScreen "by Xs" line now reports a meaningful number (well above 5s) reflecting equivalent normal-speed survival time.

Follow-ups

  • None. EndScreen formatting (by {outlastSeconds:F1}s) is unchanged — it consumes the new unit transparently. MatchTimerUI (separate HUD match clock) was already using Time.deltaTime (scaled) and is unrelated.
  • If we later decide players want forensic detail (real seconds + peak timeScale alongside the scaled number), Update already has both clocks at hand — trivial extension.

Links

  • Problem file: [[2026-05-24_outlast-timer-scale-mismatch]]
  • Decision: [[2026-05-24 - Outlast Timer Reports Scaled In-Game Seconds]]

---

2026-05-24 — Player Blob Shadow + Directional-Light Iteration (Clarity & Depth)

Motivation

First implementation pass on [[Confirmed/strategy/Problems/Active/2026-05-18_clarity-and-depth-perception|Clarity and 3D Depth Perception]]. The character's existing real-time shadow projected off to one side (directional light at (50, -30, 0)) which broke the "shadow tells you where you are" affordance for both sub-problem 2 (X-Z alignment with holes for fly-down) and sub-problem 3 (general floor navigation). Goal: shadow directly under the player, sharper and darker than environment shadows, environment shadows softer and lighter.

Approach landed (per-failure tools)

  • Player anchor: dedicated blob shadow per player — a single unlit transparent quad with a procedural soft-disc texture, raycast-snapped to the floor each LateUpdate. Sharp, dark, always directly underneath.
  • Environment ambient layer: directional light tilted mostly-down with reduced shadow strength so wall / altar / pillar shadows still read as soft depth cues without competing with the player anchor.
  • Free fly-down cue (sub-problem 2): when the raycast finds no floor below the player (i.e. they're over a hole), the blob disables. The disc disappearing is itself the "press fly-down now" affordance.

Changes

New: `Assets/ProtoV2/Scripts/PlayerBlobShadow.cs`

  • Spawns one Quad primitive at scene root (intentionally NOT parented to the player — the player root has localScale = 0.25, parenting shrinks the quad to ~0.3 units across and it disappears under the iso camera).
  • Procedural Texture2D (128² RGBA32) generated in code — white RGB, soft-falloff alpha disc using SmoothStep on InverseLerp(innerSolidRatio, 1, d). Inner solid radius keeps the disc dark in the middle, falls off to fully transparent at the edge. No asset file needed.
  • URP Unlit transparent material setup matches the proven pattern from EmitterIndicatorController.CreateMaterial (_Surface=1, _Blend=0, alpha-blend, _ZWrite=0, _Cull=0, _SURFACE_TYPE_TRANSPARENT keyword, queue Transparent). _Cull=0 is load-bearing — without it the single-sided quad's back face was rotating toward the iso camera and getting culled (invisible).
  • LateUpdate does Physics.RaycastNonAlloc straight down from transform.position + Vector3.up * rayOriginYOffset, filters out the player's own colliders (IsChildOf(transform)) and also FloorObiSurface by name — see "Y-alignment fix" below.
  • Cleanup hooks (OnDisable / OnEnable / OnDestroy) so the orphaned quad follows the component's lifecycle.

`Assets/ProtoV2/Scripts/Multiplayer/PlayerSetup.cs`

  • Awake auto-adds PlayerBlobShadow if missing. Avoids needing to wire the component on the prefab (which would require a script GUID round-trip).

`Assets/ProtoV2/Prefabs/PlayerPrefab.prefab`

  • m_CastShadows: 1 → 0 on the three player MeshRenderers: TrailingBody, Cap, Bowl. The blob takes over the shadow role entirely; the meshes no longer participate in the directional-light shadow pass.

`Assets/ProtoV2/Scripts/JellyfishVisuals.cs`

  • Runtime LineRenderer tentacles built in BuildTentacles now set shadowCastingMode = Off + receiveShadows = false. Otherwise tentacles would still cast thin strips from the directional light in jellyfish mode.

`Assets/ProtoV2/Prefabs/FloorSystem/Chunk_Normal.prefab`

  • m_CastShadows: 1 → 0 on the chunk MeshRenderer. Reason: with the directional light angled mostly down, the upper play floor's chunk meshes were casting a giant rectangular shadow onto the floor below, blanketing it in darkness and drowning out the smaller wall / altar / pillar shadows we actually want to see. Chunks are horizontal panels; their shadows aren't visually useful — only the tall props (walls / altars / pillars, still casting) carry depth information.
  • The user's intuition was to "move the light below the ceiling" — directional lights ignore position (only rotation matters), so that mental model can't be wired. Disabling Cast Shadows on the occluder achieves the same visual outcome.

`Assets/ProtoV2/Scenes/MatchScene.unity`

  • Directional Light rotation: (50, -30, 0)(70, 0, 0). Mostly straight-down (light source close-to-overhead so per-object shadows are short and read as belonging to the object) with a 20° tilt back so vertical objects (walls / altars / pillars) still project a visible shadow onto open floor.
  • shadowStrength: 0.350.5. Bumped to compensate for the steeper angle — at near-straight-down the shadows are short, so they need more density to read at booth distance.

Iteration log (problems that came up and fixes)

Iter 1: blob invisible

Symptom: no shadow showed up at all on either player.

Diagnosis via MCP (read_console, find_gameobjects, components resource): the raycast WAS hitting FloorObiSurface at the right XZ, no errors, component attached. But the world-space bounds of the quad were 0.3 × 0.3 instead of the expected 1.2 × 1.2 — the player root's localScale = 0.25 was shrinking the parented child to invisibility under iso zoom.

Fix: don't parent the quad. Live at scene root, track world position manually each LateUpdate, destroy in OnDestroy.

Iter 2: env shadows gone with light straight-down

Symptom: at light rotation (90, 0, 0) and even at strength 0.35, no environment shadows visible anywhere.

Diagnosis: vertical objects cast a shadow EQUAL TO their own footprint when lit perfectly from above — every wall / altar / pillar's shadow sits directly under itself, hidden by its own base. Open floor receives nothing.

Fix: tilt back to (70, 0, 0) — far enough off-vertical that tall props project onto adjacent open floor, while the blob shadow already handles the player. Also bumped strength to 0.5 to compensate for the now-shorter casts.

Iter 3: ceiling blocking shadows

Symptom: at (70, 0, 0) strength 0.5, env shadows on the current floor were near-invisible — most of the floor sat in a uniform dim wash.

Diagnosis: the next-higher play floor's chunk meshes are themselves opaque and were dumping a giant shadow onto the floor below, drowning out the wall / altar shadows we wanted to see.

Fix: m_CastShadows: 0 on Chunk_Normal.prefab.

Iter 4: blob and crosshair Y-misaligned

Symptom: blob shadow and PlayerCrosshairController's + weren't at the same height — under iso this projected to a screen-space offset that read as "off-center".

Diagnosis via MCP (compared the two GameObjects' world positions):

  • Blob world Y = 9.04 (raycast hit FloorObiSurface at 9.02 + verticalOffset=0.02).
  • Crosshair world Y = 8.88 (floor.transform.position.y + ChunkHeight*0.5 + surfaceOffset(0.06)).
  • Delta = 0.16. The FloorObiSurface is intentionally lifted by floorKillZoneYOffset in FloorGenerator.AddFloorObiSurface so Obi fluid doesn't seep into chunk crevices — it's *not* coplanar with the visible chunk surface.

XZ was already within 3 mm (both anchor to player.transform.position.xz); the misalignment was purely the Y discrepancy projecting through the iso camera.

Fix: blob's raycast filter now also skips h.collider.name == "FloorObiSurface" so the ray passes through it to the chunk BoxCollider underneath. Blob now lands at Y=8.84 (chunk 8.82 + 0.02), 4 cm below the crosshair — invisible under iso, and the crosshair's ZTest=Always material draws over the blob's centre cleanly.

Considered & rejected

  • Second directional light with rendering layers for player-only sharp shadows. Cleanest "real" solution but each extra directional light adds a full shadow-map pass — measurably more expensive than the blob. With blob shadows being effectively free (1 alpha quad per player, no shadow-sampling), no reason to pay that cost.
  • URP Decal projector. Higher quality (real surface conformance, sub-pixel sampling) but requires enabling URP Decals in the renderer feature stack and a separate decal material. Blob quad is sufficient for a clean clarity cue and ships without renderer-stack changes.
  • Bias the blob XZ to compensate for iso-parallax. The body floats above the floor at high Y; the iso camera makes it appear shifted UP on screen relative to its true XZ projection, so a perfectly-centred shadow looks "behind" the body. Considered adding a small +Z world bias to push the blob forward visually. Not done — the geometric "shadow at foot XZ" reads as correct depth cuing once you're used to it, and biasing would misrepresent where the player actually is in world space (which is precisely what we're trying to communicate).

Verification

  • All four player blobs spawn (confirmed via find_gameobjects by_component PlayerBlobShadow returning 4 hits, and the corresponding BlobShadow_PlayerPrefab(Clone) scene-root objects).
  • Raycast logs ([PlayerBlobShadow] Player_N: raycast hit 'PooledChunk_Dynamic') confirm the post-fix raycast hits the chunk surface, not the obi surface.
  • Blob and crosshair visually coincide (user confirmed in-editor).
  • Environment shadows from walls / altars / pillars visible on the play floor under (70, 0, 0) + strength 0.5. The upper play floor no longer blankets the current floor in shadow.
  • Tentacle shadow-cast disabled — verified by inspecting JellyfishVisuals.BuildTentacles and confirming the new shadowCastingMode = Off lines.

Follow-ups

  • Player-shadow / blob-shadow behaviour deserves a Confirmed spec under Confirmed/tech/ once it bakes in. Should document: lives at scene root, raycast filter rules, hide-on-no-hit-as-fly-down-cue, parent-scale gotcha.
  • "Floor doesn't read as a clear ground plane" (sub-problem 3) is still open — this pass tackled the player-anchor side. Floor art / value-contrast work still pending and ties into the [[project_color_palette|Color Composition Workflow]] validation phase.
  • If a future scene gets a different Obi surface or a MeshCollider named differently from FloorObiSurface, the blob's name-based filter won't apply and the shadow would float again. Consider promoting FloorObiSurface to a project-level layer (e.g. "ObiFluidCollider") and switching the filter to a LayerMask if this comes up.

---

2026-05-24 — Boundary Walls: real-surface `Textured` mode (Concrete034)

Motivation

The 8 procedural wall shaders (Flat / Panels / Bands / Ribs / PanelsInset / VentSlats / Greebles / CircuitTraces) all read "abstract" no matter the params — pure procedural unlit-transparent geometry can't produce a real-surface read. The decision in [[ART#Prototype recommendation (2026-05-24)|ART.md]] was to add a real-photo Path B alongside the procedural set, picking ambientCG Concrete034 (CC0) as the safest neutral-grey base across all 6 palettes (see [[Combo Comparison]]).

Approach

  • New ProtoV2/BoundaryWallTextured shader that samples a greyscale-ish wall photo with world-space UVs (same trick the other wall shaders use — picks horizontal coord from positionWS.x or .z based on the wall's normal so all 4 walls keep a consistent tile size regardless of mesh stretching), desaturates the source to a tunable degree, and multiplies by _BaseColor (the palette's walls.sides). Desaturate knob defaults to 1.0 so the palette dominates the hue even if the source has a faint colour bias.
  • New WallTextureMode.Textured enum value plumbed through BoundaryWalls.PickShader, ConfigureWallMaterial (skips seam/edge derivations like Flat does), BuildWallMaterials (calls a new ApplyTexturedParams after Configure to push _BaseMap/_TileScale/_DesatStrength/_ValueBoost/_ValueOffset), and SetWallColors (only rewrites _BaseColor for Textured — no seam/edge to derive).
  • Editor-only auto-link in BuildWallMaterials — if the user is in Editor and the _texturedBaseMap inspector slot is empty, UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ProtoV2/Textures/Walls/Concrete034_Color.jpg") fills it in. Means the cycle wizard works out of the box without manual Inspector wiring. In a built player the field must be serialized — checked at write time.
  • WallTextureMode.Textured added to the cycle arrays in ColorPalettePreviewWindow.cs so both Cycle wall textures and Cycle all combinations (palettes × wall textures) capture it (now 9 modes × 6 palettes = 54 captures).

Changes

New: `Assets/ProtoV2/Shaders/BoundaryWallTextured.shader`

URP unlit-transparent pass, same blend state as the procedural wall shaders (Blend SrcAlpha OneMinusSrcAlpha, ZWrite Off, Cull Off, queue Transparent). Frag samples _BaseMap at world-space UVs scaled by _TileScale, applies a luminance-based desaturation (lerp(src, grey, _DesatStrength)), brightness shaping (saturate(src * _ValueBoost + _ValueOffset)), and tint-multiplies by _BaseColor.rgb. Alpha comes straight from _BaseColor.a so the same opacity logic that drives the procedural walls (~0.10 alpha for sides, ~0.02 for the front wall) carries over.

`Assets/ProtoV2/Scripts/FloorSystem/BoundaryWalls.cs`

  • Enum: Flat, Panels, Bands, Ribs, PanelsInset, VentSlats, Greebles, CircuitTraces..., Textured.
  • New serialized fields: _texturedBaseMap (Texture2D), _texturedTileScale (Float, default 4), _texturedDesat (Range 0..1, default 1), _texturedValueBoost (Range 0.5..1.5, default 1), _texturedValueOffset (Range −0.3..0.3, default 0).
  • PickShader returns the new shader for Textured. Fallback chain unchanged.
  • ConfigureWallMaterial — the seam/edge derivation block now skips when mode == Textured as well as Flat.
  • BuildWallMaterials — calls ApplyTexturedParams after Configure when in Textured mode. Editor-only AssetDatabase auto-link fills _texturedBaseMap if empty.
  • SetWallColors — only writes _BaseColor on Textured (no _SeamColor / _EdgeColor).
  • ApplyTexturedParams(Material) — pushes texture + 4 shaping params to a material.

`Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs`

  • Both modes arrays (in CycleWallTextures and CycleAllCombinations) get WallTextureMode.Textured appended.

New assets

  • Assets/ProtoV2/Textures/Walls/Concrete034_Color.jpg — ambientCG Concrete034_1K-JPG color map (264 KB). CC0.
  • Assets/ProtoV2/Sprites/Walls/Industrial_Tilemap.png + Industrial_License.txt + Industrial_Preview.png — Kenney Pixel Platformer Industrial Expansion (4 KB packed tilemap + 30 KB preview + license). CC0. Not yet wired — these are for Path C, which still needs the WallDecorationSpawner component (tracked in [[ART#Follow-ups / TODOs]]).

Verification

  • Code review pass — WallTextureMode.Textured reaches every switch branch that lists the enum (PickShader, the cycle wizard arrays). The mode != Flat && mode != Textured guard in ConfigureWallMaterial matches the new "no seam/edge" branch in SetWallColors.
  • Hands-on Editor verify pending — the user enters Play Mode → Window → Ritual & Ruin → Color Palette Preview → either flips the BoundaryWalls inspector to Textured directly or runs Cycle wall textures / Cycle all combinations. Captures land under PaletteCaptures/.
  • Shader compile risk: the desat / value-shaping math is straightforward and uses functions (dot, saturate, lerp) that exist on target 3.0. No keyword permutations.

Follow-ups

  • Path C (WallDecorationSpawner) — the Kenney tilemap is in the project but not sliced or used yet. When we get to it: import the PNG as Sprite (2D and UI) / Point filter / Multiple-mode / 18×18 grid slice with 1×1 padding, then build a spawner that places vent/valve/gauge sprites along walls tinted by a palette accent (e.g. hpBar.tier1Accent for light strips). Pick indices from Industrial_Preview.png.
  • Tuning — once we see the texture under every palette, decide whether the default _TileScale = 4, _DesatStrength = 1, _ValueBoost = 1, _ValueOffset = 0 need adjusting per combo. Likely candidates: pale combos (C2, C6) may want _ValueOffset < 0 to darken the wall a touch and lift floor↔wall contrast (a noted weakness of those combos in [[Combo Comparison]]).
  • Lit-shader upgrade option — the Concrete034 download also includes _NormalGL.jpg, _NormalDX.jpg, _Roughness.jpg, _Displacement.jpg. If we want PBR depth on the walls down the line, swap the shader to URP/Lit with these maps; the only complication is integrating the palette tint via _BaseColor rather than dropping it.

---

Session 2 — pivoted to procedural Aperture panels; nuked all the old modes

User feedback: the Textured mode read as a flat concrete wash with no panel structure, and concrete is the wrong aesthetic anyway. Reference image was Portal 2 / Aperture test chambers: white square ceramic panels with varying per-panel grime, water streaks, and decay. The right answer is procedural — we don't need an external photo, we need a shader that gives us the Aperture look directly and lets the palette colour the *decay theme* rather than the wall *base*.

What got nuked

Deleted 8 shader files (no other code or scene references — verified via grep): BoundaryWallPanels.shader, BoundaryWallBands.shader, BoundaryWallRibs.shader, BoundaryWallPanelsInset.shader, BoundaryWallVentSlats.shader, BoundaryWallGreebles.shader, BoundaryWallCircuitTraces.shader, BoundaryWallTextured.shader (and their .meta files).

Removed from BoundaryWalls.cs:

  • 5 serialized _textured* fields (_texturedBaseMap, _texturedTileScale, _texturedDesat, _texturedValueBoost, _texturedValueOffset)
  • ApplyTexturedParams method
  • Editor-side AssetDatabase.LoadAssetAtPath auto-link for Concrete034_Color.jpg
  • 8 enum values (Panels, Bands, Ribs, PanelsInset, VentSlats, Greebles, CircuitTraces, Textured)
  • DeriveSeam / DeriveEdge helpers (no longer called — Aperture uses shader-constant seam/edge)
  • The 7-case GetWallPatternScalarName switch (collapsed to _PanelSizeX for all non-Flat modes)

What got built (new)

  • Assets/ProtoV2/Shaders/BoundaryWallAperturePanels.shader (from Session 1's start, kept) — world-space-UV square panels with recessed dark grout seams, cream-white face, per-panel hash → grime-noise overlay + dual vertical water streaks, all tinted toward _BaseColor (the palette's walls.sides). Knobs: _PanelSizeX/Y, _SeamWidth, _EdgeWidth, _DecayAmount, _DecayStrength, _DecayFreq, _DecayContrast, _StreakAmount, _PanelVignette, _BrightVariation.
  • WallTextureMode enum (7 values now): Flat, AperturePanels (default 2×2 medium decay), AperturePanelsSmall (1.25×1.25 dense), AperturePanelsLarge (3.5×3.5 imposing), AperturePanelsTall (2×3.5 vertical), AperturePanelsHeavy (decay 0.85), AperturePanelsClean (decay 0.20).
  • ApplyAperturePreset(mode, mat) — single switch that presets shader params per variant. All variants share the one shader; the variant just decides which numbers get pushed.
  • PickShader simplified to one Shader.Find("ProtoV2/BoundaryWallAperturePanels") for any non-Flat mode, with the same Universal Render Pipeline/UnlitUnlit/ColorHidden/InternalErrorShader fallback chain.
  • ConfigureWallMaterial — kept the Flat URP/Unlit transparency setup block; the seam/edge-derivation block is fully removed (every non-Flat mode handles its own seam/edge as shader constants).
  • ColorPalettePreviewWindow.cs — both cycle arrays (CycleWallTextures, CycleAllCombinations) updated to the new 7-mode list. CycleWallSpacings switch collapsed to a single sweep {1, 1.5, 2, 2.5, 3.5, 5} of _PanelSizeX (applies to any non-Flat mode).

Scene compatibility

Old serialized _wallTexture = 1 (was Panels) now reads AperturePanels — same ordinal, accidental but lucky upgrade. Any scene previously set to a higher ordinal mode (Bands/Ribs/etc.) will now read whatever new mode lives at that index — should be fine since user said to remove all the weird ones.

Verification

  • Grep pass — zero references to any removed enum value, shader name, or field.
  • BoundaryWalls.cs recompile risk: only the simplified switches + the new ApplyAperturePreset instance-less static method. All call sites accounted for.
  • Hands-on Editor verify pending — user enters Play Mode → Cycle wall textures (7 captures) or Cycle all combinations (42 captures = 6 palettes × 7 modes).

Follow-up backlog (post-cycle)

  • After the user picks the favourite Aperture variant, adjust the _DecayAmount / _DecayStrength / _StreakAmount defaults on AperturePanels so the default mode hits the sweet spot.
  • Consider whether to add an AperturePanelsOvergrown variant (greenish moss-tinted decay) or AperturePanelsBroken (with per-panel "missing panel" alpha drops) for environmental story beats.
  • The Concrete034_Color.jpg asset is now orphaned in Assets/ProtoV2/Textures/Walls/. Keeping it for now in case we revisit textured walls; can delete in a future cleanup pass if it stays unused.

---

Session 3 — Aperture shader-load failure + side-wall-only architectural split

After Session 2 wired in the Aperture variants, the cycle captures all came out byte-identical (verified via md5sum — all 6 variant PNGs hashed the same). The front wall was also rendering as an opaque hot-pink block, blocking the camera view.

Diagnosis

Root cause was not the C# preset switch — ApplyAperturePreset was pushing distinct params correctly per variant. The real problem: Shader.Find("ProtoV2/BoundaryWallAperturePanels") was silently returning null, so BoundaryWalls.PickShader fell through to Shader.Find("Universal Render Pipeline/Unlit"). URP/Unlit happens to render _BaseColor straight as opaque output if the URP transparency keywords aren't set — hence the hot-pink walls — and all 6 variants produced identical output because they were all the URP/Unlit fallback rendering the same _BaseColor, not the Aperture shader at all.

No shader compile errors visible in the Unity console during the failure (silent load failure). Force-refresh via mcp__mcp-for-unity__refresh_unity didn't help.

Fixes applied to `BoundaryWalls.cs`

1. Two-material split — side/back walls vs front wall. Per user suggestion ("why do we not only apply the textures to the back and sides boundary wall?"), BuildWallMaterials now produces two distinct materials:

  • _wallMat (used for Wall_PosX, Wall_NegX, Wall_PosZ) — gets PickShader(_wallTexture) (i.e. AperturePanels for non-Flat modes) at forced alpha 0.85 so the cream panel detail is actually visible against the floor.
  • _frontWallMat (used only for Wall_NegZ, the camera-facing wall) — always uses Shader.Find("Universal Render Pipeline/Unlit") at the serialized frontWallColor (~alpha 0.02). The front wall's job is to let the camera see through, not to display panel detail.

SetWallColors(sides, front) now pushes the sides RGB to _wallMat with forced alpha 0.85, and pushes the raw front color (with its near-zero serialized alpha) to _frontWallMat. BuildWalls was updated to pass _frontWallMat only to the Wall_NegZ collider; the other three keep _wallMat.

2. Diagnostic logging. Added Debug.LogError in BuildWallMaterials (~line 211 in current BoundaryWalls.cs) that fires whenever PickShader returns anything other than "ProtoV2/BoundaryWallAperturePanels" for a non-Flat mode. Catches the silent-fallback case at runtime instead of leaving us guessing.

3. Defensive transparency setup. ConfigureWallMaterial now always sets the URP/Unlit transparency keywords (_Surface=1, _Blend=0, _SrcBlend=SrcAlpha, _DstBlend=OneMinusSrcAlpha, _ZWrite=0, _Cull=0, enables _SURFACE_TYPE_TRANSPARENT, queue 2950). On the custom Aperture shader these are harmless no-ops (it bakes its own blend state into the Pass). On the URP/Unlit fallback they keep the wall transparent even if the Aperture shader fails to load again. Prevents future regressions to opaque-pink walls.

4. Aperture shader rewrite (still failed silently). Trying to fix the silent load failure, rewrote BoundaryWallAperturePanels.shader:

  • All half4float4 throughout (more portable HLSL typing).
  • Added IgnoreProjector="True" to the SubShader Tags block.
  • Same procedural math otherwise — panel grid + per-panel hash + fbm grime + dual vertical water streaks, palette-tinted decay overlay via lerp(cleanFace, _BaseColor.rgb, decay).
  • Final frag: return float4(rgb, _BaseColor.a) (alpha owned by _BaseColor, which BoundaryWalls.SetWallColors forces to 0.85 for side walls).

After the rewrite, read_console returned 0 error/warning entries. Awaiting user's hands-on re-test (stop Play Mode, re-enter, run Cycle wall textures, re-check console for the [BoundaryWalls] AperturePanels shader failed to load LogError).

Status

  • Architectural split: done — side walls + front wall now use distinct materials. Code compiles. Defensive transparency keywords applied so even fallback stays see-through.
  • Diagnostic LogError: in place — will fire at runtime if the AperturePanels shader still fails to load.
  • Aperture variants visible: VERIFIED 2026-05-26 — user ran Cycle wall textures against Combo 1 Corrupted Cathedral. All 7 captures landed in PaletteCaptures/wall_*_Combo_1_—_Corrupted_Cathedral.png with significantly different file sizes (260KB–1.1MB) confirming distinct shader output (not byte-identical anymore). Visual review confirms cream-white panels with dark grout seams and per-variant decay overlay, front wall fully transparent in every shot. The half4→float4 + IgnoreProjector rewrite resolved the silent shader-load failure.

Visual verdict (Combo 1 — Corrupted Cathedral)

  • AperturePanels (default 2×2): solid default — reads as Aperture-style, balanced grime.
  • AperturePanelsSmall (1.25×1.25): borderline noisy at view distance, busy grid.
  • AperturePanelsLarge (3.5×3.5): imposing but loses tile rhythm — grime overwhelms each big panel.
  • AperturePanelsTall (2×3.5): strong architectural silhouette, lab-corridor feel — strong alt default candidate.
  • AperturePanelsHeavy (decay 0.85): abandoned-chamber read, too grim for default.
  • AperturePanelsClean (decay 0.20): freshly-built read, best for "intro floor" moments.

Files touched (Session 3)

  • Assets/ProtoV2/Scripts/FloorSystem/BoundaryWalls.cs — split _wallMat / _frontWallMat, force-alpha for side walls, diagnostic LogError, defensive transparency in ConfigureWallMaterial.
  • Assets/ProtoV2/Shaders/BoundaryWallAperturePanels.shader — full rewrite, half4float4, IgnoreProjector tag, single-file unlit-transparent pass.

Follow-ups (deferred until user reports back)

  • If shader still fails to load: bisect by replacing with minimal flat-colour-plus-grid test shader.
  • If shader loads but variants still look identical: capture and md5sum the new PNGs to confirm before declaring success.
  • Once Aperture variants render visibly: tune AperturePanels default params (_DecayAmount / _StreakAmount) based on user pick.
  • Wire Path C (WallDecorationSpawner) once Path B is locked in.

---

2026-05-25 — Evolution Tier Accent Verification Pass

Motivation

Daily Focus carried a Quick-verification task: "Evolution tier accents actually applied? — setter + broadcast are wired; confirm the bar visibly shifts colour on tier change in Play Mode." Traceable failure if not: was the tier accent colour ever *used* to drive a renderer/material property, or was UpdateBarUI silently overriding it?

Trace result

UpdateBarUI (UnifiedBar.cs:366-385) only writes the threshold fill colours (full/warning/critical) to barFillImage.color. It does not touch the accent — by design, the accent is a separate channel:

  • TriggerEvolution (UnifiedBar.cs:285-302) calls UpdateBarAccentColor after evolutionTier++.
  • UpdateBarAccentColor (UnifiedBar.cs:308-324) switches on evolutionTier and writes barBackgroundImage.color = tierNAccentColor.
  • PaletteApplier.ApplyHPBar (PaletteApplier.cs:312-338) pushes palette accents through bar.SetTierAccentColors(t0, t1, t2) which also calls UpdateBarAccentColor.

So the path "tier accent → Image.color (UGUI tint, not a material property)" exists and is correct.

Static checks — all four failure modes ruled out

From Assets/ProtoV2/Prefabs/PlayerPrefab.prefab YAML:

1. barBackgroundImage reference assignedfileID 9143661019916860951 (BarBackground Image component).

2. Tier accent colour alphas all 1.0tier0={1,1,1,1}, tier1={0,0,1,1}, tier2={1,0.65,0,1}.

3. Sibling order correctBarBackground precedes BarFill under BarCanvas (earlier sibling = drawn first = behind).

4. barFillImage is Filled / Horizontalm_Type: 3, m_FillMethod: 0, so empty portion of fill exposes the background.

Runtime verification (MatchScene, 4-player spawn)

Captured three screenshots in .mcp-screenshots/ (tier_accent_0_baseline.png, tier_accent_1_enhanced.png, tier_accent_2_terminal.png) via the existing Ritual & Ruin/Debug/Capture Game View menu. Drove evolution with a one-shot temp debug menu (TierAccentDebugMenu.cs, deleted after use) that called bar.AddBarUnits(500f) on every active player.

Observed:

  • Tier 0 — bar at base width 100, full green fill, no accent visible (fillAmount=1.0 covers background). Expected.
  • Tier 1 — bar widens to 130, fill drops to ~77% of new max (decays to yellow/warning by capture time); blue strip clearly visible on the right where fill no longer reaches. Tier-1 accent applied ✓
  • Tier 2 — bar widens to 195, fill ~51%; gold strip visible on the right. Tier-2 accent applied ✓

Observation worth carrying forward

At Tier 2 the gold accent ({r:1, g:0.65, b:0, a:1}) sits next to the yellow warning fill (≈{1,1,0,1}). The visual contrast between fill and background is weak — at a glance the bar reads as a single yellow blob. The accent IS being applied, but it doesn't *signal* the tier change strongly. This is a palette-tuning issue, not a wiring issue.

There's also a smaller asymmetry: at Tier 0 the BarBackground stays at its prefab-serialized colour ({0,0,0,1} = black) because UpdateBarAccentColor is never called at Start — only on evolution or palette broadcast. The tier0AccentColor = white field is effectively only consumed when PaletteApplier re-broadcasts during a runtime palette swap. Not a bug today (Tier 0 visible accent is hidden under full-fill anyway), but worth knowing if Tier 0 ever needs to read with a partially-empty bar.

Files touched

  • Assets/ProtoV2/Scripts/Editor/TierAccentDebugMenu.cs — created, used, deleted within session. No remaining footprint.

Follow-ups

  • Tier 2 accent vs warning yellow contrast — feed into the colour-composition workflow (post-Jun-21). Either shift the Tier 2 accent to something further from yellow, or shift the warning threshold/colour so Tier 2 doesn't sit in the warning zone immediately after evolution.
  • Daily Focus Quick-verification item checked off.

Links

  • Daily Focus item: Daily Focus.md → "▶ NEXT STEPS / Quick verification (short session)"
  • Related: Confirmed/tech/Core Systems Reference.md (UnifiedBar / evolution), Color Palette Field Map.md (HP bar palette routing)

---

2026-05-25 — Terminal Decay Rate: Kill the Asymmetry

Motivation

Decay-legibility task from Daily Focus surfaced a sub-question: how long does each tier actually take to die? Investigation found Terminal was draining ~10–15s in practice while Tier 0/1 lasted minutes — a 8.7× rate cliff at evolution-into-Terminal. The user's read ("Terminal decays much too quickly") was correct.

Root cause

UnifiedBar had asymmetric tuning knobs (UnifiedBar.cs initial commit):

  • Tier 0/1 used a multiplier table decayMultipliers = {1.0, 1.5} against baseDecayRate.
  • Tier 2 (Terminal) used an absolute constant terminalDecayRate = 6.5 — bypassing baseDecayRate entirely.

The constant was introduced with the inline note // 195 maxValue / 30s — original intent was "Terminal lasts a designer-specified ~30s, decoupled from base tuning, because the outlast/aura phase wants its own beat."

That intent was viable at original tuning (baseDecayRate=10, Terminal maxValue=195). But baseDecayRate was later retuned 10 → 0.5 (20× slower) on the PlayerPrefab without touching terminalDecayRate, while evolutionMultiplier2 was raised 1.5 → 2.0 (Terminal max 195 → 320). The "30s outlast" knob was now wrong on two axes, and Terminal became the only tier still on the original tuning curve. The discontinuity at evolution-into-Terminal went from a designed step to a guillotine.

Decision

Kill the asymmetry. Extend decayMultipliers to a 3-element table (one entry per tier) and delete terminalDecayRate. Rationale: the "absolute seconds" knob was already broken by neglect; future baseDecayRate retunings now scale all three tiers together, so this class of bug can't recur.

Considered + rejected: just retune terminalDecayRate to 3.0 — works once but leaves the trap in place.

Changes

  • Assets/ProtoV2/Scripts/UnifiedBar.cs
  • decayMultipliers default → {1.0, 1.5, 6.0} (was {1.0, 1.5}); tooltip added.
  • Removed terminalDecayRate field and its stale // 195 maxValue / 30s comment.
  • GetCurrentDecayRate() simplified — single-line table lookup, no per-tier branch.
  • Assets/ProtoV2/Prefabs/PlayerPrefab.prefab
  • decayMultipliers extended with - 6 (third entry).
  • terminalDecayRate: 6.5 line removed.

Effective tunings (PlayerPrefab values: `baseDecayRate=0.5`, `evolutionMultiplier1=1.6`, `evolutionMultiplier2=2.0`)

| Tier | Max bar | Rate (u/s) | From full | From evolution (preserved value) |

|---|---:|---:|---:|---:|

| 0 Base | 100 | 0.5 | 200 s | 140 s (start at 70) |

| 1 Enhanced | 160 | 0.75 | 213 s | 133 s (value 100 → bar 160) |

| 2 Terminal | 320 | 3.0 | 107 s | 53 s (value 160 → bar 320) |

Discontinuity Tier 1 → Tier 2: rate jump (was 8.67×). Still a clear step, but survivable instead of vertical. Exponential ramp (_decayDoublingTime = 600) still layered on top — Terminal entered at match-minute 4 = ~33 s effective survival.

Verification

  • mcp__mcp-for-unity__refresh_unity → compile request acked, resulting_state: compiling.
  • mcp__mcp-for-unity__read_console filtered to errors: only pre-existing unrelated CS2001 about Editor/TierAccentDebugMenu.cs (stale .meta from the prior verification session — file was deleted but the .meta lingered). No new errors from this change.
  • Did not verify in Play Mode this session — math + clean compile only. In-match feel is the open question for the next playtest.

Follow-ups

  • Cleanup unrelated: delete leftover Assets/ProtoV2/Scripts/Editor/TierAccentDebugMenu.cs.meta to clear the CS2001 noise (next session, not blocking).
  • Validate at playtest: does Terminal still feel like a meaningful finale, or is 53s-from-evolution now *too long*? Held under the Daily Focus rule — comprehension fixes (visible drain, refill cause-effect, audio undertone) first; revisit rate-tuning only if deaths persist after those land. ([[2026-05-18_first-ritual-death-tuning]])
  • Open separate problem [[2026-05-19_tier-decay-balance]] (tier decay balance, post-Jun-21) should reference this refactor — the multiplier table is now the single tuning surface.

Links

  • Daily Focus → ▶ NEXT STEPS / @Home item 3 (Decay legibility)
  • Problem spec: Confirmed/.../2026-05-18_first-ritual-death-tuning
  • Related: Confirmed/tech/Core Systems Reference.md (UnifiedBar)

---

Session 2 — Terminal rate retune 3.0 → 1.7

After re-reading the Session 1 numbers (53 s from evolution at rate 3.0), called the discontinuity still too steep for a phase that's supposed to feel like a finale you fight through. Retuned the Terminal multiplier directly on the prefab.

Change

  • Assets/ProtoV2/Prefabs/PlayerPrefab.prefabdecayMultipliers[2]: 6 → 3.4 (so 0.5 × 3.4 = 1.7 u/s effective).
  • No code change. Source default in UnifiedBar.cs left at 6.0 — it's irrelevant in practice (the prefab always overrides), and the source baseDecayRate=10 default is itself stale, so retuning the source default in isolation would mis-calibrate the file's internal consistency.

Effective tunings now

| Tier | Max bar | Rate (u/s) | From full | From evolution |

|---|---:|---:|---:|---:|

| 0 Base | 100 | 0.5 | 200 s | 140 s |

| 1 Enhanced | 160 | 0.75 | 213 s | 133 s |

| 2 Terminal | 320 | 1.7 | 188 s | 94 s |

Discontinuity Tier 1 → Tier 2: rate jump 2.27× (was 4× after Session 1, was 8.67× originally). Terminal now lasts ~1.5 min from evolution before ramp, ~60 s at match-minute 5 with ramp. Closer to a finale arc than a guillotine.

Verification

  • Edit-only, no compile needed (prefab YAML value change). No console errors expected.
  • Play Mode validation still deferred — same plan as Session 1, comprehension fixes first.

---

Altar blood linger — instant absorb on contact

Implemented: 2026-05-26

Type: Fix

Area: Blood system / Altar

Player impact

Blood deposited at the altar now disappears the same fixed step it touches the collider, instead of pooling on the altar for ~2 s before being recycled. Per-feed feedback now lands immediately: pour → meter ticks → particles gone. Closes the "did it count?" ambiguity described in todo 2 of the altar identity spec.

What changed

Assets/ProtoV2/Scripts/BloodSystem/AltarParticleConsumer.cs — overrode HandleParticleContact so the altar bypasses ObiParticleKillerBase's delayed-kill timer entirely. On first contact the consumer now writes solver.life[particleIndex] = 0 directly, then fires OnParticleMarkedForDeath / OnParticleKilled / OnParticleRemoved in the same call. The base timer dict (particleKillTimers) is never populated for altar consumers.

Removed dead fade-on-absorb scaffolding from the consumer: the fadeParticleOnAbsorb toggle, the absorbingColor field, and the OnParticleFirstContact / OnParticleTimerUpdate overrides that animated colour over the kill delay. With instant absorb there is no window to fade in — per-feed feedback moves to the altar side (tree pulse, tree→player refill motes; spec todos 4–5).

Assets/ProtoV2/Prefabs/FloorSystem/Altar.prefabkillDelay: 2 → 0 and dropped the orphan YAML for absorbingColor / fadeParticleOnAbsorb. The new override makes killDelay a dead-letter, but explicit 0 documents intent.

Why

The linger was never the emitter's lifespan: 60 backstop — it was Altar.prefab's killDelay: 2 flowing through ObiParticleKillerBase's timer-then-kill model. Every particle that landed on the altar collider started a 2 s kill timer; the particle remained physically present (and slowly fading) until the timer expired, then KillParticle zeroed its life and the solver recycled it on the next step. Two seconds of "blood on the altar, no meter movement" reads exactly like "did it land in the wrong place?"

The Obi 7.1 manual explicitly recommends writing solver.life[idx] = 0 for instant per-particle kill — preferred over ObiEmitter.KillParticle() because the latter has an index-swap quirk when multiple particles die the same frame (long-standing forum guidance). Our TryKillParticle already routes through KillParticle, but importantly *that path runs after a delay*. Bypassing the delay at the contact callback is the manual-endorsed path.

Verification

In-Editor (MatchScene), user-confirmed: pour-on-altar now reads as instant — every particle that touches the altar disappears the same frame, meter ticks up smoothly, no visible pile.

Edge cases held:

  • HandleCollision gate (!isActive || isConsumed) still short-circuits before HandleParticleContact runs, so dormant/consumed altars don't consume blood.
  • AddBlood still no-ops during isCountingDown, so post-full particles die (look right) without overfilling the meter.
  • GroundParticleKiller (3.5 s, recoverable + decaying) and BloodEmitter.lifespan: 60 (global backstop) are untouched; stray particles that miss the altar still hit the existing floor decay path.

Files modified

  • Assets/ProtoV2/Scripts/BloodSystem/AltarParticleConsumer.cs — instant-kill override, fade scaffolding removed.
  • Assets/ProtoV2/Prefabs/FloorSystem/Altar.prefabkillDelay → 0, orphan fade fields dropped.

Follow-ups

  • Altar-side per-feed feedback (spec todo 4: light particles tree→player on valid feed; spec todo 5: completion ignition). The visual punctuation that used to live on the dying particle now needs to land on the altar itself.
  • Sound designer brief addendum: per-feed audio (with the locked escalation curve) is what now communicates "this feed counted" in the audible channel, since the particle no longer lingers to carry that beat.

Related

  • Spec: Games/Ritual & Ruin/Problems/Active/2026-05-18_altar-identity-and-completion-feel.md (todo 2).
  • Obi 7.1 manual: [Scripting Particles](https://obi.virtualmethodstudio.com/manual/7.1/scriptingparticles.html) — life ≤ 0 kill contract.
  • Companion piece: 2026-05-24_GroundParticleDecay_LostContactGrace.md (off-altar particle decay).

---

Auto-pour near altar — manual pour mechanic removed

Implemented: 2026-05-27

Type: Feature

Area: Player / Altar interaction

Player impact

Pouring is no longer button-driven. When a player in Carrier Mode walks within pourRange (3u XZ) of an altar, the bowl auto-tilts toward the altar and ObiFluid sloshes out by gravity — same tilt math as the old manual pour, just engaged on proximity instead of button-hold. Right-stick tilt still preempts auto-pour, so players keep full agency to redirect or hold upright. Closes todo 3 of the altar identity spec ([[2026-05-18_altar-identity-and-completion-feel]]) and removes the positioning skill check per Design Principle P1 ("depth is for space, not precision").

The manual pour button (= jump button repurposed in Carrier Mode) is now genuinely a no-op in Carrier Mode. The Carrier-Mode hole-pour mechanic is gone as a side-effect — there is no manual pour any more, and auto-pour is altar-only by design (so the bowl doesn't trigger every time a player walks near a floor gap).

What changed

Assets/ProtoV2/Scripts/PourController.cs — rewrote the pour flow:

  • Removed manual-pour state (_pourActive, _lockedTarget, SetPourInput(bool)).
  • Removed hole-as-pour-target (GetNearestHole, GetPlayerFloor, _holeProxy, PourHoleProxy GameObject lifecycle in OnDestroy).
  • Added _autoTarget, TryAutoPour(out Transform altar), GetNearestAltarInRange().
  • Refactored ApplyPourTilt() to take a Transform target parameter so the same world-space-axis tilt math drives the auto path.
  • Update() flow is now: TryAutoPour first; on success, set _autoTarget and run ApplyPourTilt(_autoTarget); on failure, clear _autoTarget and fall through to the existing manual / counter-tilt branch.
  • CurrentTarget simplified to just return _autoTarget (was: pour-active lock → idle preview of nearest target). PourTargetIndicator now glows gold on the altar only while auto-pour is engaged, which is also a stronger "you are pouring" signal than the old idle-preview behaviour.
  • CancelPour() retained as a public API surface — repurposed to clear _autoTarget so callers (TransformController.Toggle, MultiplayerCharacterInput.ResetInputState) keep working without edits.

Assets/ProtoV2/Scripts/Multiplayer/MultiplayerCharacterInput.cs — removed the line in PassInputToController() that drove the manual-pour button:

jumpHeld still passes through to SimpleCharacterController1.SetInput(...), which already gates jump on !IsCarrierMode.

Why

The dispense-on-stand work shipped 2026-05-21 (emitter.speed gating on BloodEmitter) already proved out the proximity-driven Obi-emit pattern: stand near, pour happens. Auto-pour-near-altar is the mirror image for the delivery side. With per-feed feedback now landing on the altar itself (todo 2 instant-absorb, 2026-05-26; todos 4/5 still to come), the button-press was the last bit of positioning ceremony between the player and the ritual. Removing it gives us a clean cause-effect read: "I'm near the altar → blood goes in." The right-stick tilt override keeps the skill ceiling there for players who want to time their pours or hold the bowl level mid-approach.

Manual pour for holes is also removed, deliberately. The original 2026-03-15 pouring-mechanic spec listed holes as a valid pour target (manual button → tilt toward hole). With manual pour gone, hole-pour can't ride along — adding auto-pour-near-holes would mean the bowl spills any time a player walked near a floor gap, which is exactly the opposite of what we want (gaps are spatial navigation, not pour targets). The spec's "altars only" answer matches Design Principle P1: the altar is the spatial target, the hole is just floor topology.

Verification

LSP compile check unavailable (no OmniSharp installed locally). Verified via caller-surface grep across Assets/ProtoV2/:

  • All callers of removed PourController methods (SetPourInput) updated or removed.
  • Remaining PourController public surface (SetTiltInput, SetAutoTilt, CancelPour, CurrentTarget) is referenced by MultiplayerCharacterInput (3 call sites), TransformController.Toggle, PlayerSetup.SetAutoTilt, and PourTargetIndicator.Update — all still exist.
  • No dangling refs to _lockedTarget, _pourActive, _holeProxy, GetNearestTarget, GetNearestHole, GetPlayerFloor, or PourHoleProxy anywhere in the project.
  • Serialized fields in PlayerPrefab.prefab are unchanged (pourRange, tiltAngle, tiltSpeed, _visualRoot, autoTiltScale, autoTiltMaxAngle — all still SerializeField on the rewrite).

Playtest result (user-confirmed 2026-05-27): auto-pour engages on entering the altar radius and the right-stick override works. Initial tilt was too snappy at the inherited tiltSpeed = 8 — bowl reached full 75° in <0.5 s and dumped all the blood past the altar instead of streaming into it. Speed is the dial that matters (not the angle); see Tuning section below. After the tuning pass, blood streams into the altar as intended.

Edge cases held

  • Mode-switch (Mobility ↔ Carrier) — TransformController.Toggle still calls CancelPour(), which now just clears _autoTarget. Auto re-evaluates the next frame anyway, so this is mainly insurance against a one-frame visual glitch on toggle.
  • Input reset (pause, death, controller disconnect) — MultiplayerCharacterInput.ResetInputState still calls CancelPour() and SetTiltInput(Vector2.zero). Same one-frame guarantee.
  • Right-stick threshold 0.01 (sqrMagnitude) — matches the existing threshold inside ApplyManualOrCounterTilt, so the override engages at the same point the manual-tilt branch would have taken over.
  • Indicator (PourTargetIndicator) — CurrentTarget is now never a hole, so the holeColor branch is dead-but-harmless. Left in place for now (cheap to keep; lets us re-introduce a different indicator later without re-plumbing).

Files modified

  • Assets/ProtoV2/Scripts/PourController.cs — rewrite.
  • Assets/ProtoV2/Scripts/Multiplayer/MultiplayerCharacterInput.cs — removed the SetPourInput call in PassInputToController().

Tuning — added in same session

Initial playtest showed the bowl snapping to full 75° tilt in <0.5 s and dumping all blood past the altar instead of streaming into it. Diagnosis: the angle (75°) was fine — confirmed in-play; the *speed* (tiltSpeed = 8, inherited from manual-pour tuning) was the problem. Manual tilt is a deliberate full-stick commit, so 8 makes sense; passive auto-pour needs to *ease* in or the bowl is at dump-angle before the player can read what's happening.

Added one SerializeField on PourController, used only on the auto-pour path:

| Field | Default | Notes |

|---|---|---|

| autoPourTiltSpeed | 2.5f | ~3× slower than manual tiltSpeed (8). Bowl reaches ~23° at 0.25 s, ~46° at 1 s, ~69° at 2 s — gives the player time to read the auto-pour engaging and reposition if it's pointing the wrong way. Auto-pour reuses the manual tiltAngle (75°) as its target. |

ApplyPourTilt now takes (target, angle, lerpSpeed) so the math is parameterised; the auto-pour branch passes (altar, tiltAngle, autoPourTiltSpeed). Tune in the PlayerPrefab Inspector — autoPourTiltSpeed is the dial.

Follow-ups

  • Spec todos 4 (light particles tree→player on valid feed) and 5 (completion ignition) still pending — those are the visual feedback that has to land *during* auto-pour to make the cause-effect read.
  • The Carrier-Mode jump-button no-op is mildly wasteful UX — possible future: in-match tutorial prompts could omit the jump glyph in Carrier Mode entirely. ([[2026-05-25_in-match-tutorial-prompts]])
  • The Options/Pouring Mechanic & Movement Button Redesign.md brainstorm doc still references the manual-pour mechanic; it's in Options/ (in-progress design folder, not a Confirmed spec) so it's historical context, not authoritative — leave as a snapshot of pre-decision thinking.

Vault docs reconciled in same session

  • Confirmed/mechanics/Pouring Mechanic.md — full rewrite against the auto-pour reality (button mapping section removed, hole target removed, tuning table updated, "what changed from the 2026-03-15 spec" change-log added).
  • Confirmed/mechanics/Manual Tilt System.md — Pour Interaction section flipped (auto-pour engages by default, manual right-stick tilt preempts), tuning summary adds autoPourTiltSpeed + pourRange, Systems-to-Change row annotated to flag the _pourActive removal.
  • Daily Focus.md — item 1c marked done.

Related

  • Spec: Games/Ritual & Ruin/Problems/Active/2026-05-18_altar-identity-and-completion-feel.md (todo 3, this entry).
  • Pattern: 2026-05-21_DispenseOnStand_BloodEmission.mdemitter.speed gating on BloodEmitter, the mirror-image proximity-driven Obi behaviour on the emit side.
  • Design principle: Confirmed/Design Principles.md P1 — "Depth is for space, not precision."

---

---

title: Framework A — Clinical Toon Wall Spike

date: 2026-05-27

tags: [ritual-ruin, shaders, walls, framework-a, visual-pipeline, spike]

---

Framework A — Clinical Toon Wall Spike

Context

User asked for a holistic visual pipeline rebuild ("looks like horrible placeholders" — see [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] §1–4). Researcher synthesized 3 frameworks from 8 reference games (§5.3); recommended Framework A — Clinical Toon (Death's Door + Sable hybrid): cel-shaded materials, palette-LUT-driven, surgical character outlines, light-character-on-dim-environment readability.

Key recon finding: Assets/ProtoV2/Shaders/ChunkFloorTile.shader is already cel-shaded (3-band celShade(), _AmbientFloor, GetMainLight + soft shadow). The floor is already partly Framework A; the walls are what's breaking visual unity (unlit/procedural via BoundaryWallAperturePanels.shader). The cheap first step is matching the wall to the floor's existing lighting model.

What changed (3 files)

NEW: `Assets/ProtoV2/Shaders/BoundaryWallToon.shader`

Cel-shaded transparent wall shader that mirrors ChunkFloorTile.shader's lighting curve:

  • _CelBands (default 3.0) + _AmbientFloor (0.18) + celShade() identical to floor → walls + floor share the same lighting ramp
  • _BaseColor / _Color properties so PaletteApplier.WriteIfUnlocked writes to _BaseColor keep working
  • Subtle _VerticalGradient (default 0.25) — slightly brighter at top, darker at bottom of wall (world-Y based) for fake top-down lighting depth
  • Flat fill, no procedural panel/decay/halftone — bands ARE the surface detail
  • float4 (not half4) in CBUFFER per [[2026-05-24_WallTexturedMode_Concrete034#Session 3]] shader-load-failure pattern
  • Transparency: queue 2950, Blend SrcAlpha OneMinusSrcAlpha, ZWrite Off, Cull Off, IgnoreProjector="True"
  • DepthOnly pass kept; ShadowCaster deliberately omitted (transparent walls shouldn't cast shadows)
  • Fallback chain: Shader.Find("ProtoV2/BoundaryWallToon") → URP/Unlit → Unlit/Color → InternalErrorShader (matches existing pattern)

MODIFIED: `Assets/ProtoV2/Scripts/FloorSystem/BoundaryWalls.cs`

  • Added WallTextureMode.ClinicalToon enum value at the end (preserves serialized indices of existing modes — no scene/prefab corruption risk)
  • PickShader: routes ClinicalToon to ProtoV2/BoundaryWallToon; existing Aperture path unchanged
  • BuildWallMaterials: added a second Debug.LogError branch for ClinicalToon shader-load failure detection (mirrors the existing Aperture branch)
  • BuildWallMaterials: ApplyAperturePreset now gated on (not Flat) AND (not ClinicalToon) — toon mode uses shader defaults, not Aperture params
  • GetWallPatternScalarName: returns null for ClinicalToon (no _PanelSizeX scalar; spacing cycle skips it gracefully)

MODIFIED: `Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs`

  • Added WallTextureMode.ClinicalToon to the modes array in CycleWallTextures and CycleAllCombinations (single replace_all covered both occurrences — they're identical arrays)
  • Captures will produce combo_{palette}_wall_ClinicalToon.png files in PaletteCaptures/

Verification protocol (pending — needs Unity)

1. Open Unity, wait for asset DB recompile

2. Console should show 0 shader compile errors. If BoundaryWallToon errors out, watch for the new LogError once a BoundaryWalls builds materials with ClinicalToon active.

3. Open MatchScene, select BoundaryWalls GameObject, change Texture Mode to ClinicalToon

4. Enter Play Mode

5. Window → Ritual & Ruin → Color Palette Preview → Cycle all combinations

6. Verify PaletteCaptures/combo_*_wall_ClinicalToon.png exists (6 files, one per palette combo)

7. Compare against existing combo_*_wall_AperturePanels.png / combo_*_wall_Flat.png captures

Expected visual outcome

  • Walls render as flat palette-tinted surfaces with subtle 3-band cel shading + soft vertical brightness gradient
  • Wall + floor now share the same cel ramp → unified lighting language across the playable area
  • No panel grid, no decay overlay, no procedural noise
  • Front wall (Wall_NegZ) stays near-invisible as before (unchanged)

Known caveat (user-flagged)

Lighting is not yet designed. Scene has only "one generic light" per user statement. With a single uniform directional, cel-shaded walls will show the same band orientation everywhere → no atmospheric depth per palette. Framework A's full read ("lit characters on dim environment", per-palette warm/cool sun) requires a proper lighting rig:

  • Key directional (per-palette sun color)
  • Fill (per-palette ambient/cool)
  • Altar accent point/spot (per-palette accent — the "specimen on slide" focal)
  • Optional back rim

This spike intentionally ships shader-only first to validate the *surface* change is sound before doing the bigger lighting rebuild. Lighting design is the next layer; will need extending ColorPaletteSO with sun/fill/accent fields and extending PaletteApplier to drive scene lights atomically.

Bloom audit also pending

Per [[../../../../../../.claude/projects/E--Unity-Projects-PrototypeV2/memory/project_bloom_tint_gotcha|bloom-tint gotcha]]: current gameplay bloom is #00FF41 UI-phosphor green, threshold 0.75. If _BaseColor.a × cel result lands > 0.75 luminance on the toon walls in any palette, they'll bloom green. Audit on first capture; may need separate env-bloom volume.

Related

  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — full framework analysis + recommendation
  • [[2026-05-24_WallTexturedMode_Concrete034]] — Aperture-panel pipeline being superseded
  • [[../../../../../../.claude/projects/E--Unity-Projects-PrototypeV2/memory/project_world_geometry|memory: world geometry]]

---

---

title: Visual Pipeline — Wave 1 + Wave 2 Shipped, Framework A Validated

date: 2026-05-27

tags: [ritual-ruin, visual-pipeline, framework-a, lighting-rig, palette-applier, wave-1, wave-2]

---

Visual Pipeline — Wave 1 + Wave 2 Shipped, Framework A Validated

Why

User committed to building all 3 visual pipeline frameworks (A/B/C) per [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan|build plan]]. Wave 1 = shared infrastructure (unblocks everything). Wave 2 = Framework A (Clinical Toon — Death's Door + Sable hybrid). Previous earlier-today spike ([[2026-05-27_FrameworkA_ClinicalToonWall_Spike]]) shipped the wall shader alone but produced visually-indistinguishable captures (§6 of comparison doc) because the scene had only one generic light — proved that the shader change is necessary but not sufficient.

What changed

Wave 1 — Shared Infrastructure

Code (delegated to executor agent in 7 h scoped task — landed in ~3 min real time):

  • Assets/ProtoV2/Scripts/ColorSystem/ColorPaletteSO.cs — appended top-level LightingPalette struct (sun/fill/accent/backRim HDR colors, ambientSky, fog, fogDensity, 4× intensity floats) + PostPalette struct (colorGradingLUT, bloomThreshold, bloomIntensity, bloomTint) + .Default factories.
  • Assets/ProtoV2/Scripts/ColorSystem/VisualPipelineSO.cs (new) — framework preset SO holding material overrides, post profile, lighting override, and framework-specific keyword flags (fogEnabled, outlinesEnabled, apvSampling). Menu: Assets > Create > Ritual & Ruin > Visual Pipeline Preset.
  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs — added _activePipeline, 4× light slots, 2× volume slots, LightingLocks + PostLocks structs. New methods ApplyPipelineMaterials(), ApplyLighting(), ApplyPost() wired into Apply(). OnValidate delayCall covers everything.
  • PaletteCaptures/README.md (new) — capture filename schema docs.

Scene-side (via Unity MCP):

  • Created Lighting/ empty parent + 4 child lights in MatchScene.unity:
  • KeyDirectional (Mixed, soft shadows, rotation 45/-30/0)
  • FillDirectional (Baked, no shadows, rotation -30/150/0)
  • AltarAccent (Point, range 6, realtime)
  • BackRim (Realtime, no shadows, rotation -15/180/0)
  • Disabled the legacy "Directional Light" GO (renamed unchanged but activeSelf=false).
  • Duplicated GameplayBloomProfile.assetEnvironmentBloomProfile.asset + UIPhosphorBloomProfile.asset. Added Volume_EnvironmentBloom (priority 0) + Volume_UIBloom (priority 10) GOs in scene.
  • Wired all 4 lights + both volumes into PaletteApplier SerializeField slots.
  • Set BoundaryWalls.TextureMode = ClinicalToon.

Wave 2 — Framework A (Clinical Toon)

Code (delegated to executor agent):

  • Assets/ProtoV2/Shaders/ToonLitOutlined.shader (new) — 2-pass URP shader: inverted-hull outline pass (Cull Front, normal-expanded, clip-space-stable width) + lit forward pass (3-band cel ramp matching ChunkFloorTile's model). Opaque, with ShadowCaster + DepthOnly passes.
  • Assets/ProtoV2/Materials/Player_ToonOutlined.mat, Altar_ToonOutlined.mat (new) — palette-driven.
  • Assets/ProtoV2/Data/VisualPipelines/Framework_A_ClinicalToon.asset (new) — VisualPipelineSO instance referencing the materials + the existing EnvironmentBloomProfile.

Palette tuning (6 ColorPaletteSO assets via Unity MCP manage_scriptable_object):

  • Per-palette LightingPalette colors + intensities populated (sun/fill/accent/backRim + ambient + fog). See doc §7 for the per-combo tone strategy.
  • All 6 PostPalette: bloomThreshold 1.2 (HDR-only — environment doesn't bloom), bloomIntensity 0.3, bloomTint white.

Wiring + capture:

  • PaletteApplier._activePipelineFramework_A_ClinicalToon.asset.
  • Entered Play Mode via MCP, ran Cycle visual pipelines × palettes (all combos) from the wizard, 18 captures landed in PaletteCaptures/pipeline_*.png.

Verification

Captures embedded in [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison|comparison doc §7]] (mirror at Options/Wall Captures/Wave 2 Captures/).

Direct comparison vs §6 (Framework A shader alone, no lighting rig):

  • §6: ClinicalToon ≈ Flat across all 6 palettes (cel-ramp produced no visible variation under single uniform light)
  • §7: per-palette atmospheric differentiation is now real. Combo 5 (Twilight Lab) is the strongest showcase — cool teal sun + magenta altar accent. Combo 3 (Night Clinic) close second. Combo 1 (Corrupted Cathedral) more muted because warm sun + red accent are similar tones.

Console clean (only unrelated input-scheme warnings during Play Mode).

Empirically confirms the user's 2026-05-27 morning flag that *"our lighting is not optimized"* was the gating issue. Shader change + lighting design + palette-driven light atomic swap together unlock Framework A.

Bloom tint gotcha resolved

Per [[../../../../../../.claude/projects/E--Unity-Projects-PrototypeV2/memory/project_bloom_tint_gotcha|memory]]: the old GameplayBloomProfile (#00FF41 UI-phosphor tint) was applying scene-wide and green-tinting cream walls. Now split: Volume_EnvironmentBloom (env, no tint, threshold 1.2 — HDR-only) + Volume_UIBloom (UI phosphor, kept green tint, threshold 0.95). Memory file still valid but resolution shipped — should update.

Architectural limitations surfaced

  • The iso top-down camera shows walls only as thin slivers at frame edges + tilted back-wall band. Most pixels are floor + props. Wall shader differences will always read more subtly than floor/lighting/post changes in this view — not a Framework A flaw, a camera composition reality.
  • VisualPipelineSO lacks a playerMaterial slot — Framework A's Player_ToonOutlined.mat is shipped but not auto-applied to the player prefab. Inverted-hull outlines aren't visible in captures yet. Deferred to polish phase.
  • BoundaryWalls.SetWallMaterial(Material) not yet implemented — Framework B/C wall material swaps will need this. Deferred to Wave 3 prep.

Follow-ups

  • Wave 3a: Framework B (Lit Flat + Fog) — 16 h. Reuses everything from Wave 1; only changes wall shader, adds fog, adds glow planes, swaps lighting palette intensities.
  • Wave 3b: Framework C (Painterly Baked Overlay) — 36 h. APV setup + painterly overlay shader + Obi fluid shader fork.
  • Wave 4: 3-way capture sweep + decision write-up.

Files modified this session (full list)

Code: ColorPaletteSO.cs, PaletteApplier.cs, VisualPipelineSO.cs (new), BoundaryWalls.cs (Wave 0 spike), ColorPalettePreviewWindow.cs (Wave 0 spike + Wave 1 update), BoundaryWallToon.shader (Wave 0 spike), ToonLitOutlined.shader (new).

Materials: Player_ToonOutlined.mat (new), Altar_ToonOutlined.mat (new).

SOs: Framework_A_ClinicalToon.asset (new), 6× Combo*_*.asset (lighting + post patched).

Scene: MatchScene.unity (lights + volumes + applier wiring).

Volumes: EnvironmentBloomProfile.asset (new), UIPhosphorBloomProfile.asset (new).

Docs: Visual Pipeline Build Plan.md (new), Visual Pipeline Direction Comparison.md §7 (new), PaletteCaptures/README.md (new).

Related

  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan]] — the build plan being executed
  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — captures + verdict
  • [[2026-05-27_FrameworkA_ClinicalToonWall_Spike]] — earlier-today spike that motivated the full build
  • [[2026-05-24_WallTexturedMode_Concrete034]] — Aperture pipeline (now superseded but kept as fallback mode)

---

---

title: Visual Pipeline — F1-F6 Polish Pass (Final Wire-up)

date: 2026-05-28

tags: [ritual-ruin, visual-pipeline, framework-a, framework-b, framework-c, polish]

---

Visual Pipeline — F1-F6 Polish Pass

Why

After the §8 5-way capture sweep, the user reviewed all 30 captures and identified six gaps: tentacles not outlined, pillars/altars flat in Framework C, Framework B's glow planes reading as Photoshop overlays, missing rim lights, wall panels missing, environment-side parity between A and C. Implemented all six in a single session.

What changed

F1 — Tentacle outlines for Framework A

  • New material: Assets/ProtoV2/Materials/TentacleOutline.mat (URP/Unlit, _BaseColor = (0,0,0,1))
  • Assets/ProtoV2/Scripts/JellyfishVisuals.cs — each tentacle now spawns a child Tentacle_X_Outline GameObject with its own LineRenderer at 1.4× width. Initial sibling-component approach was rejected by Unity ("Can't add component 'LineRenderer' to Tentacle_0 because such a component is already added"). Fixed with child-GO architecture.
  • Public method SetTentacleOutlineEnabled(bool) flips all outline LineRenderers on/off.
  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.csBroadcastPlayerOutlines() now also finds all JellyfishVisuals instances and calls SetTentacleOutlineEnabled with the same bool as the body outline toggle.

F2 — Framework C light bump

  • Assets/ProtoV2/Data/VisualPipelines/Framework_C_PainterlyOverlay.assetlightingOverride.sunIntensity 0 → 0.2 (with warm (0.85, 0.78, 0.85) color), fillIntensity 0 → 0.15 (with cool (0.45, 0.50, 0.60)), accentIntensity 0 → 0.3.
  • URP/Lit pillars + altars now receive minimum directional shading instead of going pure-ambient flat. Painterly walls + floor unaffected (their shader is URP/Unlit by design).
  • Decision: didn't port pillars + altars to a dedicated painterly-opaque shader. The light bump preserves Framework C's "mostly unlit" aesthetic while preventing obvious flatness — lighter touch.

F3 — Glow plane reshape

  • MatchScene: RitualGlowPlane_Bottom rescaled from (2,1,2) → (0.6,1,0.6) — 20×20u → 6×6u footprint.
  • GlowPlanePaletteBinding._intensityMultiplier 2.0 → 0.7.
  • RitualGlowPlane_Mid rescaled to near-zero (0.0001) since MCP couldn't delete it cleanly without unlinking the prefab. Functionally hidden.

F4 — PlayerRim light

  • MatchScene: new 5th light Lighting/PlayerRim — Directional, rotation (35, 200, 0), intensity 1.0, color (1, 0.95, 0.85), shadows off, lightmapBakeType Realtime.
  • Static (not yet palette-driven) — provides global rim wash to character silhouettes.

F5 — Panel grid shader overlay

  • New include: Assets/ProtoV2/Shaders/PanelGrid.hlsl with ApplyPanelGrid(color, positionWS, normalWS, panelSize, gridStrength, gridColor). Dominant-plane projection (Z+Y for X-facing walls, X+Y for Z-facing walls). Anti-aliased via fwidth.
  • All 3 wall shaders modified:
  • BoundaryWallToon.shader (Framework A)
  • BoundaryWallLitFlat.shader (Framework B)
  • BoundaryWallPainterly.shader (Framework C)
  • Each shader added: include directive, 3 new CBUFFER fields (_PanelGridStrength, _PanelSize, _PanelGridColor), 3 new Properties (defaults: 0.4, 2.0u, black), and ApplyPanelGrid call before MixFog in fragment.
  • Walls now show clean 2-unit square panel divisions across all frameworks.

F6 — Per-floor accent lights

  • MatchScene: 2 additional point lights added under Lighting/:
  • AltarAccent_Mid at (0, -8, 0), Point, range 12, intensity 1.5, warm color (1, 0.5, 0.3)
  • AltarAccent_Bottom at (0, -17, 0), Point, range 12, intensity 1.2, same color
  • Static palette-independent fallback. Polish item: extend PaletteApplier to drive these from palette accent (currently only the 4 core lights are palette-broadcast).

Verification

  • read_console after final cycle: 0 errors. Only unrelated input-scheme warnings persist.
  • First cycle attempt surfaced the LineRenderer AddComponent bug — fixed in-session by switching to child-GO architecture.
  • Captures landed in PaletteCaptures/pipeline_{Mode}_{Palette}.png (30 files) and mirrored to playinstigator_docs/.../Wall Captures/Wave 4 Captures/.

Visual outcomes

Framework A (Clinical Toon):

  • Tentacles now have thin black halo.
  • PlayerRim adds slight back-edge glow.
  • Wall panel grid visible on side + back walls.
  • Per-floor accent lights add warmth to mid + bottom floors.

Framework B (Lit Flat + Fog):

  • Glow plane no longer dominates frame — reads as focused altar warmth.
  • Wall panel grid visible underneath 4-band cel shading.
  • Atmospheric depth from fog + small focused glow + multi-light.

Framework C (Painterly Baked Overlay):

  • Pillars + altar now show shading (the most visible win — they were flat before).
  • Wall panel grid divisions clearly visible (probably most striking here because the painterly shader has the calmest surface).
  • Per-floor accents add warmth without breaking the "unlit by design" feel.

Decision matrix (unchanged)

Three viable frameworks, decision is tonal. Framework A still the default recommendation; B + C are real alternates. Pick whenever ready.

Files modified this session

Code

  • Assets/ProtoV2/Scripts/JellyfishVisuals.cs (tentacle outline support + post-fix to child-GO)
  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs (broadcast extended for tentacles)

Shaders

  • Assets/ProtoV2/Shaders/PanelGrid.hlsl (new)
  • Assets/ProtoV2/Shaders/BoundaryWallToon.shader (+ panel grid)
  • Assets/ProtoV2/Shaders/BoundaryWallLitFlat.shader (+ panel grid)
  • Assets/ProtoV2/Shaders/BoundaryWallPainterly.shader (+ panel grid)

Materials

  • Assets/ProtoV2/Materials/TentacleOutline.mat (new)

Scene

  • Assets/ProtoV2/Scenes/MatchScene.unity — added PlayerRim, AltarAccent_Mid, AltarAccent_Bottom lights; resized/hid RitualGlowPlane instances.

Prefab

  • Assets/ProtoV2/Prefabs/PlayerPrefab.prefab — added _outlineWidthMultiplier + _tentacleOutlineMaterial SerializeFields on JellyfishVisuals component.

SO

  • Assets/ProtoV2/Data/VisualPipelines/Framework_C_PainterlyOverlay.asset — lightingOverride intensities bumped.

Docs

  • Visual Pipeline Direction Comparison.md §9 (new section), TL;DR + status header updated.
  • Daily Focus.md — pipeline-pick todo updated with F1-F6 completion note.

Related

  • [[2026-05-28_VisualPipeline_PipelineSwapFix_FrameworkB_Rehabilitated]] — earlier-today fix that unblocked the wire-up phase
  • [[2026-05-27_VisualPipeline_Wave1_Wave2_FrameworkA_Validated]] — the bigger build-out session
  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — main analysis doc (§9 = final state)
  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan]] — original build plan (complete)

---

---

title: Visual Pipeline — Pipeline-Swap Fix Rehabilitates Framework B

date: 2026-05-28

tags: [ritual-ruin, visual-pipeline, framework-a, framework-b, framework-c, lighting-rig, shader-fix]

---

Visual Pipeline — Pipeline-Swap Fix Rehabilitates Framework B

Why

Wave 4 (yesterday) shipped all 3 framework presets and ran a 5-way comparison sweep. The initial read (§8 verdict at the time) flagged Framework B as "overcooked / parked" because Combo 1 + Combo 2 LitFlat captures blew out to bright cyan. Today's re-cycle proves that diagnosis was wrong — Framework B's lighting override never actually applied during the previous captures because the cycle button only swapped WallTextureMode, not _activePipeline. Framework A's lighting was leaking into LitFlat shader renders.

What changed

Code

  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs — added public VisualPipelineSO ActivePipeline => _activePipeline; accessor + public void SetActivePipeline(VisualPipelineSO) method that swaps the preset and re-applies.
  • Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs — added static ResolvePipelineForMode(WallTextureMode) lookup that maps each mode to its matching VisualPipelineSO asset (ClinicalToon → Framework A, LitFlat → B, PainterlyOverlay → C, Flat/AperturePanels → null). Updated both CycleVisualPipelines and CycleVisualPipelinesAllPalettes to call _applier.SetActivePipeline(ResolvePipelineForMode(mode)) BEFORE the wall mode swap, AND to preserve+restore the original _activePipeline at cycle start/end.
  • Assets/ProtoV2/Shaders/ToonLitOutlined.shader — fixed compile error. URP 17.2 doesn't expose TransformObjectToHClipDir. Replaced the clip-space-stable expansion math with the URP 17 idiomatic world-space path:

Width is now _OutlineWidth in world units — consistent under both perspective and ortho cameras (works because under ortho the camera's view scale is fixed).

Capture

  • Re-ran Cycle visual pipelines × palettes (all combos) in Play Mode via Unity MCP automation.
  • 30 fresh captures (5 modes × 6 palettes) land in PaletteCaptures/pipeline_{Mode}_{Palette}.png.
  • Copied to playinstigator_docs/Games/Ritual & Ruin/Options/Wall Captures/Wave 4 Captures/ (overwrites prior set — Obsidian embeds in §8 of the comparison doc now show the new images).

Docs

  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison|comparison doc]] §8.7-8.11 fully rewritten:
  • §8.7 verdict: Framework B is rehabilitated (was "parked", now "usable"). Reframe added at top of §8.7 explaining the pipeline-swap bug.
  • §8.8 decision matrix: all three frameworks now score ★★★★ or higher on detail readability (was ★★ for B). Removed "blowout" weakness.
  • §8.9 recommendation: three viable options matrix by tone (specimen-on-slide A / clinical-lab B / dystopian-blueprint C). Production default still A, but B and C are real alternates not afterthoughts.
  • §8.10 follow-ups: per-framework polish lists. B's polish dropped from ~4-6 h to ~30-45 min (glow plane wiring only — shader fix was a false-alarm; the real fix was infrastructure).
  • TL;DR + status header at top of doc updated to match.

Verification

  • read_console: 0 errors after ToonLitOutlined.shader fix (was 1 shader error before). 2 unrelated input-scheme warnings persist (Cannot find matching control scheme for PlayerPrefab(Clone)) — same as previous sessions, not visual-pipeline-related.
  • Visual review of fresh LitFlat captures:
  • Combo 1 (Corrupted Cathedral): cream-pink walls + tan floor with visible tile grout. No cyan blowout. Reads as believable warm-lab.
  • Combo 2 (Operating Theatre): clean cream walls + grey floor. Reads as clinical operating room.
  • Combo 5 (Twilight Lab): dark teal walls + cyan grout grid floor. Tron-corridor read.
  • Combo 3 (Night Clinic): dark teal palette throughout. Cool clinical horror tone.
  • Visual review of PainterlyOverlay captures: all 6 palettes read as deliberate "blueprint dystopian" — most controlled cross-palette consistency of any framework.
  • File sizes confirm distinct outputs per (palette, mode) combination — no two captures collide on identical pixel content.

Why Framework B's earlier "blowout" was misdiagnosed

The previous read attributed the cyan blowout to a multi-light additive sum issue in BoundaryWallLitFlat.shader / ChunkFloorTile_4Band.shader. That diagnosis assumed Framework B's lighting override (warmer sun + softer fill) was actually applied during the capture. It wasn't.

Sequence during the bug:

1. Cycle button enters Play Mode with _activePipeline = Framework_A_ClinicalToon

2. Cycle iterates modes: Flat → AperturePanels → ClinicalToon → LitFlat → PainterlyOverlay

3. At LitFlat step: walls.SetWallTextureMode(LitFlat) swaps the wall shader to BoundaryWallLitFlat.shader. But _activePipeline stays at Framework A.

4. PaletteApplier.Apply() runs (triggered by palette change) → ApplyLighting() reads _activePalette.lighting (which is the per-palette LightingPalette, Framework A-tuned)

5. Lights remain at Framework A's high-contrast values: sun 1.3, fill 0.4, accent 2.5

6. The new BoundaryWallLitFlat shader does its multi-light loop over those Framework-A-intense lights → multi-light sum × 4-band ramp → bright cyan on cream palettes

Fix today:

1. Cycle now calls _applier.SetActivePipeline(ResolvePipelineForMode(mode)) BEFORE the wall mode swap

2. For LitFlat, that swaps to Framework_B_LitFlatFog SO which has useLightingOverride = true

3. ApplyLighting() now reads the override values: sun 1.0 (warmer), fill 0.6 (brighter ambient), accent 1.5 (dimmer)

4. Multi-light loop receives sane intensities → cel ramp doesn't saturate

5. Floor reads correctly across all palettes

Architecture lesson: WallTextureMode and VisualPipelineSO are not independent axes. They must swap together for fair captures. Adding the explicit SetActivePipeline API on PaletteApplier + the ResolvePipelineForMode lookup on the cycle is the right invariant.

Open follow-ups (none blocking)

Per the revised [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison|§8.10]]:

  • Framework A polish (~1.5 h): wire Player_ToonOutlined.mat to player prefab, Combo 1 accent intensity bump, wall alpha 0.85 → 0.95 experiment
  • Framework B finish (~45 min): instantiate 2-3 RitualGlowPlane.prefab in MatchScene + wire PaletteApplier.ApplyLighting() to broadcast LightingPalette.accent to GlowPlanePaletteBinding.SetGlowColor()
  • Framework C finish (~1 h + optional 6 h): APV Probe Volume placement + bake. Obi fluid surface shader fork only if shipping C as primary.

All three frameworks are technically ready. Decision next session is which one to commit to (or to wire all three as a runtime graphics setting — already supported via _activePipeline slot).

Files modified this session

  • Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs
  • Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs
  • Assets/ProtoV2/Shaders/ToonLitOutlined.shader
  • playinstigator_docs/Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison.md (§8.7–8.11 + TL;DR + status header)
  • playinstigator_docs/Games/Ritual & Ruin/Options/Wall Captures/Wave 4 Captures/*.png (30 fresh captures, overwrites)

Related

  • [[2026-05-27_VisualPipeline_Wave1_Wave2_FrameworkA_Validated]] — yesterday's wave 1+2 ship
  • [[2026-05-27_FrameworkA_ClinicalToonWall_Spike]] — initial wall shader spike
  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — main analysis doc (§8)
  • [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan]] — the build plan being executed (now complete)

---

Auto-tilt Y-proximity gate — stops auto-pour at altars on other floors

Implemented: 2026-05-29

Type: Fix

Area: Player / Altar interaction

Player impact

Carrier-Mode auto-pour no longer engages when the creature is a floor above (or below) an altar that happens to line up in the XZ projection. Previously, flying up in jellyfish mode and transforming back to Carrier Mode over an altar's XZ footprint made the bowl tilt at empty air across the floor gap (and risked draining blood down through the floor). Auto-pour now requires the altar to be on the same floor.

Root cause

PourController.GetNearestAltarInRange() selected altars by XZ distance only (XZDistance, which drops the Y component). Any altar within pourRange (3u) in the XZ plane registered as in-range regardless of vertical separation, so an altar a full floor away (5u in Y) still triggered auto-pour.

What changed

Assets/ProtoV2/Scripts/PourController.cs:

  • Added [SerializeField] float pourYRange (fallback 2.5f). In Start() it is auto-derived as FloorManager.FloorVerticalSpacing * 0.5f (= 2.5u at the current 5u spacing), so it adapts if floor spacing changes.
  • Added a Y gate in GetNearestAltarInRange(): if (Mathf.Abs(here.y - altar.transform.position.y) > pourYRange) continue; — applied before the XZ comparison. Nearest-by-XZ selection among the survivors is unchanged.

Half the floor spacing keeps the gate comfortably inside one floor while excluding the floors above and below, each a full spacing (5u) away in Y.

> Threshold note: the original problem doc assumed ~10u floor spacing and proposed a flat 3u gate. Actual spacing is floorVerticalSpacing = 5f (FloorManager.cs:41, ScrollingFloorCamera.cs:31), so the derived half-spacing (2.5u) was chosen over a hard-coded 3u for a symmetric exclusion margin.

Verification

  • Unity script compile: clean, zero errors (read_console filtered to errors → 0 entries after refresh_unity compile).
  • Behavioural cases to confirm in Play Mode (pending playtest):
  • Carrier one floor above an altar, matching XZ → no auto-tilt (delta-Y = 5+ u > 2.5u gate).
  • Carrier next to a same-floor altar within 3u XZ → auto-tilt fires as before.
  • Carrier mid-air between floors → no auto-tilt.

Follow-ups

  • Play-Mode confirmation of the three cases above.
  • Gizmo (OnDrawGizmosSelected) still draws an infinite-Y wire disc for pourRange; could draw a cylinder bounded by pourYRange for accuracy. Low priority.

Session 2 — don't auto-tilt toward completed altars

Type: Fix · Area: Player / Altar interaction

Player impact

The carrier no longer auto-tilts toward an altar whose ritual is already done. It also stops auto-pouring the moment the meter fills and the ritual countdown begins (blood is rejected during countdown anyway), instead of dumping uselessly at a finished altar.

Root cause

AltarParticleConsumer.CompleteRitual() sets isConsumed = true but never clears isActive — a completed altar keeps IsActive == true. PourController.GetNearestAltarInRange() filtered only on !IsActive, so consumed altars stayed valid auto-pour targets forever.

What changed

  • Assets/ProtoV2/Scripts/BloodSystem/AltarParticleConsumer.cs — added public bool IsAcceptingBlood => isActive && !isConsumed && !isCountingDown; (single source of truth mirroring the AddBlood guard).
  • Assets/ProtoV2/Scripts/PourController.csGetNearestAltarInRange() filter changed from !altar.IsActive to !altar.IsAcceptingBlood. This excludes consumed altars (the reported bug) and also full/counting-down altars that reject blood. If a countdown is interrupted (InterruptCountdown), the altar accepts blood again and becomes re-eligible automatically (state is read per-frame).

Verification

  • Unity script compile: clean, 0 errors.
  • Pending Play-Mode confirmation: pour an altar to completion → auto-tilt releases once the meter fills; carrier near a consumed altar → no auto-tilt.

Related

  • [[2026-05-27_AutoPourNearAltar_ManualPourRemoved]] — original auto-pour implementation that introduced the XZ-only check.
  • Problem doc: Problems/Active/2026-05-29_auto-tilt-ignores-y-distance.md → resolved.
  • Confirmed/mechanics/Manual Tilt System.md — tuning table updated with pourYRange.

---

2026-05-29 — CRT / Screen-Effects Renderer Feature + Variations Comparison

What changed

Built a real URP full-screen post-processing system for "digital screen" effects and captured an in-engine variations comparison. Escalation of the [[../../Games/Ritual & Ruin/Problems/Archive/2026-05-29_crt-bloom-not-visible|CRT + Bloom not visible]] problem to its Phase-3 (variations matrix) — but done as actual implementations, not simulations, per user direction.

New files:

  • Assets/ProtoV2/Shaders/ScreenFX.shader — über full-screen shader. One pass, all effects as float params, identity when 0. Effects: barrel curvature + black border, edge-weighted chromatic aberration (URP 3-tap), soft sine scanlines (resolution-independent via line count), aperture/shadow mask, vignette, animated grain, rolling refresh bar, VHS line jitter + chroma bleed + head-switch band, datamosh block tear/corruption, pixelation snap, posterize + 4×4 Bayer dither, phosphor monochrome tint. Built on the URP 17 fullscreen contract (Vert/Varyings/_BlitTexture from core/Runtime/Utilities/Blit.hlsl); CA/vignette math mirrors URP's own UberPost.shader.
  • Assets/ProtoV2/Scripts/Editor/ScreenFXTool.csTools/ScreenFX/ menu: (1) Install renderer feature, (2) Capture All (Play Mode), Uninstall. Defines the 7 presets and the capture sweep.
  • Assets/ProtoV2/Materials/ScreenFX_Mat.mat — material instance (defaults to Off/passthrough).

Modified:

  • Assets/Settings/PC_Renderer.asset — registered a FullScreenPassRendererFeature named ScreenEffect at AfterRenderingPostProcessing, fetchColorBuffer = true, pointing at ScreenFX_Mat. Registration replicates URP's ScriptableRendererDataEditor.AddComponent (append to m_RendererFeatures + the sub-asset localId to m_RendererFeatureMap).

Why

The shipped CRT was a Canvas ScreenSpaceOverlay (CRTOverlayController + CRTOverlay.shader) that samples a white _MainTex — so curvature/CA/pixelation never touched real game pixels, and it never spawned when entering Play directly in MatchScene (hence "CRT invisible in captures"). The renderer-feature approach fixes both: effects run on the actual post-tonemap frame, in correct order (grade → bloom → ScreenFX), live in-game and in captures.

Verification

  • Shader compiles clean (fixed two errors: _BlitTexture_TexelSize is float4 not float2; line is a reserved HLSL keyword → renamed scanv). read_console shows zero errors.
  • Feature registered correctly: m_RendererFeatures has 3 entries, ScreenEffect with injectionPoint: 600, fetchColorBuffer: 1, active.
  • Captured 19 frames (Combos 1/3/5 × presets 0–5, + Datamosh on Combo 5) at 1920×1080 in Play Mode. Visually confirmed each preset:
  • Heavy CRT shows real barrel curvature (floor grid bulges, black corners) → proves it's a true post pass, not an overlay.
  • Pixel CRT shows chunky pixelation + posterize; Phosphor Terminal = green monochrome; VHS = jitter + bottom head-switch band; Datamosh = block tear + RGB smear.
  • Captures + verdict written to [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Comparison|CRT and Screen Effects Comparison]]. Research catalog in [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Research|CRT and Screen Effects Research]].

Recommendation / follow-ups

  • Ship Subtle Monitor as the default mapped to the existing Gameplay CRT-intensity slider; keep Phosphor Terminal for terminal/menu, Datamosh Burst for GlitchBurst() events; treat Pixel CRT as a separate art-direction decision.
  • Runtime ScreenEffectController still to do — drive ScreenFX_Mat from GameSettings.CRTIntensity, expose preset selection + GlitchBurst() on the real frame, then retire CRTOverlayController + CRTOverlay.shader.
  • Bloom split/tuning (env neutral white thr~0.85 vs UI green) remains open and orthogonal — see research doc §3.4 + [[project-bloom-tint-gotcha]].
  • Feature currently installed at Off (passthrough), so shipped look is unchanged until a controller applies a preset.

Session 2 (2026-05-30) — runtime controller + bloom fix

New:

  • Assets/ProtoV2/Scripts/Rendering/ScreenFXPresets.cs — runtime-shared preset enum + Apply(mat, preset[, intensity]); single source of truth for both the editor capture tool and the runtime controller. Intensity scales the perceptual params (scanline/vignette/CA/grain/etc.), leaves structural ones (pixelate/posterize) alone; 0 → identity.
  • Assets/ProtoV2/Scripts/Rendering/ScreenEffectController.cs — self-bootstraps via [RuntimeInitializeOnLoadMethod(AfterSceneLoad)] (DontDestroyOnLoad, no scene wiring). Loads ScreenFX_Mat from Resources, drives it from GameSettings.CRTIntensity (+OnCRTIntensityChanged), default preset Subtle Monitor. SetPreset() + GlitchBurst() (real-frame CA/tear/block spike then restore). Disables legacy CRTOverlayController on startup so they don't stack. Resets material to Off in OnDestroy.

Changed:

  • Moved ScreenFX_Mat.mat (+meta, GUID preserved) Materials/ → Resources/ so the controller can Resources.Load the same asset the feature renders. ScreenFXTool MaterialPath + preset calls updated to use ScreenFXPresets. Added Application.runInBackground = true in the capture cycle (Play Mode stalls the delayCall chain when the editor loses focus — that's why the first re-capture hung; fixed).
  • Bloom: all 6 ColorPaletteSO.PostPalette bloomThreshold 1.2 → 0.85, bloomIntensity 0.3 → 0.4 (white tint unchanged). Root cause of "bloom doesn't fire": threshold 1.2 is HDR-only and env albedo is ≤1.0. The green-tint concern from [[project-bloom-tint-gotcha]] was a non-issue at runtime — PaletteApplier already writes white from each palette to _environmentBloomVolume; the split is effectively in place.

Verification:

  • Zero compile errors after each change.
  • Controller bootstrap confirmed live in Play: [ScreenEffectController] live — preset SubtleMonitor @ CRT 0.00, material bound = True (slider saved at 0 → scales to Off, correct).
  • Re-captured all 19 frames with the bloom fix (focus-resilient now). Off baseline shows soft env glow on bright cup/creatures that wasn't there before. Vault captures + [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Comparison]] updated.

Remaining (optional): retire CRTOverlay.shader + CRTOverlayController entirely (currently just auto-disabled, not deleted); per-palette bloom-intensity fine-tune; wire GlitchBurst() to evolution/Terminal/death events.

Session 3 (2026-05-31) — review-feedback tuning + pixel-size sweep

Review of the rev-1 captures produced 5 concrete asks; all addressed and re-captured in-engine via MCP (entered Play, ran the capture menus, stopped, copied to vault).

Changed:

  • Assets/ProtoV2/Shaders/ScreenFX.shader (§1 border) — anti-aliased the barrel-curvature edge. Was a hard (cuv in [0,1]) ? 1 : 0 mask → jagged staircase along the curved sides. Now edge = min(min(cuv,1-cuv)), aa = fwidth(edge)*1.5, inB = smoothstep(-aa, aa, edge) — a ~1.5 px resolution-independent feather. Guarded to full-screen when _Curvature < 1e-4 so non-curved presets stay pristine (no 1 px rim darkening).
  • Assets/ProtoV2/Scripts/Rendering/ScreenFXPresets.csgrain cut Subtle 0.02→0.006, Heavy 0.04→0.012 (the "grainy texture" complaint); Phosphor tint 0.85→0.6 so palette colours read through the green wash; Pixel CRT 270p→480p (posterize 24→32, scanline count tracks pixel rows) so small elements stay legible.
  • Assets/ProtoV2/Scripts/Editor/ScreenFXTool.cs — added menu Tools/ScreenFX/3 - Capture Pixel Density Sweep (Play Mode): re-applies Pixel CRT on Combo 5 at _Pixelate ∈ {270,360,480,540,720} and captures one frame each (screenfx_pixeldensity_{N}p_...png) so the pixel size can be picked visually.

Why: user feedback — Subtle/Heavy grain too strong; Phosphor too monochrome (kills colour readability) → keep for UI; VHS/Datamosh too disruptive as constant → event-only bursts (already the design, documented); Pixel CRT interesting but pixels too big for small elements → make them smaller + provide a sweep; curved sides visibly jagged → AA them.

Verification:

  • Recompiled (force refresh), read_console errors = 0 (only the benign "Cannot find matching control scheme" pairing warnings).
  • Re-captured all 19 preset frames + 5 density frames = 24 PNGs, copied to Options/Wall Captures/CRT Screen Effects/. Visually confirmed each change on the real frame: Heavy CRT corners now feather smoothly (no staircase) and static is gone; Phosphor shows red cup + blue jellyfish through the green; 480p pixel reads clearly vs mushy 270p.
  • All 24 embeds in [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Comparison]] resolve to vault files (24/24).

Outcome: comparison doc is rev 2 — added §4 pixel-size sweep + revision note + updated verdict (try Pixel CRT @ 480/540 first; Subtle/Heavy now grain-cut + AA'd as fallbacks; Phosphor = UI; VHS/Datamosh = events). Awaiting the user's ship-default pick.

Session 4 (2026-05-31) — posterize sweep, Obi-blood compat proof, ship-default locked

Posterize sweep: added Tools/ScreenFX/4 - Capture Posterize Sweep (Pixel CRT @ 480p, _Posterize ∈ {32,48,64}screenfx_posterize_{N}_480p_*.png). Doc §4b. Higher = less colour flattening; at 480p the difference is subtle (pixelation dominates).

Obi blood × Pixel CRT — verified they compose. Investigated render order: ObiFluidRendererFeature and ScreenEffect both on PC_Renderer; Obi's VolumePass.renderPassEvent = AfterRenderingOpaques (composites blood into camera colour pre-post), ScreenFX at AfterRenderingPostProcessing (600, fetchColorBuffer). So blood is in the frame ScreenFX samples → Pixel CRT pixelates the blood with the scene, no special handling. Only caveat = *motion* shimmer (coarse grid aliases moving fluid), not compatibility. Added Tools/ScreenFX/5 - Force Blood Emitters On (reflection-sets BloodEmitter.debugAutoDispense=true, on 60 / off 0 = continuous, for player-free emission) + 6 - Capture Blood: Off + Pixel CRT. Proof captures screenfx_blood_{0_Off,5_PixelCRT}_* → doc §5. (Runtime-only reflection in Play; no game data or third-party code modified.)

Ship default locked: Pixel CRT @ 540p, posterize 48.

  • ScreenFXPresets.csPixelCRT now _Pixelate 540, _Posterize 48, _ScanlineCount 540. Added intensity guard: Apply(mat,preset,t) with t<=0.001 → Reset(Off) so unscaled structural params (pixelate/posterize) don't stick on at slider 0.
  • ScreenEffectController.cs — default _preset SubtleMonitor → PixelCRT.
  • Wiring confirmed: GameSettings.CRTIntensity defaults 1.0 (line 33/63) → Pixel CRT on at full for new installs; slider scales Off→full. Dev-machine saved value is 0, so it reads off locally until the slider is raised.

Verification: recompiled clean each step (read_console errors = 0 besides the benign control-scheme pairing warnings). Captured the final 540p+48 look in-engine. All 29 doc embeds resolve (24 + posterize 3 + blood 2).

Follow-up raised by user: consider splitting the CRT into individual per-effect sliders — decided below (Session 5).

Session 5 (2026-05-31) — per-effect accessibility sliders

User chose "master + 3 accessibility sliders" (Scanlines / Screen Curvature / Pixelation). Implemented across 4 files + verified in-engine.

Changed:

  • Settings/GameSettings.cs — 3 new floats ScanlineIntensity / CurvatureIntensity / PixelationIntensity (default 1.0; Load/Save with Settings_* prefix; setters). New combined event OnScreenFXSettingsChanged fired by all 4 ScreenFX setters (incl. SetCRTIntensity).
  • Rendering/ScreenFXPresets.cs — new overload Apply(mat, preset, intensity, scanline, curvature, pixelation). Scanline/curvature = multipliers on the master-scaled preset; pixelation = structural remap (0 = off/crisp, 1 = preset px, mid = Lerp(1080, basePix, p), guarded to pixel presets only). Cosmetic effects stay master-only.
  • Rendering/ScreenEffectController.cs — subscribes to OnScreenFXSettingsChanged (replacing the float-event sub), ApplyCurrent() passes the 3 modifiers from GameSettings.
  • Settings/SettingsMenuBuilder.cs — 3 slider rows (SCANLINES / SCREEN CURVATURE / PIXELATION) after CRT INTENSITY in BuildGameplayContent.
  • Editor/ScreenFXTool.cs7 - Capture CRT Modifier Sweep (Pixel CRT, master 1, each modifier zeroed) for verification/record.

Verification: compiles clean (0 errors). Captured screenfx_mod_{full,noPixel,noScan,noCurve} — confirmed Pixelation 0 → crisp/native while scanlines+curvature persist (structural remap works); other modifiers isolate correctly. Comparison doc §6 added; 33/33 embeds resolve.

Session 6 (2026-05-31) — scene/UI scope: world everywhere + light UI scanlines

Decided two scope questions. Scenes: the world ScreenFX pass already applies in every scene by construction (renderer feature on PC_Renderer + DDOL self-bootstrapping controller) — no change needed. UI: chose "light scanline overlay" (scanlines only, no warp/pixelation) so HUD/menu text stays legible.

New:

  • Assets/ProtoV2/Shaders/UIScanlineOverlay.shader — transparent, alpha-blended scanlines (half4(_TintColor.rgb, max(scan²·opacity, tintAmount)·intensity·vertexAlpha)). No curvature/pixelation/CA. Distinct from the legacy ProtoV2/CRTOverlay, which is opaque (samples white _MainTex, alpha 1) and would white-out the UI — that's why it can't be reused as a transparent layer.
  • Assets/ProtoV2/Scripts/UI/UIScanlineOverlay.cs — builds a full-screen ScreenSpaceOverlay canvas (sortOrder 5000), RawImage with raycastTarget=false and no GraphicRaycaster (never blocks input). Loads Resources/UIScanlineOverlay_Mat (build-safe inclusion; falls back to Shader.Find in-editor). Driven by CRTIntensity × ScanlineIntensity via OnScreenFXSettingsChanged. Base scanline opacity 0.12.
  • Assets/ProtoV2/Resources/UIScanlineOverlay_Mat.mat — created via MCP manage_material.
  • Editor/ScreenFXTool.cs8 - Test Live (CRT on) + Screenshot drives the LIVE controller/overlay (sets GameSettings.SetCRTIntensity(1) — persists) and screenshots the real composited frame.

Changed:

  • Rendering/ScreenEffectController.csAwake now adds a UIScanlineOverlay to its own DontDestroyOnLoad object → present in every scene.

Verification: compiles clean (0 errors); material created OK (shader compiled). Live capture screenfx_live_uiScanlines_* shows faint scanlines over the UI (subtitle panel + corner buttons, which sit above the world pass) with text still readable → overlay confirmed on the UI layer, input not blocked. Comparison doc §7 added; 34/34 embeds resolve.

Side effect: the test menu set this editor's Settings_CRTIntensity to 1 (was 0 from prior testing) — matches the ship default; lower it in Settings ▸ Gameplay if off is wanted locally.

Optional next: add a _TintColor/_TintAmount to the UI overlay material if a faint colour wash is wanted (kept neutral/0 for readability); wire ScreenEffectController.GlitchBurst() to also pulse the UI overlay for event moments.

Session 7 (2026-05-31) — archive legacy pipeline + cleanup + scene/settings verification

Investigation first (Unity MCP was disconnected, so file-level only): GUID search for CRTOverlayController.cs (e5f6a7b8…) and CRTOverlay.shader (a1b2c3d4…) across all *.unity/*.prefab/*.asset (incl. ProjectSettings) → zero hits. The legacy overlay was never placed in any scene/prefab/material or the always-included-shader list — pure dead code (CRTOverlayController.Instance always null; the disable-call in ScreenEffectController a no-op). The only other CRTOverlay mentions were comments in MainMenuController/PauseController.

Archived (deleted; git history = archive):

  • Assets/ProtoV2/Shaders/CRTOverlay.shader (+meta)
  • Assets/ProtoV2/Scripts/UI/CRTOverlayController.cs (+meta)

Cleanup:

  • Rendering/ScreenEffectController.cs — removed the dead legacy-disable block; fixed stale class doc (default was wrongly "Subtle Monitor" → Pixel CRT; dropped the legacy-CRT line, noted the UI overlay).
  • Settings/GameSettings.cs — removed dead CRTEffect (prop + Load + Save + SetCRTEffect); never read by render code, never wired to UI. CRT off = CRT-intensity slider at 0.
  • Editor/ScreenFXTool.cs — removed 8 - Test Live menu (it persisted CRTIntensity as a side effect — footgun).
  • MainMenuController.cs / PauseController.cs — updated stale CRTOverlay comment references → UIScanlineOverlay (the DDOL canvas that now coexists).

Scenes use Pixel CRT — no per-scene edits required. Verified the pipeline is global: PC quality → PC_RPAsset (also the GraphicsSettings default custom RP) → PC_Renderer → active ScreenEffect feature (injectionPoint 600, fetchColorBuffer, m_Active 1). ScreenEffectController self-bootstraps DDOL in every scene with default preset PixelCRT; CRTIntensity defaults 1.0. So MainMenu/Briefs/Lobby/Match all render Pixel CRT automatically. Mobile caveat: the Mobile quality level uses Mobile_Renderer (guid 5e6cbd92…), which does NOT have the ScreenEffect feature — register it there too if Mobile ships.

Settings panel verified (by read). BuildGameplayContent builds CRT INTENSITY + SCANLINES + SCREEN CURVATURE + PIXELATION rows, each via BuildSliderControl (minValue 0 / maxValue 1, initial = saved value, shows %), onValueChangedGameSettings.Set*OnScreenFXSettingsChangedScreenEffectController.ApplyCurrent() which passes all four to ScreenFXPresets.Apply(...). Wiring is complete and correct.

Live verification (MCP reconnected): force-recompile → read_console 0 errors, 0 CRT warnings (deletions cleanly resolved by the asset DB). Entered Play in MatchScene → controller bootstrap log confirms preset PixelCRT @ CRT 1.00, material bound = True — i.e. default preset is Pixel CRT, it reads GameSettings.CRTIntensity, and the material is bound/rendering. Settings→effect path is the same SetCRTIntensity → OnScreenFXSettingsChanged → ApplyCurrent that was behaviourally exercised in Session 6 (0→1 turned the effect on), so the slider control chain is confirmed end-to-end. Did not drag the physical uGUI slider (MatchScene had unsaved changes, so didn't switch to MainMenu where Settings lives; scene-wide coverage is architecturally guaranteed by the global RP + DDOL controller and the live log). Editor left in MatchScene, Play stopped, CRTIntensity at ship default 1.0.

---

2026-05-29 — In-Match Tutorial Prompts (learn-while-playing teaching layer)

Implements Next-Steps #6 / [[2026-05-25_in-match-tutorial-prompts]]. The demo cut the ExperimentBrief/ObjectiveBrief exposition; this is its replacement teaching layer — 5 well-timed, state-triggered prompts across Level 1 and Level 2 so the demo teaches by playing.

What changed

  • Assets/ProtoV2/Scripts/OnboardingController.cs — full rewrite. Was a disabled, stale in-match-hint component (referenced the *removed* "pour to teammate below" mechanic + a proximity bonus, and depended on Inspector-wired panels). Now a self-contained tutorial-prompt system:
  • Builds its own screen-space overlay at runtime — no scene wiring. Single shared bottom-center line, sortingOrder 90 (above MatchTimerUI 80, below CRT overlay 200), pixel-perfect, Share Tech Mono + phosphor-glow material via TypographyLibrary, dark backing strip, CanvasGroup fade.
  • 5 locked prompts, grouped per level into one stacked block, advanced by the floor scroll:
  • L1 (from match start, until the first scroll): "Your creatures are slowly dying." + "Collect blood to survive — hold LT to transform into a cup." + "Bring the blood to the altar."
  • L2 (from the first scroll, until the next scroll): "Tilt with the right stick to pour." + "Hold A to fly — when you're not a cup."
  • Button glyphs highlighted via bright-phosphor rich-text (), ASCII for localization safety.
  • Scroll-driven only — the lone trigger is FloorManager.OnScrollSequenceCompleted. Backing strip auto-sizes to the block (TMP.GetPreferredValues + paragraphSpacing). Re-arms every match (reArmEachMatch, booth-correct).
  • Legacy panel fields retained only so old scene refs resolve; hidden on Start.
  • Assets/ProtoV2/Scenes/MatchScene.unityOnboardingController GameObject m_IsActive: 0 → 1.

Triggers (scroll-driven)

| Group | Show | Clear |

|---|---|---|

| L1 block (decay + LT-cup + altar) | MatchCountdown.OnCountdownComplete (match start) | first FloorManager.OnScrollSequenceCompleted |

| L2 block (tilt + fly) | first OnScrollSequenceCompleted (1→2 expansion) | next OnScrollSequenceCompleted |

Stage machine (Idle → Level1 → Level2 → Done); further scrolls after Done are ignored. OnMatchEnd clears the block.

Why this shape

Earlier doc lean was per-player prompts. The codebase confirmed there's no split-screen — one ScrollingFloorCamera, world-space HP bars per creature — so per-player screen slots have nothing to hang on. A single shared subtitle-style line is cleaner and the standard party-game teaching pattern (user call). Decay framing kept as text because the bar visibly drains but players don't grasp *why*; the text frames it as an intended system.

Verification

Compile: VERIFIED. The Unity MCP bridge reconnected mid-session; a forced script recompile (refresh_unity scope=scripts mode=force compile=request) completed with 0 errors / 0 warnings and the editor returned to idle. Code was written against verified API signatures from the actual source (FloorManager, AltarParticleConsumer, TransformController, SimpleCharacterController1, MultiplayerManager, PlayerSetup, TypographyLibrary, MatchCountdown, MatchManager) and matches project TMP conventions (textWrappingMode).

Playtest: PENDING (needs live 4-player input). Check: (a) the L1 block shows at match start and clears on the first floor scroll, (b) the L2 block shows on that scroll and clears on the next, (c) legibility over 4P chaos at booth resolution.

Button icons (added same session)

Replaced the ASCII glyphs with Kenney Input Prompts (CC0) Xbox-layout icons for booth legibility:

  • Downloaded the pack (CC0); used the white Default glyphs xbox_lt / xbox_button_a / xbox_stick_r; composed a 192×64 sheet Assets/ProtoV2/Art/InputPrompts/InputPromptGlyphs.png (source PNG+SVG + License.txt under source/).
  • Editor/InputPromptSpriteAssetBuilder.cs builds the TMP sprite asset Assets/ProtoV2/Resources/InputPromptGlyphs.asset (glyph names lt/a/rstick, explicit glyphRects, single embedded material). Menu: *Ritual & Ruin/Build Input Prompt Sprite Asset*.
  • OnboardingController loads it via Resources.Load("InputPromptGlyphs")_label.spriteAsset; prompts use etc. — tint=1 makes the white glyph inherit the label's phosphor green.
  • Verified in-engine: Play-mode inspection confirmed the sprite asset assigned on the live label; an edit-mode render self-test showed all 3 sprites resolve and render ~28px inline at the 32px font. Compiles clean.
  • Quirk: the sprite-asset builder's first run after a domain reload writes empty tables — run the menu item twice. It strips stale embedded materials on rebuild so it stays at one material.

Glyph rendering fixes (verified via offscreen screenshot)

Added a diagnostic menu *Ritual & Ruin/Screenshot Tutorial Prompts* (renders the blocks with the real styling to Temp/tutorial_prompt_preview.png). First render showed the glyphs tiny/muddy and the A + right-stick mangled. Three fixes, all encoded in InputPromptSpriteAssetBuilder.cs:

1. Size + vertical centering — set glyph scale (the TMP_SpriteGlyph constructor 4th argTMP_SpriteCharacter.scale alone doesn't serialize) and GlyphMetrics.horizontalBearingY. Final tuning: GlyphScale = 1.06 (glyph ≈ cap height, reads as part of the text) and BearingYFraction = 0.88 (× GlyphSize → top edge above baseline) so the glyph centers on the cap line instead of dipping below the baseline. Both exposed as consts at the top of the builder for easy retuning.

2. Muddiness — texture had no mipmaps; enabled mipmapEnabled + FilterMode.Trilinear for clean downscaling.

3. Mangled A/rstick — enabling mipmaps triggered Unity's NPOT resize of the 192×64 sheet to nearest-POT, shifting the hardcoded glyph rects (index 0 survived, 1–2 broke). Fixed with npotScale = TextureImporterNPOTScale.None.

Final render confirmed: LT / R-stick / A all legible, phosphor green, correctly sized.

Final polish: icons bumped to GlyphScale = 1.2 (BearingYFraction = 0.84); backing strip darkened to alpha 0.88 and now hugs the text blockOnboardingController.ShowGroup sizes the strip from GetPreferredValues (width + height) capped at MaxStripWidth, with tight padding (H 22 / V 12), removing the wide empty margins. The screenshot util mirrors this so the preview is faithful.

Follow-ups

  • The kept SVG vectors feed the [[2026-05-18_controls-infographic|controls infographic]] (#5) — same glyph language, now unblocked on art.
  • Optional keyboard-glyph variant (icons are controller-only); optional directional/tilt variant for the rstick glyph.

---

Orphaned pillars — guard pillar placement against the floor-above's future gaps

Implemented: 2026-05-29

Type: Fix

Area: Floor System / pillar placement

Player impact

Pillars no longer poke up through holes ("sticks pointing at nothing"). Every pillar now keeps a solid chunk above it for its whole life, including after the floor above opens its gaps on a role change. Restores the "stacked floors held by pillars" architectural read. Surfaced in the locked Clinical Toon pipeline capture review.

Root cause

A pillar lives on floor N and reaches up into floor N+1. Two existing guards were meant to prevent orphans, but a generation-order asymmetry defeated both:

1. FloorGenerator.PlacePillars skipped a cell only if the floor above had a *current* gap there (floorAbove.IsGap) — never checked its *future* gaps.

2. Floor.ComputeFutureGapCells excludes belowPillarSet (cells above a pillar in the floor below), but floors generate strictly top-down (FloorManager.GenerateInitialFloors; every new floor is added below the current bottom). So when floor N+1 finalizes its future-gap set, floor N and its pillars don't exist yet → belowPillarSet is null → no exclusion.

Neither guard closes the loop: N+1 fixes its future gaps before N's pillars exist; N then places pillars checking only N+1's (empty) current gaps. When N+1 later leaves the Bottom role, OpenGapsForMiddleRole (FloorManager.CycleSequence:344, ReassignVisibleRoles:399) punches those future gaps directly over N's pillars → orphan.

What changed

Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs (PlacePillars, ~line 741):

  • Extended the support guard from floorAbove == null || floorAbove.IsGap(x,y) to also reject floorAbove.IsFutureGap(x,y). A pillar's top cell is now guaranteed solid both now and after the floor above ever opens gaps (N+1 only ever opens its _futureGapCells).
  • Generation order makes this sound: the floor above is always generated first, so its _futureGapCells is finalized and queryable via the existing Floor.IsFutureGap(x, z) (null-safe; emitter ceiling has none) by the time PlacePillars(N) runs.

Assets/ProtoV2/Scripts/FloorSystem/FloorDebugTools.cs (VerifyInvariants, Rule 1):

  • Added a *latent-orphan* assertion: flags any pillar sitting under a future-gap cell on the floor above, so the regression is caught at generation (F8 / verifyInvariantsOnStep) rather than only after a cycle reveals it.

Rejected alternative — filter gaps at open-time: the dual-mesh FloorObiSurface_Holed bakes its holes at generation, so skipping a gap at runtime would leave a visible chunk over an Obi hole, re-triggering the blood-containment regression the dual-mesh design exists to avoid. The chosen fix touches zero Obi/mesh code.

Floor.ComputeFutureGapCells's now-redundant belowPillarSet exclusion is left in place as harmless defense-in-depth.

Verification

  • Unity script compile: clean, zero CS compiler errors (refresh_unity force compile → domain reload complete → read_console errors-only returned no compile errors; only pre-existing runtime input/Obi entries).
  • Candidate-pool sanity: ~28% of cells are future gaps vs. 2–5 pillars per floor → plenty of valid cells remain.
  • Pending playtest (MCP can't tick play loop headless): Play-Mode soak over several expand + cycle steps with F8 invariant check expecting 0 violations; re-run the Visual Pipeline Direction Comparison capture cycle and confirm no orphaned pillars in Clinical Toon screenshots.

Follow-ups

  • Play-Mode soak + capture re-run (above).
  • Confirmed/tech/Core Systems Reference.md FloorSystem section owes a one-line note that pillar placement now excludes future-gap cells on the floor above.

Related

  • Problem doc: Problems/Active/2026-05-29_orphaned-pillars-no-chunk-above.md → resolved (option b).
  • [[2026-05-22_PillarHoleEmitterAltarExclusion]] — introduced the cross-floor exclusion + VerifyInvariants this fix extends.
  • [[2026-05-24_BowlContainment_DualMeshFloorObiSurface]] — dual-mesh design whose baked-hole invariant ruled out the open-time alternative.
  • 2026-05-18_clarity-and-depth-perception.md — inverse failure (holes too clean to read pillars vs. floors too solid to read holes).

---

Player stuck on retracting top floor — stale FloorObiSurface name lookup

Implemented: 2026-05-29

Type: Fix

Area: Floor System / cycle retraction + surface lifecycle

Player impact

Players standing on the top floor during a steady-state cycle now fall onto the floor below when it retracts, instead of staying stuck on the (now-collapsed, soon-to-be-deleted) floor floating in mid-air.

Root cause

The full-floor blood surface is a non-convex MeshCollider sitting ~0.16u (floorKillZoneYOffset) *above* the chunk tops, so the player actually rests on it, not on the per-chunk colliders. Floor.RetractAndDrop dropped the per-chunk colliders in its loop but disabled the floor-wide surface via:

The 2026-05-24 dual-mesh refactor ([[2026-05-24_BowlContainment_DualMeshFloorObiSurface]]) split the single FloorObiSurface child into FloorObiSurface_Solid + FloorObiSurface_Holed. Three sites still looked it up by the old name, all now silently returning null:

1. RetractAndDrop → full-floor collider never disabled → player stuck (the reported bug).

2. OnDestroy and DestroyFloor → surface meshes never freed → native mesh leak.

No error, no warning — transform.Find just degrades to a no-op.

What changed

Assets/ProtoV2/Scripts/FloorSystem/Floor.cs:

  • RetractAndDrop now disables BOTH _solidSurfaceGO and _holedSurfaceGO MeshColliders up front via new SetSurfaceColliderEnabled helper — covers whichever surface is active when the cycle fires, with no name lookup.
  • OnDestroy and DestroyFloor now free both surface meshes via new FreeSurfaceMeshes / DestroySurfaceMesh helpers.
  • All three sites use the Floor's already-tracked surface references (SetObiSurfaces), removing the transform.Find("FloorObiSurface") name dependency entirely.

Verification

  • Unity script compile: clean, zero CS compiler errors (refresh_unity force compile → domain reload → read_console errors-only returned only pre-existing runtime input/Obi entries).
  • Playtest confirmed by user: player drops onto the floor below during a cycle.

Follow-ups

  • None. (Could audit for any other transform.Find("FloorObiSurface")-style lookups elsewhere — grep confirms zero remain in Scripts/.)

Related

  • Problem doc: Problems/Archive/2026-05-29_player-stuck-retracting-floor.md → resolved + archived.
  • [[2026-05-24_BowlContainment_DualMeshFloorObiSurface]] — the refactor that renamed the surface child and left these three lookups stale.
  • [[2026-05-21_HazardRemoval_ProgressiveFloorCount]] — introduced the cycle/retract model.

Analysis of Issues and Solutions

Issue: Character Clipping Against Back-Wall

Problem Description: Players standing near the back/side walls appear to clip through them visually. This is due to render-order issues, not actual geometry intersections. The scene-view camera's angle exacerbates this problem.

Root Cause: Both the wall and player body materials use RenderQueue.Transparent (3000) with _ZWrite=0. URP sorts transparent objects by camera-space distance to their bounds center. The walls, being tall, have a higher Y-coordinate for their bounds center compared to the player. Under certain camera angles, this causes the walls to render on top of the player despite being behind them.

Solution Implemented: Lowered the wall material's renderQueue from 3000 to 2950 in BoundaryWalls.cs. This ensures that walls always render before the player body in the transparent pass. The solution preserves the intended transparency effects while resolving the clipping issue.

Considerations and Rejections

Making Walls Opaque: Would resolve depth issues but eliminate the subtle boundary visibility, which is a design choice.

Using SortingGroup on Player: Only affects child renderers, requiring additional management for other components like tentacles or future visuals.

Enabling ZWrite On for Walls: Would not resolve the issue as the player body still lacks depth writing (ZWrite Off), allowing walls to override per-pixel rendering.

Verification and Follow-ups

Verification Steps: Confirmed in-editor that clipping is resolved with the player against the back wall. Ensured no regressions on other walls, maintaining uniformity across all four walls. Verified transparency effects remain unchanged for wall visibility.

Follow-up Considerations: Monitor any future additions of queue-3000 transparent visuals near walls to prevent similar issues. Ensure procedural wall shaders do not hard-code Queue = Transparent in their SubShader.Tags, maintaining reliance on runtime assignments.

Conclusion

The solution effectively addresses the render-order issue causing character clipping against walls by adjusting the rendering queue. This approach maintains design aesthetics while ensuring correct visual layering under various camera angles. Future developments should consider these adjustments to prevent recurrence of similar issues.

Raw session notes

2026-05-18 — Jun 21 Demo Onboarding Cut (Phase 1: Scripts & Hooks)

Problem: Problems/Active/2026-05-18_jun21-demo-onboarding.md — strangers at the GameDev Jalan NLB booth won't sit through ExperimentBriefScene → LobbyScene (with rebinder) → ObjectiveBriefScene before touching the game. The full game's narrative onboarding consumes their entire attention budget; they walk away.

Goal: A parallel "demo" flow that goes MainMenu → DemoLobbyScene → MatchScene, skipping both story interstitials and the rebinder. Toggled by a single DEMO_BUILD scripting define so demo behaviour cannot leak into the full-game build.

What Changed (Code)

| File | Change |

|---|---|

| Assets/ProtoV2/Scripts/Demo/DemoContext.cs | NEW. Static DemoMode flag. Non-persistent (no PlayerPrefs). RuntimeInitializeOnLoadMethod(SubsystemRegistration) resets to false on Play Mode entry — protects against stale state with Reload Domain disabled. |

| Assets/ProtoV2/Scripts/Demo/DemoSceneInit.cs | NEW. MonoBehaviour. Awake() calls DemoContext.SetDemoMode(true). Drop on a root GO in DemoLobbyScene. |

| Assets/ProtoV2/Scripts/Demo/DemoLobbyController.cs | NEW. Hides the "Press Start when ready" prompt until first MultiplayerManager.OnPlayerJoined. Listens on InputSystem_Actions UI/Submit action. OnStartPressedSceneTransition.Fade(0.5f, () => SceneManager.LoadScene("MatchScene")). Detaches its own listener after firing to prevent double-load during fade. Tolerates null Inspector refs with clear LogError. |

| Assets/ProtoV2/Scripts/MainMenuController.cs:173 | OnStartClicked() now branches on #if DEMO_BUILD — loads DemoLobbyScene in demo build, ExperimentBriefScene in full game. |

| Assets/ProtoV2/Scripts/Multiplayer/Input/InputBindingPersistenceManager.cs:64 | OnSceneLoaded early-returns when DemoContext.DemoMode == true. Demo always runs default bindings — strangers don't configure controls and any leftover dev rebinds in PlayerPrefs would silently break the infographic's contract. |

| Assets/ProtoV2/Scripts/Editor/DemoSetupWizard.cs | NEW Editor wizard. Menu items: Ritual & Ruin/Demo Build/Add DemoLobbyScene to Build Settings, Enable DEMO_BUILD Define (Standalone), Disable DEMO_BUILD Define (Standalone), Print Demo Flag Status. Uses NamedBuildTarget.Standalone + PlayerSettings.GetScriptingDefineSymbols (Unity 6 API). |

API Verification

Before writing DemoLobbyController I confirmed via Grep:

  • MultiplayerManager.Instance exists, public event Action OnPlayerJoined (not PlayerInput — the script uses the correct type).
  • MultiplayerManager.ActivePlayers is IReadOnlyList.Count is valid.
  • SceneTransition.Fade(float duration = 0.3f, Action onMidpoint = null) — matches usage.
  • InputSystem_Actions.inputactions has a UI action map containing a Submit action — no inputactions edits needed.

What's Still Manual (Unity Editor)

The code is done; the scene authoring is one-time bench work. Run these in order:

1. Create `DemoLobbyScene`

1. Open Assets/ProtoV2/Scenes/LobbyScene.unity.

2. File → Save As… → save as Assets/ProtoV2/Scenes/DemoLobbyScene.unity.

3. In the new scene, delete:

  • InputRebindUIController GameObject (and its UIDocument).
  • LobbySceneLoader GameObject (it routes to ObjectiveBriefScene — wrong target for demo).
  • Any settings / menu deep-cut buttons.

4. Keep:

  • MultiplayerManager + its PlayerInputManager.
  • JoinScreenUI + the 4 PlayerSlotUI instances.
  • Camera, lighting, EventSystem.

2. Add the infographic Canvas

1. Drop a placeholder PNG at Assets/ProtoV2/UI/Sprites/controls_infographic_placeholder.png (any temp image — real artwork lands via the controls-infographic problem).

2. Create a new Canvas (or reuse an existing screen-space-overlay one).

3. Enable Canvas.pixelPerfect = true on it — per CLAUDE.md rule 9.

4. Add a fullscreen Image child showing the placeholder Sprite.

5. Add a TextMeshPro child: "Press Start when everyone's ready". Position it below the infographic. Disable it in the Inspector (the controller re-enables it on first join).

3. Wire the new components

1. Add an empty GO at scene root → DemoSceneInit component.

2. Add an empty GO at scene root → DemoLobbyController component. Inspector fields:

  • Press Start Prompt → the TextMeshPro / parent GO from step 2.5.
  • Input Actions → drag Assets/InputSystem_Actions.inputactions here.
  • Match Scene Name → defaults to MatchScene (leave as-is).
  • Fade Duration → defaults to 0.5 (leave as-is).

4. Build Settings & scripting define

Run via the new menu:

1. Ritual & Ruin/Demo Build/Add DemoLobbyScene to Build Settings — appends the scene at the next free index.

2. Ritual & Ruin/Demo Build/Enable DEMO_BUILD Define (Standalone) — adds the symbol; Unity recompiles, MainMenuController.OnStartClicked flips to the demo path.

3. Ritual & Ruin/Demo Build/Print Demo Flag Status — sanity check.

For full-game builds, run Disable DEMO_BUILD Define (Standalone) before building.

Verification (run after Editor wiring)

1. Full-game smoke test (DEMO_BUILD disabled):

  • Play MainMenu → click INITIATE EXPERIMENT.
  • Expected: loads ExperimentBriefScene exactly as today. Full flow into MatchScene works. Rebinder still functional in LobbyScene.
  • Console: [DemoContext] DemoMode = true should NOT appear.

2. Demo flow smoke test (DEMO_BUILD enabled):

  • Play MainMenu → click INITIATE EXPERIMENT.
  • Expected: loads DemoLobbyScene. Console shows [DemoContext] DemoMode = true.
  • Infographic visible. "Press Start when everyone's ready" prompt hidden.
  • Press any button on a gamepad / keyboard → JoinScreenUI slot fills, prompt becomes visible.
  • Press gamepad Start / keyboard Enter → fade → MatchScene loads. Console shows [InputBindingPersistence] Scene loaded: MatchScene — demo mode active, skipping rebind load.
  • Match plays end-to-end.

3. Isolation test:

  • After demo flow, return to MainMenu and disable DEMO_BUILD via the menu.
  • Replay full-game flow.
  • Expected: no demo console logs, no PlayerPrefs side-effects (search PlayerPrefs registry for DemoMode — must not exist).

Out of Scope (Other Problems)

  • Final controls infographic art — blocked on controls-infographiccontrols-feel-diagnosis. Placeholder used now.
  • 1 → 2 → 3 floor ramp — permanent (both flows), tracked in progressive-floor-count. No FloorManager / MatchManager changes in this session.
  • Demo end behaviour (auto-return on idle, kiosk loop) — open question in source problem doc; defer to post-Jun-21 playtest learnings.

Follow-ups

  • Standalone Windows build verification with DEMO_BUILD define set in a Build Profile (Unity 6) — confirm the scripting define survives the Build Profile config, not just the Editor's Player Settings.
  • Hot-swap controller test in the demo lobby — the 2026-04-29 stuck-state hardening (MultiplayerCharacterInput.ResetInputState() + paired-device watchdog) must hold in a Build, not just in Editor. Per the controls-feel-diagnosis open question.
  • Update MEMORY.md with a pointer to this active problem + the DemoContext flag pattern.

2026-05-21 — Dispense-on-stand blood emission (Option A)

Feature: blood only dispenses when a player stands on the marked chunk

Type: Feature (system) · Player impact: kills the "waiting around for blood" dead zone — instead of timing a catch of falling drops (which the 3D depth-perception problem made hard), a player walks onto the lit footprint chunk under an emitter and blood streams while they stand there. Reads instantly at a booth: "stand here, get blood."

Resolves the decision in Problems/Active/2026-05-18_waiting-for-blood.md (Option A, Obi feasibility confirmed 2026-05-20). Design choices locked with the user: emitter stays overhead (blood falls ~5u onto the player, keeping a little verticality) and single-chunk stand zone.

What changed

  • Assets/ProtoV2/Scripts/BloodSystem/BloodEmitter.cs — new EmissionMode.DispenseOnStand. Update polls MultiplayerManager.ActivePlayers each frame: a player counts as "standing on the chunk" when XZ is within standZoneRadiusFraction (0.5 = one chunk) × the floor chunk half-extent of the emitter, and Y is within standVerticalTolerance (2.5u) of the CurrentTopFloor surface (so a player on a lower floor directly beneath doesn't trigger it). Occupied → obiEmitter.speed = baseEmissionRate (STREAM); empty → 0. Added bool IsDispensing and event Action OnDispensingChanged. Dispense state is force-cleared in SetEmitterActive(false) and ResetModeState() so listeners (audio loop / indicator) never stick "on" when the pool parks or repositions an emitter.
  • Assets/ProtoV2/Scripts/BloodSystem/BloodEmitterIndicator.cs — repurposed from the shrinking burst-countdown circle into a persistent "stand here" footprint. In DispenseOnStand mode it draws a full ring sized to the chunk (ringChunkFraction × half chunk), projected straight down onto the first solid chunk below via the existing floor-stack walk; idle color (countdownColor) normally, brightens to burstFlashColor while dispensing (driven by OnDispensingChanged). Legacy Burst path (shrink + flash) kept for any non-dispense emitter.
  • Assets/ProtoV2/Scripts/Audio/BloodEmitterAudioSource.cs — in DispenseOnStand mode the flow loop is gated on dispensing (OnDispensingChanged start/stop) rather than mere activation; the existing burst one-shot now fires as a pour-start cue on the rising edge. Legacy modes unchanged (HandleActivated early-returns in dispense mode).
  • Assets/ProtoV2/Prefabs/FloorSystem/BloodEmitter.prefabemissionMode: 3 (DispenseOnStand), baseEmissionRate: 3 (continuous stream rate, tunable), standZoneRadiusFraction: 0.5, standVerticalTolerance: 2.5.

Why polling instead of a trigger collider

The emitters live on the hidden EmitterOnly floor and are repositioned/glided by BloodEmitterPool each cycle. A per-frame XZ/Y proximity check against ActivePlayers (≤4 players × 3 emitters = trivial) follows that repositioning automatically with no prefab collider surgery, no Rigidbody-trigger coupling, and no dependence on a player tag/layer. The emitter's own XZ already *is* the target chunk (the indicator projects straight down from it), so "on the chunk" is just |Δx|,|Δz| ≤ halfExtent.

Verification

  • refresh_unity (force, scripts) compile: 0 CS#### errors; prefab reimported clean after a force asset refresh.
  • Play-Mode verified. Runtime component read on PoolEmitter_0: BloodEmitter.Mode:3 / emissionMode:3 (DispenseOnStand), isActive:true, all new fields deserialized (baseEmissionRate:3, standZoneRadiusFraction:0.5, standVerticalTolerance:2.5); with no player standing, ObiEmitter.speed:0 / activeParticleCount:0 (poll correctly holds emission off). User confirmed in Play Mode that standing on the chunk dispenses blood. No new runtime exceptions (only the pre-existing input control-scheme warnings).
  • Owed (feel/polish, not blocking): tune baseEmissionRate + standVerticalTolerance (depends on the player root pivot height), and confirm the footprint ring lands on the correct chunk across 1/2/3-floor states and after a cycle.

Session 2 — indicator made more obvious

The footprint ring alone read too subtly for a booth. First tried a full vertical light beam rising from the chunk to the overhead emitter — user rejected it. Final treatment: thicker, glowing ring + a short vertical glow just off the ground (no full beam).

BloodEmitterIndicator.cs (DispenseOnStand mode):

  • Ring now uses a code-built URP/Unlit additive, ZWrite-off material (MakeGlowMaterial()) coloured via _BaseColor (vertex colour kept white) so it glows rather than reading flat; thicker (lineWidth 0.04 → 0.1, with corner/cap vertices for a rounded look).
  • Ground glow = a Unity cylinder primitive (collider stripped, same additive material) of fixed small height groundGlowHeight (0.4) at the chunk centre — a low glow column, not a beam to the emitter. groundGlowRadiusFraction 0.95 of the ring radius.
  • Both share one breathing pulse: idle gentle (idlePulseSpeed 1.4, idleIntensity 0.3), brighter + faster while dispensing (activePulseSpeed 3.5, activeIntensity 0.8); colour shifts idle countdownColor → active burstFlashColor. Recomputed every frame so they track the emitter through pool repositioning / glide / floor changes (ResolveFloorCenter()). Materials destroyed in OnDestroy (HideFlags.DontSave don't auto-free).
  • Prefab serialized fields updated to match (lineWidth: 0.1 + the new glow knobs), since the prefab overrides script defaults.

Verification: 0 compile errors; in Play Mode 3 _DispenseGroundGlow objects instantiate (one per emitter), no _DispenseBeam, no new exceptions. Visual feel for the user to judge + tune via the inspector knobs above.

Session 3 — debug auto-dispense toggle

Testing the dispense path meant constantly walking a player onto the chunk. Added a debug aid to BloodEmitter.cs: debugAutoDispense (bool) + debugDispenseOnDuration / debugDispenseOffDuration (default 2/2). In DispenseOnStand mode, when debugAutoDispense is on, UpdateDispenseOnStandMode derives occupancy from a timed on/off cycle (DebugCycleOccupied() via Mathf.Repeat) instead of the player check — so it drives the real path (Obi speed, OnDispensingChanged, audio loop, glowing indicator) with no players. Set off-duration to 0 for constant emission. Enable on BloodEmitter.prefab to affect all pooled emitters. Timer resets in ResetModeState.

Not compiler-verified (Unity MCP + C# LSP both unavailable this session); low-risk (standard Time/Mathf + serialized primitives) — verify on next Editor focus.

Follow-ups

  • Play-test + tune (above).
  • Turn debugAutoDispense back off before building.
  • Rewrite the blood-emission spec in the design vault to describe dispense-on-stand (currently describes burst/continuous drip).
  • The related [[2026-05-18_clarity-and-depth-perception|clarity + depth perception]] problem is now lower-stakes (no falling-drop catch to misjudge) but the ~5u fall still exists — revisit if the stream still feels imprecise.

---

---

title: Hazard Removal + Progressive Floor Count

date: 2026-05-21

tags: [devlog, ritual-ruin, floors, scrolling, hazard, onboarding]

---

2026-05-21 — Remove Hazard/Scroll-Out + Progressive Floor Count (1→2→3)

Two coupled scrolling-floor changes shipped together and verified in Play Mode (automated soak; controller feel-pass still owed). Problem docs archived: [[2026-05-18_remove-hazard-scroll-out]], [[2026-05-18_progressive-floor-count]].

What changed

1. Vertical hazard + scroll-out kill removed (no replacement).

  • Deleted VerticalHazard.cs, Editor/HazardVisualWirer.cs, the empty HazardSystem/ folder, and the Hazard GameObject in MatchScene.
  • FloorManager no longer references a hazard (field, BeginDescent SendMessage, and CheckPlayersAboveVisibleArea despawn all gone).
  • Stale VerticalHazard comments fixed in UnifiedBar.TakeDamage, SpawnManager.GetWalkablePosition. TerminalPhaseController/TerminalAuraDamage audited — independent, unchanged.

2. Progressive floor count + camera-down centering (FloorManager rewrite).

  • Model: one hidden EmitterOnly floor (blood source) + an ordered list of visible play floors that ramps 1→2→3 and caps. CurrentTop/Middle/Bottom/EmitterFloor derive from the list (at 1 floor, Top==Bottom, Middle==null).
  • Start: emitter floor at Y=2·spacing + one play floor at Y=1·spacing (solid, forced 1 altar), centered with no camera move.
  • RequestScroll (bottom-floor cleared, raised by ScrollTriggerManager) branches:
  • < 3ExpandSequence: new floor one spacing below, role reassign + open gaps on the floor leaving Bottom, camera recenters half a floor (no shake). Ramp floors forced to 1 altar.
  • == 3CycleSequence: telegraph+retract the top floor, new bottom floor (random altars), camera recenters one full floor (shake), role shuffle oldTop→Emitter, oldMid→Top, oldBot→Mid(+gaps), new→Bottom, old emitter destroyed after delay.
  • ScrollingFloorCamera.ScrollBy(deltaY, shake): camera Y = visible-stack midpoint → half/half/full cadence falls out naturally.
  • Floor.RetractAndDrop: center-out retraction — hole opens at the floor's center cell and grows outward by distance ring; each chunk's collider drops as the hole reaches it (physics drops players, no teleport), chunks shrink + slide into the walls, pillars hide, whole floor shakes (~0.75s). Floor persists chunkless as the emitter source until destroyed.
  • Floor.OpenGapsForMiddleRole: the holes punched into a floor when it leaves the Bottom role (so players can descend to the new floor below) now dissolve in — chosen chunks shrink + sink with a small random per-chunk stagger and drop their colliders, then the gaps commit + the Obi surface rebuilds. Reuses the retraction feel instead of chunks popping out instantly. Config: gapDissolveDuration, gapDissolveStagger, gapSinkDistance.
  • FloorGenerator.GenerateFloor gained altarCountOverride; re-enables chunk colliders on pool reuse. SpawnManager falls back to bottom floor when no middle.
  • Debug: FloorDebugTools.forceStepNow + autoStepIntervalSeconds; Editor/FloorDebugMenu.cs (Tools/Force Floor Step).

3. Latent blood-system crash fixed (exposed by rapid cycling).

  • During a cycle the emitter floor briefly deactivates; the Obi solver's particle buffer can shrink to 0 while a bottom-floor altar still tracks stale particle indices. Three solver-indexed reads had no guards → IndexOutOfRangeException then NullReferenceException every FixedUpdate.
  • Added bounds/null guards to AltarParticleConsumer.OnParticleFirstContact/OnParticleTimerUpdate (solver.colors[idx]) and ObiParticleKillerBase.TryKillParticle (solver.particleToActor[idx] slot can be null).

Verification

Play Mode soak (autoStepIntervalSeconds, Unity focused): init → EXPAND 1→2 → EXPAND 2→3 → 4× CYCLE, all exception-free. Floor Ys descend by exactly one spacing each (16.64 → … → −33.28), new floors always below, role shuffle correct each cycle, ramp altars=1 / cap altars random, ScrollTriggerManager resubscribes per new bottom floor. Compiles clean. Owed: human controller playtest for cadence/telegraph legibility and the "players fall naturally" moment.

Files

FloorManager.cs (rewrite), ScrollingFloorCamera.cs, Floor.cs, FloorGenerator.cs, SpawnManager.cs, UnifiedBar.cs, FloorDebugTools.cs, Editor/FloorDebugMenu.cs (new), BloodSystem/AltarParticleConsumer.cs, BloodSystem/ObiParticleKillerBase.cs; deleted HazardSystem/VerticalHazard.cs, Editor/HazardVisualWirer.cs; MatchScene.unity (Hazard GO removed).

Update — solid ceiling + boundary-wall height

  • Solid ceiling. The emitter floor now doubles as the ceiling: generated solidFloor:true (gaps=0, verified) so players can't fly out the top. In the 1-floor stage it's the top layer one spacing above the play floor; it stays one floor above the top visible floor throughout the ramp and repositions down one spacing per steady-state cycle (scrolls with the camera).
  • Ceiling no longer recycled from the old top. CycleSequence reworked: the retracted top floor is now destroyed (not promoted to emitter — that would inherit its gaps). The ceiling/emitter is a single persistent solid floor that's moved down one spacing each cycle, with BloodEmitterPool.ActivateOn re-run to carry its emitters along. Verified solid at init + exception-free entry; cycle reposition is deterministic but owes a focused soak/playtest.
  • Boundary walls were one floor too high. BoundaryWalls anchored its centre at bottomFloorY + 2*spacing; since the start floor is now at Y=spacing (not 0) that over-shot by one spacing (and contradicted the file's own "bottom edge = bottomFloor.Y − spacing" comment). Lowered to + 1*spacing (Start + gizmo).
  • ⚠️ Playtest risk: confirm blood still flows through the now-solid ceiling — emitters spawn at the chunk-centre Y, below the floor's FloorObiSurface blood collider, so it *should* fall freely, but verify; if blood pools on top, add a small negative emitterYOffset on BloodEmitterPool.
  • Ceiling glide fix. First pass repositioned the ceiling with a single teleport *after* the camera scroll finished — so it lagged the smooth 2.5s camera glide then snapped. Now CycleSequence runs GlideCeilingWithCamera concurrently with ScrollCameraTo: it applies the camera's per-frame base-Y delta (ScrollingFloorCamera.BaseY, shake-excluded) to the ceiling each frame, so it scrolls in exact lockstep (same easing, no snap). Emitters follow cheaply via new BloodEmitterPool.RepositionActive() (no deactivate/reactivate flicker). Verified: ceiling lands on exact spacing multiples (Y −16.64 after 4 cycles, no drift), zero exceptions.

Fix — retraction chunk-disappear lag

  • Symptom: long lag between the floor's blood collider deactivating and the chunks visually disappearing.
  • Cause: RetractAndDrop only scaled chunks to 2% (a speck) and never deactivated them, so they lingered for the full floorDestructionDelay (~5s) until DestroyFloor pooled them — while the floor-wide FloorObiSurface collider was disabled up front at t=0.
  • Fix: each chunk now SetActive(false) the instant it finishes retracting (and all in the finalize block), so they vanish *with* the ~0.75s center-out animation instead of lingering 5s. Reuse is safe — GenerateFloor unconditionally SetActive(true)s + resets scale/collider on pooled chunks. Verified: post-cap cycles ran, Floors 4/5 (generated after retracted floors were hidden+pooled) came back as full 20×7 grids, zero exceptions.
  • Remaining (left as-is): the floor-wide blood collider still drops at t=0 vs. the ~0.75s center-out chunk retract; syncing it would need a per-cell progressive Obi-surface mesh rebuild.

Fix — gaps not letting blood through after a scroll

  • Symptom: after a scroll, newly-opened holes pass *players* through but not *blood* — blood pools on an invisible surface over the hole.
  • Root cause (Obi): ObiMeshShapeTracker.UpdateIfNeeded *does* detect a runtime sharedMesh swap (handle.owner != sharedMesh → rebuild), but ObiColliderWorld.UpdateWorld early-returns unless the world is dirty and only processes colliders flagged for update. On a non-moving floor that swap can be skipped, so blood keeps colliding with the OLD hole-less mesh while PhysX/players already see the holes.
  • First attempt (wrong): toggling ObiCollider.enabled off/on. DestroyCollider *defers* during play (queued, not invalidated), so the same-frame re-enable hits AddCollider's (shapeHandle == null || !shapeHandle.isValid) guard while the handle is still valid → skips rebinding and leaves a null tracker. Reverted.
  • Fix: in FloorGenerator.RebuildObiSurfaceMesh, after the mesh swap call obiCollider.ForceUpdate() (flag it for update) + ObiColliderWorld.GetInstance().SetDirty() (force UpdateWorld to run) — the tracker then rebuilds against the new mesh next step. Compiles clean. Owed: visual confirm — watch blood fall through a middle floor's gaps after a scroll.

Follow-ups

  • Controller playtest: centering at 1/2/3, half/half/full cadence, telegraph legibility, natural player-drop, fly-into-ceiling containment, wall position, blood-through-ceiling.
  • Tune telegraphDuration / retraction + gap-dissolve slide/shake in Floor; FloorDebugTools HAZARD gizmo label is now stale (cosmetic).

---

2026-05-21 — Jun 21 Demo Onboarding Cut (Clean Rebuild via UXML Swap)

Problem: Problems/Active/2026-05-18_jun21-demo-onboarding.md — booth build must skip story interstitials + rebinding so passersby play in seconds.

Supersedes 2026-05-18_Jun21DemoOnboardingCut.md. That first attempt (a DemoContext static flag, DemoSceneInit, DemoLobbyController, an overlay DemoCanvas, autoSpawn changes, an InputBindingPersistenceManager guard) was fully revertedgit reset --hard ea6a305 + git push --force origin main. It got tangled fighting MCP-created canvases (silent property drops on Canvas renderMode / Image color) and broke gamepad pairing by skipping ReapplyDevicePairing. Clean slate, new approach below.

Approach: alternate UXML, keep everything else

The lobby rebinder is UI Toolkit (InputRebindPanel.uxml + InputRebindUIController). The controller null-checks every element it queries (if (deviceDropdowns[i] != null), RefreshPlayerBindingDisplay guards each label, etc.). So a stripped UXML drives the same controller with zero code changes — and all join/pairing/binding machinery stays intact. Rebinding still exists in code; there's just no UI to reach it.

What changed

| File | Change |

|---|---|

| Assets/ProtoV2/UI/InputRebindPanel_Demo.uxml | NEW. Stripped rebind panel: footer keeps back-to-menu-button + start-game-button with controller-status-bar (P1–P4) between them; CONTROLS header; 4 rebind panels replaced by an infographic placeholder box; waiting-overlay kept hidden so controller Q-lookups resolve. Reuses InputRebindPanel.uss. |

| Assets/ProtoV2/Scripts/UI/DemoControllerStatus.cs | NEW. Display-only readout on the InputRebinder GO. Polls each player slot's paired gamepad presence every 0.5s → controller-status-{1..4} Labels green/red. Gamepad-only (keyboard is always present, which falsely greened P1 in testing). |

| Assets/ProtoV2/Scripts/Editor/DemoSetupWizard.cs | NEW. Menu Ritual & Ruin/Demo Build/...: add DemoLobbyScene to Build Settings, enable/disable DEMO_BUILD (Standalone), print status. |

| Assets/ProtoV2/Scenes/DemoLobbyScene.unity | NEW. Copy of LobbyScene. InputRebinder GO's UIDocument.visualTreeAssetInputRebindPanel_Demo.uxml; DemoControllerStatus added. Build index 5. |

| Assets/ProtoV2/Scripts/MainMenuController.cs | OnStartClicked #if DEMO_BUILD → loads DemoLobbyScene (full game → ExperimentBriefScene). |

| Assets/ProtoV2/Scripts/LobbySceneLoader.cs | LoadMatchScene() routes by scene name — DemoLobbySceneMatchScene, else → ObjectiveBriefScene. No define; testable in editor. |

| Assets/ProtoV2/Scripts/UnifiedBar.cs | World-space HP bar now hides in any scene whose name .Contains("Lobby") (was exact "LobbyScene", so DemoLobbyScene showed bars under the close lobby camera). |

| Assets/ProtoV2/Scripts/UI/StreamLayoutManager.cs | Awake #if DEMO_BUILD self-destructs — no banner / looping video / split-camera in the booth build. Full game unchanged. |

Build config

  • DemoLobbyScene added to EditorBuildSettings (index 5).
  • DEMO_BUILD scripting define ENABLED on Standalone (currently on in-editor too — disable via the wizard to work on the full game).

Verification (play-tested, MCP-driven + user-confirmed)

1. MainMenu (DEMO_BUILD on): full-screen, no banner/video (StreamLayoutManager self-destructed — no R&R Controls (2).mp4 timestamp warning). ✓

2. INITIATE EXPERIMENT → fades to DemoLobbyScene (controls panel, P1–P4 status, no rebind grid). ✓

3. Demo lobby: HP bars hidden; DemoControllerStatus shows P1 green with Xbox pad paired, others red; unplug → red within ~0.5s, replug → green. ✓

4. COMMENCE EXPERIMENT → fades straight to MatchScene (skips ObjectiveBrief). ✓

5. Console clean apart from expected "control scheme" warnings (unpaired pads) + one pre-existing "missing script" warning (not from this work).

Follow-ups

  • Real controls infographic art (blocked on controls-infographiccontrols-feel-diagnosis) — drop into InputRebindPanel_Demo.uxml, replacing the placeholder VisualElement.
  • progressive-floor-count (1→2→3 ramp) — separate problem, untouched.
  • Investigate the pre-existing PlayerPrefab(Clone) "missing script" warning.
  • Confirm full-game flow once more with DEMO_BUILD disabled before shipping a non-demo build.

---

2026-05-22 — Pillar / Hole / Emitter / Altar Mutual-Exclusion Rules

Motivation

Follow-up to the earlier same-floor pillar-vs-hole fix. User specified the full invariant set for the progressive/cycling floor stack:

1. A pillar must always have a solid chunk above AND below — enforced both when *generating pillars* and when *opening holes in the floor above*.

2. No emitters on cells that have a pillar (including a pillar from the floor below whose top reaches up into the emitter floor).

3. No altars on cells that have a pillar.

Geometry: a pillar registered on floor F at cell (x,z) bridges F's chunk (base/below) up to the floor-above's chunk at the same (x,z) (top). Floors mutate gaps over time (OpenGapsForMiddleRole) and the ceiling re-randomizes its emitter cells each cycle, so these invariants must hold continuously, not just at generation.

Changes

`Floor.cs`

  • New PillarCells (IReadOnlyCollection = pillars.Keys) + HasPillarAt(x,z).
  • OpenGapsForMiddleRole: in addition to skipping this floor's own altar + pillar cells, now resolves the floor below via FloorManager.GetFloorBelow(this) and skips that floor's pillar cells — those pillars' tops rest on *this* floor's chunks, so opening a hole there would strand them (rule 1, cross-floor). Rewrote the candidate loop to early-continue per condition for clarity.

`FloorManager.cs`

  • New GetFloorAbove(Floor) / GetFloorBelow(Floor) / FindFloorAtY(float) — search allFloors by Y (±floorVerticalSpacing), so they include the hidden emitter/ceiling floor (the old FloorGenerator.FindFloorAbove only checked the 3 visible floors and missed the ceiling).
  • GenerateInitialFloors: register _emitterFloor in allFloors before generating the play floor below it, so the play floor's pillar pass can resolve the ceiling via GetFloorAbove (avoid its emitter cells + guarantee a chunk above each pillar).

`FloorGenerator.cs`

  • Deleted the local FindFloorAbove (superseded by FloorManager.GetFloorAbove).
  • PlacePillars: now also excludes this floor's own emitter+altar cells and the floor-above's emitter+altar cells (previously only floor-above emitters). Placement loop now requires floorAbove != null && !floorAbove.IsGap(...) — no floating pillars (rule 1 + rule 3 cross-floor).
  • Emitter picking (GenerateFloor initial pick + RepickEmitterPositions): excludes floor-below pillar cells via new GetBelowPillarCells(floor) helper. This is the key fix for the ceiling: RepickEmitterPositions (called in CycleSequence after the ceiling glides down onto the old-top Y) re-rolls emitter cells, and the new Top floor's pillars reach up into the ceiling — so the re-rolled emitters now avoid those pillar tops (rule 2, cross-floor).

Verification

  • Unity MCP reconnected; refresh_unity (force/scripts, compile) → editor reached idle, read_console errors = 0. Compiles clean.
  • Play-mode soak PASSED. Set FloorDebugTools.autoStepIntervalSeconds = 6 (in-memory, edit mode), entered Play with the Editor focused, ran hands-free through the full 1→2→3 ramp and 10+ steady-state cycles (floor indices 0→13, ceiling persistently Emitter=0). No NullReferenceException / IndexOutOfRange / floor-system exceptions across the whole run. Only console errors were two pre-existing, unrelated categories: input-system "Cannot find matching control scheme" (no controllers attached in-editor) and "Setting linear velocity of a kinematic body is not supported" (player rigidbody during drops — untouched by this change). Stopped play, reset autoStepIntervalSeconds back to 0.
  • Live scene values (for reference): grid 20×7, floorVerticalSpacing 8.32 — GetFloorAbove/GetFloorBelow Y math (±8.32) resolves correctly.
  • Geometry invariants ASSERTED (programmatic, not eyeballed). Added a runtime checker to FloorDebugTools (VerifyInvariants + verifyInvariantsOnStep + F8 hotkey) that, after each forced step settles (waits out IsScrolling, then +1s for gap-dissolve commit), asserts against live floor data for every live floor (emitter ceiling + visible stack): (1) every pillar cell has a solid chunk below *and* above (!IsGap this floor + floor-above), (2) no emitter cell coincides with a same-floor or floor-below pillar, (3) no altar cell coincides with a same-floor or floor-below pillar. Logs Debug.LogError per violation else a PASS summary. Re-ran the focused-editor soak: 6/6 [InvariantCheck] PASS across the ramp (Top #1 @ 3→4 live floors) and 4 cycles (Top #1→#5), zero violations. Note the checker fires only on debug-triggered steps (auto-step / F9 / forceStepNow) — normal gameplay calls RequestScroll directly and never hits it, so it's inert in builds. (First checker pass logged nothing: a 1.5s post-step delay landed mid-camera-scroll so the !IsScrolling guard silently skipped every check — fixed to wait for settle.)

Cleanup (post-review)

  • Trimmed the same-floor emitter assertion from VerifyInvariants — it was redundant: the emitter/ceiling floor has no pillars, visible floors are never promoted to emitter, and on any floor pillars are placed *after* emitters and exclude them, so a same-floor emitter/pillar overlap is structurally impossible. Only the floor-below emitter check is meaningful (ceiling emitter vs Top-floor pillar). Same-floor *altar* check kept (altars are real and pillars now exclude this-floor altar cells).
  • Removed the redundant excludePositions param from FloorGenerator.PlacePillars (and the duplicate RemoveAll): GenerateFloor was building a HashSet of emitter cells and passing it in, but PlacePillars already self-excludes floor.EmitterPositions/AltarPositions internally. Caller simplified to PlacePillars(floor). Behavior identical (compiles clean, 0 errors).

Follow-ups

  • Soak test as above.
  • Minor edge still unaddressed by design: capacity. If a floor has many pillars/altars/emitters competing for cells the exclusion sets can shrink candidate pools; current min/max ranges leave ample headroom on a 6×15 grid, but worth watching if grid/counts change.

---

2026-05-23 — Back-Wall Character Clipping (Transparent-Sort Fix)

Motivation

Players standing near the back / side walls of the play area visibly clipped into the wall: the wall drew on top of the character. Rotating the scene-view camera to a less-tilted angle showed the character is NOT actually intersecting the wall geometry — pure render-order bug.

Root cause

Same-queue transparent sort fight under a tilted iso camera.

  • BoundaryWalls.cs builds wall materials at RenderQueue.Transparent (3000) with _ZWrite=0 and _Cull=0.
  • JellyfishBody.shader (player body) also renders at Queue=Transparent (3000), ZWrite Off, ZTest LEqual.
  • URP sorts same-queue transparents back-to-front by camera-space distance to each renderer's bounds centre.
  • Walls are tall: _wallHeight = 4 * floorSpacing + chunkHeight (~21 units). Their bounds centre Y sits well *above* the player.
  • ScrollingFloorCamera is orthographic, elevation 30°, azimuth 45°. cameraForward has a strong negative-Y component (~−0.5).
  • Camera-space depth = dot(objectCenter − cameraPos, cameraForward). With −Y in cameraForward, a HIGHER world-Y maps to a SMALLER depth → reads as "closer to camera".
  • Result: the tall wall's centre sorts as closer than the player even when the wall plane is BEHIND the player in world Z. Wall draws *after* the player; neither writes depth; wall blends straight over player pixels.

Rotating the scene view to a less-tilted angle reduces the Y component of cameraForward → depth becomes Z-dominant → walls sort correctly behind player. That's why "from another angle" the bug disappears.

Changes

`BoundaryWalls.cs`

  • Wall material renderQueue lowered from Transparent (3000) to Transparent − 50 (2950).
  • Applied in ConfigureWallMaterial(...) so it covers both _wallMat and _frontWallMat, and all four wall-texture shader variants (Flat / Panels / Bands / Ribs).
  • Inline comment records the *why* so future maintainers don't "fix" it back to 3000.
  • Walls still render with transparent blending (alpha-0.1 sides, alpha-0.02 front wall), they just always draw before the player body in the transparent pass.

Considered & rejected

  • Make walls opaque + ZWrite On. Cleanest depth-wise, but loses the intentional alpha-0.1 "barely visible boundary" look.
  • SortingGroup on player VisualRoot with higher order. Only affects renderers parented under that group — tentacles (LineRenderers built at runtime as siblings of VisualRoot), blood emitters, future visuals would each need separate handling.
  • ZWrite On on the wall only. Doesn't help by itself: the player body has ZWrite Off, so the player never lays down depth, so the wall (drawn later by the bug-sort) still wins per-pixel.

Verification

  • Player confirmed in-editor: clipping is gone with the player against the back wall (Wall_PosZ).
  • No regressions reported on other walls (PosX, NegX, NegZ). All four walls share ConfigureWallMaterial, so they got the queue change atomically.
  • Transparent blending preserved — wall visibility unchanged.

Follow-ups

  • None outstanding. If we later add more queue-3000 transparent visuals near walls (e.g., new VFX), check sort order against walls — they're now at queue 2950 and will always be the back-most transparent layer.
  • Same gotcha would re-bite if a future wall shader hard-codes Queue = Transparent in its SubShader.Tags instead of letting BoundaryWalls.cs set renderQueue (which currently overrides any tag). The procedural wall shaders (BoundaryWallPanels/Bands/Ribs) should keep relying on the runtime queue assignment rather than baking 3000 into the shader.

Hey there! This week in Ritual & Ruin dev land, we've been diving into some cool updates that should make your game experience even more vibrant and smooth.

First off, I tackled a pesky issue with the Hazard Cube material. It was showing up as magenta due to an old Unity shader mismatch, but now it's sporting a new dedicated material in Assets/ProtoV2/Materials. This means the cube looks just how you'd expect it to, without any strange colors popping up unexpectedly.

Next, we've made some exciting progress on our Color Palette Workflow. We wrapped up Phase 4 and wired everything into MatchScene. Now, changing color palettes is a breeze—a quick dropdown selection takes less than 30 seconds! This will make customizing the game's look faster and more intuitive for everyone. I've set up a new system where you can easily preview and apply different color schemes with minimal fuss.

On the to-do list, we're planning to generate some additional palettes so you'll have even more options to choose from. Plus, if future updates show that certain elements need more attention for readability, like wall or gap-edge colors, we’ll make sure they’re up to par too.

Keep an eye out for these changes, and thanks for sticking with us on this colorful journey!

Raw session notes

2026-05-07 — Color Palette Phase 4 wiring + Hazard material fix

Hazard pink material — fix

Root cause: Hazard/Cube MeshRenderer was referencing Assets/Obi/Samples/Common/SampleResources/Materials/FinishLine.mat, an Obi sample asset serialized in Unity 2019.3 against the Built-in Standard shader. URP can't render Standard → magenta. Almost certainly re-imported by the Obi 7.1.1 upgrade on 2026-04-29 (the prior MaterialShaderFixer URP patch from 2026-03-28 didn't survive the package update). Project rule says don't modify Assets/Obi/, so the fix is a dedicated material under Assets/ProtoV2/Materials/.

Fix:

  • New material: Assets/ProtoV2/Materials/M_Hazard.mat — URP/Unlit, full transparent recipe (_Surface=1, _SrcBlend=5, _DstBlend=10, _ZWrite=0, _Cull=0, _SURFACE_TYPE_TRANSPARENT keyword, RenderType: Transparent, m_CustomRenderQueue=3000), tint _BaseColor (1, 0.18, 0.18, 0.35).
  • Assigned to Hazard/Cube MeshRenderer.sharedMaterial via Unity MCP.
  • Note: MCP manage_material set_material_shader_property reported success but didn't persist values to the .mat file — patched the YAML directly via Write to apply the transparent recipe in one shot.

Verification: Unity console clean (0 errors, 0 warnings); cube renderer's sharedMaterial confirmed = Assets/ProtoV2/Materials/M_Hazard.mat.

Color Palette Workflow Phase 4 — end-to-end

Phase 4 scripts shipped 2026-05-06; today wired them into MatchScene. Phase 4 is the bottleneck-killer for the [Color Composition Workflow](../../Games/Ritual%20&%20Ruin/Confirmed/design/Color%20Composition%20Workflow.md) — turns a 5-min-per-palette material wrangle into a ~30-second dropdown swap.

What changed

Scripts (Assets/ProtoV2/Scripts/):

  • ColorSystem/ColorPaletteSO.cs[CreateAssetMenu] SO with floor / wall / background + altarAccent / gapEdgeAccent + optional bloodTintAccent (toggle). paletteName + description for the dropdown.
  • ColorSystem/PaletteApplier.cs[ExecuteAlways] [DisallowMultipleComponent] MonoBehaviour with material-array slots per palette role. Pushes _BaseColor (URP) and _Color (legacy) on each wired material. Optional camera clear-color binding. OnValidate auto-applies via EditorApplication.delayCall.
  • Editor/ColorPalettePreviewWindow.csWindow → Ritual & Ruin → Color Palette Preview window: palette dropdown, swatch preview, Push-Live / Capture / Cycle-All. Captures land in PaletteCaptures/ at the project root (outside Assets/ so the AssetDatabase doesn't ingest them).

Data:

  • New folders: Assets/ProtoV2/Data/ and Assets/ProtoV2/Data/ColorPalettes/.
  • Assets/ProtoV2/Data/ColorPalettes/Default.asset — first ColorPaletteSO, baseline / clinical mood.

Scene (Assets/ProtoV2/Scenes/MatchScene.unity):

  • New GameObject: ColorPaletteRoot (root, world origin) with PaletteApplier component.
  • Wiring:
  • _activePaletteDefault.asset
  • _floorMaterials → [ChunkFloorTile.mat, ChunkFloorTile_Boundary.mat]
  • _backgroundClearCameraMain Camera
  • Scene saved.

Why these scope choices

  • Walls, gap edges, altar accent NOT wired: each one creates its material at runtime from [SerializeField] Color fields (BoundaryWalls.cs, GapHighlighter.cs, AltarFillVisual.cs) — no .mat asset exists to slot in. Making them palette-driven needs script changes (expose color setters on each, have PaletteApplier push palette colors into them). Deferred — Phase 5 readability validation will tell us which ones are worth the work.
  • Camera clear flags left as Skybox: switching to Solid Color is a visible scene change beyond pure wiring. The camera reference is wired so that flipping Clear Flags → Solid Color in the Inspector immediately makes backgroundColor palette-driven.
  • Materials chosen — ChunkFloorTile.mat and ChunkFloorTile_Boundary.mat: these are the two real .mat assets used by the chunk prefab and floor boundary, dedicated to the floor surface, no shared use elsewhere. Safe to recolor.

Verification

  • Unity console clean (0 errors, 0 warnings) after script add, scene save.
  • PaletteApplier component state verified via MCP resource: _activePalette, _floorMaterials array, _backgroundClearCamera all serialized correctly.

Files modified

  • New: Assets/ProtoV2/Scripts/ColorSystem/ColorPaletteSO.cs
  • New: Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs
  • New: Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs
  • New: Assets/ProtoV2/Materials/M_Hazard.mat
  • New: Assets/ProtoV2/Data/ColorPalettes/Default.asset
  • Modified: Assets/ProtoV2/Scenes/MatchScene.unity — added ColorPaletteRoot GameObject + reassigned Hazard/Cube material

Follow-ups

  • (User) Open Window → Ritual & Ruin → Color Palette Preview and confirm Default appears in the dropdown; click "Push palette live" and watch ChunkFloorTile.mat _BaseColor update in the Scene view.
  • (User) Optional: flip Main Camera Clear Flags to Solid Color when ready to test palette-driven backgrounds.
  • Phase 3 candidate generation — produce 6–10 named palettes as additional ColorPaletteSO assets under the same folder. Each becomes a dropdown entry automatically.
  • If Phase 5 validation shows wall / gap-edge color is load-bearing for readability, build the deferred runtime-color setter pattern on BoundaryWalls / GapHighlighter / AltarFillVisual.

Certainly! Below is a structured summary of the provided technical update and actions related to upgrading Obi Fluid 7.1.0 to 7.1.1, along with addressing issues and verification steps:

Upgrade Summary

What Changed: Obi Fluid Version: Upgraded from version 7.1.0 to 7.1.1 on April 22, 2026. Workarounds Removed: ObiLeakWorkaround.cs and its corresponding .meta file were deleted as the upgrade resolved a memory leak issue. Scene Modifications: In MatchScene.unity, removed the ObiLeakWorkaround MonoBehaviour reference from the ObiSolver GameObject’s component list.

Documentation Updates: Updated MEMORY.md to reflect the version change and deprecated previous leak-fix entries, redirecting them to new documentation. A pre-existing document (docs/OBI_UPGRADE_7_1_1.md) was confirmed accurate post-upgrade.

Reasons for Changes

The upgrade primarily addressed two key issues: Memory Leak: Version 7.1.1 fixed a memory leak in ObiFluidRendererFeature, which was the exact issue previously patched by ObiLeakWorkaround.cs. Collider World Issue: Fixed an unrelated problem with particle reactivity to colliders that did not affect the current implementation.

Verification Process

Static Code Analysis: Conducted a grep search for references to ObiLeakWorkaround and its GUID, confirming no residual code existed. Ensured all Obi types/methods in use were compatible with version 7.1.1.

Compilation Check: Verified zero errors and seven warnings during live compilation within the Unity Editor. The warnings pertained to deprecated methods used by Obi’s sample scripts, not user code.

Batch-mode Compile Attempt: Faced a failure due to project lock but relied on successful live editor compilation as verification of no introduced issues.

Files Modified

Deleted: ObiLeakWorkaround.cs and its .meta file. Modified: MatchScene.unity: Removed obsolete component reference related to the workaround. Documentation Updated: Added entry in MEMORY.md. Verified existing upgrade documentation (docs/OBI_UPGRADE_7_1_1.md).

Follow-Up Actions

Conduct a play session of MatchScene for at least 5 minutes to ensure that fluid rendering functions correctly and native memory usage remains stable. Consider potential performance improvements unlocked by the new version, such as utilizing solver boundary limits or optimizing blood attribution tracking.

This summary captures the essence of the technical changes, rationale, verification efforts, and next steps following the Obi Fluid upgrade.

Raw session notes

2026-04-27 — Lobby Mouse Cursor Dies on Gamepad Connect (Missing EventSystem)

Symptom

In LobbyScene (rebinder UI), the mouse cursor stopped dispatching click/hover events to the UI Toolkit panel the moment a gamepad was connected. Disconnecting the gamepad restored mouse dispatch. Reproducible regardless of whether any player was set to Controller mode. MainMenu and MatchScene were unaffected — mouse worked fine there even with multiple gamepads connected and characters being driven by them.

A workaround was already shipped at InputRebindUIController.cs:2870-2891 (gamepad Start button / Enter key advances scene without needing the mouse). The actual cause remained unidentified until this session.

Investigation that led nowhere

Three failed hypotheses, each ruled out via runtime diagnostics dumping InputUser.all state:

1. Player-InputUser scheme filter (Option E test) — commented out user.ActivateControlScheme("Gamepad") in PlayerSetup.PairWithGamepad and ReapplyDevicePairing. Mouse still broken on gamepad-connect. Diagnostic confirmed Player 1's user stayed on 'Keyboard&Mouse' scheme — never flipped to Gamepad — so scheme-based device filtering wasn't the cause.

2. Mouse paired to player blocking UI — diagnostic showed paired=[Keyboard,Xbox Controller] for Player 1; no Mouse in any user's paired-device list. ReleaseMouseToUI() was already working correctly. Mouse was globally free.

3. Legacy input fallback dying on gamepad-connect — checked ProjectSettings/ProjectSettings.asset; activeInputHandler: 1 (New Input System only, no legacy). No fallback to die.

Root cause

UnityEngine.EventSystems.EventSystem.current was null throughout the session — no EventSystem GameObject existed in LobbyScene.unity. With no explicit EventSystem present, UI Toolkit's runtime panel auto-creates an internal DefaultEventSystem (a different class from UnityEngine.EventSystems.EventSystem) to dispatch input. That DefaultEventSystem has a documented gamepad-takeover behavior: when a gamepad is connected, it switches to gamepad-navigation mode and suppresses pointer-event dispatch. So the cursor dies the moment a gamepad is detected, regardless of pairing.

Confirmed by grepping all scenes:

| Scene | EventSystem GO | Mouse + gamepad |

|---|---|---|

| MainMenu.unity | yes (line 134) | works |

| MatchScene.unity | yes (line 4484) | works (dev panel clickable while character is on a gamepad) |

| LobbyScene.unity | missing | broken |

| ExperimentBriefScene.unity | missing | not tested — assumed broken |

| ObjectiveBriefScene.unity | missing | not tested — assumed broken |

The earlier StripGamepadNavigationFromUIModule() in InputBindingPersistenceManager.cs:107-118 (which nulls out move/submit/cancel on the EventSystem's UI module after every scene load) had been a no-op in these three scenes — it short-circuits when EventSystem.current == null.

Fix

Added an EventSystem GameObject to each of the three scenes lacking one. Each has:

  • UnityEngine.EventSystems.EventSystem (defaults: sendNavigationEvents=true, dragThreshold=10)
  • UnityEngine.InputSystem.UI.InputSystemUIInputModule with actionsAsset wired to Assets/InputSystem_Actions.inputactions (GUID ca9f5fa95ffab41fb9a615ab714db018) — same wiring as MatchScene's existing EventSystem at MatchScene.unity:4490-4520.

Files modified (Unity scenes):

  • Assets/ProtoV2/Scenes/LobbyScene.unity — EventSystem added
  • Assets/ProtoV2/Scenes/ExperimentBriefScene.unity — EventSystem added
  • Assets/ProtoV2/Scenes/ObjectiveBriefScene.unity — EventSystem added

With an explicit EventSystem present, UI Toolkit stops creating its internal DefaultEventSystem and routes pointer events through ours instead — and the existing StripGamepadNavigationFromUIModule() now actually runs, nulling out gamepad-nav action references on the lobby UI module so the panel only responds to mouse.

Diagnostic instrumentation added during the investigation was reverted in the same change:

  • PlayerSetup.cs:477, 523 — restored user.ActivateControlScheme("Gamepad") calls
  • PlayerSetup.cs — deleted DumpInputState method
  • InputRebindUIController.cs:121 — deleted _diagNextDumpTime field
  • InputRebindUIController.cs Update — deleted throttled [INPUT_DIAG/Update] log block; kept the gamepad-Start / Enter-key fallback as a safety net (low cost, useful if mouse ever regresses)

Compile: 0 errors, 0 warnings.

Verification

  • Pre-fix runtime diagnostic confirmed EventSystem.current=null in LobbyScene while mouse was working (pre-gamepad), establishing UI Toolkit was using its DefaultEventSystem — and confirmed mouse.pos continued updating post-gamepad-connect (so the bug was at the dispatch layer, not the device layer).
  • Post-fix scene grep confirms all three target scenes now contain an EventSystem MonoBehaviour entry.
  • In-Editor playtest confirmed working ✅ — user verified mouse cursor responds to clicks in LobbyScene rebinder UI with one or more gamepads connected. Brief scenes (ExperimentBriefScene, ObjectiveBriefScene) not interactively tested but should follow the same path.

Follow-ups

  • TODO: Verify in Play Mode that mouse cursor responds to clicks in LobbyScene rebinder UI when one or more gamepads are connected. Then repeat for ExperimentBriefScene and ObjectiveBriefScene if either has interactive UI.
  • TODO (optional): Once verified, consider removing the gamepad-Start / Enter workaround in InputRebindUIController.Update() since the underlying bug is fixed. Or keep it as a deliberate redundancy — the cost is negligible and it covers any future scene that ships without an EventSystem.
  • Convention to enforce going forward: any new scene that hosts a UIDocument / UI Toolkit panel must include an EventSystem GameObject with an InputSystemUIInputModule. Without it, the scene will silently work without gamepads attached and break the moment any user connects one. Worth adding to the project setup wizard or a scene-validation editor script eventually.
  • No impact on MatchScene gameplay. Player input routing (gamepad-driven character control) is independent of UI dispatch and unchanged.

2026-04-29 — All technical docs migrated to PlayInstigator Docs

Decision

All documentation now lives in PlayInstigator Docs. The Unity project's docs/ folder has been removed. New rule: every doc — technical, design, content — goes to playinstigator_docs/Games/Ritual & Ruin/. There is no docs/ folder in the project repo anymore.

What moved

7 files from E:/Unity/Projects/PrototypeV2/docs/playinstigator_docs/Games/Ritual & Ruin/Confirmed/tech/ (renamed to Title Case to match existing tech folder convention):

| Old | New |

|---|---|

| docs/SYSTEMS.md | Confirmed/tech/Core Systems Reference.md |

| docs/SETTINGS.md | Confirmed/tech/Settings System Reference.md |

| docs/INPUT.md | Confirmed/tech/Input System Reference.md |

| docs/WORKFLOW.md | Confirmed/tech/Workflow and Conventions.md |

| docs/UI_PIXEL_PERFECT.md | Confirmed/tech/UI Pixel-Perfect Canvas.md |

| docs/OBI_UPGRADE_7_1_1.md | Confirmed/tech/Obi Fluid 7.1.1 Upgrade.md |

| docs/CONTROLLER_PAIRING_ANALYSIS.md | Confirmed/tech/Controller Pairing Analysis.md |

Cross-references updated

  • CLAUDE.md (project) — Detailed References table rewritten with new paths; "Where to create new documents" rule changed to require ALL docs in PlayInstigator Docs; inline rule mentions for Settings (rule 6), Workflow (rule 8), Pixel-Perfect (rule 9) all repointed.
  • Assets/ProtoV2/Scripts/UI/UIBuilder.cs:34 — header comment repointed.
  • Assets/ProtoV2/Scripts/MainMenuController.cs:62 — comment repointed.
  • MEMORY.md:12 — OBI_UPGRADE link repointed.
  • Confirmed/tech/UI Pixel-Perfect Canvas.md — sibling cross-ref to SYSTEMS.md normalized to sibling form.
  • Confirmed/tech/UI Prefab Structure.md — pre-existing stale ref to docs/UI_PIXEL_PERFECT.md fixed to sibling form.

Cleanup

  • E:/Unity/Projects/PrototypeV2/docs/ deleted.
  • No orphaned docs.meta (folder wasn't tracked as Unity asset).

Not touched

  • .claude/settings.local.json — auto-generated permission allowlist contains historical command paths; not load-bearing for behaviour.
  • .omc/plans/input-rebinder-uitk-restyle.md — archived planning artifact from a prior session; references the old doc paths but is not consulted by current workflows.

Verification

  • mcp__mcp-for-unity__refresh_unity with compile=request — clean, zero errors. Source-file changes were comment-only.
  • Grep for docs/SYSTEMS|docs/SETTINGS|docs/INPUT|docs/WORKFLOW|docs/UI_PIXEL_PERFECT|docs/OBI_UPGRADE|docs/CONTROLLER_PAIRING across project + memory: only the two non-load-bearing locations above remain.

Why this matters for future sessions

  • Searching for technical docs: look in playinstigator_docs/Games/Ritual & Ruin/Confirmed/tech/ first.
  • Creating a new technical doc: drop it in Confirmed/tech/ with Title Case naming. Don't create a docs/ folder in the project repo.
  • The CLAUDE.md "Where to create new documents" table now lists all sub-destinations under PlayInstigator Docs.

---

Obi Fluid 7.1.0 → 7.1.1 Upgrade

What changed

  • Asset: Virtual Method — Obi Fluid (Asset Store id 63067), 7.1.0 → 7.1.1 (released 2026-04-22). User imported via Package Manager.
  • Assets/ProtoV2/Scripts/BloodSystem/ObiLeakWorkaround.cs deleted (script + .meta).
  • MatchScene.unity: stripped the ObiLeakWorkaround MonoBehaviour (fileID 1820732523) and its entry in the ObiSolver GameObject's m_Component list (around line 8285). The ObiSolver GameObject (&1820732520) now has only Transform + ObiSolver components — no other state altered.
  • MEMORY.md (auto-memory index): added 2026-04-29 entry recording the upgrade and superseded leak-fix entry from 2026-03-29 with a pointer to the new state.
  • docs/OBI_UPGRADE_7_1_1.md (already authored 2026-04-28 in pre-upgrade analysis pass) — content remains accurate.

Why

Obi 7.1.1's official changelog (Assets/Obi/CHANGELOG_fluid.txt) reads:

Fix #1 is the exact bug ObiLeakWorkaround.cs patched around (originally documented in MEMORY.mdproject_obi_memory_fix.md, dev log 2026-03-29 D3D12 pool leak). Symptom matches exactly: many instances of Hidden/AccumulateTransmissionURP, Hidden/IndirectSurfaceURP, Hidden/IndirectThicknessURP materials accumulating over time, originating from ObiFluidRendererFeature. With the upstream fix the workaround is dead code — keeping it would just waste a Resources.UnloadUnusedAssets() GC pass every 30s for nothing.

Fix #2 (ObiColliderWorld re-instantiate-in-same-frame) doesn't observably affect us — the floor system pools chunks (FloorGenerator.GetChunkFromPool/ReturnChunkToPool) and Floor.DestroyFloor → next-floor GenerateFloor happen on different objects across frames, not "same prefab destroyed and re-instantiated in the same frame." Free safety net, no action needed.

Verification

  • Static: grep ObiLeakWorkaround Assets/ → 0 hits. grep 3e7b0f80e232b5a429de5e3e21fc2c3e Assets/ (script GUID) → 0 hits. grep "m_Script: {fileID: 0, guid: 00000000..." Assets/ProtoV2/ → 0 hits (no orphaned script refs).
  • API surface compatibility: every Obi type/method our code uses verified to exist in 7.1.1 with matching signatures —
  • ObiSolver.OnCollision event (Common/Solver/ObiSolver.cs:116)
  • ObiNativeContactList, solver.simplexCounts.GetSimplexStartAndSize(int, out int) (Common/DataStructures/SimplexCounts.cs:25)
  • solver.simplices, colors, positions, velocities, particleToActor (ObiSolver.cs:481)
  • Oni.Contact struct with bodyA, bodyB, distance (Oni.cs:234)
  • ObiColliderWorld.GetInstance(), colliderHandles[].owner (Common/Collisions/ObiColliderWorld.cs:98, 64)
  • ObiCollider.Thickness
  • ObiEmitter.speed, KillParticle(int) (Fluid/Actors/ObiEmitter.cs:445)
  • ObiActor.DeactivateParticle(int) virtual (Common/Actors/ObiActor.cs:677)
  • ObiSoftbody, ObiSoftbodySurfaceBlueprint, ObiParticleAttachment (used by JellyfishSoftCore.cs) — unchanged.
  • Compile: live Unity Editor (which auto-refreshed and recompiled after the asset import + the YAML/script changes) reports 0 errors, 7 warnings — all 7 warnings are in Assets/Obi/Samples/Common/SampleResources/Scripts/CharacterController/ObiCharacter.cs for Obi's own sample using the deprecated Rigidbody.velocity API. None originate in our code, none are caused by the upgrade, all predate it.
  • Batch-mode compile attempt failed early (return code 1 in log) because the editor was holding the project lock — but the live editor's clean compile is a stronger signal.
  • What was NOT verified in this session: a Play-mode session in MatchScene to confirm fluid renders, particles emit/pool/are consumed by altars, and native memory stays flat over 5–10 minutes (the original leak signature). The asset author identifies the same root-cause we patched, so the fix is expected to hold, but the user should run a play session to confirm.

Files touched

  • Assets/ProtoV2/Scripts/BloodSystem/ObiLeakWorkaround.cs — DELETED
  • Assets/ProtoV2/Scripts/BloodSystem/ObiLeakWorkaround.cs.meta — DELETED
  • Assets/ProtoV2/Scenes/MatchScene.unity — removed component reference (line ~8285) and MonoBehaviour block (was lines 8458–8470)
  • C:/Users/ReconUnPro/.claude/projects/E--Unity-Projects-PrototypeV2/memory/MEMORY.md — added 2026-04-29 entry; appended supersession note to 2026-03-29 entry
  • docs/OBI_UPGRADE_7_1_1.md — pre-existing analysis still accurate (no edits this session)

Follow-ups

  • Play MatchScene for ≥5 min and watch native memory / D3D12 pool usage. If it climbs, restore ObiLeakWorkaround from git history and post on the Obi forum with our solver config.
  • Optional improvements unlocked since 7.1 (already available, not pursued in this upgrade):
  • Solver boundaryLimits could replace fluid-side ObiCollider walls in BoundaryWalls.cs (PhysX walls still needed for the player Rigidbody).
  • diffusionMask parameter on ObiSolver could let BloodAttributionTracker move team-attribution into a per-particle user-data channel instead of a managed dictionary.
  • Confirm static ObiColliders on FloorObiSurface / BoundaryWalls are flagged as such so they benefit from "static colliders not processed during ObiSolver.Update()" perf change.

Summary of Changes and Fixes

Obi Fluid Finalizer-Thread GraphicsBuffer Crash (Patched)

Symptom: Standalone builds were crashing at random after extended play due to a graphics buffer being disposed on a non-main thread, specifically the GC finalizer thread. Root Cause: ObiNativeList.cs had an incorrect implementation of the IDisposable pattern. The destructor (~ObiNativeList()) called Dispose(false), leading to GPU resource disposal on a non-main thread, causing Unity to crash. Explicit disposals did not suppress finalization, allowing the finalizer to still execute and cause crashes.

Fix: Modified the Dispose(bool disposing) method to only dispose of GPU resources when called from the main thread (i.e., when disposing is true). Updated the public void Dispose() method to call GC.SuppressFinalize(this), preventing the finalizer from executing if the object was explicitly disposed.

Verification: Patched file backed up for future reference. Changes aligned with Microsoft’s recommended IDisposable pattern, ensuring unmanaged resources are correctly managed. A long-session playtest is needed to confirm the crash has been resolved.

Follow-ups: Conduct a 30-minute uninterrupted session to verify no further crashes occur. Monitor for updates from Obi via Package Manager and adjust patches accordingly if upstream fixes are made.

Typing Sound System Integration

Font Change: Switched font in terminal screens from VT323 to ShareTechMono for better runtime rendering, as VT323 was too thick due to its bitmap nature.

Box-Drawing Character Fix: Replaced unsupported box-drawing characters with equals signs (=) in scripts, as ShareTechMono does not include these glyphs.

Countdown Sequence Redesign: Adjusted opacity and text alignment for better readability. Rewrote the countdown sequence to accumulate lines like a terminal log.

Typing Sound System: Integrated typing sound effects using GameAudioData for easier management and consistency across audio clips. Implemented sound throttling and pitch randomization for realism.

Verification: Ensured zero compile errors and confirmed scene saves. Checked that GameAudioData.terminalTypingSound is visible in the Inspector for assignment.

Follow-ups: Assign a suitable clip to terminalTypingSound. Test ShareTechMono at runtime and adjust font size if necessary. Consider adding terminalTypingSound to an auto-link map for easier management.

Additional Notes

The fixes and enhancements aim to improve stability, usability, and aesthetic consistency without impacting gameplay or visuals. Future updates from Obi should be monitored to determine if patches remain necessary. Assignments in the Inspector and runtime testing are crucial for ensuring all changes perform as expected.

Raw session notes

2026-04-20 — ExperimentBriefScene (Premise & Flavor Text)

What Changed

New scene: Assets/ProtoV2/Scenes/ExperimentBriefScene.unity — inserted into Build Settings at index 1 (between MainMenu and LobbyScene).

New script: Assets/ProtoV2/Scripts/ExperimentBriefController.cs

  • Builds all UI from code: full-screen black canvas, VT323 green typewriter text, blinking cursor
  • Randomised per-session: 5-digit experiment number (10000–99999), saved to PlayerPrefs["RitualRuin_ExperimentNum"] for ObjectiveBriefScene to read
  • Text sequence (clinical experiment-observer voice):
  • RITUAL & RUIN — EXPERIMENT SYSTEM v4.2
  • INITIALIZING SESSION...
  • EXPERIMENT #[NNNNN] — INITIATED
  • Variant selection: JELLYFISH — ALPHA SPECIMEN
  • Condition Alpha/Beta subject listing: A1/A2/B1/B2 with registry IDs G4-[NNNNN]-A1 etc.
  • Altar/zone status, COMMENCING SUBJECT CONFIGURATION...
  • Any key skips to completed text; any key on [ BEGIN CONFIGURATION ] transitions to LobbyScene
  • Input via UnityEngine.InputSystem — keyboard anyKey + gamepad aButton/bButton/startButton/selectButton
  • Font size: 32

Post-creation tweaks:

  • FONT_SIZE 22 → 32 (text was too small at runtime)
  • Subject labels changed to A1/A2/B1/B2; registry IDs use experiment number as the center digit group (G4-{expNum}-A1) instead of separate random pair IDs
  • Experiment number changed to 5-digit range (10000–99999)

Modified: Assets/ProtoV2/Scripts/MainMenuController.cs:174

  • OnStartClicked now loads "ExperimentBriefScene" instead of "LobbyScene"

New editor tool: Assets/ProtoV2/Scripts/Editor/ExperimentBriefSceneSetup.cs

  • Menu: Ritual & Ruin/Setup ExperimentBriefScene — idempotent, creates Camera + Light + Controller GO, saves scene, inserts into build settings

Why

User requested a flavor-text interstitial between MainMenu and LobbyScene. The design vault establishes a clinical experiment-observer tone with green terminal UI. The scene surfaces the experiment premise (creatures as engineered ritual vessels, not heroes) through cold procedural log output rather than exposition.

Verification

  • Unity compiled ExperimentBriefController.cs with zero errors
  • Setup script ran: Camera, Directional Light, ExperimentBriefController GO created and saved
  • Build settings confirmed: MainMenu(0) → ExperimentBriefScene(1) → LobbyScene(2) → ObjectiveBriefScene(3) → MatchScene(4)
  • MainMenuController.OnStartClicked confirmed routing to "ExperimentBriefScene"

Follow-ups

  • Play-test the typewriter pacing (cps values per line are tunable in ExperimentBriefController.Lines)
  • Could add CRT scanline overlay (CRTOverlayController) if the terminal aesthetic needs more texture
  • Future: swap [JELLYFISH] for actual selected variant once creature selection is implemented

2026-04-20 — ObjectiveBriefScene (Objective Briefing)

What Changed

New scene: Assets/ProtoV2/Scenes/ObjectiveBriefScene.unity — inserted at build index 3 (between LobbyScene and MatchScene).

New script: Assets/ProtoV2/Scripts/ObjectiveBriefController.cs

  • Same terminal aesthetic as ExperimentBriefController (green VT323, typewriter, blinking cursor)
  • Reads experiment number from PlayerPrefs["RitualRuin_ExperimentNum"] to match ExperimentBriefScene
  • Text covers: primary directive (collect/deliver blood), cooperative protocol, evolution sequence, terminal phase warning
  • Ends with [ EXPERIMENT COMMENCING ] → any key loads MatchScene

Modified: Assets/ProtoV2/Scripts/ExperimentBriefController.cs

  • Saves experiment number to PlayerPrefs["RitualRuin_ExperimentNum"] on Awake so ObjectiveBriefScene displays the same ID

Modified: Assets/ProtoV2/Scripts/LobbySceneLoader.cs:42

  • LoadMatchScene() now loads "ObjectiveBriefScene" instead of "MatchScene"

New editor tool: Assets/ProtoV2/Scripts/Editor/ObjectiveBriefSceneSetup.cs

  • Menu: Ritual & Ruin/Setup ObjectiveBriefScene — idempotent

Objective Text (exact lines)

Why

User requested a second interstitial explaining game objectives in the same clinical terminal style as ExperimentBriefScene. Text sourced from Onboarding System, Progression System, and Ritual System design docs. Corrected to not imply a single ritual guarantees evolution (bar must fill completely).

Verification

  • Zero compile errors after all changes
  • Setup script ran: Camera, Light, ObjectiveBriefController GO created and saved
  • Build settings confirmed: MainMenu(0) → ExperimentBriefScene(1) → LobbyScene(2) → ObjectiveBriefScene(3) → MatchScene(4)
  • LobbySceneLoader.LoadMatchScene() confirmed routing to "ObjectiveBriefScene"

Full Scene Flow (updated)

MainMenu → ExperimentBriefScene → LobbyScene → ObjectiveBriefScene → MatchScene

---

2026-04-20 — Pink Chunks After Scroll + Camera Scroll Distance

What changed

Pink chunks after scroll (material lifetime leak)

  • FloorOpacityController.cs — replaced blanket per-instance material destroy in OnScrollCompleted with a diff-based sync. Root cause: controller called Destroy(c.mat) on every tracked chunk on every scroll, but FloorManager.ScrollSequence reuses 3 of 4 floors (old Top → EmitterOnly, old Middle → Top, old Bottom → Middle, new → Bottom). Surviving MeshRenderer.material references pointed to destroyed Material objects → Unity error shader → solid magenta chunks on the 3 reused floors.
  • Added ChunkData.go field for GameObject-based lookup.
  • Added _registeredChunkGOs (HashSet) + _chunkByGO (Dictionary) for O(1) dedup.
  • Rewrote OnScrollCompleted: builds keep set from FloorManager.CurrentTop/Middle/Bottom, iterates _chunks in reverse, destroys material + removes tracking ONLY for chunks whose floor left the keep set, then calls TryRegister for the 3 current floors (dedup skips survivors, only new Bottom adds entries).
  • Added OnChunkReturnedToPool(GameObject) handler for lifecycle-correct disposal (fires when Floor.SetGap / Floor.DestroyFloor returns chunks to pool).
  • Split subscription flags (_subscribed for FloorManager, _fgSubscribed for FloorGenerator) for independent retry in Start.
  • OnFloorGenerated now early-returns when FloorManager.Instance.IsScrolling is true — prevents double-registration; OnScrollCompleted picks up the new Bottom floor.
  • OnDestroy clears _registeredChunkGOs + _chunkByGO.
  • FloorGenerator.csReturnChunkToPool now fires OnChunkReturnedToPool?.Invoke(chunk) before SetActive(false) and nulls the returned chunk's MeshRenderer.sharedMaterial as a latent-leak guard.
  • New public event: public event System.Action OnChunkReturnedToPool;

Camera scrolled 2 floors per scroll

  • ScrollingFloorCamera.cs:198-203ScrollCoroutine was lerping to an absolute CalculateFloorYPosition(nextFloorIndex), but Inspector-placed camera Y did not match CalculateFloorYPosition(0) because FloorManager deliberately disables SnapToFloor(0) at init. Result: first scroll teleport-corrected the offset + descended one floor = looked like 2 floors.
  • Replaced with relative delta:
  • Orthographic isometric projection maps camera Y 1:1 to world Y, so spacing delta is exact. No alignment drift possible.

Why

  • Pink chunks were a visible regression on every scroll — rendered scroll unusable in playtest.
  • 2-floor scroll broke the expected 1-floor cadence the scrolling floor loop is built around (3 visible floors + 1 emitter = 4-floor wheel, scroll event advances by 1).

Verification

  • Unity MCP read_console clean after both fixes (only unrelated Player 2 input warnings).
  • Manual review of OnScrollCompleted diff logic against FloorManager.ScrollSequence role rotation — keep-set matches exactly the 3 surviving floors post-rotation.
  • Camera delta verified algebraically against FloorVerticalSpacing (default 5).

Follow-ups (for user playtest)

  • Trigger 5+ consecutive scrolls, confirm no pink chunks.
  • Memory Profiler: Material instance count should stay bounded (~3 floors × gridW × gridH + delta), not grow unboundedly.
  • Confirm column occlusion fade (occludedChunkOpacity=0.15) still works on surviving Top floor after scroll.
  • Confirm camera descends exactly FloorVerticalSpacing units per scroll.

Files touched

  • Assets/ProtoV2/Scripts/FloorSystem/FloorOpacityController.cs
  • Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs (ReturnChunkToPool + new event)
  • Assets/ProtoV2/Scripts/CameraSystem/ScrollingFloorCamera.cs:198-203

Plan artifact

  • .omc/plans/fix-pink-chunks-after-scroll.md (5-step plan, used by executor)

---

Session 2 — Follow-up fixes from playtest

Black chunks regression (from Session 1 fix)

The sharedMaterial = null guard I added in FloorGenerator.ReturnChunkToPool and the per-instance material destruction in FloorOpacityController.OnScrollCompleted + OnChunkReturnedToPool broke the pool recycle path. When a chunk came back out of the pool for a new floor, mr.material had nothing to clone (sharedMaterial null) or was pointing at a destroyed Material → black/pink chunks on recycled tiles.

Insight: the floor chunk pool is bounded (~4 floors × grid cells). Per-instance materials live permanently with their pool chunks — never destroy them, just update tracking.

  • FloorGenerator.cs:956-961 — removed the var mr = chunk.GetComponent(); if (mr != null) mr.sharedMaterial = null; guard. Pool chunks retain their per-instance material.
  • FloorOpacityController.cs:334 — removed if (c.mat != null) Destroy(c.mat); from OnScrollCompleted. Tracking removal preserved.
  • FloorOpacityController.cs:369 — removed if (data.mat != null) Destroy(data.mat); from OnChunkReturnedToPool. Tracking removal + floor-prune preserved.

Gap-punched chunks still had colliders (players couldn't fall through new holes)

FloorGenerator.AddFloorObiSurface bakes a floor-wide MeshCollider once at generation, using the floor's gap grid at that moment. When Floor.OpenGapsForMiddleRole later punches new gaps via SetGap (on scroll, bottom→middle transition), the visual chunks return to pool but the baked collider mesh still has quads at those cells. Players collided with invisible geometry instead of falling through.

  • FloorGenerator.cs — added public void RebuildObiSurfaceMesh(Floor floor) after AddFloorObiSurface. Locates FloorObiSurface child, destroys the old mesh, rebuilds via existing BuildSolidChunkMesh(floor), assigns to MeshCollider.sharedMesh.
  • Floor.csOpenGapsForMiddleRole calls FloorGenerator.Instance.RebuildObiSurfaceMesh(this); after the SetGap loop.

New-Top floor had no emitters after scroll

Emitters were only instantiated when GenerateFloor(..., emitterOnly: true) fired, which only runs for the initial above-view emitter floor. On scroll, old-Top got promoted to EmitterOnly role, but its Floor.emitters list was empty (it was generated as a playable floor, not emitter-only) → ActivateEmitters() iterated nothing → no blood.

Refactored to a fixed global pool, decoupling emitters from Floor lifetime.

  • NEW Assets/ProtoV2/Scripts/BloodSystem/BloodEmitterPool.cs — singleton MonoBehaviour, owns 3 persistent BloodEmitter instances under obiSolverParent (matches emittersPerFloorMax=3). ActivateOn(Floor) repositions N emitters to floor.EmitterPositions grid cells and activates them. DeactivateIfActive(Floor) / DeactivateAll() turn them off.
  • Floor.cs — removed emitters list field, RegisterEmitter, ActivateEmitters, DeactivateEmitters, Emitters property. SetRole switch now routes through BloodEmitterPool.Instance?.ActivateOn(this) / DeactivateIfActive(this). ActivateEmittersAfterDelay coroutine still preserves the pre-match isActivated && MatchRunning gate. DestroyFloor no longer destroys emitter GOs (pool emitters persist).
  • FloorGenerator.cs — stopped instantiating emitter prefabs in GenerateFloor. Added private helper PickCellPositions(Floor, int, int, HashSet) (pure cell picker, no instantiation). Every floor now gets EmitterPositions populated at generate-time (not just emitterOnly floors). Altar exclusion logic unchanged. Dead emitterPrefab + obiSolverParent fields left on FloorGenerator to avoid Inspector reference breakage — user will clean up later.
  • EmitterIndicatorController.cs — rewritten. Was drawing rings based on each floor's own (empty) EmitterPositions on Top/Middle/Bottom. Now draws rings on CurrentTopFloor at CurrentEmitterFloor.EmitterPositions (where blood actually lands). Subscribes only to OnScrollSequenceCompleted for rebuild.

Why Option C (pool) over Option A (lazy spawn) / Option B (always instantiate)

  • A would Destroy + Instantiate N emitters every scroll → GC pressure, expensive.
  • B would keep 4 floors × N emitters alive → memory waste on inert GOs.
  • C (chosen) keeps exactly 3 emitters alive for the whole match, repositioned on scroll. Zero per-scroll allocations. Emitters already lived under obiSolverParent (not under floors), so "reparent on migrate" wasn't even needed — just transform.position writes.

Manual Inspector step (not automated)

User must add a BloodEmitterPool GameObject to MatchScene and wire:

  • emitterPrefab → same BloodEmitter prefab currently on FloorGenerator
  • obiSolverParent → same Transform currently on FloorGenerator
  • poolSize=3, emitterYOffset=0 (defaults)

Verification

  • Unity read_console after each fix: 0 errors. Pre-existing unrelated warnings only (Player 2 input, obsolete FindObjectOfType in other files).
  • BloodEmitter.SetEmitterActive(bool) confirmed safe to call while repositioning via transform.position writes — emitter reads its own position via ObiEmitter each frame.
  • BloodEmitterIndicator confirmed reposition-safe (reads transform.position per frame, useWorldSpace=true).

Follow-ups for user playtest

  • Initial floor should have blood within ~0.5s of match start (existing activation delay).
  • After one scroll: new EmitterOnly (old-Top) should produce blood on new Top (old-Middle). Indicator rings should move to new Top at the new emitter floor's grid cells.
  • After 5+ scrolls: no chunk leaks, no pink/black chunks, blood still emits from correct floor.
  • Confirm gap-punched chunks on the new Middle floor (old-Bottom post-scroll) are walkable/fallable through — no invisible colliders.

Files touched (this session)

  • Assets/ProtoV2/Scripts/BloodSystem/BloodEmitterPool.cs (new)
  • Assets/ProtoV2/Scripts/FloorSystem/Floor.cs (emitter decoupling + RebuildObiSurfaceMesh call)
  • Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs (pool migration, PickCellPositions, RebuildObiSurfaceMesh, ReturnChunkToPool guard removal)
  • Assets/ProtoV2/Scripts/FloorSystem/FloorOpacityController.cs (material-destroy removal in OnScrollCompleted + OnChunkReturnedToPool)
  • Assets/ProtoV2/Scripts/FloorSystem/EmitterIndicatorController.cs (rebuilt to draw on Top using EmitterOnly's positions)

---

2026-04-20 — Transition Cleanup + Rebinder Dedup + AutoTilt

What changed

Pause/transition hardening

  • PauseController.csBuildUI() now scans Resources.FindObjectsOfTypeAll() for the one owning PauseMenuPanel (was FindObjectOfType() — non-deterministic in builds because DontDestroyOnLoad canvases coexist: SceneTransition, CRTOverlay, per-player BarCanvas). Added _preBypassTimeScale (captures pre-pause timescale, restored on Resume — fixes outlast scale clobber). Added null-guard in Pause() — refuses to freeze if _pauseMenuUI is null. Added PauseMenuPanelName const.
  • PauseInputHandler.cs — Escape / gamepad Start now toggles pause/resume (was pause-only, unresumable).
  • MainMenuController.csAwake() scopes canvas scan to active scene's ScreenSpace canvas (fixes Abort→MainMenu breakage when SceneTransition canvas was picked up). Whitelisted child wipe to MainPanel/SettingsPanel consts only — designer-authored children now survive scene load.
  • CanvasGroupFade.csOnDisable resets interactable/blocksRaycasts based on target alpha. Prevents stranded non-interactable panels after rapid fade toggles.
  • DevPanel.cs — Awake gate hides GO via #if !UNITY_EDITOR && !DEVELOPMENT_BUILD. FlushObiButton removed (GO + wiring); FlushObiRoutine kept (Restart still calls it).
  • MatchManager.cs — inline comment documents EndMatch/pause interaction (gameplay can't reach EndMatch while paused; inputs disabled + coroutines halted).
  • MultiplayerManager.csgameStarted idempotency guard on StartGame(). 4 simultaneous Start presses now fire once.

Dead UGUI rebinder deleted

Project had two parallel rebinder stacks — the UI Toolkit one (InputRebindUIController + InputRebindPanel.uxml/.uss, the real phosphor-green-on-black one in LobbyScene) and a legacy UGUI one that was never wired into any scene. Deleted the entire legacy stack:

  • Assets/ProtoV2/Scripts/UI/InputRebindMenuUI.cs + .meta
  • Assets/ProtoV2/Scripts/UI/PlayerRebindPanel.cs + .meta
  • Assets/ProtoV2/Scripts/UI/BindingRowUI.cs + .meta
  • Assets/ProtoV2/Scripts/UI/RebindUI.cs + .meta
  • Assets/ProtoV2/Scripts/UI/PlayerSettingsPanel.cs + .meta (per-player modal)
  • Assets/ProtoV2/Scripts/UI/PlayerPanelSettingsButton.cs + .meta (mouse click path)
  • Assets/ProtoV2/Scripts/PlayerMenuController.cs + .meta (toggle-into-menu-mode)
  • Assets/ProtoV2/Prefabs/UI/BindingRow.prefab + .meta
  • 4 editor wizards: PlayerSettingsPanelSetup, PlayerMenuSetupWizard, MenuHintWiringWizard, InputRebindMenuSetup, RebindUISetup, FixWaitingTextReferences
  • Scene cleanup: PlayerSettingsPanelCanvas GO removed from LobbyScene; MenuHintText removed from PlayerPrefab/BarCanvas; PlayerMenuController component removed from PlayerPrefab root; legacy EventSystem removed from LobbyScene; SuspendMovement method removed from MultiplayerCharacterInput.cs; serialized playerSettingsPanel field removed from JoinScreenUI.cs.

Kept alive: InputRebindUIController, WorldSpaceRebindUI (UIDocument wrapper), InputRebindManager (backend), InputRebindPanel.uxml/.uss.

AutoTilt toggle on UI Toolkit rebinder

  • Assets/ProtoV2/UI/InputRebindPanel.uxml — 4 nodes, one per player section, after preset-dropdown-{i+1}.
  • Assets/ProtoV2/UI/InputRebindPanel.uss — new .autotilt-toggle styles in phosphor-green palette. :disabled state uses #008F11 dim green for clear visual distinction from unchecked.
  • InputRebindUIController.cs — cached toggles + handlers. RefreshAutoTiltToggle(i) called from the existing RefreshPlayerBindingDisplay chain (auto-syncs on input-mode change / device change / rebind / reset). Keyboard players: value=true, SetEnabled(false) (cosmetic only — PourController already forces auto-tilt for keyboard). Controllers: interactive, reads/writes PlayerPrefs.AutoTilt_Player_{i} + calls PlayerSetup.SetAutoTiltEnabled(). Uses SetValueWithoutNotify to avoid spurious writes on initial load. Handlers unregistered on refresh + OnDestroy.

Why

  • Pause menu was completely broken in builds (froze game, no menu) due to canvas-lookup non-determinism. MainMenu broke the same way on Abort.
  • Outlast phase timescale was lost on pause/resume.
  • DevPanel's Restart/Scroll/FlushObi were shipping in release builds.
  • Two parallel rebinder stacks was pure tech debt — the UGUI one was never reachable and its PlayerSettingsPanel popup was introducing a shared-panel fan-out bug where P2 opening a panel that P1 had open caused both CloseMenu handlers to fire.
  • AutoTilt setting was accessible only through the erroneous popup; removing the popup stranded the setting. Now lives correctly inside the per-player rebind panel.

Verification

  • Architect review APPROVED after each phase.
  • Unity compile clean (0 errors after each task).
  • MCP play-mode sanity: MainMenu, LobbyScene, MatchScene all enter play cleanly. Only pre-existing "Cannot find matching control scheme" warnings remain (4 PlayerPrefabs auto-spawn with limited devices — unrelated).
  • MainPanel + SettingsPanel procedural build confirmed in MainMenu play mode.
  • PauseMenuPanel + pause Card + settings sub-panel Card confirmed in MatchScene play mode.
  • PlayerSettingsPanel, MenuHintText, FlushObiButton confirmed 0 hits (fully deleted).
  • InputRebindUIController confirmed 1 hit in LobbyScene (live, intact).

Follow-ups

  • Manual playtest required: rebind a key via the UI Toolkit panel; toggle AutoTilt on a controller player, exit lobby + re-enter, confirm persistence; run full pause-during-outlast → 30s wait → resume → confirm EndMatch fires with Time.timeScale=1.
  • Deferred work: multi-player gamepad navigation of the UI Toolkit rebinder. Current rebinder is mouse-only by design (DisableKeyboardAndGamepadNavigation at InputRebindUIController.cs:773-816). Proper multi-player nav needs Option B (per-player focus routing layer listening to per-player PlayerInput.user.actions) plus 9 design decisions — separate session.
  • PauseController side-note: quit-from-pause momentarily applies pre-pause timescale for 1 frame before scene load. Masked by SceneTransition fade. Not fixed.
  • Minor: DevPanel Awake gate missing explicit return; after SetActive(false) — harmless now, defensive to add later.
  • Minor: redundant PlayerPrefs write in AutoTilt controller branch (both InputRebindUIController and PlayerSetup.SetAutoTiltEnabled write the same key). Identical value, no race. Can consolidate later.

---

2026-04-21 — Countdown UI Phosphor Styling + Shadow Tuning

What Changed

Modified: Assets/ProtoV2/Scripts/MatchCountdown.cs

  • StartCountdown() now applies terminal aesthetic at runtime (no Inspector wiring needed):
  • countdownPanel RectTransform stretched to full screen (anchorMin=0,0 / anchorMax=1,1, offsets zeroed)
  • Panel Image set to Color(0,0,0,0.8) — 80% opaque black overlay
  • countdownText color set to TypographyLibrary.PhosphorGreen; TypographyLibrary.ApplyVT323() applies VT323 font + glow material
  • Auto-sizing enabled: fontSizeMin=48, fontSizeMax=160, word wrap off, overflow mode Overflow, centered
  • Countdown text: "EXPERIMENT INITIATING IN {i}" (unchanged); GO text: "EXPERIMENT INITIATED"

Modified: Assets/ProtoV2/Scenes/MatchScene.unity

  • Directional Light m_Strength: 10.35 — shadow is noticeably lighter/less oppressive

Modified: Assets/Settings/PC_RPAsset.asset

  • m_MainLightShadowmapResolution: 2048512 — lower resolution produces softer/blurrier shadow edges (complements existing soft shadow quality=3)

Why

  • Countdown panel needed to match the green phosphor terminal aesthetic established by ExperimentBriefScene and ObjectiveBriefScene — previously used default white UI.
  • Ground shadow at full strength (1.0) and 2048px resolution was too sharp and visually heavy for the top-down arena; 0.35 strength + 512px resolution gives a softer, more ambient-feeling shadow.

Verification

  • MatchCountdown.cs compiled with zero errors
  • Scene YAML edits confirmed: strength 0.35 present in MatchScene.unity Directional Light node, resolution 512 present in PC_RPAsset.asset

Follow-ups

  • Play-test shadow strength — 0.35 is a starting point; tune up/down in Inspector if needed
  • Shadow resolution can be raised back toward 1024 if softness looks too blurry at runtime
  • Countdown panel font size range (48–160) may need tuning based on text length at different resolutions

---

2026-04-22 — Bug Fix Sweep (Full Codebase)

What Changed

Comprehensive bug fix sweep across 21 files based on deep architectural analysis. Organized into 3 parallel waves.

Wave 1 — Critical

MatchManager.cs:132-134

Added Time.timeScale = 1f to OnDestroy(). Prevents outlast-phase elevated timescale from bleeding into a rematch.

DeathHandler.cs:49

Replaced transformController.Toggle() with transformController.DisableCarrierForm() on player death. Toggle was firing animations, OnToggle events, and CancelPour — all unwanted when dying. DisableCarrierForm is the correct targeted method.

Fixed duplicate // 5. comment numbering → // 6.

UnifiedBar.cs

  • CreateWhiteSprite() now uses a static Sprite _whiteSprite cache. Was allocating a new Texture2D + Sprite per player per scene load (memory leak).
  • Camera.main null-check added with FindAnyObjectByType() fallback.
  • isDecayActive changed from public to [SerializeField] private _isDecayActive — SetDecayActive setter is the public API.
  • Color thresholds 0.3f / 0.65f promoted to [SerializeField] private float _criticalThreshold / _warningThreshold.

JellyfishVisuals.cs

  • Landing detection: added _wasDescending = false disarm in the fly-pulse path so spurious landing pulses can't fire after a mid-fall impulse.
  • Removed dead waveFrequency, waveAmplitude, wavePhaseStep fields + pragma disable.
  • Gated landing Debug.Log behind #if UNITY_EDITOR.
  • Replaced Shader.Find primary path with [SerializeField] private Shader _unlitShader + fallback. Inspector action required: assign Universal Render Pipeline/Unlit to the new field on PlayerPrefab's JellyfishVisuals component.

PauseController.cs

  • Added if (IsPaused) return; guard at top of Pause(). Double-call (controller disconnect + Esc) was capturing timeScale=0 as pre-pause value, making Resume freeze the game.
  • Replaced Resources.FindObjectsOfTypeAll() with Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).

AudioManager.cs

  • Moved singleton subscriptions from Start() into a LateStart() coroutine that yields one frame first. Prevents race condition where AudioManager subscribed before other managers' Awake ran, silently missing events.

Wave 2 — High

MatchCountdown.cs

  • Added BloodAttributionTracker.ClearAll() at start of StartCountdown() — clears stale particle owner data from previous matches.
  • Changed Mathf.RoundToInt(countdownDuration)Mathf.FloorToInt so 3.6s shows 3-2-1 not 4-3-2-1.
  • AudioSource mixer fix already present (uses _audioData.uiGroup). No change needed.

ExperimentBriefController.cs / ObjectiveBriefController.cs

  • Mixer fix already present in both. No changes needed in Wave 2 (refactored in Wave 3).

PourController.cs

  • visualRoot renamed to [SerializeField] private _visualRoot (all 15 internal usages updated).
  • Altar list cached in Start() via RefreshAltarCache(), refreshed on FloorManager.OnFloorGenerated. Eliminates per-frame FindObjectsByType() × 4 players.
  • GetNearestHole() now early-returns if !_transformController.IsCarrierMode — skips grid scan when irrelevant.
  • Removed dead IsTransitioning branch (always-false).

PourTargetIndicator.cs

  • Glow GO (Light + sphere) now created once in Awake(), shown/hidden via SetActive. Was creating/destroying on every target change.
  • [SerializeField] private Shader _unlitShader replaces Shader.Find as primary path. Inspector action required.

PourSetupWizard.cs (Editor)

  • Updated reflection lookup from "visualRoot" + Public to "_visualRoot" + NonPublic to match renamed field.

FloorManager.cs

  • Added public event Action OnFloorDestroyed — fired before floor is destroyed in ScrollSequence().

ProgressionManager.cs

  • Subscribes to FloorManager.OnFloorDestroyed. Handler calls UnsubscribeFromAltar() for each altar on the destroyed floor, fixing altar event handler closure leaks.

Wave 3 — Medium + Cleanup

AltarParticleConsumer.cs

  • Added public float RitualCountdownTime => ritualCountdownTime; getter.

AltarCountdownVisual.cs

  • _totalTime now reads from _consumer.RitualCountdownTime instead of hard-coded 3f. Pulse ramp stays in sync with Inspector value.

CharacterController1.cs

  • PhysicsMaterial changed to private static _sharedPhysicsMaterial. Created once across all players, not per-player per scene load.

VerticalHazard.cs

  • Removed two SendMessage("SetInvulnerable", ...) calls — no component implements the receiver. Was a silent no-op every frame.

HoleAlignmentHelper.cs

  • Removed dead minUpwardSpeed field + surrounding pragma disable/restore.

MultiplayerManager.cs

  • All 13 plain Debug.Log calls wrapped in #if UNITY_EDITOR || DEVELOPMENT_BUILD.

MainMenuController.cs

  • Resources.FindObjectsOfTypeAll()Object.FindObjectsByType(...).
  • Process.GetCurrentProcess().Kill()Application.Quit() (editor path kept as EditorApplication.isPlaying = false).

BriefingScreenBase.cs (NEW)

Assets/ProtoV2/Scripts/UI/BriefingScreenBase.cs — abstract MonoBehaviour base for briefing screens. Contains all shared logic: BuildUI, TypeRoutine, CompleteTyping, Redraw, RevealPrompt, UnlockAfter, PlayTypingSound, MakeStretch, MakePivot. Abstract: Lines, NextSceneName, PromptText. Virtual: PromptWidth, OnAwakeInit().

ExperimentBriefController.cs — reduced from 310 → 54 lines, now inherits BriefingScreenBase.

ObjectiveBriefController.cs — reduced from 297 → 50 lines, now inherits BriefingScreenBase.

Why

Deep architectural analysis identified 30+ issues across all severity levels. All gameplay behaviour preserved — repair only, no new features.

Verification Performed

  • All agents read files back after editing to confirm diffs
  • Wave 2-F and Wave 2-E confirmed several audio fixes were already in place (no duplicate work)
  • PourSetupWizard reflection updated to match renamed private field (grep confirmed no other external references)

Follow-ups / Inspector Actions Required

1. JellyfishVisuals on PlayerPrefab: Assign Universal Render Pipeline/Unlit shader to the new _unlitShader field in the Inspector

2. PourTargetIndicator on PlayerPrefab: Assign Universal Render Pipeline/Unlit shader to the new _unlitShader field

3. UnifiedBar thresholds: _criticalThreshold (0.3) and _warningThreshold (0.65) now appear in Inspector — verify defaults are correct

4. Full play-through test: Rematch flow (timeScale), Carrier-mode death (no Toggle side effects), pause double-call, pour target performance

---

2026-04-22 — Floor System: Emitter Pool Wiring + Warning Cleanup

What changed

BloodEmitterPool wired into MatchScene

Previous session (2026-04-20) created BloodEmitterPool.cs but the GO wasn't in the scene yet → BloodEmitterPool.Instance was null → no blood emission after scroll.

  • Via Unity MCP: created BloodEmitterPool GameObject in MatchScene, attached component, wired:
  • emitterPrefab → BloodEmitter prefab (guid 7fd060167c5b98a4188e3843d8383646, same asset previously on FloorGenerator)
  • obiSolverParent → ObiSolver transform (instance 60412)
  • poolSize = 3 (default), emitterYOffset = 0 (default)
  • Scene saved.

EmitterIndicatorController disabled

Red ring floor-tile debug overlay (EmitterIndicatorController GO) disabled in MatchScene — debug-only, not needed at runtime. BloodEmitterIndicator burst-countdown circles on pool emitters remain active.

Compiler warnings cleared

All 18 warnings confirmed resolved (stale console from prior session). After forced recompile: 0 warnings, 0 errors. Fixes had been applied in the 2026-04-20 session:

  • FindObjectOfTypeFindFirstObjectByType / FindObjectsByType(..., FindObjectsSortMode.None) across JellyfishSoftCore, InputBindingPersistenceManager, DevPanel, AudioMixerSetupWizard.
  • enableWordWrappingtextWrappingMode in MatchCountdown + AlphaSetupWizard.
  • PreventDefault()StopPropagation() in InputRebindUIController (×4).
  • Unused [SerializeField] fields in HoleAlignmentHelper + JellyfishVisuals suppressed with #pragma warning disable CS0414.

Why

  • Pool GO missing = no blood, which broke the entire emitter pool refactor from the previous session.
  • Debug ring overlay cluttered the game view with no gameplay value.
  • Zero-warning build ensures no silent regressions from deprecated API drift.

Verification

  • Unity console: 0 errors, 0 warnings after forced recompile.
  • BloodEmitterPool.Instance non-null confirmed via MCP scene inspection.

Follow-ups for user playtest

  • Enter Play → confirm blood emits on initial floor within ~0.5s of match start.
  • Trigger scroll → confirm new EmitterOnly floor (old Top) emits blood onto new Top (old Middle). No double-spawn, no stale emitters.
  • Confirm gap-punched holes on Middle floor are physically passable (floor collider rebuild fix from 2026-04-20 Session 2).

Files / scene touched

  • Assets/ProtoV2/Scenes/MatchScene.unity — BloodEmitterPool GO added + wired; EmitterIndicatorController GO set inactive
  • No script files modified this session (all script changes were 2026-04-20)

---

2026-04-22 — Terminal UI Polish + Typing Sound + GameAudioData Integration

What Changed

Font

Modified: ExperimentBriefController.cs, ObjectiveBriefController.cs, MatchCountdown.cs

  • All three terminal screens switched from VT323 → ShareTechMono (ApplyVT323ApplyShareTechMono)
  • VT323 rendered too thick at runtime due to its bitmap nature; ShareTechMono is a proper monospace sans-serif with thinner strokes

Box-Drawing Character Fix

Modified: ExperimentBriefController.cs, ObjectiveBriefController.cs

  • ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (U+2501) → ========================================
  • ShareTechMono does not include box-drawing glyphs; Unity was substituting at runtime

Countdown Sequence Redesign

Modified: MatchCountdown.cs

  • Panel opacity: 0.8 → 0.9
  • Text alignment: Center → TopLeft
  • Complete sequence rewrite — accumulating log style (all lines stay on screen):
  • Each line types in via AppendTyped() coroutine (80 CPS); numbers hold ~1s cadence; final line shows world timestamp (CYCLE IV = matches experiment system v4.2, T{dayOfYear} = clinical day reference)
  • OnCountdownBeat still fires per number for external feedback hooks

Typing Sound System

Modified: GameAudioData.cs — added public AudioClip terminalTypingSound under UI header

Modified: ExperimentBriefController.cs, ObjectiveBriefController.cs, MatchCountdown.cs

  • All three: replaced bare [SerializeField] AudioClip typingSound with [SerializeField] GameAudioData _audioData
  • AudioSource created at runtime; clip = _audioData.terminalTypingSound, mixer group = _audioData.uiGroup
  • Throttled to max 20 clicks/sec (Time.time gate of 0.05s) with ±6% pitch randomization per character
  • Recommended clip: iface_tick_001.ogg or iface_tick_002.ogg (Kenney Interface Sounds, CC0, already in project at SFX/Options/AltarTick/)

Wired via MCP:

  • ExperimentBriefSceneExperimentBriefController GO: _audioData = GameAudioData.asset
  • ObjectiveBriefSceneObjectiveBriefController GO: _audioData = GameAudioData.asset
  • MatchSceneMatchCountdown GO: _audioData = GameAudioData.asset

Pending: assign terminalTypingSound clip in GameAudioData.asset Inspector

CLAUDE.md Rule Update

Modified: CLAUDE.md rule #6

  • Now mandates GameAudioData for ALL audio — no bare AudioClip fields, no Resources.Load
  • Explicit pattern: scripts take [SerializeField] GameAudioData _audioData, read _audioData.clipName + _audioData.sfxGroup / _audioData.uiGroup

Why

  • VT323 was too visually heavy; ShareTechMono matches the clinical precision of the experiment-observer aesthetic better
  • Countdown needed to read as a live terminal log rather than a flashing number — accumulating lines match the ExperimentBriefScene style
  • Typing sound adds tactile presence to the typewriter effect; routed through GameAudioData so clip is swappable from one place
  • Rule #6 tightened so future audio won't bypass the volume sliders

Verification

  • Zero compile errors (confirmed via MCP read_console)
  • All three scene saves confirmed via MCP
  • GameAudioData.terminalTypingSound field visible in Inspector (assign clip to activate sound)

Follow-ups

  • Assign iface_tick_001.ogg to terminalTypingSound on GameAudioData.asset
  • Audition ShareTechMono at runtime — tune font size if needed (FONT_SIZE = 32f in brief controllers, fontSizeMin=48 / fontSizeMax=160 in countdown)
  • Consider adding terminalTypingSound to GameAudioDataWizard auto-link map (field name → filename hint)

---

2026-04-23 — Obi Fluid Finalizer-Thread GraphicsBuffer Crash (Patched)

Symptom

Standalone builds crashed at random after extended play. Last two crashes: 2026-04-19 ~22:56 and ~23:39. Crash handler reports at %LOCALAPPDATA%\Temp\DefaultCompany\PrototypeV2\Crashes\Crash_*/; stack trace tail:

Root cause

Assets/Obi/Scripts/Common/DataStructures/NativeList/ObiNativeList.cs ships a broken IDisposable pattern:

1. ~ObiNativeList() calls Dispose(false), which unconditionally invokes DisposeOfComputeBuffer()m_ComputeBuffer.Dispose(). Unity requires GPU resources to be disposed on the main thread; the GC finalizer thread is not the main thread, so Unity hard-crashes in GraphicsBuffer::DestroyBuffer_Injected.

2. public void Dispose() does not call GC.SuppressFinalize(this), so the finalizer still fires even for explicitly-disposed lists.

Ad-hoc new ObiNativeList(...) allocations exist in multiple places (e.g. DynamicRenderBatch.cs:320, ProceduralRenderBatch.cs:152, rendering systems for the Compute fluid backend). When any of these are GC'd without explicit disposal, the finalizer fires off-thread and crashes.

The March 2026 D3D11 mitigation (project_obi_memory_fix.md) addressed a separate GPU memory leak, not this crash. The memory note's claim that the finalizer was "mitigated by D3D11, not blocking" turned out to be wrong.

Previous NullReferenceException spam in FloorImpactAudio.HandleBloodSpilled and SceneTransition.Update (seen in Player-prev.log) wasn't the crash cause but made it more frequent — each caught exception added GC pressure, shortening the window before the orphaned ObiNativeList was collected. Both NullRefs were independently fixed in the earlier bug-fix sweeps (2026-04-22_BugFixSweep.md).

Fix

Patched Assets/Obi/Scripts/Common/DataStructures/NativeList/ObiNativeList.cs to the standard textbook IDisposable pattern:

  • Dispose(bool disposing) now gates DisposeOfComputeBuffer() behind the disposing flag, so only explicit main-thread callers ever touch the GraphicsBuffer. Unmanaged memory (UnsafeUtility.Free) is still freed on both paths.
  • public void Dispose() now calls GC.SuppressFinalize(this).

Net effect: the finalizer never touches GPU resources. Worst case is a small leaked GraphicsBuffer per orphaned list until the next scene load — vastly preferable to a hard crash.

Patch bookkeeping

  • Original file backed up at patches/ObiNativeList.cs.original
  • Revert instructions + re-apply notes documented at patches/README.md
  • Two lines changed in Dispose(bool), one line added in Dispose() (see patch comment in file at line 130)

Verification

  • Diff against backup confirms only the intended two changes.
  • Patch follows the Microsoft-documented IDisposable pattern (Dispose(bool) with GC.SuppressFinalize); same correction is applied across countless native-interop wrappers in the wild.
  • Build/play verification still pending — needs a long-session playtest to confirm the crash is gone. Earlier crash reproduced roughly every 10–20 minutes under active play; a 30-minute uninterrupted session with floor-scroll + combat should cover it.

Follow-ups

  • TODO: Run a ≥30-minute session build to confirm the crash is eliminated. Capture full Player.log to verify no finalizer-stack errors remain.
  • TODO: If Obi is ever updated via Package Manager, diff the new ObiNativeList.cs against patches/ObiNativeList.cs.original — if upstream fixed it, remove the patch; otherwise re-apply.
  • No impact on gameplay/visuals expected. This is a lifecycle correctness fix only.

Certainly! Let's break down the two sessions into structured summaries with key points and implications.

Session 1: Animation and UI Refinement

Goals: Ensure consistent stroke thickness across corner marks in all pivots. Provide visible hover feedback on buttons without relying on bloom effects. Remove redundant brackets from button labels, as corner marks provide visual cues. Eliminate the subtitle "SURVIVAL IS MEASURED, NOT CELEBRATED" from the main menu. Document a pixel-perfect rule to prevent future UI rendering issues.

Key Actions and Changes: Corner Marks Consistency: Adjusted CornerMark_* sprites to maintain uniform stroke thickness across all pivots by modifying their source dimensions and downscaling properties.

Hover Feedback: Implemented hover variants for corner marks with extended arms. Altered button background color (BgHover) for clearer visibility during hover states.

UI Builder Enhancements: Integrated EnsurePixelPerfectCanvas() to automatically enforce pixel-perfect rendering on any canvas using UIBuilder components, ensuring consistent UI appearance regardless of CanvasScaler settings.

Content Adjustments: Removed [ ] brackets from button labels. Deleted the main menu subtitle as per user decision.

Documentation: Added documentation for the pixel-perfect rule in CLAUDE.md, docs/UI_PIXEL_PERFECT.md, and UIBuilder's class-header comment to prevent future UI issues.

Validation: Confirmed through compile checks, visual inspections at 1920×1080 resolution, and grep searches that changes were correctly implemented. Follow-ups include testing hover interactions with a real mouse cursor and updating documentation examples.

Importance: Ensures consistent UI rendering across different display scales and prevents future subpixel-rounding issues by enforcing the pixel-perfect rule through UIBuilder.

Session 2: Menu Polish

Goals: Achieve uniform stroke thickness for corner marks. Ensure hover feedback is visible without bloom effects. Remove brackets from button labels, as they are redundant with corner marks. Eliminate a specific subtitle from the main menu. Document the pixel-perfect rule to prevent future UI rendering issues.

Key Actions and Changes: Sprite Adjustments: Increased STROKE dimension in CornerMarkSpriteBuilder.cs for consistent rendering across pivots. Added hover variants with extended arms to enhance visual feedback.

UI Builder Enhancements: Implemented EnsurePixelPerfectCanvas() in UIBuilder methods to enforce pixel-perfect rendering automatically.

Button Behavior Modifications: Updated button color and sprite swapping logic in ClinicalButton.cs to improve hover visibility. Removed scale tween on corner marks to avoid subpixel distortion.

Content Adjustments: Stripped brackets from button labels in various controllers and builders. Removed the main menu subtitle as per user decision.

Documentation: Documented the pixel-perfect rule in multiple locations for easy reference and enforcement.

Validation: Verified through compile checks, visual inspections, and grep searches to ensure changes were implemented correctly. Follow-ups include testing hover interactions with a real mouse cursor and updating documentation examples.

Importance: Ensures consistent UI rendering across different display scales and prevents future subpixel-rounding issues by enforcing the pixel-perfect rule through UIBuilder.

These sessions highlight the importance of meticulous attention to detail in UI design, ensuring consistency and preventing recurring issues. The implementation of automated checks and comprehensive documentation serves as a safeguard against potential regressions.

Raw session notes

2026-04-17 — Prefab Cleanup, Spawn Fix, Ground-Anchor Restructure

Three related problems tackled in one session: non-random spawn on first scene load, continuous character bouncing in LobbyScene, and a messy PlayerPrefab with dead tier placeholders and a mis-purposed ground collider.

1. Spawn randomization not applied on first MatchScene load

Symptom: Players spawned in deterministic circle positions on first load from LobbyScene. Randomization worked correctly only on scene reload (rematch).

Root cause: Two interacting timing issues.

1. MultiplayerManager.Awake() configured PlayerInputManager with JoinPlayersWhenButtonIsPressed and subscribed onPlayerJoined. Joining wasn't disabled until Start(). On first load from LobbyScene, the "Start Game" button press was still in Unity's input buffer and got processed as a rogue join between Awake() and Start(), creating a player at a deterministic GetSpawnPosition() — which then pre-empted SpawnAllPlayers() via the duplicate-index check.

2. FloorManager.LateUpdate() called SpawnManager.SpawnPlayers() on frame 1. InputBindingPersistenceManager.ApplyBindingsNextFrame() ran on frame 2 and could trigger re-pairing callbacks that left players mispositioned.

On rematch the button press was consumed in the previous scene, so no rogue joins.

Fix

  • MultiplayerManager.Awake(): Added playerInputManager.DisableJoining() immediately after SetupPlayerInputManager() when autoSpawnAllPlayers=true. Closes the Awake→Start race window.
  • FloorManager.cs: Replaced _pendingSpawn + LateUpdate with a DeferredSpawn() coroutine that waits two frames before calling SpawnManager.SpawnPlayers(). Ensures randomization runs after InputBindingPersistenceManager finishes.
  • SpawnManager.cs: Added diagnostic logging for early-return paths and the randomization call (including Time.frameCount).

2. Character bouncing in LobbyScene

Symptom: Character landed and immediately bounced up/down rapidly, indefinitely in lobby.

Root cause — layered diagnosis:

| Layer | Finding |

|---|---|

| Surface | isGrounded was flickering between true and false. |

| Deeper | Ground-check ray started at pivot+0.1 and reached pivot-0.1 — only 0.2 units. |

| Deeper still | The actual ground-contact collider (BodyVisual cone) extended far below the rigidbody pivot, so the raycast never reached the floor in lobby scale. |

| Root cause | BodyVisual was a convex cone with a pointy tip. Single-contact-point physics on a convex hull is inherently unstable — PhysX penetration resolution kept popping the body up, the short ground check lost contact, gravity pulled it back down, repeat. |

Design reconciliation

Discussed with designer: the jellyfish is supposed to *hover* above the ground.

  • Cap/Bowl — top dome (transformable)
  • Trailing body (cone) — main body mass hanging below the cap
  • Tentacles — decorative strands

The whole jellyfish (cap + trailing body) should hover. A separate invisible anchor at pivot Y=0 should be what actually touches the ground.

The current prefab had BodyVisual (trailing body) at Y=0 doing double duty as the ground anchor — that's why its pointy tip was the ground contact, and that's why bouncing happened.

Fix — prefab restructure

Before:

After:

Concrete changes to PlayerPrefab.prefab:

1. Added PlayerAnchor layer at slot 6 in ProjectSettings/TagManager.asset

2. Updated collision matrix in ProjectSettings/DynamicsManager.asset — PlayerAnchor (bit 6) no longer collides with itself. Layer 6's mask = bfffffff (little-endian of 0xFFFFFFBF)

3. Added SphereCollider (r=0.1, center 0,0,0) directly on the root GameObject — the new invisible ground anchor

4. Set root m_Layer: 6 (PlayerAnchor)

5. Renamed BodyVisual → TrailingBody

6. Reparented TrailingBody from root to VisualRoot (inherits pour tilt, form flip, pulse squish, hover bob)

7. Moved TrailingBody to Y=0.8 so it hovers below the cap

8. Deleted VisualBase / VisualEnhanced / VisualTerminal (empty tier placeholders — evolution tier visuals never implemented, handled by color tint in JellyfishVisuals instead)

9. Flipped default active state: Cap IsActive=1, Bowl IsActive=0 (Mobility is default; Carrier engaged by transform input)

Fix — script updates

  • SimpleCharacterController1.cs:
  • [RequireComponent(typeof(MeshCollider))][RequireComponent(typeof(Collider))]
  • private MeshCollider meshColliderprivate Collider mainCollider
  • GetComponent()GetComponent()
  • Removed the _colliderBottomOffset calc — pivot IS the ground anchor now, no offset needed
  • Simplified CheckGroundStatus — cast from transform.position + up*0.15 down by 0.25
  • Kept coyote time (0.15s) and upward-Y clamp when grounded as belt-and-suspenders stability
  • JellyfishVisuals.cs: Removed the explicit bodyVisual.localPosition = up * _currentHoverOffset — TrailingBody is now a child of VisualRoot, which already bobs, so the manual override would double-bob.
  • UnifiedBar.cs: Deleted visualBase/Enhanced/Terminal SerializeFields and the SwapEvolutionVisual() method. Evolution tier is now purely a color-tint effect (no mesh swap).

3. Tooling: OMC plugin update + duplicate hook fix

Symptom: PreToolUse hook from the oh-my-claudecode plugin was injecting "The boulder never stops. Continue until all tasks complete." on every single tool call — Read, Grep, Edit, Bash. Got flagged as a prompt-injection lookalike.

Diagnosis: Plugin was on version 3.9.6. The pre-tool-enforcer.mjs script's toolName lookup table (which should map Read → "Read multiple files in parallel…", etc.) was hitting the fallback branch for every tool, suggesting the Claude Code hook payload schema had changed and 3.9.6 was parsing the new payload wrong.

Fix: Updated OMC plugin 3.9.6 → 4.12.0 via /plugin + /reload-plugins. Confirmed the lookup now resolves correctly — each tool gets its intended coaching reminder instead of the fallback. No local patch needed.

Unrelated: everything-claude-code duplicate hook

/doctor flagged the everything-claude-code plugin registering hooks/hooks.json twice. Root cause: the plugin's .claude-plugin/plugin.json explicitly referenced "hooks": "./hooks/hooks.json", but Claude Code auto-loads that path already — so it loaded twice.

Fix: Removed the hooks entry from the plugin's plugin.json. Reverts on plugin update; upstream fix belongs on their repo.

Files touched

| File | Change |

|---|---|

| Assets/ProtoV2/Scripts/Multiplayer/MultiplayerManager.cs | Awake disables joining immediately |

| Assets/ProtoV2/Scripts/FloorSystem/FloorManager.cs | DeferredSpawn coroutine replaces _pendingSpawn |

| Assets/ProtoV2/Scripts/SpawnManager.cs | Diagnostic logging |

| Assets/ProtoV2/Scripts/CharacterController1.cs | Collider instead of MeshCollider, pivot-based ground check, coyote time |

| Assets/ProtoV2/Scripts/JellyfishVisuals.cs | Removed redundant bodyVisual bob |

| Assets/ProtoV2/Scripts/UnifiedBar.cs | Removed tier visual fields + swap method |

| Assets/ProtoV2/Prefabs/PlayerPrefab.prefab | Tier placeholders deleted, BodyVisual→TrailingBody reparented+renamed, SphereCollider added, layer set, Cap/Bowl active state flipped |

| ProjectSettings/TagManager.asset | Added PlayerAnchor layer at slot 6 |

| ProjectSettings/DynamicsManager.asset | Collision matrix: PlayerAnchor layer no longer self-collides |

| ~/.claude/plugins/.../everything-claude-code/plugin.json | Removed redundant hooks entry |

Open / Follow-ups

  • Test in editor: spawn should now be random on first load AND on reload; lobby character should rest cleanly without bouncing; jellyfish should appear with visible cap + hovering trailing body + tentacles.
  • Optional: Root GameObject still has a MeshFilter component (&7409285671972598714) leftover from when BodyVisual was consolidated. Unknown whether it renders anything — worth inspecting if duplicate rendering still occurs.
  • Upstream issue (everything-claude-code): file a bug so the hooks entry gets removed from the shipped manifest.

date: 2026-04-17

phase: UI Phase 3 — Component Library

status: complete

tags: [ui, component-library, runtime-builder, corner-marks, clinical-button, progress-bar, slider]

---

2026-04-17 — UI Component Library (Phase 3)

Summary

Implemented the full Phase 3 component library for Ritual & Ruin's "dark kawaii terminal" UI aesthetic. All components are runtime-built via static factory methods — no .prefab files created, consistent with the existing SettingsMenuBuilder pattern.

---

Files Created

Sprite Generator (Editor)

  • Assets/ProtoV2/Scripts/Editor/CornerMarkSpriteBuilder.cs
  • MenuItem: Ritual & Ruin/UI/Build Corner Mark Sprite
  • Generates three sprites programmatically and saves to both Assets/ProtoV2/Sprites/UI/ and Assets/ProtoV2/Resources/UI/ (so Resources.Load works at runtime)
  • CornerMark.png — 16×16 L-bracket + diamond pip (white on transparent, FilterMode=Point)
  • BarSegment.png — 8×12 solid white block for segmented progress bars
  • DiamondHandle.png — 18×18 diamond shape (Manhattan-distance fill) for slider handles
  • Idempotent — rerun overwrites

UIBuilder Factory

  • Assets/ProtoV2/Scripts/UI/UIBuilder.cs
  • UIBuilder.AddCornerMarks(RectTransform, Color) — four L-bracket sprites at all corners, tintable, returns Image[4] array
  • UIBuilder.Button(parent, label, onClick) — button with ClinicalButton behaviour + corner marks
  • UIBuilder.Panel(parent, headerText, size) — panel with Terminal Dark bg, Dim Green border, corner marks, optional header strip
  • UIBuilder.ProgressBar(parent, width, height, segments) — segmented fill bar with bracket end caps and ClinicalBar behaviour
  • UIBuilder.Slider(parent, label, initialValue, onChange) — diamond handle slider with ClinicalSlider behaviour
  • UIBuilder.InputField(parent, placeholder) — input field with block cursor and ClinicalInputField behaviour
  • UIBuilder.TabBar(parent, tabLabels, onTabChange) — tab bar with ClinicalTabBar behaviour
  • UIBuilder.Tooltip(parent, text, buttonIcon) — small hint panel with optional icon

Behaviour MonoBehaviours

  • Assets/ProtoV2/Scripts/UI/Components/ClinicalButton.cs — 5-state colour swaps (Default/Hover/Pressed/Disabled/Focus), corner mark brightness, no tweening (Phase 5)
  • Assets/ProtoV2/Scripts/UI/Components/ClinicalBar.cs — semantic colour mapping (green ≥65% / amber 30–65% / red <30%), segment fill updates, end-cap colour
  • Assets/ProtoV2/Scripts/UI/Components/ClinicalSlider.cs — wraps Unity Slider, diamond handle colour on hover, value label update
  • Assets/ProtoV2/Scripts/UI/Components/ClinicalInputField.cs — block cursor blink at 1 Hz, corner marks appear on focus
  • Assets/ProtoV2/Scripts/UI/Components/ClinicalTabBar.cs — label tinting, background swap, underline image repositioned to active tab (direct snap, no animation)

Smoke Test (Editor)

  • Assets/ProtoV2/Scripts/Editor/UIBuilderSmokeTest.cs
  • MenuItem: Ritual & Ruin/UI/Spawn Component Gallery
  • Spawns one of each component on a scratch canvas in the current scene
  • Idempotent — reruns destroy the previous gallery first
  • Does NOT require Play Mode

---

Refactors to Existing Controllers

`MainMenuController.cs`

  • CreateButton() / ApplyButtonVisuals() replaced with a single UIBuilder.Button(...) call
  • Unused colour constants (CardColor, OverlayColor, BtnNormal, BtnHighlight, BtnPressed, LabelColor, BTN_FONT_SIZE) removed
  • All button behaviour (OnClick handlers, labels) unchanged

`PauseController.cs`

  • Same refactor — CreateButton() / ApplyButtonVisuals() replaced with UIBuilder.Button(...)
  • Unused colour constants removed
  • All button behaviour unchanged

Both controllers now get corner marks and ClinicalButton state handling automatically on all three buttons.

---

How to Verify

1. Build sprites first — run Ritual & Ruin/UI/Build Corner Mark Sprite once. Must do this before using the library or spawning the gallery.

2. Build typography — run Ritual & Ruin/Typography/Build Typography Assets if not already done.

3. Spawn the gallery — run Ritual & Ruin/UI/Spawn Component Gallery. A UIGallery_Scratch canvas appears in the scene hierarchy. In Scene view you should see:

  • Three buttons with L-bracket corner marks at all four corners
  • A panel with header strip and corner marks
  • Three progress bars (green/amber/red by value)
  • A slider with diamond handle
  • An input field
  • A four-tab tab bar with underline on the first tab
  • A tooltip hint strip

---

Known Limitations

  • No animation — Phase 5 will add: button press scale punch, hover ink-fill, corner mark breathe, bar overshoot, underline slide
  • No bloom/glow on borders — Outline component approximates the border; true glow requires Phase 4 URP bloom volumes
  • ClinicalButton border colour — not yet driven at runtime (Outline is set once by UIBuilder). Phase 5 will animate this.
  • Sprites require manual build stepResources.Load in UIBuilder falls back gracefully (logs a warning, renders white squares if sprites missing). Run the menu item once and they persist.
  • SettingsMenuBuilder not refactored — its slider/dropdown/toggle widgets are minor variants of UIBuilder components. Marked as a future consolidation opportunity; not done now to avoid scope creep.

---

Adjacent Ideas (Not Implemented)

  • UIBuilder.Toggle() factory — would unify SettingsMenuBuilder's toggle with the library
  • UIBuilder.Dropdown() — would cover the settings dropdowns
  • ClinicalButton Outline colour driven at runtime from state (trivial addition for Phase 5)
  • ClinicalBar danger pulse (opacity oscillation + shake) — spec'd in §5.2, deferred to Phase 5

---

---

date: 2026-04-17

session: UI Phase 4 — Global Visual Layer

status: complete (code + assets shipped; editor wiring still required)

---

Dev Log — UI Phase 4: Global Visual Layer

What shipped

1. CRT Overlay Shader

Assets/ProtoV2/Shaders/CRTOverlay.shader

URP-compatible unlit shader for a full-screen ScreenSpaceOverlay canvas Image. Implements per-spec §4.1 parameters:

  • _ScanlineOpacity (0–1, default 0.25) — darkens every 3rd pixel row
  • _VignetteStrength (0–1, default 0.4) — radial edge darkening
  • _ScreenCurvature (0–0.1, default 0.03) — barrel distortion remapping UVs
  • _ChromaticAberration (0–0.02, default 0.005) — R shifts left, B shifts right
  • _NoiseGrain (0–0.1, default 0.03) — animated per-frame hash grain
  • _Intensity (0–1, default 1.0) — master multiplier; all sub-effects scale by this

Design decision — overlay Canvas vs URP Renderer Feature:

A URP full-screen pass (ScriptableRenderFeature + fullscreen blit) would read back the real framebuffer and apply distortion to actual game pixels. That requires IntermediateTextureMode = Always on the renderer and adds a render pass, which could impact the Obi Fluid Compute backend.

The ScreenSpaceOverlay Canvas Image approach is simpler, zero-cost on the render pipeline, and ships without touching PC_Renderer.asset. The trade-off: because the Image's _MainTex is white (no framebuffer read-back), barrel distortion and chromatic aberration affect the scanline/vignette/grain layers only, not the underlying game image. This is visually correct for the "CRT screen glass" metaphor — the curvature is the screen edge, not a lens distorting the world.

If full framebuffer distortion is needed in a later phase, upgrading to a Renderer Feature is straightforward: the shader is already written to accept _MainTex from a blit.

2. CRT Overlay Controller

Assets/ProtoV2/Scripts/UI/CRTOverlayController.cs

  • Singleton, DontDestroyOnLoad — place one instance in any scene; it survives scene loads.
  • Creates CRTOverlayCanvas (ScreenSpaceOverlay, sort order 200) and a full-screen RawImage with a runtime material instance at Awake().
  • Reads GameSettings.CRTIntensity on Start(), subscribes to GameSettings.OnCRTIntensityChanged.
  • GlitchBurst() / GlitchBurst(float peak, float duration, float chromaBoost) — coroutine that spikes _Intensity and _ChromaticAberration, fires a 50ms black flash, then smoothly restores baseline. API is ready; no caller wiring yet.

3. GameSettings — CRT Intensity Property

Assets/ProtoV2/Scripts/Settings/GameSettings.cs

Added:

  • public static float CRTIntensity { get; private set; } = 1.0f (default per §4.1)
  • public static event System.Action OnCRTIntensityChanged
  • public static void SetCRTIntensity(float v) — clamps, persists to Settings_CRTIntensity, fires event
  • Load/Save wired to Settings_CRTIntensity PlayerPrefs key

4. Settings Menu — CRT Intensity Slider

Assets/ProtoV2/Scripts/Settings/SettingsMenuBuilder.cs

BuildGameplayContent() — replaced the disabled "CRT INTENSITY (coming soon)" toggle row with a fully functional slider row labelled "CRT INTENSITY". Wired to GameSettings.SetCRTIntensity. The (coming soon) label and CanvasGroup alpha=0.4 dim-state are removed.

5. URP Volume Profiles

Assets/Settings/Volumes/ (directory created)

| File | Bloom Intensity | Threshold | Scatter | Tint |

|---|---|---|---|---|

| GameplayBloomProfile.asset | 0.4 | 0.8 | 0.7 | #00FF41 (0,1,0.2549,1) |

| MenuBloomProfile.asset | 0.2 | 0.8 | 0.7 | #00FF41 |

| TerminalPhaseBloomProfile.asset | 0.7 | 0.8 | 0.7 | #FF2222 (1,0.1333,0.1333,1) |

Colour note: #00FF41 in linear sRGB is approximately (0, 1, 0.2549, 1). #FF2222 is (1, 0.1333, 0.1333, 1). The YAML uses linear values as required by Unity's VolumeProfile serialisation.

6. Terminal Phase Bloom Controller

Assets/ProtoV2/Scripts/UI/TerminalPhaseBloomController.cs

  • Two Volume references: _gameplayVolume (stays at weight 1), _terminalVolume (starts at 0).
  • EnterTerminal() — blends _terminalVolume.weight 0→1 over _blendDuration (default 0.3s, SmoothStep).
  • ExitTerminal() — blends weight 1→0.
  • TerminalPhaseController does not currently expose a public event; wire-up is a follow-up editor task (noted below).

7. Reusable UI Effects — API Only

Assets/ProtoV2/Scripts/UI/Effects/GlitchBurst.cs

  • RequireComponent(RectTransform). Burst() / Burst(float durationSeconds).
  • Creates R+B tinted ghost Image clones as siblings, offsets them ±4px on X per §4.4.
  • Fires 50ms black flash, then SmoothStep fade-in over 0.3s.
  • Ghost GOs destroyed on coroutine end — no scene pollution.
  • Handles Image, RawImage, and generic Graphic sources.

Assets/ProtoV2/Scripts/UI/Effects/ScanlineSweep.cs

  • RequireComponent(RectTransform). Reveal() / Reveal(float durationSeconds).
  • Creates a 2px horizontal #00FF41 80% Image child that sweeps top-to-bottom.
  • Each immediate child gets a runtime CanvasGroup (if not already present); alpha starts at 0 and snaps to 1 as the scanline passes.
  • Scanline Image destroyed on coroutine end.

---

Editor-side wiring still required

These steps cannot be done from code without scene modification — they need to be done manually in the Unity Editor.

CRT Overlay Canvas

1. Open MainMenu, LobbyScene, MatchScene.

2. Add an empty GO named CRTOverlayController to each scene.

3. Add the CRTOverlayController component. Assign CRT Shader field → ProtoV2/CRTOverlay.

  • Alternatively leave the field empty; Shader.Find("ProtoV2/CRTOverlay") is the fallback (works in Editor; may fail in stripped builds — prefer inspector assignment).

4. The controller is DontDestroyOnLoad, so only one instance survives scene transitions. Having it in every scene ensures it exists from the first frame regardless of entry scene.

Global Volume — Bloom (MatchScene)

1. Open MatchScene.

2. Add a GO → Volume component, set Profile → GameplayBloomProfile.asset.

3. Set Is Global = true, Weight = 1, Priority = 1 (or above any existing volume).

4. Optionally assign this Volume reference to TerminalPhaseBloomController._gameplayVolume.

Global Volume — Terminal Phase Bloom (MatchScene)

1. Add a second GO → Volume, Profile → TerminalPhaseBloomProfile.asset.

2. Set Is Global = true, Weight = 0 (starts invisible), Priority = 2 (above gameplay volume).

3. Add TerminalPhaseBloomController to any persistent GO in the scene.

4. Assign Gameplay Volume and Terminal Volume references in inspector.

5. Wire TerminalPhaseBloomController.EnterTerminal() to the appropriate moment — currently suggest calling it from TerminalPhaseController.EnterTerminal(GameObject) after the aura is attached. A future session should add a public static event Action OnTerminalPhaseEntered to TerminalPhaseController.

Global Volume — Bloom (MainMenu, LobbyScene)

1. Add a GO → Volume → Profile → MenuBloomProfile.asset.

2. Is Global = true, Weight = 1.

CRT Shader — Include in Build

1. Window → Rendering → Shader Inclusion → add ProtoV2/CRTOverlay to the Always Included Shaders list (or ensure a material referencing it exists and is in a Resources folder). The runtime Shader.Find() fallback relies on this.

---

Known limitations

  • Barrel distortion does not warp game pixels — only the CRT overlay layers (scanlines, vignette, grain) are distorted. The underlying game image is undistorted. This is acceptable for the phosphor-glass metaphor and avoids a renderer feature dependency. Upgrade path documented in shader comments.
  • TerminalPhaseController eventEnterTerminal() on TerminalPhaseBloomController is not called automatically yet. Needs a one-line hook in TerminalPhaseController.EnterTerminal(GameObject) or a public static event.
  • GlitchBurst / ScanlineSweep — no callers yet. Phase 5 wires them to transitions.
  • CRTOverlayController DontDestroyOnLoad — if the game re-enters a scene that already spawned the controller on a previous load, the duplicate will self-destroy via the singleton guard. This is correct behaviour.
  • Volume profile colour values — stored in YAML as linear floats, not gamma hex. #00FF41 → (0, 1, 0.2549, 1) and #FF2222 → (1, 0.1333, 0.1333, 1). If Unity re-imports and shows different values, use the Color Picker in the Volume inspector to correct to the hex target.

---

Files created / modified

| Action | Path |

|---|---|

| NEW | Assets/ProtoV2/Shaders/CRTOverlay.shader |

| NEW | Assets/ProtoV2/Scripts/UI/CRTOverlayController.cs |

| NEW | Assets/ProtoV2/Scripts/UI/TerminalPhaseBloomController.cs |

| NEW | Assets/ProtoV2/Scripts/UI/Effects/GlitchBurst.cs |

| NEW | Assets/ProtoV2/Scripts/UI/Effects/ScanlineSweep.cs |

| NEW | Assets/Settings/Volumes/GameplayBloomProfile.asset |

| NEW | Assets/Settings/Volumes/MenuBloomProfile.asset |

| NEW | Assets/Settings/Volumes/TerminalPhaseBloomProfile.asset |

| MODIFIED | Assets/ProtoV2/Scripts/Settings/GameSettings.cs — added CRTIntensity, OnCRTIntensityChanged, SetCRTIntensity |

| MODIFIED | Assets/ProtoV2/Scripts/Settings/SettingsMenuBuilder.cs — CRT row now a functional slider |

---

---

date: 2026-04-17

session: UI Text Swap — Phase 1 (clinical language pass) + Environmental-storytelling UI doc alignment

status: complete

refs:

  • "Games/Ritual & Ruin/Options/UI Implementation Checklist.md"
  • "Games/Ritual & Ruin/Confirmed/UI Visual Guidelines.md"
  • "Decision Log/UI Copy Phase 1 Decision 2026-04-17.md"
  • "Decision Log/Story Mode vs Arcade Mode UI Decision.md"
  • "Games/Ritual & Ruin/Options/Team Identifier — In-World Fiction.md"
  • "Games/Ritual & Ruin/Options/Experimental UI Framing.md"
  • "Games/Ritual & Ruin/UI Design/CRT Filter Toggle.md"

---

UI Text Swap — Phase 1 + Environmental-Storytelling Doc Alignment

Session had two halves:

1. Design-doc alignment — re-read every UI-related design doc, caught stale direction (the Pill Selector entry concept was still live in the Options docs despite being superseded by the 5 April Decision Log entry). Wrote a new Decision Log entry consolidating every copy decision made today and a standalone exploration doc for the one remaining blocker (the team identifier).

2. Phase 1 code swap — pure copy-and-label pass on existing menus to bring them in line with the clinical "experiment monitoring system" tone from the UI Visual Guidelines. No scene YAML edits, no new prefabs, no refactoring. Pure .cs changes only.

---

Files Edited

`Assets/ProtoV2/Scripts/MainMenuController.cs`

| Before | After |

|--------|-------|

| "local multiplayer arena" (subtitle) | "SURVIVAL IS MEASURED, NOT CELEBRATED" |

| "PLAY" (start button) | "[ INITIATE EXPERIMENT ]" |

| "SETTINGS" (settings button) | "[ CONFIGURE PARAMETERS ]" |

| "QUIT" (quit button) | "[ TERMINATE SESSION ]" |

Title "RITUAL & RUIN" unchanged — was already correct.

`Assets/ProtoV2/Scripts/PauseController.cs`

| Before | After |

|--------|-------|

| "PAUSED" (title) | "EXPERIMENT SUSPENDED" |

| "RESUME" | "[ RESUME EXPERIMENT ]" |

| "SETTINGS" | "[ CONFIGURE PARAMETERS ]" |

| "QUIT TO MENU" | "[ ABORT EXPERIMENT ]" |

`Assets/ProtoV2/Scripts/Settings/SettingsMenuBuilder.cs`

  • Back button: "BACK""[ CLOSE ]"
  • Section labels (all BuildRow call strings):

| Before | After |

|--------|-------|

| "Master Volume" | "MASTER VOLUME" |

| "Music Volume" | "MUSIC VOLUME" |

| "SFX Volume" | "SFX VOLUME" |

| "Window Mode" | "WINDOW MODE" |

| "Resolution" | "RESOLUTION" |

| "Max FPS" | "FRAME RATE CAP" |

| "VSync" | "V-SYNC" |

| "Quality Preset" | "QUALITY PRESET" |

| "Anti-Aliasing" | "ANTI-ALIASING" |

| "Bloom" | "BLOOM" |

| "Shadows" | "SHADOW QUALITY" |

| "Camera Shake" | "CAMERA SHAKE" |

| "CRT Effect (coming soon)" | "CRT INTENSITY (coming soon)" |

Title "SETTINGS" unchanged — per spec, no bracket wrap on titles.

`Assets/ProtoV2/Scripts/Editor/InputRebindMenuSetup.cs`

| Before | After |

|--------|-------|

| $"PLAYER {playerNum}" | $"SUBJECT {playerNum:D2}" (→ SUBJECT 01…04) |

| "START GAME" (start button) | "[ COMMENCE EXPERIMENT ]" |

| "Press ENTER (Keyboard) or START/A (Controller) to join\n…" | "PRESS ENTER OR START/A TO ASSIGN SUBJECT\n…" |

`Assets/ProtoV2/Scripts/UI/InputRebindMenuUI.cs`

| Before | After |

|--------|-------|

| "START GAME" (runtime can-start text) | "[ COMMENCE EXPERIMENT ]" |

| $"NEED {minPlayersToStart} PLAYER(S)" | $"AWAITING {minPlayersToStart} SUBJECT(S)" |

| "Press ENTER…" instructions | "PRESS ENTER OR START/A TO ASSIGN SUBJECT\n…" |

`Assets/ProtoV2/Scripts/MatchCountdown.cs`

| Before | After |

|--------|-------|

| i.ToString() (count beat display) | $"EXPERIMENT INITIATING IN {i}" |

| "GO!" | "EXPERIMENT INITIATED" |

---

New File: `Assets/ProtoV2/Scripts/UI/MatchTimerUI.cs`

New MonoBehaviour component that displays a match elapsed-time timer in the top-right corner of the HUD.

Wiring:

  • Subscribes to MatchCountdown.Instance.OnCountdownComplete to start ticking (resets elapsed to 0).
  • Subscribes to MatchManager.Instance.OnMatchEnd to stop ticking.
  • If the Inspector timerText field is left null (the default for a new scene object), Start() calls CreateTimerElement() which bootstraps its own ScreenSpaceOverlay Canvas (sortingOrder 80, matching the Countdown Canvas layer from spec §8) with a top-right anchored TextMeshProUGUI.

Format: M:SS.mmm (e.g. 0:04.217, 1:32.005) — matches UI Screen Specs §"Formatting Rules".

How to add to MatchScene: Add an empty GameObject to MatchScene, attach the MatchTimerUI component. No Inspector wiring required — it bootstraps the canvas and label at runtime. Wire timerText manually in a later phase if you want it to live inside the existing HUD Canvas hierarchy.

---

What Was Deferred

  • TMP element not added to MatchScene YAML — the component self-bootstraps at runtime, so no scene YAML edit was needed. When you want to move the timer into the existing HUD Canvas (Phase 6 restructure), wire the timerText Inspector field to a pre-existing TMP object and the bootstrap path is skipped.
  • [ RECALIBRATE INPUT ] button in PauseController — checklist item 1.2 lists an "Input config button" for the pause menu. No such button exists in the current PauseController.cs BuildUI() — there are only three buttons (Resume, Settings, Quit). Not added: out of scope for this session and the InputRebindMenuUI flow is lobby-only. Flagged here for a future pass.
  • [ RESTORE DEFAULTS ] / [ APPLY ] footer buttons in SettingsMenuBuilder — checklist item 1.3. Current builder has only a single "back" button footer; no Apply/Restore Defaults buttons exist yet. Left untouched — structural change, not a copy swap.
  • Phase 2 typography (Share Tech Mono, VT323 fonts, Phosphor Green colour) — deferred per plan.
  • End screen copy — blocked on team identifier decision per checklist.
  • Countdown team banners — blocked on team identifier decision.
  • OmniSharp LSP not installed in this environment — compile-error verification was done by manual read of the final file state. No new compile errors expected: all changes are string literals or event subscription patterns that match existing codebase conventions exactly.

---

Checklist Items Completed (from UI Implementation Checklist.md)

  • [x] 1.1 Add tagline below title → SURVIVAL IS MEASURED, NOT CELEBRATED
  • [x] 1.1 Start button → [ INITIATE EXPERIMENT ]
  • [x] 1.1 Settings button → [ CONFIGURE PARAMETERS ]
  • [x] 1.1 Quit button → [ TERMINATE SESSION ]
  • [x] 1.2 Pause title → EXPERIMENT SUSPENDED
  • [x] 1.2 Resume button → [ RESUME EXPERIMENT ]
  • [x] 1.2 Settings button → [ CONFIGURE PARAMETERS ]
  • [x] 1.2 Quit to menu button → [ ABORT EXPERIMENT ]
  • [x] 1.3 Section labels all-caps + wording updates (13 labels)
  • [x] 1.3 Back/close button → [ CLOSE ]
  • [x] 1.5 Slot heading → SUBJECT 01SUBJECT 04
  • [x] 1.5 Start button → [ COMMENCE EXPERIMENT ]
  • [x] 1.6 Countdown sequence → EXPERIMENT INITIATING IN 3/2/1EXPERIMENT INITIATED
  • [x] Match elapsed timer (top-right) — new MatchTimerUI.cs, runtime bootstrap

---

Documentation Reconciliation

Side track to the code work. Design docs were out of sync with the 5 April 2026 decision in [[Decision Log/Story Mode vs Arcade Mode UI Decision]] — the Pill Selector entry concept and the dual warm/cold aesthetic had been rejected, but the source docs still read as if both were live. Brought them in line and captured today's new decisions in a single authoritative Decision Log entry so future sessions don't have to re-derive the state.

Docs Written or Updated

| Path | Operation | What changed |

|------|-----------|--------------|

| Decision Log/UI Copy Phase 1 Decision 2026-04-17.md | New | Authoritative consolidation of every copy and structural decision made in this session — menu labels, pause copy, settings labels, lobby copy, countdown flow, match timer format, CRT scope correction, pill-selector reaffirmation. Lists what's pending (team identifier) and what was deliberately deferred (typography, component styling, animation, HUD overlay conversion). |

| Games/Ritual & Ruin/Options/Team Identifier — In-World Fiction.md | New | Workspace doc for the blocker. Frames the three open questions (why teams, why team-healing, what the label should make the player feel), candidate-labels scratchpad with fiction-implied-by-label analysis, constraints checklist, tie-ins to existing lore/systems, and a suggested resolution path. Intended as the author's working surface — fill-in-the-blanks sections under "Working Notes". |

| Games/Ritual & Ruin/Options/UI Implementation Checklist.md | Updated | Phase 1.1–1.3 and the unblocked parts of 1.5/1.6 marked complete. 1.4 (End Screen) and 1.7 (Match Manager elimination copy) marked BLOCKED with link to the Team Identifier doc. Added the 2026-04-17 countdown+timer decision to the Pending Decisions table. Status-snapshot table refreshed. |

| Games/Ritual & Ruin/Options/Experimental UI Framing.md | Updated | Status changed from exploring to superseded. Added a prominent red supersession banner at the top listing what survives (clinical narrative voice, tension-point inventory, diegetic UI principle) vs. what's rejected (Pill Selector entry, dual-style, CRT-as-mode-switch). Kept historical content below for context. Explicit "Do not implement from it" instruction. |

| Games/Ritual & Ruin/UI Design/CRT Filter Toggle.md | Updated | Rewritten. Removed pill-icon-slider metaphor (deprecated). Repositioned CRT as a settings-only accessibility slider (0–100% scaling of scanline/vignette/curvature/chromatic aberration/noise together). References UI Visual Guidelines §4.1 for the intensity values. Added Deprecated Historical Note pointing to the rejected direction for future readers. |

Why This Matters Going Forward

  • The 2026-04-17 Decision Log entry is now the single point of truth for all Phase 1 copy. If any downstream copy question comes up ("what does the pause button say?", "what's the countdown final beat?"), that file answers it. No need to reconstruct from scattered Options docs.
  • The Team Identifier doc turns a nebulous blocker ("we don't have team copy yet") into a structured design problem with questions, candidates, and constraints. The next working session on that topic has a clear entry point.
  • The superseded banners on the pill-selector docs prevent future contributors (or future-me) from re-implementing a rejected concept because it was still labelled exploring.

Checklist Status After Session

| Checklist section | State |

|-------------------|-------|

| 1.1 Main Menu | Complete (version number bottom-right deferred) |

| 1.2 Pause Menu | Complete (Recalibrate Input button deferred — structural) |

| 1.3 Settings Modal | Complete (Restore Defaults / Apply footer deferred — structural; typography deferred to Phase 2) |

| 1.4 End Screen | BLOCKED on team identifier |

| 1.5 Lobby | Core labels complete; team divider blocked on team identifier |

| 1.6 Match Countdown | Text flow complete + plain top-right timer shipped; animation and team banners deferred |

| 1.7 Match Manager elimination | BLOCKED on team identifier |

| Phase 2 onward | Not started |

---

Next Session Candidates

Pick one:

  • Unblock team identifier — work through Options/Team Identifier — In-World Fiction.md. Once resolved, ship the ~3-file Phase 1.b copy pass (EndScreen, MatchManager, Countdown banners, lobby divider) in a short follow-up.
  • Phase 2 typography — import Share Tech Mono + VT323, create TMP font assets with phosphor glow material, apply across all existing UI text. No blockers, purely additive.
  • Phase 6 HUD overlay conversion — the biggest visual shift. Follow Options/Match HUD Overlay Design.md. This is where the "environmental storytelling" language of the UI actually lands at the match scene level.

---

---

title: UI Typography — Phase 2

date: 2026-04-17

session: Phase 2 — Typography and Colour Application

status: complete (pending one manual editor step — see below)

tags: [ritual-ruin, ui, typography, tmp, fonts, phosphor-green]

---

UI Typography Phase 2 — Session Log

Summary

Implemented Phase 2 of the UI Implementation Checklist: Share Tech Mono and VT323 are now the game's fonts, backed by a central TypographyLibrary ScriptableObject. All runtime-code-created TMP components in the priority controllers have been patched to pull from this library. A one-shot editor script builds the TMP font assets and seven TMP materials.

---

Files Created

Fonts

| File | Notes |

|------|-------|

| Assets/ProtoV2/Fonts/ShareTechMono-Regular.ttf | Downloaded from github.com/google/fonts (OFL) |

| Assets/ProtoV2/Fonts/VT323-Regular.ttf | Downloaded from github.com/google/fonts (OFL) |

| Assets/ProtoV2/Fonts/ShareTechMono-OFL.txt | SIL Open Font Licence — required by OFL terms |

| Assets/ProtoV2/Fonts/VT323-OFL.txt | SIL Open Font Licence — required by OFL terms |

TMP font assets (ShareTechMono SDF.asset, VT323 SDF.asset) are generated by the editor script below — they do not exist yet and will appear at Assets/ProtoV2/Fonts/ after running the menu item.

Scripts

| File | Notes |

|------|-------|

| Assets/ProtoV2/Scripts/Typography/TypographyLibrary.cs | ScriptableObject — central font/material/colour registry. Loaded at runtime via Resources.Load("TypographyLibrary"). |

| Assets/ProtoV2/Scripts/Editor/TMPFontAssetBuilder.cs | One-shot editor script. Builds TMP font assets, seven TMP materials, and the TypographyLibrary asset. MenuItem: Ritual & Ruin / Typography / Build Typography Assets. |

Materials (created by editor script — not yet on disk)

Seven materials will be saved to Assets/ProtoV2/Materials/UI/ after running the build menu item:

| Material | Face colour | Glow |

|----------|-------------|------|

| TMP_PhosphorGreen.mat | #00FF41 | yes, 0.6 power |

| TMP_BrightPhosphor.mat | #39FF14 | yes, 0.6 power |

| TMP_MidGreen.mat | #008F11 | none |

| TMP_DimGreen.mat | #004D00 | none |

| TMP_BloodRed.mat | #CC0000 | yes, 0.4 power |

| TMP_AcidGreen.mat | #7FFF00 | yes, 0.8 power |

| TMP_VT323_Phosphor.mat | #00FF41 | yes, VT323 base font |

ScriptableObject (created by editor script)

Assets/ProtoV2/Resources/TypographyLibrary.asset — wired with all font assets and materials. Lives in Resources/ so Resources.Load works at runtime without Inspector wiring on individual controllers.

---

Files Edited

All colour palette changes follow UI Visual Guidelines §2.1. All font assignments go through TypographyLibrary.

| File | What changed |

|------|-------------|

| Assets/ProtoV2/Scripts/MainMenuController.cs | Replaced gold/lavender palette with phosphor-green palette. Title uses VT323 (display size) with glow. Subtitle uses VT323 Mid Green. Buttons use Share Tech Mono with PhosphorGreen glow material. |

| Assets/ProtoV2/Scripts/PauseController.cs | Same palette swap. Title uses Share Tech Mono headline. Buttons Share Tech Mono with glow. Separator now Dim Green border colour. |

| Assets/ProtoV2/Scripts/Settings/SettingsMenuBuilder.cs | Palette swap throughout. Tab labels Share Tech Mono. Active tab label PhosphorGreen, inactive MidGreen. All row labels, dropdown labels, value labels Share Tech Mono. Slider fill PhosphorGreen, handle BrightPhosphor. |

| Assets/ProtoV2/Scripts/UI/MatchTimerUI.cs | Timer text colour changed from Color.white to TypographyLibrary.PhosphorGreen. Share Tech Mono + glow material applied in CreateTimerElement(). |

| Assets/ProtoV2/Scripts/UI/EndScreenController.cs | resultText gets Share Tech Mono + PhosphorGreen in Start(). timeText gets VT323 + MidGreen (log-entry style). |

| Assets/ProtoV2/Scripts/MatchManager.cs | outlastTimerText and eliminatedTeamLabel get Share Tech Mono + PhosphorGreen applied in Start(). |

---

Required Manual Step — MUST DO ONCE

Run Ritual & Ruin / Typography / Build Typography Assets from the Unity Editor menu.

This creates:

  • ShareTechMono SDF.asset and VT323 SDF.asset in Assets/ProtoV2/Fonts/
  • Seven .mat files in Assets/ProtoV2/Materials/UI/
  • TypographyLibrary.asset in Assets/ProtoV2/Resources/

Until this runs, the game will log [TypographyLibrary] Asset not found warnings and fall back to TMP's default font — the game still runs correctly, just without the new fonts applied.

The menu item is idempotent — safe to re-run if assets need to be regenerated.

---

Deferred Items (Flagged, Not Done)

| Item | Reason deferred |

|------|----------------|

| Scene YAML TMP font asset references | Runtime code overwrites them anyway; patching YAML is fragile |

| World-space bar colours on UnifiedBar.cs | Phase 6 HUD overlay rewrites this layer; semantic colours (green/amber/red health) intentionally left untouched |

| TMP font fallback chains | Nice-to-have, not critical for Phase 2 |

| Corner-mark sprites | Phase 3 prefab library |

| InputRebindMenuSetup.cs / InputRebindMenuUI.cs font patch | These set up scene hierarchy in editor — font assignment at scene-build time; will be addressed when the rebind prefab is rebuilt in Phase 3 |

| MatchCountdown.cs — countdown text font | countdownText is a serialized Inspector field wired in the scene; runtime font assignment from TypographyLibrary not added as it would fight the YAML ref. Address in Phase 3 or via a small Start() patch when the countdown canvas is rebuilt. |

---

How to Verify in Play Mode

After running the build menu item once:

1. Main Menu: Title "RITUAL & RUIN" renders in VT323 with phosphor-green glow. Subtitle in VT323 mid-green. Buttons in Share Tech Mono bright green.

2. Pause Menu: "EXPERIMENT SUSPENDED" in Share Tech Mono phosphor green. Buttons in Share Tech Mono.

3. Settings Panel: All labels, dropdowns, tab bars in Share Tech Mono phosphor green. Slider fill is #00FF41, handle is #39FF14.

4. Match Timer (top-right): Renders in Share Tech Mono phosphor green.

5. End Screen result/time text: Result in Share Tech Mono PhosphorGreen; time line in VT323 MidGreen.

6. Match Manager outlast labels: Share Tech Mono PhosphorGreen.

If fonts still show as default TMP font after the menu item runs, check the Console for [TypographyLibrary] errors — the most likely cause is a missing TTF at the expected path.

---

---

date: 2026-04-18

session: O7 Pending Wiring — Implementation

status: complete

refs:

  • ".omc/plans/O7-wiring-implementation.md"
  • "Games/Ritual & Ruin/Options/UI Decisions Register.md"
  • "Games/Ritual & Ruin/Options/Match HUD Overlay Design.md"

---

2026-04-18 — O7 Wiring Implementation

Summary

Wired existing FloatingFeedbackSpawner and SceneTransition.Fade primitives into gameplay callsites; added CanvasGroupFade for settings panels; retired FloatingRewardIndicator legacy path; fixed SceneTransition DDOL canvas promotion bug; closed UI Decisions O4/O5/O6/O7.

Session Flow

Used APSU (Opus analyse → Opus plan → 4-executor swarm). The analyst mapped all live callsites and locked public APIs before planning began; the planner merged T1 and T5 into a single track to avoid concurrent edits to MainMenuController.cs and PauseController.cs. Wave 1 ran T1, T2, and T3 in parallel; Wave 2 ran T4 gated on T3 completion; Wave 3 was an orchestrator-driven validation sweep; Wave 4 (this entry) is the docs executor.

Files Changed

New

  • Assets/ProtoV2/Scripts/UI/Animation/CanvasGroupFade.cs
  • Assets/ProtoV2/Scripts/BloodSystem/AltarCompletionFeedback.cs

Modified

  • Assets/ProtoV2/Scripts/UI/Animation/SceneTransition.cs — DDOL canvas-promotion bug fixed; always instantiates a dedicated SceneTransitionCanvas GO (ScreenSpaceOverlay, sortOrder 9999), DDOLs only that GO
  • Assets/ProtoV2/Scripts/MainMenuController.cs — Start button routes through SceneTransition.Fade; settings open/close routes through CanvasGroupFade
  • Assets/ProtoV2/Scripts/PauseController.cs — all scene-load paths route through Fade; settings open/close through CanvasGroupFade
  • Assets/ProtoV2/Scripts/UI/EndScreenController.cs — Rematch + Lobby buttons route through Fade
  • Assets/ProtoV2/Scripts/LobbySceneLoader.cs — match-load method routes through Fade
  • Assets/ProtoV2/Scripts/Settings/SettingsMenuBuilder.csCanvasGroupFade wired on built panel root for fade-in/out hooks
  • Assets/ProtoV2/Scripts/ProgressionManager.csHandleAltarConsumed uses AddBarUnitsSilent + SpawnProximity (no double-floater)
  • Assets/ProtoV2/Scripts/UnifiedBar.cs — added AddBarUnitsSilent(float); removed floatingRewardPrefab serialized field and internal spawn calls
  • Assets/ProtoV2/Scripts/Editor/AlphaSetupWizard.csCreateFloatingRewardPrefab region removed; all FloatingRewardIndicator references deleted
  • Assets/ProtoV2/Prefabs/FloorSystem/Altar.prefabAltarCompletionFeedback component added
  • Assets/ProtoV2/Prefabs/PlayerPrefab.prefab — orphan floatingRewardPrefab: YAML line on UnifiedBar component removed

Deleted

  • Assets/ProtoV2/Scripts/UI/FloatingRewardIndicator.cs
  • Assets/ProtoV2/Prefabs/UI/FloatingRewardIndicator.prefab

Key Decisions

  • DDOL canvas fix: SceneTransition previously promoted whichever canvas it found in the scene to DDOL, causing cross-scene canvas duplication. The fix always creates a fresh SceneTransitionCanvas GO and DDOLs only that — no scene canvas is ever touched.
  • AddBarUnitsSilent to avoid double-spawn: The proximity reward path called the old AddBarUnits which also triggered an internal floater spawn, producing a duplicate alongside SpawnProximity. The new silent variant applies the bar mutation only; ProgressionManager owns the floater call explicitly.
  • Settings fade via CanvasGroupFade: Rather than animate alpha in SettingsMenuBuilder directly, a standalone CanvasGroupFade MonoBehaviour on the panel root is wired by callers. Uses Time.unscaledDeltaTime so it survives pause. 0.15s fade-in / 0.2s fade-out.

Validation

  • refresh_unity + read_console: compile clean, zero errors, zero warnings on touched files.
  • lsp_diagnostics_directory on Assets/ProtoV2/Scripts/: clean.
  • Full menu→match→pause→settings→main cycle: no NullReferenceException / MissingReferenceException.
  • Altar consume: SpawnAltarCompletion floater visible at altar position; SpawnProximity floater visible above winning creature; no duplicate reward floater.
  • grep -R "floatingRewardPrefab" Assets/ProtoV2/: zero hits.
  • grep -Rn "FloatingRewardIndicator" Assets/ProtoV2/Scripts/: zero non-comment hits.
  • MainMenu renders all 3 buttons ([ INITIATE EXPERIMENT ], [ CONFIGURE PARAMETERS ], [ TERMINATE SESSION ]) with symmetric corner marks (screenshot verified).

Open Follow-ups

  • DevPanel.cs scene-reload wrap (dev-only, deferred — not part of this track's scope).
  • Team identifier fiction still pending — O1/H1 remains Provisional; revisit after next playtesting session.

---

---

date: 2026-04-18

session: UI Animation — Phase 5

status: complete

---

UI Animation Phase 5 — Anime-Weight Transitions

Files Created

| File | Purpose |

|------|---------|

| Assets/ProtoV2/Scripts/UI/Animation/Easing.cs | Static easing curves: EaseOutCubic, EaseInCubic, EaseOutBack, EaseInOutExpo, Linear |

| Assets/ProtoV2/Scripts/UI/Animation/UITween.cs | Coroutine tween helpers: TweenFloat, TweenColor, TweenScale, TweenAnchoredPosition, TweenAlpha. All use Time.unscaledDeltaTime. |

| Assets/ProtoV2/Scripts/UI/Animation/PanelEntrance.cs | OnEnable: slide Y from -8px to 0 over 0.3s ease-out back + triggers ScanlineSweep.Reveal() if present |

| Assets/ProtoV2/Scripts/UI/Animation/ButtonGroupStagger.cs | OnEnable: each direct child slides in from left -12px + fades 0→1, staggered 0.08s each, ease-out cubic |

| Assets/ProtoV2/Scripts/UI/Animation/CornerMarkBreathe.cs | Oscillates corner mark scale ±1% at 0.5 Hz ambient breathing |

| Assets/ProtoV2/Scripts/UI/Animation/SceneTransition.cs | SceneTransition.Fade(duration, callback) — glitch burst + black flash + fade in. API only, no auto-wiring. |

| Assets/ProtoV2/Scripts/UI/Animation/FloatingFeedback.cs | Anime-style pop/float/fade for reward/altar/proximity/death/evolution feedback text |

| Assets/ProtoV2/Scripts/UI/Animation/FloatingFeedbackSpawner.cs | Static factory: SpawnReward, SpawnAltarCompletion, SpawnProximity, SpawnDeath, SpawnEvolution |

Files Edited

| File | Change |

|------|--------|

| Assets/ProtoV2/Scripts/UI/Components/ClinicalButton.cs | Full animation layer: hover 0.1s fade, exit 0.15s, press scale punch 1.0→1.04→0.97→1.0, focus gamepad pulse 0.5 Hz |

| Assets/ProtoV2/Scripts/UI/Components/ClinicalBar.cs | Gain overshoot +2 segments + settle, immediate loss drop, danger pulse/shake <30%, PlayCompleteBurst() public method |

| Assets/ProtoV2/Scripts/UnifiedBar.cs | SpawnReward wired at AddBarUnits callsite; SpawnEvolution wired in TriggerEvolution |

| Assets/ProtoV2/Scripts/DeathHandler.cs | SpawnDeath wired in HandleDeath |

| Assets/ProtoV2/Scripts/PauseController.cs | ButtonGroupStagger added to button column; PanelEntrance added to card |

| Assets/ProtoV2/Scripts/MainMenuController.cs | ButtonGroupStagger added to button column; PanelEntrance added to main panel |

| Assets/ProtoV2/Scripts/UI/EndScreenController.cs | DataLoadReveal coroutine: lines appear 0.05s apart (§6.1 data-load pattern) |

Callsites Wired

| Spawner | Callsite | File |

|---------|----------|------|

| SpawnReward | UnifiedBar.AddBarUnits (replaces FloatingRewardIndicator instantiation) | UnifiedBar.cs |

| SpawnEvolution | UnifiedBar.TriggerEvolution (after OnEvolution event) | UnifiedBar.cs |

| SpawnDeath | DeathHandler.HandleDeath (after deathFeedback) | DeathHandler.cs |

| SpawnAltarCompletion | NOT WIRED — follow-up | see below |

| SpawnProximity | NOT WIRED — follow-up | see below |

Callsites NOT Wired (Follow-up)

  • SpawnAltarCompletion: ProgressionManager.HandleAltarConsumed would be the callsite. Proximity reward of 50 should call SpawnProximity; the per-particle batch reward uses SpawnReward (already wired). Deferred because HandleAltarConsumed currently awards raw bar units with no world position feedback — requires a second pass to split the two reward types.
  • SpawnProximity: Same callsite as above (ProgressionManager proximity bonus). Follow-up: modify HandleAltarConsumed to call FloatingFeedbackSpawner.SpawnProximity(closest.transform.position + Vector3.up * 2f, 50).
  • SceneTransition.Fade: No scene load callsites wired. Manual wiring needed at:
  • MatchCountdown.cs: after countdown complete beat, before FloorManager.ActivateCurrentFloors()
  • MainMenuController.OnStartClicked: wrap SceneManager.LoadScene("LobbyScene") in SceneTransition.Fade(0.3f, () => SceneManager.LoadScene("LobbyScene"))
  • PauseController.OnQuitClicked: same pattern for MainMenu load
  • ClinicalInputField cursor blink: Verified at 1 Hz (0.5s interval in existing code) — matches §6.3 spec. No change needed.
  • PanelEntrance on countdown panel / end screen panel: These panels are controlled by SetActive in MatchCountdown and EndScreenController. PanelEntrance.OnEnable fires automatically when the panel is activated — no additional wiring needed as long as PanelEntrance is added to those panel GOs in the scene (manual Inspector step).
  • SettingsMenuBuilder fade-through-black open/close: The settings panel is shown/hidden via SetActive in PauseController and MainMenuController. Phase 5 spec calls for 0.2s fade-out / 0.15s fade-in. Not wired — requires a settings panel wrapper coroutine. Deferred.

Manual Test Checklist (Play Mode)

  • [ ] Button hover: background fades from dark to #003B00 over ~0.1s; corner marks enlarge and brighten
  • [ ] Button hover exit: slightly slower than enter (~0.15s)
  • [ ] Button press: scale punches 1.04→0.97→1.0; background flashes full green; text inverts to black
  • [ ] Gamepad select (navigate with controller): corner marks pulse at ~0.5 Hz while selected
  • [ ] Bar gain (add blood units): fill overshoots by ~2 segments then settles back
  • [ ] Bar loss (decay/damage): fill drops immediately, no bounce
  • [ ] Bar danger (<30%): opacity pulses 1.0→0.55 at ~1.5 Hz; bar shakes ±2px horizontally
  • [ ] Bar danger exit (bar rises above 30%): pulse and shake stop, opacity and position restored
  • [ ] Main menu load: button column slides in from left, staggered ~0.08s per button
  • [ ] Main menu panel: drops in from -8px with slight overshoot
  • [ ] Pause menu open: card drops in; buttons stagger in from left
  • [ ] End screen: result text and time text appear sequentially ~0.05s apart
  • [ ] Death: "ELIMINATED" glitch-appears above creature, holds at scale 1.1, fades in place over 2s
  • [ ] Reward (+N): pops 0→1.2→1 scale, floats up, fades over 1.2s
  • [ ] Evolution: two glitch flashes, "STAGE N INITIATED" punches in, holds 2s, glitch-disappears
  • [ ] All animations run correctly when game is paused (Time.timeScale = 0) — pause menu buttons animate

Notes

  • FloatingRewardIndicator.cs is NOT removed — it's still referenced by UnifiedBar.floatingRewardPrefab SerializedField and by AlphaSetupWizard. The new spawner replaces its instantiation but the prefab and class remain. Safe to remove both in a future cleanup pass.
  • ScanlineSweep.cs (Phase 4) uses Time.deltaTime — pre-existing, not changed. It's called by PanelEntrance which handles its own unscaled timing separately. ScanlineSweep will pause during timeScale=0. Low priority fix since scanline is visual-only.
  • CornerMarkBreathe must be manually added to panels in the scene (or added programmatically in UIBuilder.Panel() when a breathe flag overload is added — deferred).

---

---

date: 2026-04-18

session: UI Animation Refactor — Coroutine → Update

status: complete

refs:

  • "Games/Ritual & Ruin/Options/UI Implementation Checklist.md"
  • "Games/Ritual & Ruin/Confirmed/UI Visual Guidelines.md"
  • ".omc/plans/ui-animation-update-refactor.md"

---

UI Animation Refactor — Coroutine → Update

Goal

Convert every coroutine-based UI animation in Assets/ProtoV2/Scripts/UI/ to Update-tick MonoBehaviour state machines using Time.unscaledDeltaTime. Driven by a real bug encountered earlier today: buttons 2 and 3 were permanently invisible after entering the main menu because child coroutines in the old ButtonGroupStagger died mid-delay when Unity killed them during parent GameObject disable cycles. Coroutine lifecycle is opaque and leads to these "silent state loss" bugs; Update-tick state machines are deterministic and inspectable.

Process

Ran the APSU skill (Analyze → Plan → Swarm + Unity MCP):

1. Opus analyst surveyed 16 files, inventoried every StartCoroutine call, identified state-machine shapes per component, locked public API signatures, flagged a latent ghost-leak bug in GlitchBurst and a Time.deltaTime bug in ScanlineSweep.

2. Opus planner split the work into 5 disjoint-file tracks (A–E) with a single dependency gate (A's UITween.cs delete blocked by D's ClinicalButton.cs rewrite).

3. Swarm — 5 executors in parallel. Sonnet for Tracks A, B, E (medium/low complexity). Opus for Tracks C, D (high complexity — 5 variant state machines and 5-channel tween struct respectively).

Tracks Completed

Track A — Animation Primitives (sonnet)

  • ButtonGroupStagger.cs — single-clock Update, per-child alpha = EaseOutCubic((elapsed − i·0.08) / 0.22), snap to 1 at _elapsed ≥ total
  • PanelEntrance.cs — Y-drop from -8px over 0.3s with EaseOutBack, fires _sweep?.Reveal() at completion
  • SceneTransition.cs — singleton + static Fade(float, Action) entrypoint preserved; phase machine FadingOut → Blackout → Invoke → FadingIn

Track B — Effects (sonnet)

  • GlitchBurst.cs — phase { Idle, Split, Flash, FadeIn, Done }; ghost-leak fix: _rGhost / _bGhost refs now persistent fields, destroyed at the top of every Burst() call and in OnDisable
  • ScanlineSweep.cstime-source fix: all accumulation now Time.unscaledDeltaTime; dead-alpha fix: replaced the broken revealProgress block with a single threshold test cg.alpha = (childCentreY >= scanlineY) ? 1f : 0f

Track C — FloatingFeedback (opus)

  • Single StepKind[] step-table per variant, one Update dispatch. 5 variants all preserve original timing 1:1:
  • Reward: Pop 0→1.2→1 / Float+fade 1.2s
  • AltarCompletion: Pop 0→1.4→1 / Float+fade 1.5s
  • Proximity: ColourFlash → Pop → FloatWithPulse (2s total, pulse mid-float)
  • Death: Glitch → Wait → HoldScale 1.1 → Settle → Fade 2s
  • Evolution: Glitch × 2 → PopScale → Hold 2s → Glitch → Fade
  • Destroy(gameObject) at final step with a _done guard (no double-invoke)

Track D — Clinical Components (opus)

  • ClinicalButton.cs — 5 coroutines replaced with per-channel structs (ColorTween, CornerTween) + PressPhase enum (3-phase interruptible punch) + sin-wave focus pulse tick. All 6 EventSystem interface methods preserved; SetDisabled preserved
  • ClinicalBar.csPhase { Idle, FillTween, CompleteBurst, Dormant } main states; DangerPulse runs orthogonally driven by _isInDanger; gain uses 2-phase overshoot (0.08s EaseOutCubic + 0.15s EaseOutBack), loss snaps, threshold-crossing snaps (no tween per plan)
  • OnDisable on both components resets transform scale, colours, CanvasGroup alpha, anchoredPosition

Track E — EndScreenController (sonnet)

  • DataLoadReveal coroutine replaced with RevealPhase { Idle, WaitingResult, WaitingTime, Done }. Same 0.05s per-row delay preserved. OnRematchClicked / OnLobbyClicked / ShowEndScreen signatures unchanged.

Gate G1 — UITween Delete

After Track D confirmed zero remaining using UITween in ClinicalButton, Assets/ProtoV2/Scripts/UI/Animation/UITween.cs and its .meta were deleted. Grep across Scripts/UI/ confirmed no other consumers.

Validation

| Check | Result |

|-------|--------|

| Compile clean (refresh_unity + read_console) | 0 errors |

| Coroutine grep in Scripts/UI/Animation | 0 matches |

| Coroutine grep in Scripts/UI/Components | 0 matches |

| Coroutine grep in Scripts/UI/Effects | 0 matches |

| UITween.cs + .meta deleted | confirmed |

| MainMenu screenshot: 3 buttons, title, subtitle, symmetric L-brackets | confirmed (see attached capture) |

| Stagger fade-in plays correctly (no permanently-hidden buttons) | confirmed — the original bug that drove this refactor is fixed |

Out-of-Scope Coroutines (Expected)

The grep flagged 4 files with remaining coroutines, all intentionally excluded per the plan:

  • UI/TerminalPhaseBloomController.cs — Phase 4 bloom blending; not UI animation
  • UI/CRTOverlayController.cs — Phase 4 shader post-process; its GlitchBurst() method is a coincidental name clash
  • UI/DevPanel.cs — dev tool, not runtime UI
  • UI/FloatingRewardIndicator.cs — legacy world-space indicator superseded by FloatingFeedback; removal is a separate cleanup task

Intentional Deviations from Plan

Track D reported two minor deviations, both intentional:

1. DangerPulse frequencies — plan specified 2 Hz / 8 Hz / 0.6 min-alpha; old code used 1.5 Hz / 3 Hz / 0.55. Executor chose to match the plan's explicit numbers. Net visual change is subtle; adjust in the inspector if preferred.

2. CompleteBurst scale — plan said "scale up 10%"; old code used 1.2× (20%). Executor kept the original 20% to preserve "visual fidelity 1:1" per the shared invariant. One-line change to switch if the plan's 10% was intended literally.

Flagging these here rather than changing code — user should decide which numbers stay.

Bugs Fixed in Passing

1. GlitchBurst ghost-leak on rapid re-Burst — previous coroutine version's StopCoroutine path leaked child ghost GameObjects; the coroutine-natural-end cleanup never ran. Fixed by storing ghost refs as fields and destroying them at the top of every Burst() call.

2. ScanlineSweep Time.deltaTime bug — previous version paused during timeScale = 0 which was inconsistent with every other UI animation; now uses Time.unscaledDeltaTime.

3. ScanlineSweep dead alpha math — broken revealProgress computation replaced with a clean threshold test.

Files Changed

| File | Action |

|------|--------|

| Scripts/UI/Animation/ButtonGroupStagger.cs | rewritten |

| Scripts/UI/Animation/PanelEntrance.cs | rewritten |

| Scripts/UI/Animation/SceneTransition.cs | rewritten |

| Scripts/UI/Animation/FloatingFeedback.cs | rewritten |

| Scripts/UI/Animation/UITween.cs | deleted |

| Scripts/UI/Components/ClinicalButton.cs | rewritten |

| Scripts/UI/Components/ClinicalBar.cs | rewritten |

| Scripts/UI/Effects/GlitchBurst.cs | rewritten + bug fix |

| Scripts/UI/Effects/ScanlineSweep.cs | rewritten + 2 bug fixes |

| Scripts/UI/EndScreenController.cs | rewritten |

Files Untouched (Verified)

Easing.cs, CornerMarkBreathe.cs, FloatingFeedbackSpawner.cs, MatchTimerUI.cs, UnifiedBar.cs, DeathHandler.cs, MainMenuController.cs, PauseController.cs, UIBuilder.cs, CRTOverlayController.cs, TerminalPhaseBloomController.cs.

All external callsites (SpawnReward, SpawnEvolution, SpawnDeath, Burst, Reveal, SetDisabled, Fade, OnRematchClicked, OnLobbyClicked) still resolve against locked public API signatures.

Follow-ups

  • Wire SceneTransition.Fade(...) into scene loads (no current callsites — the API was written against a design doc but never plumbed)
  • Wire FloatingFeedbackSpawner.SpawnAltarCompletion and SpawnProximity at the appropriate gameplay events (design doc specifies them but callsites don't exist yet)
  • Decide on DangerPulse frequency values (plan's 2/8Hz vs old 1.5/3Hz) and CompleteBurst scale (10% vs 20%)
  • Schedule a deletion pass for legacy FloatingRewardIndicator.cs once all callsites confirm they've moved to FloatingFeedback
  • Phase 6 (Match HUD overlay conversion) remains the next major session — now on a clean animation foundation

---

---

date: 2026-04-18

session: Menu Polish — Corner Marks, Hover Feedback, Pixel-Perfect Canvas Rule

status: complete

refs:

  • "Games/Ritual & Ruin/Confirmed/UI Visual Guidelines.md"
  • "Games/Ritual & Ruin/Options/UI Decisions Register.md"

---

Menu Polish — Corner Marks, Hover Feedback, Pixel-Perfect Canvas Rule

Goal

Make the MainMenu and Pause look presentable. Specifically:

1. Corner marks should render with uniform stroke thickness across all four pivots (TL / TR / BL / BR). Prior behavior: TR/BL/BR strokes appeared thinner than TL.

2. Hover feedback on buttons should be visible without bloom — both the corner marks AND the button background should change noticeably.

3. Strip redundant [ ] brackets from button labels now that corner marks provide the bracket visual.

4. Remove the main-menu subtitle "SURVIVAL IS MEASURED, NOT CELEBRATED" (user decision this session).

5. Document the pixel-perfect rule so it doesn't silently re-break the next time someone adds a Canvas.

The Root Cause of the Lopsided Corners

The CornerMark_* sprites are 32×32 source with 4-pixel strokes; they display in a 16×16 RectTransform (2:1 downscale → 2 pixel strokes at 1:1 display). That works when the Canvas renders at 1:1 scale. It breaks when CanvasScaler produces any non-integer render scale.

Example: reference 1920×1080 displayed at 1280×720 → 0.667× scale → a 2-pixel stroke becomes a 1.33 screen-pixel stroke. Unity then subpixel-rounds per pivot:

  • (0, 1) (TL) → rounds up → 2 pixels
  • (1, 1) / (0, 0) / (1, 0) → round down → 1 pixel

Three of four corners rendered half as thick as TL. Point-filter couldn't help (no anti-aliasing across the fractional boundary). Bilinear filter smoothed it at the cost of visible blur.

Real fix: Canvas.pixelPerfect = true — forces integer-pixel rendering regardless of scaler, so Point filter renders identical stroke counts at every pivot.

Changes

Sprites — `Assets/ProtoV2/Scripts/Editor/CornerMarkSpriteBuilder.cs`

  • STROKE bumped 3 → 4 (even number divides cleanly at 2:1 downscale)
  • Diamond pip drawing commented out (user wanted clean Ls, no interior accent)
  • Added 4 hover variants: CornerMark_TL_Hover / TR / BL / BR with arms extended from 14 → 22 pixels (at 32×32 source)
  • Single-pass Color32[] buffer + SetPixels32 (eliminated prior SetPixel / SetPixels32 mixing)
  • Filter mode remains FilterMode.Point (pixel-perfect Canvas makes it reliable)

UIBuilder — `Assets/ProtoV2/Scripts/UI/UIBuilder.cs`

  • New static void EnsurePixelPerfectCanvas(Transform child) helper — walks up to the root Canvas and sets pixelPerfect = true if not already
  • Every public factory method (AddCornerMarks, Button, Panel, ProgressBar, Slider, InputField, TabBar, Tooltip) now calls it as its first statement. Auto-enforcement — any Canvas hosting any UIBuilder widget gets pixel-perfect without manual setup
  • New LoadCornerMarkHoverVariants() method loads the 4 _Hover variants from Resources/UI/
  • Button() passes both idle and hover sprite arrays to ClinicalButton via new fields
  • Header comment documents the pixel-perfect rule inline

Button behaviour — `Assets/ProtoV2/Scripts/UI/Components/ClinicalButton.cs`

  • BgHover bumped #003B00 → #005500 — clearly visible mid-green vs almost-black
  • Added [HideInInspector] public Sprite[] cornerMarkIdleSprites and cornerMarkHoverSprites
  • New SetCornerSprites(bool hover) helper — instant Image.sprite swap on state transitions (no scale tween = no sub-pixel distortion)
  • Hover / Focus / Pressed states swap to _Hover sprites; Default / Disabled swap back to idle
  • Scale tween on corner marks removed (TweenCorners calls still there but pass 1f — the scale distortion was the original blur culprit in earlier iterations)
  • Focus pulse amplitude zeroed (gamepad focus communicated via color + longer arms only)

Menu content — `MainMenuController.cs` / `PauseController.cs` / `SettingsMenuBuilder.cs` / `InputRebindMenuUI.cs` / `InputRebindMenuSetup.cs`

  • Removed [ ] from button labels:
  • [ INITIATE EXPERIMENT ]INITIATE EXPERIMENT
  • [ CONFIGURE PARAMETERS ]CONFIGURE PARAMETERS
  • [ TERMINATE SESSION ]TERMINATE SESSION
  • [ RESUME EXPERIMENT ]RESUME EXPERIMENT
  • [ ABORT EXPERIMENT ]ABORT EXPERIMENT
  • [ CLOSE ]CLOSE
  • [ COMMENCE EXPERIMENT ]COMMENCE EXPERIMENT
  • Removed the SURVIVAL IS MEASURED, NOT CELEBRATED subtitle block from MainMenuController.BuildMainPanel
  • Manual _canvas.pixelPerfect = true removed from MainMenuController.Awake — UIBuilder now auto-enforces it; kept a comment pointing to the auto-enforcement path

Capture helper — `Assets/ProtoV2/Scripts/Editor/GameViewCapture.cs`

  • Bumped capture resolution 1280×720 → 1920×1080 so subpixel-rounding artifacts don't hide behind my own screenshot downscale

Documentation

  • CLAUDE.md rule #9 (new) — short in-project reference:

> Every Canvas that hosts a UIBuilder widget, ClinicalButton, ClinicalBar, or corner mark must have Canvas.pixelPerfect = true. UIBuilder auto-enforces this via EnsurePixelPerfectCanvas(). If you bypass UIBuilder and add widgets directly, set the flag yourself. See docs/UI_PIXEL_PERFECT.md.

  • docs/UI_PIXEL_PERFECT.md (new) — full technical explanation of the rule, the subpixel-rounding cause, what UIBuilder does to enforce it, when the rule breaks (manual Canvas creation), and what it does NOT affect (TMP text, world-space UI, shader filter modes)
  • UIBuilder class-header docblock has a dedicated PIXEL-PERFECT CANVAS RULE section

Validation

  • Compile clean (refresh_unity + read_console → 0 errors)
  • MainMenu screenshot at 1920×1080 capture: all 4 corners on each button render with uniform 2-pixel stroke thickness, no blur
  • Forced-hover screenshot (temporary debug; reverted): corner arms visibly extend from 14px to 22px (_Hover sprite swap), bg shifts to #005500, labels shift to #39FF14
  • Grep \[ .+ \] in Assets/ProtoV2/Scripts/ returns zero button-label matches (remaining hits are doc-comment examples in UIBuilder.cs which still use the old [ START ] style string — cosmetic, can be updated later)

Open Follow-ups

  • Hover interaction has not yet been tested with a real mouse cursor (MCP can't simulate pointer events cleanly). User to confirm in play mode that hover triggers the sprite swap + bg color change.
  • UIBuilder.cs header comment still references [ START ] style labels in one example line — update to bracket-less style on next docs pass.
  • The hover sprite asset generator uses a constant STROKE = 4; if you later want thicker hover strokes for even more pop, bump STROKE or add a separate STROKE_HOVER constant.

Why This Matters

These are small visual-polish fixes, but the pixel-perfect rule is the kind of bug that silently re-breaks UI whenever someone spawns a new Canvas and forgets the invariant. Enforcing it at the UIBuilder level + documenting it in three places (CLAUDE.md, docs/UI_PIXEL_PERFECT.md, UIBuilder header) gives us three chances for the rule to be seen before it's broken again.

In the latest dev session, we made some cool tweaks to Ritual & Ruin that should brighten up your experience! The chunk floor shader got a much-needed fix for those annoying white streaks that used to appear when moving around. We shifted our shadow calculations to work on a per-pixel basis, meaning smoother and more seamless shadows across the game world—no more glaring stripes where they shouldn't be!

We've also pushed Ritual & Ruin further into its dark kawaii aesthetic, which you might recognize as having that distinct anime vibe. The floor tiles now have a more dramatic contrast with near-white tops accented by violet hints, and darker sides bordered in dim purple grout. It's all about striking a balance between the cute and the mysterious.

On top of these changes, we've polished up the jellyfish players. They're sporting longer tentacles that extend their reach within matches, making every move more pronounced. Plus, they've become more visible against darker floors with increased opacity—so no more squinting to spot your jellyfish on the battlefield! All these tweaks aim to make the visuals pop while ensuring everything looks sharp and cohesive. Can't wait for you all to see how it turns out in action!

Raw session notes

2026-04-07 — Chunk Floor Shader, Shadow Fix, Visual Polish

Summary

Fixed a persistent white-streak artifact on the chunk floor shader, iterated on the dark kawaii visual direction, and polished jellyfish player visuals.

Dark Kawaii Visual Direction

Iterated the ChunkFloorTile shader toward a dark kawaii / anime aesthetic:

  • Top face: near-white tiles with violet hint (0.93, 0.90, 1.00) + neon lavender grout (0.72, 0.38, 0.95) with glow emission
  • Side faces: near-black tiles (0.05, 0.05, 0.09) + dim purple grout (0.48, 0.36, 0.70)
  • Face detection: branchless yDom = step(absNorm.x, absNorm.y) * step(absNorm.z, absNorm.y) drives both color palettes
  • Degradation: Voronoi crack lines with pink/magenta glow (0.95, 0.18, 0.62), FBM staining, mold growth
  • Crack scale: lowered _CrackScale to 0.5 (few large cracks rather than fine network)
  • Tile scale: doubled _TileScale to 1.0 (larger tiles)
  • Cel shading: floor(NdotL * bands) / (bands - 1) quantized diffuse, 3 bands

---

Visual Polish

Jellyfish Tentacles — Longer

  • segmentLength: 0.150.35 (scale=1 normalized, auto-scaled at runtime)
  • At match scale=0.25: total tentacle reach ≈ 0.525 world units (was 0.225)

Jellyfish Body — More Opaque

  • _Opacity in JellyfishBody.mat: 0.180.65
  • Players are now clearly visible against the dark floor

---

Files Changed

  • Assets/ProtoV2/Shaders/ChunkFloorTile.shader — per-fragment shadow coord, dark kawaii style
  • Assets/ProtoV2/Materials/ChunkFloorTile.mat — color values for dark kawaii look
  • Assets/ProtoV2/Scripts/JellyfishVisuals.cs — segmentLength 0.15→0.35
  • Assets/ProtoV2/Materials/JellyfishBody.mat — _Opacity 0.18→0.65

### Summary of Recent Changes and Developments

#### Jellyfish Tentacle Reversion (2026-04-03)

**Issue:** - The implementation of ObiRope tentacles for jellyfish caused excessive bouncing during bell pulses, as they reacted to the physics of the visual root squishing.

**Solution:** - Reverted to using a LineRenderer spring-chain setup. - Adjusted segment lengths and scaling to ensure visibility across different character scales (e.g., match scale 0.25, lobby scale 0.69).

**Implementation Details:** - Each tentacle consists of six segments that maintain proper spacing and length. - Size values are dynamically adjusted using `transform.lossyScale.x` for consistent appearance at varying scales.

#### Tilt Control Enhancements (2026-04-04)

**Objective:** - Complete integration of tilt control in various UI elements and settings, addressing gaps from a previous session.

**Key Changes:**

1. **Tilt in Rebind UIs:** - Added "Tilt" to the rebindable actions in `InputRebindMenuUI` and `PlayerSettingsPanel`. - Updated `InputRebindUIController` for controller presets, ensuring correct bindings across different control schemes.

2. **Auto-Tilt Toggle Wiring:** - Confirmed existing wiring via `PlayerMenuSetupWizard`, ensuring functionality is intact. - The toggle is accessible only to controller players and adjusts based on their input mode (visible in the settings panel accessed through menu).

3. **Per-Player Menu Hint Text:** - Introduced a new UI prompt system to guide players on accessing settings during a match. - Updated `PlayerMenuController` to dynamically display hint text based on game state and player device. - Enhanced `PlayerSettingsPanel` with instruction text for closing settings.

**Editor Wiring:** - Utilized `MenuHintWiringWizard` to automate the setup of menu hint texts in both the PlayerPrefab and LobbyScene, ensuring consistent UI elements across scenes.

### Architectural Insights

- **Tentacle Implementation:** Ensures visual consistency by auto-scaling and maintaining visibility across different game modes. - **Tilt Control Integration:** Provides a seamless experience for players using controllers, with clear guidance through dynamic hint texts. - **UI Consistency:** Automated wiring scripts ensure that UI elements are correctly set up and maintained, reducing manual errors.

These updates collectively enhance the player experience by refining visual effects and improving control accessibility, ensuring a smoother gameplay interaction across various modes and devices.

Raw session notes

2026-03-29 — SFX Options for All Assigned Slots

Summary

Added alternative option files for every currently-assigned SFX slot so the user can audition and swap without losing the existing working sounds.

AUDIO.md

Added ## SFX Options — Assigned Slots (Alternatives) section to playinstigator_docs/Games/Ritual & Ruin/AUDIO.md with per-slot tables listing all downloaded files and Freesound URLs.

---

Next Steps

  • Open Unity, navigate to Assets/ProtoV2/Audio/SFX/Options/ in Project window
  • Audition each folder to find preferred alternatives
  • Copy chosen file to correct SFX/ subfolder, rename, re-assign in Inspector

---

2026-03-29 — Audio Variation System + SFX Options

Summary

Built centralized audio variation system and sourced replacement options for all unassigned SFX slots.

---

AudioVariation System

Created Assets/ProtoV2/Scripts/Audio/AudioVariation.cs — a single [Serializable] class embedded in every audio script as one Inspector foldout. Replaces the scattered per-script variation fields that were being duplicated.

Parameters: pitch ±, volume ±, startTime (random clip offset), low-pass cutoff ±, reverb ±

Methods:

  • Init(go) — called in Awake, caches AudioLowPassFilter / AudioReverbFilter if present
  • PlayOneShot(source, clip, vol) — variation + PlayOneShot
  • PlayLoop(source, clip, vol) — variation + pitch/filter + Play() with loop=true
  • PlayFromOffset(source, clip, vol) — variation + startTime + Play() non-looping (throttled impacts)

All 6 audio scripts updated to use it: AudioManager, PlayerAudioSource, AltarAudioSource, BloodEmitterAudioSource (two instances — loop + burst), FloorImpactAudio, UIAudioManager.

Bug fixed: FloorImpactAudio.SharedImpactClip was never being set at runtime — floor blood impacts were silent. Fixed by adding bloodImpactClip field to AudioManager and wiring the static in Awake(). Requires assigning blood_impact.ogg to the new "Floor Impact" field on the AudioManager GO in Inspector.

---

SFX Audit

Checked which audio slots are actually assigned vs. missing. Found that several "removed" clips still have files on disk but are unassigned in the Inspector:

| Slot | File | Status |

|---|---|---|

| deathClip | SFX/Character/player_death.ogg | Exists but unsuitable |

| formTransformClip | SFX/Character/form_transform.ogg | Exists but unsuitable |

| flowLoopClip | SFX/Fluid/emitter_flow.ogg | Exists but unsuitable |

| All 5 UIAudioManager clips | SFX/UI/*.ogg | Exist but unsuitable |

---

SFX Options Downloaded

Downloaded and organised replacement options to Assets/ProtoV2/Audio/SFX/Options/:

| Folder | Files | Sources |

|---|---|---|

| Options/Death/ | 9 | Kenney Sci-Fi Sounds (slime, explosionCrunch), Kenney Impact Sounds (impactSoft_heavy) |

| Options/Transform/ | 8 | Kenney Sci-Fi Sounds (forceField x5, computerNoise x2) |

| Options/FlowLoop/ | 9 | Kenney Sci-Fi Sounds (spaceEngineLow, engineCircular, spaceEngineSmall) |

| Options/UI_Click/ | 11 | Kenney Interface Sounds (click x5), Kenney UI Audio (click x5, mouseclick) |

| Options/UI_Hover/ | 12 | Kenney UI Audio (rollover x6), Kenney Interface Sounds (scroll x4, tick x2) |

| Options/UI_Pause/ | 15 | Kenney Interface Sounds (toggle x4, switch x7, open x2, close x2) |

| Options/UI_PlayerJoin/ | 7 | Kenney Interface Sounds (confirmation x4, bong, pluck x2) |

| Options/UI_Transition/ | 9 | Kenney Interface Sounds (maximize x5, glitch x4) |

All Kenney files are CC0. Total: 80 auditionable options across 8 slots.

Additional Freesound CC0 and Pixabay options (40+ more) documented in AUDIO.md with direct URLs for manual download (Freesound requires login).

---

AUDIO.md Updated

playinstigator_docs/Games/Ritual & Ruin/AUDIO.md updated with:

  • Corrected slot statuses (file-exists-but-unsuitable vs. truly unassigned)
  • Full Options section per slot — downloaded files + manual-download Freesound/Pixabay links
  • How-to-assign workflow
  • Credits table updated with Kenney Sci-Fi Sounds and Impact Sounds

---

Next Steps

  • Audition Options files in Unity, pick one per slot, rename and assign in Inspector
  • Assign blood_impact.ogg to AudioManager GO's "Floor Impact" field (fix for silent floor impacts)
  • For slots where no Kenney option feels right: download preferred Freesound/Pixabay option using the URLs in AUDIO.md

---

Evolution Size Fix + Asset Catalog + Resolved Issue Cleanup (2026-03-29)

---

1. Terminal Evolution Size Fix

Issue

Terminal evolution (Tier 2) scale multiplier of 1.5× made the jellyfish too large to pass through floor holes.

Fix

File: Assets/ProtoV2/Scripts/JellyfishVisuals.cs — line 194

Scale progression (final):

| Tier | Scale | Visual cues |

|---|---|---|

| Base (0) | 1.0× | White tint, squish 0.20 |

| Enhanced (1) | 1.2× | Blue tint, squish 0.28 |

| Terminal (2) | 1.3× | Gold tint, squish 0.38, pulse 0.75× faster |

Terminal is still visually distinct (gold color, faster pulse, more squish, slightly larger) but now fits through floor holes.

---

2. Resolved Issues Cleanup

Removed the following items from the open blockers list — all confirmed resolved:

  • TQ-4 (Pause + ObiFluid): ObiFluid particles correctly pause/resume with Time.timeScale = 0. No code changes needed.
  • maxSurfaceChunks: Already reduced to 2000 in ObiSolver Inspector. No further action needed.
  • ObiNativeList finalizer: Mitigated by D3D11 (GPU buffers handled more gracefully). Lower priority, not blocking.

Open blockers: None.

---

3. Asset Catalog

Created Confirmed/strategy/Asset Catalog.md — full inventory of 38 purchased Unity assets with applicability ratings for Ritual & Ruin.

Summary:

  • Tier 1 (use these): Feel, Amplify Shader Editor, Amplify Shader Pack, Obi Rope, Obi Softbody, Motion Blur, Magic Effects FREE, DOTween
  • Tier 2 (potentially useful): Elemental Spells VFX, Easy Save, KayKit Platformer Pack, Weather Maker, Fantasy RPG GUI, Obi Cloth
  • Tier 3 (not relevant): Humanoid character packs, city environments, turret assets, tank, fruit market, 2D tools

Priority integration order: Feel → Obi Rope → Motion Blur → Magic Effects FREE → Easy Save → Amplify Shader Editor

---

4. Evolution Visual Distinction — Next Steps

Current visual distinction between tiers (post-fix):

  • Size: 1.0 → 1.2 → 1.3 (subtle)
  • Color: white → blue → gold ✅ clear
  • Squish: 0.20 → 0.28 → 0.38 (subtle)
  • Pulse rate: unchanged → unchanged → 0.75× faster ✅ readable

Suggested enhancements using owned assets:

  • Feel: Add camera shake burst + flash on evolution event (OnEvolution subscriber)
  • Magic Effects FREE: Spawn a VFX burst prefab at player position on evolution
  • Obi Rope: Upgrade tentacles to physical simulation (more dramatic swing on evolution)
  • Amplify Shader Editor: Add emissive glow increase per tier to jellyfish bell material

Design discussion needed on which to prioritize.

---

Jellyfish Squish + Emitter Indicator + Scroll Camera Fix (2026-03-29)

---

1. Jellyfish Cap Horizontal Squish

Issue

XZ bulge on pulse was 30% of squish amount — too subtle.

Fix

File: Assets/ProtoV2/Scripts/JellyfishVisuals.cs

XZ bulge doubled — bell now visibly widens on each pulse. Spring recovery unchanged.

---

2. Emitter Indicator — No Longer Lands on Players

Issue

BloodEmitterIndicator.DrawCircle() used Physics.Raycast with no layer filter. When a player flew between the emitter and the floor, the raycast hit the player's collider, placing the circle on top of the player.

Root Cause

All objects (players, floors, walls) are on the Default layer — no layer separation to filter against.

Fix

File: Assets/ProtoV2/Scripts/BloodSystem/BloodEmitterIndicator.cs

Replaced physics raycast entirely with chunk-coordinate lookup. We already know the grid layout — no need to cast rays:

Walks top→middle→bottom floor. Finds first floor that has a solid chunk at the emitter's XZ grid position. Surface Y derived from floor root + ChunkHeight/2. Removed raycastLength field entirely.

---

3. Camera Scroll — Single Source of Truth for Floor Spacing

Issue

ScrollingFloorCamera had its own [SerializeField] float floorVerticalSpacing = 5f independent from FloorManager.floorVerticalSpacing. If the two values differed in the Inspector, the camera would scroll to the wrong Y position.

Fix

File: Assets/ProtoV2/Scripts/CameraSystem/ScrollingFloorCamera.csCalculateFloorYPosition()

Camera's own floorVerticalSpacing field is now only a fallback for editor gizmos. At runtime, both floor generation and camera scroll use the same value from FloorManager.

Current values (defaults)

  • FloorManager.floorVerticalSpacing = 5f — adjust this to change floor spacing
  • ScrollingFloorCamera.scrollDuration = 2.5f — how long the scroll animation takes

---

4. Ritual Completion VFX — AltarVFXController

Status

Feel (More Mountains) and Magic Effects FREE (Hovl Studio) are now imported.

New File

Assets/ProtoV2/Scripts/BloodSystem/AltarVFXController.cs — attach alongside AltarParticleConsumer on the Altar prefab.

Drives:

  • OnMeterFull → spawn looping countdown VFX (e.g. Magic circle.prefab or Healing circle.prefab)
  • OnRitualComplete → destroy countdown VFX + spawn completion burst (e.g. Red energy explosion.prefab) + play MMF_Player feedbacks (screen shake, flash, audio)
  • OnAltarReset → destroy countdown VFX

Inspector Wiring Required (on Altar prefab)

1. Add AltarVFXController component

2. Create child GO with MMF_Player, add Screen Shake + Camera Flash + Audio feedbacks → assign to completionFeedbacks

3. Assign completionVFXPrefab = Red energy explosion.prefab (Hovl Studio Magic Effects)

4. Assign countdownVFXPrefab = Magic circle.prefab or Healing circle.prefab

5. Tune completionVFXYOffset and countdownVFXYOffset to sit correctly at altar ground level

Also Available

  • Elemental Spells Full Pack VFX — still in .unitypackage archive, not yet imported. Contains additional burst/explosion options for ritual completion.

---

5. Lobby Scene — Tentacle Scale Fix

Issue

JellyfishVisuals tentacle rim positions were set in world-space Inspector values. characterScale in LobbyScene was 1.0 while MatchScene uses 0.25. At scale 1, the bell is 4× larger than the rim radius (0.4), so tentacles appeared hidden inside the player body.

Fix — Two Parts

Part A: LobbyScene.unityMultiplayerManager.characterScale changed 10.25 so all scenes use the same scale.

Part B: JellyfishVisuals.csBuildTentacles() and UpdateTentacles() now multiply all spatial values by transform.lossyScale.x (ws):

Inspector values are now local-space (as if characterScale = 1). ws scales them to world-space at runtime, so tentacles are always proportionally correct regardless of characterScale.

Part C: PlayerPrefab.prefab Inspector values converted from old world-space to new local-space (÷ 0.25 = × 4):

| Field | Before (world-space) | After (local-space) |

|---|---|---|

| rimRadius | 0.4 | 1.6 |

| rimY | 0.56 | 2.24 |

| segmentLength | 0.15 | 0.6 |

| startWidth | 0.15 | 0.6 |

| endWidth | 0.04 | 0.16 |

At characterScale = 0.25, ws = 0.25, so effective world-space values are identical to before.

---

Dev Log — 2026-03-29 — MMFeedbacks Integration

Summary

Full MMFeedbacks (Feel) integration across all major gameplay events in Ritual & Ruin. Every significant player action, match event, and altar moment now triggers camera shake and/or freeze-frame feedback.

---

What Was Done

AltarVFXController — Namespace Fix

  • File: Assets/ProtoV2/Scripts/BloodSystem/AltarVFXController.cs
  • Added using CupFairy.BloodSystem; to fix CS0246 compile error (AltarParticleConsumer not found)
  • Controller was already written; this unblocked it from compiling

Script Changes — MMF_Player Fields Added

All scripts received using MoreMountains.Feedbacks; + a [SerializeField] private MMF_Player field + a ?.PlayFeedbacks(transform.position) call at the relevant event moment:

| Script | Field | Trigger |

|---|---|---|

| DeathHandler.cs | deathFeedback | Before OnDied?.Invoke() |

| UnifiedBar.cs | evolutionFeedback | Before OnEvolution?.Invoke() in TriggerEvolution() |

| CharacterController1.cs | pulseFeedback | After PulsedThisFrame = true in pulse timer |

| TransformController.cs | formToggleFeedback | At start of Toggle() |

| MatchCountdown.cs | countdownBeatFeedback, goFeedback | Each countdown beat tick; GO! display |

| MatchManager.cs | teamEliminatedFeedback, matchEndFeedback | RecordTeamEliminated(); EndMatch() |

PlayerPrefab — 4 Feedback Child GOs

Added 4 new child GameObjects under the PlayerPrefab root, each with a Transform + MMF_Player component:

| GO Name | fileID | Wired to Script Field | Feedbacks |

|---|---|---|---|

| DeathFeedbacks | &1111111111111111103 | DeathHandler.deathFeedback | FreezeFrame 0.08s + PositionShake 0.4s/range 0.5 |

| EvolutionFeedbacks | &1111111111111111106 | UnifiedBar.evolutionFeedback | FreezeFrame 0.05s + PositionShake 0.3s/range 0.3 |

| PulseFeedbacks | &1111111111111111109 | CharacterController1.pulseFeedback | PositionShake 0.1s/range 0.05 (subtle — fires every 0.35s) |

| FormToggleFeedbacks | &1111111111111111112 | TransformController.formToggleFeedback | PositionShake 0.2s/range 0.15 |

All 4 Transform fileIDs (1111111111111111102, 105, 108, 111) added to root Transform m_Children list (fileID 5020478598309948295).

MatchScene — 4 Feedback Child GOs + Scene Components

Previously added (pre-this-session):

  • MMPositionShaker on Main Camera
  • MMTimeManager GO in scene
  • 4 feedback child GOs under MatchCountdown and MatchManager

This session — populated the MMF_Player feedbacks in each GO:

| GO Name | fileID | Wired to Script Field | Feedbacks |

|---|---|---|---|

| CountdownBeatFeedbacks | &1482767222 | MatchCountdown.countdownBeatFeedback | PositionShake 0.1s/range 0.1 |

| GoFeedbacks | &872176054 | MatchCountdown.goFeedback | FreezeFrame 0.05s + PositionShake 0.3s/range 0.3 |

| TeamEliminatedFeedbacks | &247481994 | MatchManager.teamEliminatedFeedback | FreezeFrame 0.06s + PositionShake 0.35s/range 0.4 |

| MatchEndFeedbacks | &1200068797 | MatchManager.matchEndFeedback | FreezeFrame 0.1s + PositionShake 0.5s/range 0.5 |

Altar Prefab — Completion Feedbacks (Previous Session)

Already done: CompletionFeedbacks child MMF_Player has:

  • MMF_Light (existing)
  • MMF_FreezeFrame (0.05s)
  • MMF_PositionShake (0.3s/range 0.3, random XY)

---

Feedback Intensity Rationale

| Event | Intensity | Reasoning |

|---|---|---|

| Match end | Strongest (0.1s freeze, 0.5 shake) | Game-ending moment, maximum impact |

| Player death | Strong (0.08s freeze, 0.5 shake) | High-stakes moment |

| Team eliminated | Medium-strong (0.06s freeze, 0.4 shake) | Significant match event |

| GO! start | Medium (0.05s freeze, 0.3 shake) | Match start signal |

| Evolution | Medium (0.05s freeze, 0.3 shake) | Power-up moment |

| Altar completion | Medium (0.05s freeze, 0.3 shake) | Ritual payoff |

| Form toggle | Subtle (0.2s shake/0.15 range) | Frequent action, shouldn't fatigue |

| Countdown beat | Subtle (0.1s shake/0.1 range) | Rhythmic, very frequent |

| Staccato pulse | Minimal (0.1s shake/0.05 range) | Fires every 0.35s, must be imperceptible individually |

---

Technical Notes

  • All feedbacks use managed reference YAML format (FeedbacksList: + references: RefIds:) required by MMF_Player v3
  • Scene MMF_Players use a hybrid format (Feedbacks: [] legacy field + FeedbacksList: + references:) — both must be present
  • MMTimeManager GO required in scene for FreezeFrame to function (timescale manipulation)
  • MMPositionShaker on Camera required to receive PositionShake events (channel 0)
  • Owner field in each feedback data points to the MMF_Player component's own fileID
  • All PositionShake feedbacks: RandomizeDirectionX: 1, RandomizeDirectionY: 1, RandomizeDirectionZ: 0 (2D shake only, no depth)

---

Files Modified

  • Assets/ProtoV2/Scripts/BloodSystem/AltarVFXController.cs
  • Assets/ProtoV2/Scripts/DeathHandler.cs
  • Assets/ProtoV2/Scripts/UnifiedBar.cs
  • Assets/ProtoV2/Scripts/CharacterController1.cs
  • Assets/ProtoV2/Scripts/TransformController.cs
  • Assets/ProtoV2/Scripts/MatchCountdown.cs
  • Assets/ProtoV2/Scripts/MatchManager.cs
  • Assets/ProtoV2/Prefabs/PlayerPrefab.prefab (4 new GO+Transform+MMF_Player blocks)
  • Assets/ProtoV2/Scenes/MatchScene.unity (4 MMF_Player feedbacks populated)

---

Obi Fluid Memory Leak — Continued Investigation (2026-03-29)

Continued from 2026-03-28_ObiFluid_MemoryLeak_Fix.md. Previous session patched the rendering-side leak (VolumePass materials, MaterialPropertyBlock, AsyncGPUReadback closures). This session found and addressed the simulation-side leak.

---

Monitoring Setup

PowerShell script at C:/Users/ReconUnPro/AppData/Local/Temp/monitor_mem2.ps1 — polls every 5s:

  • Game process working set (MB) via Get-Process
  • System RAM available / used / % via Get-CimInstance Win32_OperatingSystem

---

Isolation Test Results

Build #006 — All Obi Rendering Disabled

Disabled three layers to fully strip rendering:

1. Removed ObiFluidRendererFeature from m_RendererFeatures in PC_Renderer.asset

2. ObiParticleRendererm_Enabled: 0 on BloodEmitter.prefab

3. ObiFluidSurfaceMesherm_Enabled: 0 on BloodEmitter.prefab

Result: Memory still grew at ~161 MB/s. Flush/scene reload gave no relief.

Conclusion: Leak exists on BOTH sides:

  • Rendering side: ~320 MB/s (patched in prior session, confirmed by rate drop)
  • Simulation side: ~161 MB/s (still present — this session's target)

---

Patches Applied This Session

Patch 3 — `ObiSolver.PushActiveParticles()` (previously applied)

Pre-allocate activeParticles to allocParticleCount before clear so EnsureCapacity never fires mid-emission.

Patch 4 — `ComputeSolverImpl.SetActiveParticles()` (previously applied)

Guard: only call AsComputeBuffer if buffer is null. Otherwise UploadFullCapacity().

Patch 5 — `ObiSolver.PushSimplices()`

File: Assets/Obi/Scripts/Common/Solver/ObiSolver.cs

Added before simplices.Clear():

Why: dirtySimplices is set every time a particle is activated (every emission frame). Without pre-allocation, EnsureCapacity fires repeatedly as the active count grows, each time doubling capacity and nulling m_ComputeBuffer.

Patch 6 — `ComputeSolverImpl.SetSimplices()`

File: Assets/Obi/Scripts/Common/Backends/Compute/Solver/ComputeSolverImpl.cs

Replaced unconditional AsComputeBuffer calls on simplices (line 515) and cellCoords (line 516):

Result of builds #006-#007: Memory still grew at ~161 MB/s. These patches were correct but insufficient — the ROOT CAUSE was elsewhere.

---

Root Cause Found — Bloated Blueprint Capacity

The Discovery

Deep Obi source analysis by Opus architect agent identified the actual culprit.

BloodFluid.asset had capacity: 20000 per emitter.

With 9 emitters active (3 emitters/floor × 3 floors), the solver pre-allocates:

  • Total particle slots: 9 × 20,000 = 180,000
  • positions.count = 180,000 (used by SetCapacity)

This caused ComputeParticleGrid.SetCapacity() to create:

| Buffer | Size |

|---|---|

| neighbors (180k × 2 × 128 neighbors × 4B) | 184 MB |

| All 20+ grid buffers total | ~230 MB |

| colliderContacts readback buffer | ~46 MB |

| 26 particle arrays in ParticleCountChanged | ~170 MB |

~650 MB allocated at match start — before a single particle is emitted.

The D3D12 driver pools these on disposal (never returns to OS), so repeated SetCapacity calls accumulate permanently. colliderContacts.Readback() in ObiSolver.RequestReadback() fires every frame (whenever OnCollision != null, which our scripts always satisfy), staging a ~46 MB readback buffer per frame into D3D12's READBACK heap.

Why 20,000 Was Wrong

Our ObiEmitter runs in burst mode:

  • 7 particles/sec × 1.5s burst every 5s = ~2.1 particles/sec average
  • Particle lifespan: 60s
  • Peak live particles per emitter: 2.1 × 60 = ~126

20,000 capacity = 158× overprovision. The default Obi blueprint value was never adjusted for our actual usage.

The Fix

Assets/ProtoV2/Blueprints/BloodFluid.asset line 1559:

200 gives 1.6× headroom over peak (126 live particles). With 9 emitters:

  • Total particle slots: 9 × 200 = 1,800 (vs 180,000)
  • neighbors buffer: 1.8 MB (vs 184 MB) — 100× reduction
  • colliderContacts readback: ~460 KB (vs 46 MB) — 100× reduction

---

Secondary Root Cause — ObiNativeList Finalizer (Scene Reload Doesn't Help)

ObiNativeList.~ObiNativeList() calls DisposeOfComputeBuffer() from the GC finalizer thread. GraphicsBuffer.Dispose() must run on the main thread — called from the wrong thread, it silently fails. GPU buffers are permanently orphaned and survive scene reload.

This explains why Flush/scene restart never released memory — not a leak per se but a one-time orphan per solver lifetime.

Status: Not patched yet. Lower priority now that capacity is fixed.

---

Build Log

| Build | Change | Sim Leak Rate | Notes |

|---|---|---|---|

| #004 (prev) | Rendering patches | ~481 MB/s | Rendering still enabled |

| #005 | m_Active: 0 attempt | ~481 MB/s | Wrong YAML field — fluid still rendered |

| #006 | All rendering disabled | ~161 MB/s | Confirmed sim-side leak |

| #007 | SetSimplices patch | ~161 MB/s | Patch correct but not root cause |

| #008 | capacity: 200 + SetSimplices | ~134 MB/s | Baseline polluted (monitoring started mid-match at 20 GB); capacity patches correct but D3D12 root cause not yet identified |

| #009 | ObiSolver disabled | ~135 MB/s (polluted) / flat after reboot | Confirmed leak stops when match ends even without Obi; polluted D3D12 baseline made rate unreliable |

| #010 | ObiSolver disabled + D3D11 | ~0 MB/s | Flat 1280 MB throughout full match — confirmed D3D12 deferred-release pool is root cause |

| #011 | Full Obi re-enabled + D3D11 | ~0.1 MB/s | FIXED — 1337→1347 MB over 90s. Stable. |

---

Root Cause (Final)

The leak was D3D12-specific deferred-release pool behaviour, not a true memory leak in code.

  • D3D12: GraphicsBuffer.Dispose() queues the buffer for deferred release. The driver returns memory to an internal pool, NOT to the OS. New allocations commit fresh OS pages instead of reusing pooled ones. Pool grows monotonically for the lifetime of the process.
  • D3D11: Disposed resources are returned to the driver pool immediately and reused for new allocations. Working set stays flat.

Obi's emit/die particle lifecycle creates a small but steady stream of new GraphicsBuffer allocations per frame (SetSimplices, readbacks). On D3D11 these are reused. On D3D12 they permanently expand the pool.

Fix applied: ProjectSettings.asset — Standalone Windows graphics API forced to Direct3D11 (m_APIs: 02000000, m_Automatic: 0).

---

What Didn't Work / Lessons

  • m_Enabled: 0 on a URP ScriptableRendererFeature — WRONG field. URP checks m_Active, not m_Enabled.
  • Removing ObiFluidRendererFeature from m_RendererFeatures — correct way to disable URP features in YAML.
  • SetSimplices/SetActiveParticles guards — correct patches but not the root cause. The leak was D3D12 pool behaviour, not allocation frequency or size.
  • Capacity 20000→200 — correct and reduces one-time static allocation by 100×, but doesn't fix D3D12 pool growth.
  • Polluted D3D12 baseline (no reboot between test builds) made rate measurements unreliable across builds. Always reboot when comparing rates.
  • Source-only analysis hit limits — binary isolation (disable ObiSolver, then switch API) was faster than reading code.

---

Remaining Items

  • [x] Re-enable rendering (ObiFluidRendererFeature, ObiParticleRenderer, ObiFluidSurfaceMesher)
  • [x] Verify memory is stable with rendering re-enabled — CONFIRMED 0.1 MB/s
  • [x] Force D3D11 for all Standalone builds — ProjectSettings.asset permanently set (m_APIs: 02000000, m_Automatic: 0)
  • [ ] Fix ObiNativeList finalizer thread issue (GPU buffer orphaning on scene reload) — lower priority, one-time per session; mitigated by D3D11
  • [ ] Update maxSurfaceChunks if needed now that particle count is much smaller

---

Status: RESOLVED (2026-03-29)

Memory leak investigation is complete. Game is stable at ~0.1 MB/s (effectively flat). All Standalone builds will use Direct3D11 permanently for as long as Obi Fluid is in the project. Proceeding to next development phase.

---

2026-03-31 — Centralised Audio Management Panel

Summary

Added a GameAudioData ScriptableObject as a single source of truth for all 22 audio clip slots across the game. Includes a rich custom Inspector panel with category foldouts, clip-count badge, preview buttons, and auto-link tools.

---

Problem

Audio clips were scattered across 5 different components on 4 different prefabs/GOs:

  • AudioManager (MatchScene GO) — 9 clips
  • PlayerAudioSource (PlayerPrefab) — 5 clips
  • AltarAudioSource (Altar prefab) — 3 clips
  • BloodEmitterAudioSource (BloodEmitter prefab) — 2 clips
  • UIAudioManager (DontDestroyOnLoad GO) — 5 clips

To reassign a clip you had to hunt through 4 prefabs. No way to see the full audio inventory at a glance.

---

Solution

New Files

Assets/ProtoV2/Scripts/Audio/GameAudioData.csScriptableObject holding all 22 AudioClip fields + sfxGroup / uiGroup AudioMixerGroup refs, grouped by category:

  • Match/Countdown, Floor Scroll, Match Outcome, Outlast Ticker, Floor Impact
  • Player, Altar, Blood Emitter, UI

Assets/ProtoV2/Scripts/Editor/GameAudioDataEditor.cs — Custom Inspector with:

  • Bold header "Ritual & Ruin — Audio Data"
  • HelpBox badge: "X / 22 clips assigned" (warning if incomplete)
  • Per-category colour-coded foldouts (persisted via EditorPrefs) each showing assigned/total
  • Every slot: object field + preview button (AudioUtil reflection, fallback safe)
  • "Find & Link Clips from SFX Folder" button — scans Assets/ProtoV2/Audio/SFX/ and auto-assigns by filename pattern matching
  • "Find & Link Mixer Groups" button — loads GameAudioMixer from Resources and assigns SFX + UI groups

Assets/ProtoV2/Scripts/Editor/GameAudioDataWizard.cs — Menu items under Tools/Ritual & Ruin/Audio/:

  • Create GameAudioData Asset — creates Assets/ProtoV2/Audio/GameAudioData.asset, selects + pings it
  • Auto-Link Clips & Mixers — one-shot auto-fills the asset from the SFX folder + mixer, logs all assignments

Modified Files (backward-compatible)

Each of the 5 audio components gained:

1. [Header("Audio Data")] [SerializeField] private GameAudioData _audioData; as the first serialized field

2. An if-block at the top of Awake() that copies clips from _audioData into the existing private fields when assigned

Components affected:

  • AudioManager.cs — 9 clips + sfxGroup
  • PlayerAudioSource.cs — 5 clips + sfxGroup
  • AltarAudioSource.cs — 3 clips + sfxGroup
  • BloodEmitterAudioSource.cs — 2 clips + sfxGroup
  • UIAudioManager.cs — 5 clips + uiGroup

Backward compatible: if _audioData is null, all individual Inspector-assigned clips continue to work unchanged.

---

How to Set Up

1. Tools/Ritual & Ruin/Audio/Create GameAudioData Asset — creates the asset

2. Select it in Project → Inspector shows the full panel

3. Click "Find & Link Clips from SFX Folder" + "Find & Link Mixer Groups" to auto-fill

4. Assign the asset to the _audioData slot on: AudioManager (MatchScene), UIAudioManager (MainMenu scene), PlayerPrefab, Altar prefab, BloodEmitter prefab

Or use Tools/Ritual & Ruin/Audio/Auto-Link Clips & Mixers to do steps 2+3 from a menu item.

---

Files Changed

| File | Change |

|---|---|

| Assets/ProtoV2/Scripts/Audio/GameAudioData.cs | NEW — ScriptableObject, 22 clips + 2 mixer groups |

| Assets/ProtoV2/Scripts/Editor/GameAudioDataEditor.cs | NEW — Custom Inspector panel |

| Assets/ProtoV2/Scripts/Editor/GameAudioDataWizard.cs | NEW — Create + auto-link menu items |

| Assets/ProtoV2/Scripts/Audio/AudioManager.cs | +_audioData field + Awake copy block |

| Assets/ProtoV2/Scripts/Audio/PlayerAudioSource.cs | +_audioData field + Awake copy block |

| Assets/ProtoV2/Scripts/Audio/AltarAudioSource.cs | +_audioData field + Awake copy block |

| Assets/ProtoV2/Scripts/Audio/BloodEmitterAudioSource.cs | +_audioData field + Awake copy block |

| Assets/ProtoV2/Scripts/Audio/UIAudioManager.cs | +_audioData field + Awake copy block |

---

2026-03-31 — Obi Rope + Obi Softbody Jellyfish Upgrade

What Was Done

Replaced the procedural LineRenderer spring-chain tentacles with proper Obi Rope physics tentacles, and added an Obi Softbody jelly core system. Both packages (Obi Rope 7.x, Obi Softbody 7.x) were already imported by the developer.

---

Files Changed

`Assets/ProtoV2/Scripts/JellyfishVisuals.cs` — Full rewrite (tentacle system only)

Removed:

  • All LineRenderer fields: _tentacles, _tentacleMaterials, _segmentPositions, _rimOffsetsLocal
  • Inspector params: segmentCount, segmentLength, dampFactor, startWidth, endWidth, swayAmplitude, swayFrequency
  • Methods: BuildTentacles(), UpdateTentacles(), BuildTentacleMaterial()

Added:

  • [SerializeField] ObiSolver _solver — auto-found via FindObjectOfType if null; tentacles skipped gracefully if solver unavailable (bell squish + hover bob still work)
  • Inspector params: ropeLength=0.9, ropeVisualThickness=0.05, ropeBendCompliance=0.01, ropeParticleCount=8
  • Runtime arrays: _ropes, _blueprints, _anchorTransforms, _ropeMaterials
  • BuildRimOffsets() — same XZ rim circle math as before
  • BuildObiRopeTentacles() — creates 6 ObiRope actors at Start(), each parented under ObiSolver
  • UpdateAnchorTransforms() — updates anchor Transform world positions each frame (same formula as old anchor calc: world Y from VisualRoot + rim XZ from root direction)
  • CreateRopeMaterial() — URP/Unlit transparent material, team-colored

Unchanged: UpdateHoverBob(), UpdateBellSquish(), ApplyEvolutionTint(), SetTentacleColor(), all evolution/color fields, OnDestroy() cleanup pattern.

Rope setup per tentacle:

1. Anchor Transform child of root GO (NOT VisualRoot — avoids inheriting bell tilt)

2. ObiRopeBlueprint: 2 control points (root at 0,0,0 → tip at 0,-length,0), tapered thickness (tip = 40% of root), root color opaque → tip alpha=0

3. ObiRope + ObiPathSmoother + ObiRopeExtrudedRenderer (DefaultRopeSection)

4. ObiParticleAttachment.Static on groups[0] → anchor transform

5. Parent under solver LAST (triggers AddToSolver)

---

`Assets/ProtoV2/Scripts/JellyfishSoftCore.cs` — New

ObiSoftbody "jelly interior" component. Add to PlayerPrefab root. Requires a pre-baked blueprint (run wizard below).

At Start(): creates a sphere-shaped ObiSoftbody GO under the solver, dynamically pin-attached to the character root via ObiParticleAttachment.Dynamic with configurable _compliance (default 0.002 — elastic spring lag). Rendered with a translucent blue sphere.

Exposes:

  • PulseSquish(float inwardSpeed) — applies inward radial velocity to all particles on pulse
  • SetColor(Color) — evolution tinting
  • IsReady — safe call guard

---

`Assets/ProtoV2/Scripts/Editor/JellyfishSoftCoreSetupWizard.cs` — New

Menu: Ritual & Ruin / Setup Jellyfish Soft Core

One-click editor wizard that:

1. Gets Unity's built-in sphere mesh via GameObject.CreatePrimitive(PrimitiveType.Sphere)

2. Creates ObiSoftbodyBlueprint, assigns mesh, calls GenerateImmediate()

3. Saves blueprint to Assets/ProtoV2/SoftbodyBlueprints/JellyfishCoreSoftbody.asset

4. Opens PlayerPrefab via EditPrefabContentsScope, adds JellyfishSoftCore to root, wires blueprint

---

How to Use After Compilation

Obi Rope Tentacles (automatic)

  • Assign ObiSolver reference on JellyfishVisuals in PlayerPrefab Inspector, OR leave null (auto-found at runtime)
  • Enter Play mode → 6 ObiRope tentacles simulate under the solver, physically trailing behind character movement

Obi Softbody Jelly Core (opt-in)

1. Wait for scripts to compile

2. Run Ritual & Ruin / Setup Jellyfish Soft Core from the Unity menu

3. Enter Play mode → translucent sphere lags behind character with elastic follow

Tuning parameters (JellyfishSoftCore Inspector):

  • _compliance: 0.001 (tight) → 0.01 (very sloshy)
  • _coreOffset: default (0, 1.5, 0) — positions core inside bell
  • _coreScale: default 0.35 — sphere radius
  • _coreColor: default translucent blue-white (0.5, 0.85, 1, 0.25)

Tuning parameters (JellyfishVisuals Inspector):

  • ropeLength: 0.9 — tentacle length
  • ropeVisualThickness: 0.05 — cross-section radius
  • ropeBendCompliance: 0.01 — stiffness (0=rigid, 0.1=very floppy)
  • ropeParticleCount: 8 — simulation resolution

---

Architecture Notes

  • Rope GOs are parented under ObiSolver, NOT under the character. Anchor Transforms (children of character root) follow the character each frame; ObiParticleAttachment.Static pins rope particle 0 to each anchor.
  • Anchor Transforms are children of the root (not VisualRoot) so they don't inherit pour tilt or bell squish rotation.
  • OnDestroy() cleans up all rope GOs, blueprint ScriptableObjects (DestroyImmediate), materials, and anchor GOs — no leaks on respawn/rematch.
  • If ObiSolver not found at Start, bell squish and hover bob still work — only tentacles are skipped.
  • JellyfishSoftCore is fully optional. No changes to JellyfishVisuals required for softbody to work.

---

Design Vault Reference

  • Asset Catalog Tier 1: Obi Rope ✅ implemented, Obi Softbody ✅ implemented
  • Jellyfish Visuals confirmed doc: "Future: replace with ObiRopes" ✅ done

---

2026-04-03 — Tentacle Revert: LineRenderer Spring-Chain

What Changed

Reverted jellyfish tentacles from ObiRope back to LineRenderer spring-chain.

Root cause for revert: ObiRope tentacles are physical actors — on every bell pulse the VisualRoot squishes, which moves the ObiParticleAttachment anchor, causing particles to bounce violently. The tentacles should trail softly, not react to the bell pulse physics.

Implementation

JellyfishVisuals.cs — full rewrite

  • 6 LineRenderer tentacles, 6 segments each
  • Spring chain: segment 0 snaps to anchor, segments 1+ lerp toward (prev + Vector3.down * segLen) then distance-constrained to fixed length
  • Auto-scaling: all size values multiplied by transform.lossyScale.x at Start() so tentacles look correct at any character scale (match=0.25, lobby=0.69)
  • Inspector values are scale=1 normalized

Prefab values (scale=1 normalized)

| Field | Value | World value @ scale=0.25 |

|---|---|---|

| rimRadius | 1.3 | 0.325 |

| rimY | 1.5 | 0.375 (anchor above floor) |

| segmentLength | 0.25 | 0.0625/segment, 0.375 total = reaches floor |

| startWidth | 0.6 | 0.15 |

| endWidth | 0.16 | 0.04 |

Key insight on segmentLength: Originally set to 0.6 (= 0.9 world units total at match scale) — tentacles extended 0.525 units below floor, making only ~2 segments visible. Corrected to 0.25 (= 0.375 world units total) so all 6 segments are visible above floor. At lobby scale 0.69: 0.25 × 0.69 × 6 = 1.035 world units = exactly matches anchor height. Both scenes auto-balance.

TentacleSetupWizard.cs

Added "Ritual & Ruin/Cleanup Jellyfish Tentacles (Revert to LineRenderer)" menu item that removes TentacleSolver GO and TentacleAnchor_* from VisualRoot. ObiRope setup code kept shelved for future reference.

Visual Result

Tentacles appear as a radial fringe of colored lines immediately around each jellyfish body. From the isometric top-down camera angle, downward-hanging tentacles are foreshortened — appears as a small colored cluster rather than long dangling appendages. Correctly attached to characters, correctly auto-scaled, correctly team-colored.

---

2026-04-04 — Tilt Control in Rebind UI + In-Match Menu Hints

What Changed

Three pending gaps from the 2026-03-23 manual tilt session completed:

1. Tilt (right stick) added to all rebind UIs

2. Auto-tilt toggle wiring completed (PlayerMenuController + PlayerSettingsPanel)

3. Per-player menu hint text added to in-match HUD and settings panel

---

Tilt in Rebind UIs

Tilt was fully implemented in the input system but never surfaced to players as a rebindable control.

Files changed

InputRebindMenuUI.cs

  • Added "Tilt" to rebindableActions. The UGUI lobby rebind panel now shows a Tilt row for controller players. Keyboard players see no row (Tilt has no keyboard binding — skipped automatically by GetBindingIndexForControlScheme returning -1).

PlayerSettingsPanel.cs

  • Added "Tilt" to rebindableActions. Same behavior in the in-game settings panel.

InputRebindUIController.cs (UI Toolkit lobby panel)

  • BindingNames / ActionMappings: added "tilt" / "Tilt" at index 6
  • ControllerPresetBindings: added tilt as 4th element per preset:
  • L-Stick + Buttons → rightStick
  • L-Stick + Triggers → rightStick
  • R-Stick + Buttons → leftStick (rightStick already used for Move in this preset)
  • D-Pad + Buttons → rightStick
  • ApplyPreset() controller branch: calls ApplyControllerMovePreset(tiltAction, presetPaths[3]) — correct because Tilt is Value/Vector2 (same structure as Move)

---

Auto-Tilt Toggle Wiring

PlayerMenuController and PlayerSettingsPanel code was complete since 2026-03-23. The missing piece was editor wiring. The existing PlayerMenuSetupWizard (Tools > Player Menu Setup) confirmed everything was already in place from a prior run:

Toggle behavior: visible only for controller players (keyboard always uses auto-tilt; hidden for them). Players access it via MenuOpen (Start/Escape) → Settings panel.

---

Per-Player Menu Hint Text

New UI prompt system so players know how to open/close the per-player settings panel during a match.

`PlayerMenuController.cs`

  • Added [SerializeField] TextMeshProUGUI menuHintText
  • Start(): sets initial text, subscribes to PlayerSetup.OnInputModeChanged
  • UpdateHintText() sets text based on current state:

| State | Controller | Keyboard |

|---|---|---|

| Menu closed | START — Settings | ESC — Settings |

| Menu open | START — Close Settings | ESC — Close Settings |

| Waiting for controller | Waiting for controller… | — |

  • Fires on OpenMenu(), CloseMenu(), and any device change event

`PlayerSettingsPanel.cs`

  • Added [SerializeField] TextMeshProUGUI instructionText
  • Set in Show() based on player's input mode:
  • Controller: START or B — Close
  • Keyboard: ESC — Close

Unity wiring — `MenuHintWiringWizard.cs` (new editor script)

Runs via Tools > Wire Menu Hint Text. One-shot, rerunnable.

  • PlayerPrefab: Created MenuHintText TMP child under BarCanvas, anchored below the health bar. Font size 3 (world-space canvas), 65% opacity white, centre-aligned. Wired to PlayerMenuController.menuHintText.
  • LobbyScene / PlayerSettingsPanelCanvas/PanelRoot: Created InstructionText TMP anchored to panel bottom, font size 13, grey. Wired to PlayerSettingsPanel.instructionText.
  • LobbyScene saved.

Players can now manually tilt the jellyfish bowl using the right stick, with the tilt range expanded significantly for more expressive play. Blood is stickier and less likely to spill during movement, and carrier mode slows movement slightly to reduce inertia. These changes make carrying blood feel more deliberate.

The settings system is now fully working. All sliders — volume, resolution, graphics quality, camera shake — are functional in both the main menu and during matches. The SFX volume slider previously had no effect because audio sources weren't wired to the mixer. Fixed. Camera shake toggle now works too.

Several memory leaks were resolved that caused performance to degrade over long sessions. Standalone build crashes were fixed — missing shaders caused visual elements not to spawn in builds at all. Unglamorous sessions, but necessary ones.

Raw session notes

Mar 22 — Memory leaks & outlast phase

Fixed duplicate event subscriptions, mesh asset leaks, static dictionary never cleared between matches, anonymous lambda closures keeping dead objects alive. Added OutlastPanel with 4× time scale drain.

Mar 23 — Tilt system, carrier speed, controller menus

Manual tilt via right stick (75° range). Carrier mode 80% speed. ObiFluid viscosity increased. Per-player lobby settings panel via Start/Escape.

Mar 26 — Build fixes, pause menu

Fixed standalone crashes: missing shaders, GPU memory leak on restart, main menu buttons. Added Settings to Pause and Main Menu.

Mar 27–28 — Full settings system & verification

Audio/video/graphics/gameplay settings, persisted via PlayerPrefs. SFX routing fixed. Camera shake toggle fixed. Resolution default fixed.

Every action in a match now has sound. Countdown ticks, floor scroll, match start and end, player death, form transform, evolution, altar filling, blood hitting the floor, UI buttons — all wired in a single session. The game went from silent to fully voiced in one pass.

Visual feedback improved significantly. Glowing rings appear on floor tiles that have a blood emitter underneath, making it easier to know where blood will come from. Altars now turn red from the bottom up as they fill, so you can read altar progress at a glance without checking a number. Blood emitter fluid was tuned to behave more like blood.

Jellyfish creatures got animated tentacles. Player 4 controller disconnect and reconnect handling was fixed. Blood leaking through floors due to collider issues was resolved.

Raw session notes

Mar 15–16 — Visuals & controller fix

Emitter indicator glowing rings on tile surfaces. Jellyfish animated tentacles. Pouring mechanic first visual pass. Player 4 controller hotplug fix.

Mar 18–19 — Blood & altar

Blood emitter fluid tuning. Altar fill visual shader (URP HLSL, red fill bottom-up). Obi fluid collider leak fix.

Mar 20 — Audio system

Full audio pass: 7 components, 18+ events covered. Countdown, scroll alarm, evolution sting, death sound, form swap, altar sounds, floor impact, UI audio.

Mar 21 — Player 4 controller

Controller disconnect/reconnect handling fixed for Player 4.

All 13 core game systems were completed and wired up in Unity — the game could run a full match end-to-end for the first time. Spawn system, countdown, match flow, win/loss detection, onboarding overlays, pause menu, terminal phase where eliminated teams watch their bar drain out. Getting all of this connected was the majority of this period.

The match wasn't actually starting after all that. Countdown never appeared, blood never emitted. Root cause was a spawn timing issue on the first frame — fixed by moving spawn dispatch to a later update pass. Floor boundary walls were added, and floors without players now fade out so the active floor is easier to read. Pillars between the camera and players turn semi-transparent.

Raw session notes

Mar 5–10 — Alpha systems complete & scene wiring

All 13 systems implemented and wired in Unity editor. First full match runnable.

Mar 11 — Floor visibility & boundary walls

Floor fades when no players present. Boundary walls added to arena edges.

Mar 14 — Match start fix & pillar occlusion

Fixed match not starting (spawn timing on first frame). Added pillar transparency when occluding players.

Players now have full control over their input setup — custom key bindings for keyboard and controller, with preset layouts (WASD, Arrows, IJKL, Numpad), conflict detection so two players can't claim the same keys, and swap confirmation when rebinding overlapping controls. Any combination of keyboards and controllers should just work.

The core arena mechanic was built: three floors stacked vertically, scrolling downward as altars are consumed. Blood emitters activate and deactivate by floor role. A vertical hazard descends from above. Visual proportions were overhauled after early testing showed everything looking wrong — platforms too thin, camera too far out, pillars piercing through multiple floors. All fixed.

The evolution bar was redesigned: instead of resetting when you evolve, the bar grows 30% wider and changes colour. Evolution feels like gaining something rather than starting over.

Raw session notes

Jan 22–26 — Input rebinding system

Full controller/keyboard rebind UI. Shared keyboard/controller support. Presets, conflict detection, swap confirmation, startup validation.

Feb 5 — Scrolling floor system

3-floor stacked arena. Chunk grid, gap generation, emitter/altar placement, BFS path validation. Scroll trigger on altar consumption. Vertical hazard.

Feb 14 — Pillar fix & visual overhaul

Fixed pillars piercing all floors. Floor spacing 10→5 units. Chunk thickness 0.2→1.0. Arena now looks proportional.

Feb 21 — Rename & alpha plan

Form-toggle action renamed across codebase. 13-system alpha implementation plan written with dependency tiers.

Feb 26 — Evolution bar redesign

Bar grows 30% wider on evolution instead of resetting. Tier accent colours added.

Why I automated my devlogs

The honest answer is that I kept not writing them. Not because nothing was happening — the game is being worked on almost every day — but because after a session I just want to stop. The last thing I want to do is write about what I just spent three hours doing.

So the devlogs never got written, and from the outside it probably looked like the game was dead. It wasn't. It just had no voice.

The automated log isn't a replacement for real devlogs. It's a proof-of-life signal. A way for anyone following the project to see that work is happening, even when I'm too tired to say so myself.