diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs
new file mode 100644
index 000000000..f8cff10c8
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs
@@ -0,0 +1,86 @@
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Live-tunable knobs for the client-only AMBIENT LIFE slice (Phase 1.5b bundle 4: fleeing critters +
+ /// weather beats — cloud-shadow drifts + Blight lightning). Static bridge (mirrors /
+ /// ) so values can be poked at runtime via MCP execute_code without a recompile.
+ /// Read ONLY by (managed, main-thread). NEVER read from a [BurstCompile] system.
+ /// re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] (statics survive
+ /// fast-enter-playmode reloads). All effects are cosmetic + observe-only — zero netcode.
+ ///
+ public static class AmbientLifeConfig
+ {
+ /// Master gate for every ambient-life effect.
+ public static bool Enabled;
+
+ // ---- fleeing critters (ground bugs + a few birds) ----
+ /// Master gate for the critter pool.
+ public static bool CrittersEnabled;
+ /// Live critters maintained near the camera.
+ public static int CritterCount;
+ /// Radius (world u) around the camera critters live in; past it they recycle to the ring edge.
+ public static float CritterRadius;
+ /// A critter within this distance of the local player DARTS away (the scatter beat).
+ public static float CritterFleeRadius;
+ /// Idle wander speed (u/s).
+ public static float CritterIdleSpeed;
+ /// Flee dart speed (u/s) when the player is close.
+ public static float CritterFleeSpeed;
+ /// Fraction of the pool that are BIRDS (airborne, faster, flee upward) vs ground bugs.
+ public static float BirdFraction;
+
+ // ---- cloud-shadow drifts ----
+ /// Master gate for drifting cloud shadows.
+ public static bool CloudsEnabled;
+ /// Number of soft dark shadow discs drifting across the ground near the camera.
+ public static int CloudCount;
+ /// Shadow disc radius (world u).
+ public static float CloudSize;
+ /// Peak shadow opacity (alpha).
+ public static float CloudAlpha;
+ /// Drift speed (u/s) across the ground.
+ public static float CloudDriftSpeed;
+
+ // ---- Blight lightning flicker (Blight-biome only) ----
+ /// Master gate for the Blight lightning beat.
+ public static bool LightningEnabled;
+ /// Min / max seconds between lightning strikes.
+ public static float LightningMinInterval;
+ public static float LightningMaxInterval;
+ /// Peak flash directional-light intensity.
+ public static float LightningIntensity;
+ /// Flash tint (cool blight white-purple).
+ public static Color LightningColor;
+ /// Thunder SFX volume (delayed after the flash).
+ public static float ThunderVolume;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ public static void ResetDefaults()
+ {
+ Enabled = true;
+
+ CrittersEnabled = true;
+ CritterCount = 10;
+ CritterRadius = 22f;
+ CritterFleeRadius = 4f;
+ CritterIdleSpeed = 0.6f;
+ CritterFleeSpeed = 6f;
+ BirdFraction = 0.25f;
+
+ CloudsEnabled = true;
+ CloudCount = 3;
+ CloudSize = 18f;
+ CloudAlpha = 0.20f;
+ CloudDriftSpeed = 1.6f;
+
+ LightningEnabled = true;
+ LightningMinInterval = 6f;
+ LightningMaxInterval = 16f;
+ LightningIntensity = 2.4f;
+ LightningColor = new Color(0.75f, 0.7f, 1f);
+ ThunderVolume = 0.45f;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs.meta
new file mode 100644
index 000000000..68760c963
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeConfig.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 501ee5a113c66f841abbefd303ea8880
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs
new file mode 100644
index 000000000..d436c18e4
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs
@@ -0,0 +1,322 @@
+using System.Collections.Generic;
+using ProjectM.Simulation;
+using Unity.Entities;
+using Unity.NetCode;
+using Unity.Transforms;
+using UnityEngine;
+
+namespace ProjectM.Client
+{
+ ///
+ /// Client-only AMBIENT LIFE layer (Phase 1.5b bundle 4 — critters + weather beats, zero netcode). Sibling of
+ /// : an observe-only in
+ /// that never touches the sim. Three cosmetic beats, all camera-following,
+ /// scene-gated to "Game", biome-keyed off the same camera-X + replicated
+ /// switch / use:
+ /// - CRITTERS: a small pool of ground bugs (+ a few airborne birds) that idle-wander near the camera and
+ /// DART away when the local player closes in (the "scatter" beat), biome-tinted, recycled at the ring edge;
+ /// - CLOUD SHADOWS: soft dark feathered discs drifting slowly across the ground, recycled off the far edge;
+ /// - BLIGHT LIGHTNING: in Blight rooms only, a periodic double-blink directional-light flash + delayed
+ /// procedural thunder (a dedicated flash light, so it never fights WorldAtmosphereSystem's RenderSettings).
+ /// Pooled GameObjects live under a private DontDestroyOnLoad root; wall-clock time is fine (presentation).
+ /// Knobs live in .
+ ///
+ [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ public partial class AmbientLifeSystem : SystemBase
+ {
+ struct Critter { public GameObject Go; public Vector3 Pos; public Vector3 Vel; public bool IsBird; public float Phase; }
+ struct Cloud { public GameObject Go; public Vector3 Pos; }
+
+ static readonly Color[] CritterTint =
+ {
+ new Color(0.9f, 0.95f, 0.7f, 0.95f), // 0 meadow — pale mote/butterfly
+ new Color(0.8f, 0.66f, 0.42f, 0.95f), // 1 arid — tan
+ new Color(0.6f, 0.72f, 1f, 0.95f), // 2 cavern — pale blue
+ new Color(0.7f, 0.45f, 0.85f, 0.95f), // 3 blight — sickly purple
+ };
+ static readonly int ColorId = Shader.PropertyToID("_Color");
+
+ Camera _cam;
+ GameObject _root;
+ Mesh _critterMesh, _cloudMesh;
+ Material _critterMat, _cloudMat;
+ Light _flashLight;
+ AudioClip _thunderClip;
+ MaterialPropertyBlock _mpb;
+
+ readonly List _critters = new();
+ readonly List _clouds = new();
+ int _biomeKey = -1;
+
+ // lightning schedule
+ float _nextStrike = -1f;
+ float _strikeStart = -1f;
+ bool _thunderQueued;
+ float _thunderAt = -1f;
+
+ protected override void OnCreate()
+ {
+ _mpb = new MaterialPropertyBlock();
+ _thunderClip = FeedbackFx.MakeClip("thunder", 70f, 30f, 0.9f, 0.6f, noise: true, decay: 3.5f);
+ }
+
+ protected override void OnDestroy()
+ {
+ if (_root != null) Object.Destroy(_root);
+ if (_critterMesh != null) Object.Destroy(_critterMesh);
+ if (_cloudMesh != null) Object.Destroy(_cloudMesh);
+ if (_critterMat != null) Object.Destroy(_critterMat);
+ if (_cloudMat != null) Object.Destroy(_cloudMat);
+ }
+
+ protected override void OnUpdate()
+ {
+ if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") { Hide(); return; }
+ if (_cam == null) _cam = Camera.main;
+ if (_cam == null) return;
+ if (!AmbientLifeConfig.Enabled) { Hide(); return; }
+
+ if (_root == null)
+ {
+ _root = new GameObject("~AmbientLife");
+ Object.DontDestroyOnLoad(_root);
+ _critterMesh = FeedbackFx.BuildDisc(7); // tiny speck
+ _cloudMesh = FeedbackFx.BuildScorchMesh(28, 0.22f, 3); // soft feathered shadow blob
+ _critterMat = FeedbackFx.MakeParticleMaterial("AmbientCritter");
+ _cloudMat = FeedbackFx.MakeDecalMaterial("CloudShadow");
+ }
+
+ int key = BiomeKey();
+ if (key != _biomeKey)
+ {
+ _biomeKey = key;
+ _critterMat.color = CritterTint[key];
+ }
+
+ Vector3 cam = _cam.transform.position;
+ float dt = Mathf.Min(0.05f, UnityEngine.Time.deltaTime); // clamp so a hitch can't teleport critters
+ float now = UnityEngine.Time.time;
+
+ UpdateCritters(cam, dt, now);
+ UpdateClouds(cam, dt);
+ UpdateLightning(key == 3, now);
+ }
+
+ int BiomeKey()
+ {
+ int key = 0; // meadow / base hub
+ if (_cam.transform.position.x > 500f)
+ {
+ key = 1; // arid default in the expedition region
+ if (SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle != RunLifecycle.Staging)
+ switch (ri.CurrentBiome)
+ {
+ case RoomBiomeId.Meadow: key = 0; break;
+ case RoomBiomeId.Cavern: key = 2; break;
+ case RoomBiomeId.Blight: key = 3; break;
+ }
+ }
+ return key;
+ }
+
+ // ---------- critters (bugs + birds) ----------
+
+ void UpdateCritters(Vector3 cam, float dt, float now)
+ {
+ if (!AmbientLifeConfig.CrittersEnabled) { for (int i = 0; i < _critters.Count; i++) SetActive(_critters[i].Go, false); return; }
+
+ // local player position (flee source); missing -> far away so nothing flees
+ Vector3 playerPos = new Vector3(1e9f, 0f, 1e9f);
+ foreach (var lt in SystemAPI.Query>().WithAll())
+ playerPos = new Vector3(lt.ValueRO.Position.x, 0f, lt.ValueRO.Position.z);
+
+ int want = Mathf.Clamp(AmbientLifeConfig.CritterCount, 0, 64);
+ while (_critters.Count < want) SpawnCritter(cam);
+ for (int i = _critters.Count - 1; i >= want; i--) { if (_critters[i].Go != null) Object.Destroy(_critters[i].Go); _critters.RemoveAt(i); }
+
+ float radius = AmbientLifeConfig.CritterRadius;
+ float fleeR2 = AmbientLifeConfig.CritterFleeRadius * AmbientLifeConfig.CritterFleeRadius;
+ float idle = AmbientLifeConfig.CritterIdleSpeed, flee = AmbientLifeConfig.CritterFleeSpeed;
+
+ for (int i = 0; i < _critters.Count; i++)
+ {
+ var c = _critters[i];
+ if (c.Go == null) { c.Go = MakeCritterGo(c.IsBird); }
+ SetActive(c.Go, true);
+
+ float birdY = c.IsBird ? 2.6f : 0.06f;
+ Vector3 flat = new Vector3(c.Pos.x, 0f, c.Pos.z);
+ float dxp = flat.x - playerPos.x, dzp = flat.z - playerPos.z;
+ Vector3 target;
+ if (dxp * dxp + dzp * dzp < fleeR2)
+ {
+ // DART away from the player (birds also rise a touch)
+ var away = new Vector3(dxp, 0f, dzp);
+ if (away.sqrMagnitude < 0.001f) away = new Vector3(Mathf.Cos(c.Phase), 0f, Mathf.Sin(c.Phase));
+ target = away.normalized * flee;
+ }
+ else
+ {
+ // gentle wander: a slowly rotating heading per critter
+ float a = now * 0.35f + c.Phase;
+ target = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * idle * (c.IsBird ? 1.8f : 1f);
+ }
+ c.Vel = Vector3.Lerp(c.Vel, target, 1f - Mathf.Exp(-6f * dt));
+ c.Pos += c.Vel * dt;
+
+ // recycle when it wanders past the ring
+ float dxc = c.Pos.x - cam.x, dzc = c.Pos.z - cam.z;
+ if (dxc * dxc + dzc * dzc > radius * radius)
+ {
+ float ang = UnityEngine.Random.value * 6.2831f;
+ float r = radius * 0.7f;
+ c.Pos = new Vector3(cam.x + Mathf.Cos(ang) * r, 0f, cam.z + Mathf.Sin(ang) * r);
+ c.Vel = Vector3.zero;
+ }
+
+ c.Go.transform.position = new Vector3(c.Pos.x, birdY, c.Pos.z);
+ float s = c.IsBird ? 0.22f : 0.14f;
+ c.Go.transform.localScale = new Vector3(s, 1f, s);
+ _critters[i] = c;
+ }
+ }
+
+ void SpawnCritter(Vector3 cam)
+ {
+ bool bird = UnityEngine.Random.value < Mathf.Clamp01(AmbientLifeConfig.BirdFraction);
+ float ang = UnityEngine.Random.value * 6.2831f;
+ float r = UnityEngine.Random.Range(3f, Mathf.Max(4f, AmbientLifeConfig.CritterRadius * 0.9f));
+ _critters.Add(new Critter
+ {
+ Go = MakeCritterGo(bird),
+ Pos = new Vector3(cam.x + Mathf.Cos(ang) * r, 0f, cam.z + Mathf.Sin(ang) * r),
+ Vel = Vector3.zero,
+ IsBird = bird,
+ Phase = UnityEngine.Random.value * 6.2831f,
+ });
+ }
+
+ GameObject MakeCritterGo(bool bird)
+ {
+ var go = new GameObject(bird ? "Bird" : "Bug");
+ go.transform.SetParent(_root.transform, false);
+ go.AddComponent().sharedMesh = _critterMesh;
+ var mr = go.AddComponent();
+ mr.sharedMaterial = _critterMat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ return go;
+ }
+
+ // ---------- cloud-shadow drifts ----------
+
+ void UpdateClouds(Vector3 cam, float dt)
+ {
+ if (!AmbientLifeConfig.CloudsEnabled) { for (int i = 0; i < _clouds.Count; i++) SetActive(_clouds[i].Go, false); return; }
+
+ int want = Mathf.Clamp(AmbientLifeConfig.CloudCount, 0, 16);
+ while (_clouds.Count < want)
+ {
+ var go = new GameObject("CloudShadow");
+ go.transform.SetParent(_root.transform, false);
+ go.AddComponent().sharedMesh = _cloudMesh;
+ var mr = go.AddComponent();
+ mr.sharedMaterial = _cloudMat;
+ mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ mr.receiveShadows = false;
+ mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
+ float sp = AmbientLifeConfig.CritterRadius; // reuse a spread
+ _clouds.Add(new Cloud { Go = go, Pos = new Vector3(cam.x + UnityEngine.Random.Range(-sp, sp), 0f, cam.z + UnityEngine.Random.Range(-sp, sp)) });
+ }
+ for (int i = _clouds.Count - 1; i >= want; i--) { if (_clouds[i].Go != null) Object.Destroy(_clouds[i].Go); _clouds.RemoveAt(i); }
+
+ _cloudMat.color = new Color(0.02f, 0.02f, 0.04f, Mathf.Clamp01(AmbientLifeConfig.CloudAlpha));
+ float size = AmbientLifeConfig.CloudSize;
+ float drift = AmbientLifeConfig.CloudDriftSpeed;
+ float span = size * 2.2f;
+ for (int i = 0; i < _clouds.Count; i++)
+ {
+ var cl = _clouds[i];
+ SetActive(cl.Go, true);
+ var p = cl.Pos;
+ p.x += drift * dt; // drift along +X (a light prevailing wind)
+ // recycle once it drifts a span past the camera downwind edge
+ if (p.x > cam.x + size + span) { p.x = cam.x - size - span; p.z = cam.z + UnityEngine.Random.Range(-size, size); }
+ cl.Pos = p;
+ cl.Go.transform.position = new Vector3(p.x, 0.04f, p.z);
+ cl.Go.transform.localScale = new Vector3(size, 1f, size);
+ cl.Go.GetComponent().SetPropertyBlock(null); // uses shared material color
+ _clouds[i] = cl;
+ }
+ }
+
+ // ---------- Blight lightning flicker ----------
+
+ void UpdateLightning(bool inBlight, float now)
+ {
+ if (!AmbientLifeConfig.LightningEnabled || !inBlight)
+ {
+ if (_flashLight != null) _flashLight.intensity = 0f;
+ _strikeStart = -1f; _nextStrike = -1f;
+ return;
+ }
+ if (_flashLight == null)
+ {
+ var lgo = new GameObject("~BlightLightning");
+ lgo.transform.SetParent(_root.transform, false);
+ lgo.transform.rotation = Quaternion.Euler(52f, -28f, 0f);
+ _flashLight = lgo.AddComponent();
+ _flashLight.type = LightType.Directional;
+ _flashLight.shadows = LightShadows.None;
+ _flashLight.intensity = 0f;
+ }
+ _flashLight.color = AmbientLifeConfig.LightningColor;
+
+ if (_nextStrike < 0f) _nextStrike = now + UnityEngine.Random.Range(AmbientLifeConfig.LightningMinInterval, AmbientLifeConfig.LightningMaxInterval);
+
+ // begin a strike
+ if (_strikeStart < 0f && now >= _nextStrike)
+ {
+ _strikeStart = now;
+ _nextStrike = now + UnityEngine.Random.Range(AmbientLifeConfig.LightningMinInterval, AmbientLifeConfig.LightningMaxInterval);
+ _thunderAt = now + UnityEngine.Random.Range(0.3f, 0.8f); // thunder trails the flash
+ _thunderQueued = true;
+ }
+
+ // drive the double-blink envelope
+ if (_strikeStart >= 0f)
+ {
+ float t = now - _strikeStart;
+ float peak = AmbientLifeConfig.LightningIntensity;
+ float e;
+ if (t < 0.06f) e = peak;
+ else if (t < 0.11f) e = peak * 0.15f;
+ else if (t < 0.20f) e = peak; // brighter second blink
+ else if (t < 0.5f) e = peak * (1f - (t - 0.20f) / 0.30f);
+ else { e = 0f; _strikeStart = -1f; }
+ _flashLight.intensity = Mathf.Max(0f, e);
+ }
+
+ if (_thunderQueued && now >= _thunderAt)
+ {
+ _thunderQueued = false;
+ var p = _cam != null ? _cam.transform.position : Vector3.zero;
+ FeedbackFx.PlayClip(_thunderClip, p, AmbientLifeConfig.ThunderVolume);
+ }
+ }
+
+ // ---------- helpers ----------
+
+ static void SetActive(GameObject go, bool on) { if (go != null && go.activeSelf != on) go.SetActive(on); }
+
+ void Hide()
+ {
+ for (int i = 0; i < _critters.Count; i++) SetActive(_critters[i].Go, false);
+ for (int i = 0; i < _clouds.Count; i++) SetActive(_clouds[i].Go, false);
+ if (_flashLight != null) _flashLight.intensity = 0f;
+ _strikeStart = -1f; _nextStrike = -1f;
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs.meta
new file mode 100644
index 000000000..ecfb40474
--- /dev/null
+++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b3b872fb243ca8648a9be6697f40d994
\ No newline at end of file