code.pablomurad.com

the-last-glyph

Turn-based narrative RPG about a world that lost its names, drawn entirely in emoji

feat: more sutff
author
Pablo Murad
date
commit
c51419ef674b7005e6d15154a963f87e334ff23d
parent
9e9c9e01770600111d68b72cf8c937a8e9e10242
changes
33 files +1444 −106
@@ -11,6 +11,9 @@ "devDependencies": {         "typescript": "^5.6.3",
         "vite": "^5.4.11",
         "vitest": "^2.1.8"
+      },
+      "engines": {
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/aix-ppc64": {
@@ -25,10 +25,11 @@ import { DIRECTION_VECTORS, addPos, manhattan, posKey, type GridPos } from './types'; import { MapManager } from '../world/MapManager';
 import type { ExitDef, InteractableDef, MapDefinition } from '../world/MapDefinition';
 import { occupantAt } from '../world/Collision';
-import { findPath, reachableSet } from '../world/Pathfinding';
+import { findPath, reachableSet, hasLineOfSight } from '../world/Pathfinding';
 import { MovementSystem } from '../systems/MovementSystem';
 import {
   inspectEnemy,
+  inflictedCondition,
   performAttack,
   performCalm,
   performDefend,
@@ -52,12 +53,15 @@ } from '../systems/InventorySystem'; import { DialogueSystem, applyEffect } from '../systems/DialogueSystem';
 import type { DialogueNode } from '../data/dialogues/types';
 import { EffectSystem } from '../systems/EffectSystem';
+import { AudioSystem } from '../systems/AudioSystem';
 import { decideEnemyAction } from '../systems/EnemyAI';
 import { validTargets } from '../systems/Targeting';
 import { hearEcho } from '../systems/EchoSystem';
 import { storeCurrentMap, takeMapState } from '../systems/MapTransitions';
 import { aggroRangeOf } from '../systems/EnemyAI';
 import { startQuest, advanceQuest } from '../systems/QuestSystem';
+import { hintOnce } from '../systems/HintSystem';
+import { chapterComplete } from '../systems/EndingSystem';
 import { Renderer } from '../rendering/Renderer';
 import { EffectRenderer } from '../rendering/EffectRenderer';
 import { AnimationQueue } from '../rendering/AnimationQueue';
@@ -71,11 +75,15 @@ import { EchoView } from '../ui/EchoView'; import { SavesView, type SlotEntry } from '../ui/SavesView';
 import {
   buildCreditsScreen,
+  buildDeathOverlay,
+  buildEndingOverlay,
   buildGlyphGalleryScreen,
   buildNewGameScreen,
   buildPauseOverlay,
   buildSettingsScreen,
   buildTitleScreen,
+  type EndingOverlay,
+  type StoryOverlay,
   type TitleScreen,
 } from '../ui/screens';
 import { createEnemyState, createNewGameState, createNpcState } from '../entities/EntityFactory';
@@ -90,6 +98,7 @@ import { CHARACTERS, CHARACTER_IDS } from '../data/characters'; import { FLAGS, MEMORY_EVENTS } from '../data/flags';
 import { QUEST_IDS } from '../data/quests';
 import { GLYPH_IDS, GLYPHS } from '../data/glyphs';
+import { SHARDS } from '../data/shards';
 import { validateContent } from '../utils/validation';
 import { el } from '../utils/dom';
 
@@ -99,6 +108,12 @@ function glyphOf(id: string): string {   return GLYPH_LOOKUP.get(id) ?? '✨';
 }
 
+const PANEL_HINTS: Record<PanelId, { flag: string; text: string }> = {
+  inventory: { flag: FLAGS.hintPanelInventory, text: 'Inventory — press I to open or close it.' },
+  journal: { flag: FLAGS.hintPanelJournal, text: 'Journal — press J to open or close it.' },
+  character: { flag: FLAGS.hintPanelCharacter, text: 'Character — press C to open or close it.' },
+};
+
 interface TargetingState {
   abilityId: string;
   targets: ActorState[];
@@ -118,11 +133,13 @@ private readonly mapManager = new MapManager();   private readonly turnManager = new TurnManager();
   private readonly effects = new EffectSystem();
   private readonly dialogue = new DialogueSystem();
+  private readonly audio = new AudioSystem();
   private rng = new Rng(Date.now() >>> 0);
   private settings: Settings;
   private readonly devMode: boolean;
 
   private state: GameState | null = null;
+  private readonly sceneFade: HTMLElement;
   private renderer: Renderer | null = null;
   private effectRenderer: EffectRenderer | null = null;
   private animations: AnimationQueue | null = null;
@@ -135,6 +152,8 @@ private journalView: JournalView | null = null;   private echoView: EchoView | null = null;
   private savesView: SavesView | null = null;
   private pause: ReturnType<typeof buildPauseOverlay> | null = null;
+  private death: StoryOverlay | null = null;
+  private ending: EndingOverlay | null = null;
   private titleScreen: TitleScreen | null = null;
   private playerTurnActive = false;
   private playtimeTimer: number | null = null;
@@ -150,6 +169,9 @@ this.devMode =       new URLSearchParams(window.location.search).has('dev') ||
       window.location.hash === '#/gallery';
     this.settings = this.loadSettings();
+    this.sceneFade = el('div', 'scene-fade');
+    this.sceneFade.setAttribute('aria-hidden', 'true');
+    this.root.append(this.sceneFade);
   }
 
   boot(): void {
@@ -161,6 +183,11 @@ }     }
     this.buildMenuScreens();
     this.input.attach((action) => this.handleInput(action));
+    this.audio.attach();
+    this.audio.setEnabled(this.settings.sound);
+    this.audio.setVolume(this.settings.soundVolume);
+    this.audio.setAmbience('title');
+    this.bus.on('ui:toast', () => this.audio.toast());
     this.applySettings(this.settings);
     this.screens.show('title');
     if (window.location.hash === '#/gallery') this.screens.show('gallery');
@@ -225,11 +252,12 @@ this.animations,       () => this.settings.reducedMotion,
     );
     this.effects.connect((request) => {
-      if (request.kind === 'shake') this.renderer?.shake(this.settings.screenShake);
+      if (request.kind === 'shake') this.renderer?.shake(this.settings.screenShake, request.intensity);
       else this.effectRenderer?.handle(request);
     });
     this.hud = new HUD(gameScreen);
     this.hud.onAction((actionId) => this.playerCombatAction(actionId));
+    this.hud.onTouch((action) => this.handleInput(action));
     this.bus.on('ui:toast', ({ text }) => this.hud?.toast(text));
     this.dialogueView = new DialogueView(gameScreen);
     this.inventoryView = new InventoryView(gameScreen);
@@ -245,6 +273,16 @@ },       onQuit: () => this.quitToTitle(),
     });
     gameScreen.append(this.pause.element);
+    this.death = buildDeathOverlay(() => this.riseFromGrave());
+    gameScreen.append(this.death.element);
+    this.ending = buildEndingOverlay({
+      onContinue: () => this.ending?.hide(),
+      onTitle: () => {
+        this.ending?.hide();
+        this.quitToTitle();
+      },
+    });
+    gameScreen.append(this.ending.element);
     this.savesView = new SavesView(this.root);
 
     this.movement = new MovementSystem({
@@ -287,6 +325,7 @@ );   }
 
   private showNarration(lines: string[], onDone: () => void): void {
+    this.audio.echo();
     this.echoDoneCallback = onDone;
     this.echoView?.show(
       { id: 'narration_prologue', glyph: '🪦', title: 'Prologue — The Grave', lines },
@@ -317,9 +356,11 @@ this.playerTurnActive = false;   }
 
   private startPlay(): void {
-    this.ensureGameScreenBuilt();
-    this.enterCurrentMap();
-    this.screens.show('game');
+    this.fadeThrough(() => {
+      this.ensureGameScreenBuilt();
+      this.enterCurrentMap();
+      this.screens.show('game');
+    });
     this.startPlaytimeClock();
     this.autosave();
   }
@@ -383,31 +424,48 @@ const state = this.requireState();     const renderer = this.requireRenderer();
     const map = this.currentMap();
     state.region = map.region;
+    this.audio.setAmbience(map.region);
     if (map.onEnterQuest) startQuest(state, map.onEnterQuest);
     renderer.renderMap(map);
     for (const actor of allActors(state)) renderer.spawnActor(actor);
     this.reconcileActors();
     renderer.centerCameraOn(state.player.pos, false);
     this.refreshHud();
+    this.refreshTouchInteract();
     this.input.unlock();
   }
 
   private transitionTo(exit: ExitDef): void {
-    const state = this.requireState();
-    storeCurrentMap(state);
-    const stored = takeMapState(state, exit.toMapId);
-    state.mapId = exit.toMapId;
-    if (stored) {
-      state.actors = stored.actors;
-      state.consumedInteractables = stored.consumedInteractables;
-    } else {
-      this.populateMapActors(this.mapManager.get(exit.toMapId));
-      state.consumedInteractables = [];
-    }
-    state.player.pos = { ...exit.toPos };
-    this.enterCurrentMap();
+    this.fadeThrough(() => {
+      const state = this.requireState();
+      storeCurrentMap(state);
+      const stored = takeMapState(state, exit.toMapId);
+      state.mapId = exit.toMapId;
+      if (stored) {
+        state.actors = stored.actors;
+        state.consumedInteractables = stored.consumedInteractables;
+      } else {
+        this.populateMapActors(this.mapManager.get(exit.toMapId));
+        state.consumedInteractables = [];
+      }
+      state.player.pos = { ...exit.toPos };
+      this.enterCurrentMap();
+    });
     this.toast(exit.label);
     this.autosave();
+  }
+
+  /** Fade to ink, swap screens/maps underneath, fade back. Reduced motion: instant cut. */
+  private fadeThrough(swap: () => void): void {
+    if (this.settings.reducedMotion) {
+      swap();
+      return;
+    }
+    this.sceneFade.classList.add('scene-fade--black');
+    window.setTimeout(() => {
+      swap();
+      this.sceneFade.classList.remove('scene-fade--black');
+    }, 150);
   }
 
   private checkExit(): void {
@@ -440,6 +498,14 @@ if (action.type === 'interact' || action.type === 'choice') this.echoView.advance();       else if (action.type === 'cancel') this.finishEcho();
       return;
     }
+    if (this.death?.visible()) {
+      if (action.type === 'interact' || action.type === 'choice') this.riseFromGrave();
+      return; // the grave waits; nothing else answers
+    }
+    if (this.ending?.visible()) {
+      if (action.type === 'interact' || action.type === 'cancel') this.ending.hide();
+      return;
+    }
     if (this.itemChooserOpen) {
       if (action.type === 'cancel') this.cancelItemChooser();
       return;
@@ -547,7 +613,7 @@     if (occupantId) {
       const occupant = findActor(state, occupantId);
       if (occupant?.kind === 'enemy' && occupant.alive && occupant.faction === 'hostile') {
-        this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+        this.surpriseStrike(occupant);
         if (this.state?.mode === 'exploration') this.checkAggro();
         return;
       }
@@ -559,7 +625,9 @@ return;     }
     const moved = await movement.stepActor(player, to);
     if (!moved || !this.state) return;
+    this.audio.footstep();
     this.requireRenderer().centerCameraOn(player.pos, true);
+    this.refreshTouchInteract();
     if (this.state.mode === 'exploration') {
       this.checkExit();
       if (this.state.mode === 'exploration') this.checkAggro();
@@ -581,6 +649,7 @@ const steps = occupantId ? path.slice(0, -1) : path;     await movement.walkPath(player, steps);
     if (!this.state) return;
     this.requireRenderer().centerCameraOn(player.pos, true);
+    this.refreshTouchInteract();
     if (occupantId) {
       const occupant = findActor(state, occupantId);
       if (occupant?.kind === 'npc' && manhattan(player.pos, occupant.pos) === 1) {
@@ -588,7 +657,7 @@ this.startDialogueWith(occupant);         return;
       }
       if (occupant?.kind === 'enemy' && occupant.faction === 'hostile' && manhattan(player.pos, occupant.pos) === 1) {
-        this.resolveTargetedAbility(occupant, ABILITY_IDS.strike);
+        this.surpriseStrike(occupant);
       }
     }
     if (this.state?.mode === 'exploration') {
@@ -597,6 +666,18 @@ if (this.state.mode === 'exploration') this.checkAggro();     }
   }
 
+  /**
+   * Bumping a hostile out of combat is a surprise attack: a free Strike,
+   * and the target starts the fight Staggered (half speed for a turn).
+   */
+  private surpriseStrike(target: ActorState): void {
+    this.resolveTargetedAbility(target, ABILITY_IDS.strike);
+    if (target.alive && applyCondition(target, 'staggered', 1)) {
+      this.effects.word(target.pos, 'Surprise');
+      this.requireRenderer().refreshActor(target);
+    }
+  }
+
   private interact(): void {
     const state = this.requireState();
     const player = state.player;
@@ -658,15 +739,23 @@ } 
   private checkAggro(): void {
     const state = this.requireState();
+    const map = this.currentMap();
     const player = state.player;
     let aggroedAny = false;
     for (const actor of state.actors) {
       if (actor.kind !== 'enemy' || !actor.alive || actor.faction !== 'hostile' || actor.aggroed) {
         continue;
       }
-      if (manhattan(actor.pos, player.pos) <= aggroRangeOf(actor)) {
+      // Enemies only notice what they can see: range plus line of sight.
+      if (
+        manhattan(actor.pos, player.pos) <= aggroRangeOf(actor) &&
+        hasLineOfSight(map, actor.pos, player.pos)
+      ) {
         actor.aggroed = true;
         aggroedAny = true;
+        if (enemyDef(actor.defId).calmable) {
+          this.hintOnce(FLAGS.hintCalm, 'That one can be Calmed — a way out without blood.');
+        }
       }
     }
     if (aggroedAny) this.startCombat();
@@ -688,6 +777,7 @@ engaged.push(actor.id);       }
     }
     this.bus.emit('combat:started', { enemyIds: engaged });
+    this.hintOnce(FLAGS.hintCombat, 'Combat — press a number or click an action. Defend and Wait buy time.');
     this.showActionBar(true);
     this.refreshTurnOrder(null);
     void this.runTurns();
@@ -752,7 +842,8 @@ private tickTurnStart(actor: ActorState): boolean {     const tick = tickConditions(actor);
     if (tick.damage > 0) {
       this.effects.damageNumber(actor.pos, tick.damage);
-      if (actor.kind === 'player') this.effects.shake();
+      this.effects.hitFlash(actor.id, actor.pos, null);
+      if (actor.kind === 'player') this.effects.shake('sm');
     }
     for (const expired of tick.expired) {
       this.effects.word(actor.pos, `${conditionDef(expired).name} faded`);
@@ -836,6 +927,7 @@ return;     }
     const moved = await this.requireMovement().stepActor(player, to);
     if (moved && this.playerTurnActive) {
+      this.audio.footstep();
       this.requireRenderer().centerCameraOn(player.pos, true);
       this.endPlayerTurn(TURN_COSTS.move);
     }
@@ -986,11 +1078,16 @@ spendAbilityFocus(state.player, abilityId);     if (ability.cleanses) {
       state.player.conditions = [];
       this.effects.word(state.player.pos, 'Cleansed');
+      this.audio.calm();
     }
     if (ability.heals) {
       const healed = Math.min(ability.heals, state.player.maxHp - state.player.hp);
       state.player.hp += healed;
-      if (healed > 0) this.effects.healNumber(state.player.pos, healed);
+      if (healed > 0) {
+        this.effects.healNumber(state.player.pos, healed);
+        this.effects.healFlash(state.player.id, state.player.pos);
+        this.audio.heal();
+      }
     }
     if (ability.selfCondition) {
       applyCondition(state.player, ability.selfCondition.id, ability.selfCondition.duration);
@@ -1028,12 +1125,15 @@ if (ability.selfCondition) {         applyCondition(state.player, ability.selfCondition.id, ability.selfCondition.duration);
       }
       this.effects.impact(target.pos, ability.effectGlyph);
+      this.audio.condition();
     } else {
       const outcome = performAttack(state.player, target, this.rng, allActors(state), {
         mult: ability.damageMult,
         appliesCondition: ability.appliesCondition,
         selfCondition: ability.selfCondition,
       });
+      if (ability.costKind === 'heavy' || ability.costKind === 'powerful') this.audio.hitHeavy();
+      else this.audio.hitLight();
       void this.effectRenderer?.attackSequence(state.player.id, target.pos, ability.effectGlyph);
       this.applyAttackOutcome(outcome);
       if (ability.push && target.alive) {
@@ -1072,6 +1172,7 @@ private playerCalm(target: ActorState): void {     const state = this.requireState();
     const calmed = performCalm(state.player, target, state.tendencies.empathy, this.rng);
     if (calmed) {
+      this.audio.calm();
       this.effects.word(target.pos, 'Calmed');
       this.effects.impact(target.pos, glyphOf(GLYPH_IDS.fxSparkle));
       this.turnManager.removeActor(target.id);
@@ -1079,6 +1180,7 @@ state.flags[FLAGS.calmedWolf] = true;       if (target.defId === 'ashWolf') advanceQuest(state, QUEST_IDS.prologueAwakening);
       this.tendencyBump('empathy');
     } else {
+      this.audio.condition();
       this.effects.word(target.pos, 'Resisted');
     }
     this.endPlayerTurn(TURN_COSTS.attack);
@@ -1098,7 +1200,11 @@ this.toast(result.message);           return;
         }
         const def = itemDef(itemId);
-        if (def.healAmount) this.effects.healNumber(state.player.pos, def.healAmount);
+        if (def.healAmount) {
+          this.effects.healNumber(state.player.pos, def.healAmount);
+          this.effects.healFlash(state.player.id, state.player.pos);
+          this.audio.heal();
+        }
         this.requireRenderer().refreshActor(state.player);
         this.refreshHud();
         this.endPlayerTurn(TURN_COSTS.item);
@@ -1130,16 +1236,50 @@ const target = findActor(state, intent.targetId);       if (target && target.alive) {
         const effectGlyph =
           intent.type === 'rangedAttack' ? glyphOf(GLYPH_IDS.fxArrow) : glyphOf(GLYPH_IDS.fxSlash);
+        const mult = intent.type === 'attack' ? (intent.mult ?? 1) : 1;
+        if (mult > 1) {
+          this.effects.word(enemy.pos, 'Heavy Blow');
+          this.audio.hitHeavy();
+        } else {
+          this.audio.hitLight();
+        }
         if (intent.type === 'rangedAttack') {
           await this.effectRenderer?.rangedSequence(target.pos);
         } else {
           await this.effectRenderer?.attackSequence(enemy.id, target.pos, effectGlyph);
         }
-        const inflicts = enemy.kind === 'enemy' ? enemyDef(enemy.defId).appliesCondition : undefined;
         const outcome = performAttack(enemy, target, this.rng, allActors(state), {
-          appliesCondition: inflicts,
+          mult,
+          appliesCondition: inflictedCondition(enemy, target, allActors(state)),
         });
         this.applyAttackOutcome(outcome);
+        // Guard Stance: a melee hit on a watching player is answered with a free Strike.
+        if (
+          intent.type === 'attack' &&
+          target.kind === 'player' &&
+          target.alive &&
+          enemy.alive &&
+          hasCondition(target, 'guarding')
+        ) {
+          this.effects.word(target.pos, 'Counter');
+          this.audio.hitLight();
+          this.applyAttackOutcome(performAttack(target, enemy, this.rng, allActors(state), {}));
+        }
+        cost = TURN_COSTS.attack;
+      }
+    } else if (intent.type === 'push') {
+      const target = findActor(state, intent.targetId);
+      if (target && target.alive) {
+        const push = performPush(enemy, target, (pos) =>
+          this.requireMovement().checkStep(target, pos).ok,
+        );
+        if (push.moved) {
+          this.effects.word(target.pos, 'Pushed');
+          void this.animations?.enqueue(() => this.requireRenderer().placeActor(target, true));
+        } else {
+          this.effects.word(target.pos, 'Staggered');
+          this.requireRenderer().refreshActor(target);
+        }
         cost = TURN_COSTS.attack;
       }
     } else if (intent.type === 'guard') {
@@ -1159,24 +1299,43 @@ private applyAttackOutcome(outcome: AttackOutcome): void {     const state = this.requireState();
     const defender = findActor(state, outcome.defenderId);
     if (!defender) return;
+    const attacker = findActor(state, outcome.attackerId);
     this.effects.damageNumber(defender.pos, outcome.damage);
-    if (defender.kind === 'player') this.effects.shake();
+    this.effects.hitFlash(defender.id, defender.pos, attacker?.pos ?? null);
+    this.shakeForHit(defender, outcome.damage);
+    if (defender.kind === 'player') this.audio.playerHurt();
     for (const applied of outcome.conditionsApplied) {
       this.effects.word(defender.pos, conditionDef(applied).name);
     }
+    if (outcome.conditionsApplied.length > 0) this.audio.condition();
     for (const broken of outcome.brokenConditions) {
       this.effects.word(defender.pos, `${conditionDef(broken).name} broke`);
     }
     this.requireRenderer().refreshActor(defender);
-    const attacker = findActor(state, outcome.attackerId);
     if (attacker) this.requireRenderer().refreshActor(attacker);
     if (outcome.killed) this.handleDeath(defender);
     this.refreshHud();
   }
 
+  /**
+   * Shake communicates impact on the player and big events: player hits are
+   * always at least medium (large at ≥50% max HP, with a hurt vignette from
+   * 25% up); enemies only shake the screen on solid hits (≥25%).
+   */
+  private shakeForHit(defender: ActorState, damage: number): void {
+    const ratio = damage / defender.maxHp;
+    if (defender.kind === 'player') {
+      this.effects.shake(ratio >= 0.5 ? 'lg' : 'md');
+      if (ratio >= 0.25) this.renderer?.hurtVignette();
+    } else if (ratio >= 0.25) {
+      this.effects.shake('sm');
+    }
+  }
+
   private handleDeath(actor: ActorState): void {
     const state = this.requireState();
     this.turnManager.removeActor(actor.id);
+    this.effects.shake('lg'); // a death always lands heavy
     if (actor.kind === 'player') {
       this.playerDeath();
       return;
@@ -1203,23 +1362,38 @@ if (actor.kind === 'enemy' && actor.defId === 'ashWolf') {       state.flags[FLAGS.killedWolf] = true;
       advanceQuest(state, QUEST_IDS.prologueAwakening);
     }
+    this.audio.deathKnell();
     this.requireRenderer().refreshActor(actor);
   }
 
+  /**
+   * Death: enemies lose interest, the world dims behind a verse, and the
+   * grave gives you back — at the cost of your focus. The respawn itself
+   * happens on "Rise again" (or Enter), in place: no map reload.
+   */
   private playerDeath(): void {
     const state = this.requireState();
+    for (const actor of state.actors) actor.aggroed = false;
+    if (state.mode === 'combat') this.endCombat('died');
+    this.audio.deathKnell();
+    this.audio.duckAmbience(true);
+    this.death?.show();
+  }
+
+  private riseFromGrave(): void {
+    this.death?.hide();
+    this.audio.duckAmbience(false);
+    const state = this.requireState();
     const player = state.player;
     this.toast('The grave remembers you. It gives you back.');
     player.alive = true;
     player.hp = player.maxHp;
-    player.focus = Math.max(1, player.focus);
+    player.focus = 0;
     player.conditions = [];
     player.pos = { ...this.currentMap().playerSpawn };
-    for (const actor of state.actors) actor.aggroed = false;
     this.requireRenderer().placeActor(player, false);
     this.requireRenderer().refreshActor(player);
     this.requireRenderer().centerCameraOn(player.pos, false);
-    if (state.mode === 'combat') this.endCombat('died');
   }
 
   private tendencyBump(axis: 'resolve' | 'insight' | 'empathy' | undefined): void {
@@ -1270,11 +1444,13 @@ const state = this.requireState();     const choices = this.dialogue.availableChoices(node, state);
     this.dialogueView?.show(node, choices, {
       onChoice: (choiceId) => {
+        this.audio.uiSelect();
         const next = this.dialogue.choose(choiceId, state);
         if (next) this.showDialogueNode(next);
         else this.endDialogue();
       },
       onAdvance: () => {
+        this.audio.dialogueAdvance();
         const next = this.dialogue.advance(state);
         if (next) this.showDialogueNode(next);
         else this.endDialogue();
@@ -1293,17 +1469,36 @@ this.reconcileActors();     this.checkAbilityUnlocks();
     this.refreshHud();
     this.autosave();
+    // Every shard the current content can award, carried: the chapter closes.
+    if (!state.flags[FLAGS.chapterCompleteShown] && chapterComplete(state)) {
+      state.flags[FLAGS.chapterCompleteShown] = true;
+      this.showEnding('chapterComplete');
+    }
     // A dialogue may have turned an NPC hostile — combat starts now.
     if (state.mode === 'exploration' && livingHostiles(state).some((a) => a.aggroed)) {
       this.startCombat();
     }
   }
 
+  /**
+   * "Chapter complete" overlay. Not an ending — only the shards the current
+   * content can award exist, so this fires when all of them are carried.
+   */
+  private showEnding(_reason: 'chapterComplete'): void {
+    const state = this.requireState();
+    const carried = state.shards
+      .map((id) => SHARDS[id])
+      .filter((shard): shard is (typeof SHARDS)[string] => !!shard);
+    this.ending?.showFor(carried);
+    this.audio.echo();
+  }
+
   // ---- name echoes --------------------------------------------------------
 
   private openEcho(echoId: string): void {
     const state = this.requireState();
     const result = hearEcho(state, echoId);
+    this.audio.echo();
     this.echoDoneCallback = () => {
       if (result.grantedAbility) {
         this.toast(`New ability: ${abilityDef(result.grantedAbility).name}`);
@@ -1342,10 +1537,14 @@ this.characterView?.show(state, () => this.closePanels());     } else {
       this.journalView?.show(state, () => this.closePanels());
     }
+    this.audio.panel(true);
     this.openPanel = panel;
+    const hint = PANEL_HINTS[panel];
+    this.hintOnce(hint.flag, hint.text);
   }
 
   private closePanels(): void {
+    if (this.openPanel) this.audio.panel(false);
     this.openPanel = null;
     this.inventoryView?.hide();
     this.characterView?.hide();
@@ -1383,7 +1582,11 @@ const result = useConsumable(state, itemId);     this.toast(result.message);
     if (result.ok) {
       const def = itemDef(itemId);
-      if (def.healAmount) this.effects.healNumber(state.player.pos, def.healAmount);
+      if (def.healAmount) {
+        this.effects.healNumber(state.player.pos, def.healAmount);
+        this.effects.healFlash(state.player.id, state.player.pos);
+        this.audio.heal();
+      }
       this.requireRenderer().refreshActor(state.player);
     }
     this.refreshHud();
@@ -1401,9 +1604,11 @@   private openPause(): void {
     if (this.state?.mode === 'dialogue') return;
     this.pause?.show();
+    this.audio.panel(true);
   }
 
   private closePause(): void {
+    if (this.pause?.visible()) this.audio.panel(false);
     this.pause?.hide();
   }
 
@@ -1490,7 +1695,8 @@ if (this.playtimeTimer !== null) {       window.clearInterval(this.playtimeTimer);
       this.playtimeTimer = null;
     }
-    this.screens.show('title');
+    this.fadeThrough(() => this.screens.show('title'));
+    this.audio.setAmbience('title');
   }
 
   // ---- settings / misc ---------------------------------------------------
@@ -1516,6 +1722,8 @@ // storage full or blocked — settings just won't persist     }
     document.body.classList.toggle('reduced-motion', settings.reducedMotion);
     document.documentElement.style.setProperty('--text-scale', String(settings.textScale));
+    this.audio.setEnabled(settings.sound);
+    this.audio.setVolume(settings.soundVolume);
     this.renderer?.setMotionOptions(settings.reducedMotion, settings.animationSpeed);
   }
 
@@ -1552,6 +1760,35 @@ } 
   private toast(text: string): void {
     this.bus.emit('ui:toast', { text });
+  }
+
+  /** One-time contextual hint (persisted as a world flag), shown as a longer toast. */
+  private hintOnce(flagId: string, text: string): void {
+    if (!this.state) return;
+    const hint = hintOnce(this.state.flags, flagId, text);
+    if (hint) this.hud?.toast(hint, 5200);
+  }
+
+  /** The touch Act button only lights up next to someone/something interactive. */
+  private refreshTouchInteract(): void {
+    const state = this.state;
+    if (!state || state.mode !== 'exploration') {
+      this.hud?.setTouchInteractVisible(false);
+      return;
+    }
+    const player = state.player;
+    const candidates = [
+      addPos(player.pos, DIRECTION_VECTORS[player.facing]),
+      ...Object.values(DIRECTION_VECTORS).map((v) => addPos(player.pos, v)),
+    ];
+    const visible = candidates.some(
+      (pos) =>
+        state.actors.some(
+          (a) => a.kind === 'npc' && a.alive && a.pos.x === pos.x && a.pos.y === pos.y,
+        ) ||
+        this.currentMap().interactables.some((i) => i.pos.x === pos.x && i.pos.y === pos.y),
+    );
+    this.hud?.setTouchInteractVisible(visible);
   }
 
   private requireState(): GameState {
@@ -69,6 +69,9 @@ /** Multiplier on animation durations: 0.5 fast, 1 normal, 2 slow. */   animationSpeed: number;
   screenShake: boolean;
   textScale: number;
+  sound: boolean;
+  /** Master volume, 0..1. */
+  soundVolume: number;
 }
 
 export const DEFAULT_SETTINGS: Settings = {
@@ -76,6 +79,8 @@ reducedMotion: false,   animationSpeed: 1,
   screenShake: true,
   textScale: 1,
+  sound: true,
+  soundVolume: 1,
 };
 
 export type GameMode = 'exploration' | 'combat' | 'dialogue';
@@ -55,10 +55,10 @@ export type ConditionId =
   | 'bleeding'
   | 'burning'
-  | 'frozen'
   | 'staggered'
   | 'afraid'
   | 'guarded'
+  | 'guarding'
   | 'silenced'
   | 'forgotten'
   | 'remembered'
@@ -102,12 +102,13 @@ [ABILITY_IDS.guardStance]: {     id: ABILITY_IDS.guardStance,
     name: 'Guard Stance',
     glyph: glyphOf(GLYPH_IDS.fxShield),
-    description: 'Brace behind your guard (Guarded). Quick recovery.',
+    description:
+      'Brace and watch: +50% defense until your next turn, and answer any melee hit with a free counter. Quick recovery.',
     costKind: 'defend',
     damageMult: 0,
     range: 0,
     focusCost: 0,
-    selfCondition: { id: 'guarded', duration: 1 },
+    selfCondition: { id: 'guarding', duration: 1 },
     tendency: 'resolve',
     effectGlyph: glyphOf(GLYPH_IDS.fxShield),
   },
@@ -46,19 +46,9 @@ burning: {     id: 'burning',
     name: 'Burning',
     glyph: glyphOf(GLYPH_IDS.condBurning),
-    tooltip: 'Loses 3 health at the start of each turn. Water extinguishes it.',
+    tooltip: 'Loses 3 health at the start of each turn. Ember salve soothes it.',
     defaultDuration: 2,
     tickDamage: 3,
-  },
-  frozen: {
-    id: 'frozen',
-    name: 'Frozen',
-    glyph: glyphOf(GLYPH_IDS.condFrozen),
-    tooltip: 'Cannot move or act. Breaks when struck.',
-    defaultDuration: 1,
-    speedMult: 0,
-    preventsAction: true,
-    breaksOnHit: true,
   },
   staggered: {
     id: 'staggered',
@@ -81,6 +71,15 @@ id: 'guarded',     name: 'Guarded',
     glyph: glyphOf(GLYPH_IDS.condGuarded),
     tooltip: 'Braced. Defense increased by 50% until next turn.',
+    defaultDuration: 1,
+    defenseMult: 1.5,
+  },
+  guarding: {
+    id: 'guarding',
+    name: 'Guard Stance',
+    glyph: glyphOf(GLYPH_IDS.condGuarded),
+    tooltip:
+      'Braced and watching. Defense increased by 50% until next turn; melee attackers are countered.',
     defaultDuration: 1,
     defenseMult: 1.5,
   },
@@ -54,8 +54,9 @@ moveRange: 3,     attackRange: 1,
     packTactics: true,
     calmable: true,
+    appliesCondition: { id: 'bleeding', duration: 3 },
     inspectText:
-      'Hungry, but not cruel. It hunts because the woods forgot how to feed it. It might remember kindness.',
+      'Hungry, but not cruel. It hunts because the woods forgot how to feed it. In a pack, its bites bleed. It might remember kindness.',
   },
   [ENEMY_IDS.hollowSoldier]: {
     id: ENEMY_IDS.hollowSoldier,
@@ -91,7 +92,9 @@ moveArchetype: 'default',     aggroRange: 6,
     moveRange: 2,
     attackRange: 4,
-    inspectText: 'Keeps distance, counts arrows, follows orders. The orders are old.',
+    appliesCondition: { id: 'unstable', duration: 2 },
+    inspectText:
+      'Keeps distance, counts arrows, follows orders. The orders are old. Its arrows rattle your glyph loose.',
   },
   [ENEMY_IDS.crownSoldier]: {
     id: ENEMY_IDS.crownSoldier,
@@ -146,8 +149,9 @@ moveArchetype: 'heavy',     aggroRange: 3,
     moveRange: 1,
     attackRange: 1,
+    appliesCondition: { id: 'burning', duration: 2 },
     inspectText:
-      'Advance. Strike. Wind down. Repeat. It builds weapons for an army of the dead, on schedule.',
+      'Advance. Strike. Wind down. Repeat. It builds weapons for an army of the dead, on schedule. Its fists still carry the forge.',
   },
 };
 
@@ -8,6 +8,8 @@ killedWolf: 'killedWolf',   readGrave: 'readGrave',
   tookRustedSword: 'tookRustedSword',
   tookWeatheredCloak: 'tookWeatheredCloak',
+  tookHuntingBow: 'tookHuntingBow',
+  tookDrownedLetter: 'tookDrownedLetter',
   // prologue — Wayrest
   wayrestForgot: 'wayrestForgot',
   insistedTamExisted: 'insistedTamExisted',
@@ -49,6 +51,13 @@ kingAlly: 'kingAlly',   kingFought: 'kingFought',
   endingsUnlocked: 'endingsUnlocked',
   gameCompleted: 'gameCompleted',
+  // onboarding hints (one-time toasts, persisted per save)
+  hintCombat: 'hintCombat',
+  hintCalm: 'hintCalm',
+  hintPanelInventory: 'hintPanelInventory',
+  hintPanelJournal: 'hintPanelJournal',
+  hintPanelCharacter: 'hintPanelCharacter',
+  chapterCompleteShown: 'chapterCompleteShown',
 } as const;
 
 export const MEMORY_EVENTS = {
@@ -49,9 +49,9 @@ [ITEM_IDS.huntingBow]: {     id: ITEM_IDS.huntingBow,
     name: 'Hunting Bow',
     glyph: glyphOf(GLYPH_IDS.itemHuntingBow),
-    type: 'weapon',
-    description: 'Glasswood yew. The string hums near mirrors. +1 attack.',
-    attackBonus: 1,
+    type: 'relic',
+    description:
+      'Glasswood yew, the string long snapped. It hums faintly near mirrors, as if it remembers flying.',
   },
   [ITEM_IDS.weatheredCloak]: {
     id: ITEM_IDS.weatheredCloak,
@@ -72,6 +72,16 @@ text: 'The water does not reflect you. It has not decided what you are yet.',       },
     },
     {
+      id: 'wolfTracks',
+      pos: { x: 10, y: 4 },
+      glyph: null,
+      name: 'Wolf tracks',
+      action: {
+        type: 'inspect',
+        text: 'The tracks circle the clearing and never close in. Hunger moves them — but under the hunger, fear. It might be spoken to.',
+      },
+    },
+    {
       id: 'mirrorShard',
       pos: { x: 13, y: 4 },
       glyph: GLYPHS.find((g) => g.id === GLYPH_IDS.echoMirror)?.glyph ?? null,
@@ -1,6 +1,7 @@ import { defineMap, type MapDefinition } from '../../world/MapDefinition';
 import { CHARACTER_IDS } from '../characters';
 import { ENEMY_IDS } from '../enemies';
+import { ITEM_IDS } from '../items';
 import { FLAGS, MEMORY_EVENTS } from '../flags';
 import { GLYPH_IDS, GLYPHS } from '../glyphs';
 import { QUEST_IDS } from '../quests';
@@ -41,6 +42,18 @@ { defId: ENEMY_IDS.mireLurker, pos: { x: 7, y: 6 } },     { defId: ENEMY_IDS.mireLurker, pos: { x: 11, y: 7 } },
   ],
   interactables: [
+    {
+      id: 'drownedLetter',
+      pos: { x: 12, y: 4 },
+      glyph: null,
+      name: 'A waterproofed letter',
+      action: {
+        type: 'item',
+        itemId: ITEM_IDS.letterToTheDrowned,
+        onceFlag: FLAGS.tookDrownedLetter,
+        afterText: 'Only dark water, and the feeling of being politely watched.',
+      },
+    },
     {
       id: 'westBell',
       pos: { x: 1, y: 6 },
@@ -11,7 +11,8 @@ } 
 // The Old Road: a short transit map south of the grave clearing. Proves exit
 // traversal, per-map persistence, a patrolling Hollow soldier, an echo object
-// and an armor pickup. Region content arrives in a later phase.
+// and an armor pickup. A lone Rust Machine — vanguard of a march that never
+// arrived — stands guard off the road, over a snapped bow.
 export const OLD_ROAD: MapDefinition = defineMap({
   id: 'oldRoad',
   name: 'The Old Road',
@@ -43,6 +44,7 @@ { x: 8, y: 4 },         { x: 8, y: 8 },
       ],
     },
+    { defId: ENEMY_IDS.rustMachine, pos: { x: 4, y: 7 } },
   ],
   interactables: [
     {
@@ -62,6 +64,18 @@ type: 'item',         itemId: ITEM_IDS.weatheredCloak,
         onceFlag: FLAGS.tookWeatheredCloak,
         afterText: 'The pack is empty now. Whoever left it walked north, toward the graves.',
+      },
+    },
+    {
+      id: 'snappedBow',
+      pos: { x: 2, y: 9 },
+      glyph: null,
+      name: 'A snapped bow',
+      action: {
+        type: 'item',
+        itemId: ITEM_IDS.huntingBow,
+        onceFlag: FLAGS.tookHuntingBow,
+        afterText: 'Only the rust-imprints of the machine’s feet keep the grass company now.',
       },
     },
     {
@@ -29,10 +29,34 @@ break;       case 'floatingText':
         this.renderer.spawnFloatingText(request.pos, request.text, request.tone);
         break;
+      case 'flash':
+        this.flash(request);
+        break;
       case 'shake':
-        this.renderer.shake(true);
+        this.renderer.shake(true, request.intensity);
         break;
     }
+  }
+
+  /**
+   * Hit feedback on the actor itself: a brief color flash and a 3px recoil
+   * away from the attacker. Skipped entirely under reduced motion.
+   */
+  private flash(request: Extract<EffectRequest, { kind: 'flash' }>): void {
+    if (this.isReducedMotion()) return;
+    const node = this.renderer.actorElement(request.actorId);
+    if (!node) return;
+    if (request.tone === 'damage' && request.from) {
+      const dx = Math.sign(request.pos.x - request.from.x);
+      const dy = Math.sign(request.pos.y - request.from.y);
+      node.style.setProperty('--knock-x', `${dx * 3}px`);
+      node.style.setProperty('--knock-y', `${dy * 3}px`);
+    }
+    const cls = request.tone === 'heal' ? 'actor--flash-heal' : 'actor--flash-damage';
+    node.classList.remove(cls);
+    void node.offsetWidth; // force reflow so the animation restarts
+    node.classList.add(cls);
+    setTimeout(() => node.classList.remove(cls), 160);
   }
 
   /** The full attack beat. Returns a promise that resolves when it lands. */
@@ -10,6 +10,7 @@ import { conditionDef } from '../data/conditions'; import { CHARACTERS } from '../data/characters';
 import { ENEMIES } from '../data/enemies';
 import { hasCondition } from '../systems/ConditionSystem';
+import type { ShakeIntensity } from '../systems/EffectSystem';
 
 /**
  * Layered DOM renderer: terrain CSS Grid, absolutely positioned actors moved
@@ -25,6 +26,7 @@ private readonly propsLayer: HTMLElement;   private readonly highlightLayer: HTMLElement;
   private readonly actorLayer: HTMLElement;
   private readonly effectLayer: HTMLElement;
+  private readonly vignette: HTMLElement;
   private readonly camera: Camera;
   private readonly actorElements = new Map<string, HTMLElement>();
   private map: MapDefinition | null = null;
@@ -49,6 +51,9 @@ this.effectLayer,     );
     this.viewport.append(this.world);
     this.root.append(this.viewport);
+    this.vignette = el('div', 'hurt-vignette');
+    this.vignette.setAttribute('aria-hidden', 'true');
+    this.root.append(this.vignette);
     parent.append(this.root);
     this.camera = new Camera(this.viewport, this.world);
   }
@@ -107,6 +112,7 @@   spawnActor(actor: ActorState): void {
     this.removeActor(actor.id);
     const node = el('div', `actor actor--${archetypeOf(actor)}`);
+    if (actor.kind === 'player') node.classList.add('actor--player');
     node.dataset.actorId = actor.id;
     node.style.width = `${TILE_SIZE}px`;
     node.style.height = `${TILE_SIZE}px`;
@@ -140,7 +146,7 @@   refreshActor(actor: ActorState): void {
     const node = this.actorElements.get(actor.id);
     if (!node) return;
-    node.classList.toggle('actor--dead', !actor.alive);
+    this.updateDeathState(node, actor);
     node.classList.toggle('actor--unstable', hasCondition(actor, 'unstable'));
     const hpFill = node.querySelector('.actor__hp i');
     if (hpFill instanceof HTMLElement) {
@@ -156,11 +162,34 @@ }       const def = conditionDef(first.id);
       chip.textContent = def.glyph;
       (chip as HTMLElement).title = `${def.name} — ${def.tooltip}`;
+      (chip as HTMLElement).setAttribute('aria-label', `${def.name} — ${def.tooltip}`);
     } else if (chip instanceof HTMLElement) {
       chip.remove();
     }
   }
 
+  /**
+   * Death plays a fade+squash beat (longer for the player) before settling
+   * into the static actor--dead state. Reduced motion skips straight to it.
+   */
+  private updateDeathState(node: HTMLElement, actor: ActorState): void {
+    if (actor.alive) {
+      node.classList.remove('actor--dead', 'actor--dying'); // revived (respawn)
+      return;
+    }
+    if (node.classList.contains('actor--dead') || node.classList.contains('actor--dying')) return;
+    if (this.reducedMotion) {
+      node.classList.add('actor--dead');
+      return;
+    }
+    node.classList.add('actor--dying');
+    const settleMs = actor.kind === 'player' ? 700 : 400;
+    setTimeout(() => {
+      node.classList.remove('actor--dying');
+      node.classList.add('actor--dead');
+    }, settleMs);
+  }
+
   placeActor(actor: ActorState, animate: boolean): Promise<void> {
     const node = this.actorElements.get(actor.id);
     if (!node) return Promise.resolve();
@@ -260,12 +289,21 @@ this.effectLayer.append(node);     setTimeout(() => node.remove(), this.reducedMotion ? 60 : 1100);
   }
 
-  shake(enabled: boolean): void {
+  shake(enabled: boolean, intensity: ShakeIntensity = 'md'): void {
     if (!enabled || this.reducedMotion) return;
-    this.viewport.classList.remove('is-shaking');
+    this.viewport.classList.remove('is-shaking-sm', 'is-shaking-md', 'is-shaking-lg');
     // force reflow so the animation restarts
     void this.viewport.offsetWidth;
-    this.viewport.classList.add('is-shaking');
+    this.viewport.classList.add(`is-shaking-${intensity}`);
+  }
+
+  /** Dark red edge pulse when the player takes a heavy hit. */
+  hurtVignette(): void {
+    if (this.reducedMotion) return;
+    this.vignette.classList.remove('is-hurt');
+    void this.vignette.offsetWidth;
+    this.vignette.classList.add('is-hurt');
+    setTimeout(() => this.vignette.classList.remove('is-hurt'), 340);
   }
 
   tileKeyOf(pos: GridPos): string {
@@ -129,37 +129,125 @@ .actor--unstable .actor__body {   animation: flicker 900ms linear infinite;
 }
 
-/* screen shake (settings-toggleable) */
+/* hit feedback: brief color flash + 3px recoil (direction via --knock-* vars) */
+@keyframes flash-damage {
+  0% {
+    filter: brightness(2.4) drop-shadow(0 0 6px var(--danger));
+    transform: translate(var(--knock-x, 0), var(--knock-y, 0));
+  }
+  100% {
+    filter: none;
+    transform: translate(0, 0);
+  }
+}
+
+@keyframes flash-heal {
+  0% {
+    filter: brightness(1.8) drop-shadow(0 0 6px var(--amber));
+  }
+  100% {
+    filter: none;
+  }
+}
+
+.actor--flash-damage .actor__body {
+  animation: flash-damage 140ms ease-out;
+}
+
+.actor--flash-heal .actor__body {
+  animation: flash-heal 160ms ease-out;
+}
+
+/* death: fade + squash into the fallen pose (actor--dead holds it) */
+@keyframes dying-squash {
+  60% {
+    opacity: 0.5;
+    transform: scale(0.92) rotate(-4deg);
+  }
+  100% {
+    opacity: 0.35;
+    transform: scale(0.85) rotate(-6deg);
+  }
+}
+
+.actor--dying .actor__body {
+  transform-origin: 50% 100%;
+  animation: dying-squash 400ms ease-in forwards;
+}
+
+.actor--dying.actor--player .actor__body {
+  animation-duration: 700ms;
+}
+
+/* dark red edge pulse on heavy player damage */
+@keyframes hurt-pulse {
+  0% {
+    opacity: 0;
+  }
+  20% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+
+/* screen shake, amplitude scaled by --shake-amp (settings-toggleable) */
 @keyframes shake {
   0%,
   100% {
     transform: translate(0, 0);
   }
   20% {
-    transform: translate(-4px, 2px);
+    transform: translate(calc(var(--shake-amp) * -1), calc(var(--shake-amp) * 0.5));
   }
   40% {
-    transform: translate(3px, -3px);
+    transform: translate(calc(var(--shake-amp) * 0.75), calc(var(--shake-amp) * -0.75));
   }
   60% {
-    transform: translate(-2px, -2px);
+    transform: translate(calc(var(--shake-amp) * -0.5), calc(var(--shake-amp) * -0.5));
   }
   80% {
-    transform: translate(2px, 3px);
+    transform: translate(calc(var(--shake-amp) * 0.5), calc(var(--shake-amp) * 0.75));
   }
 }
 
-.is-shaking {
+.is-shaking-sm {
+  --shake-amp: 2px;
+  animation: shake 160ms linear;
+}
+
+.is-shaking-md {
+  --shake-amp: 4px;
   animation: shake 280ms linear;
 }
 
+.is-shaking-lg {
+  --shake-amp: 7px;
+  animation: shake 380ms linear;
+}
+
 @media (prefers-reduced-motion: reduce) {
   .actor--moving .actor__body,
   .actor--unstable .actor__body,
-  .is-shaking {
+  .actor--flash-damage .actor__body,
+  .actor--flash-heal .actor__body,
+  .actor--dying .actor__body,
+  .hurt-vignette.is-hurt,
+  .is-shaking-sm,
+  .is-shaking-md,
+  .is-shaking-lg {
     animation: none;
   }
 
+  .scene-fade {
+    transition: none;
+  }
+
+  .story-overlay {
+    transition-duration: 60ms;
+  }
+
   .effect,
   .float-text {
     animation-duration: 60ms;
@@ -169,8 +257,22 @@ /* settings toggle mirrors the media query */
 body.reduced-motion .actor--moving .actor__body,
 body.reduced-motion .actor--unstable .actor__body,
-body.reduced-motion .is-shaking {
+body.reduced-motion .actor--flash-damage .actor__body,
+body.reduced-motion .actor--flash-heal .actor__body,
+body.reduced-motion .actor--dying .actor__body,
+body.reduced-motion .hurt-vignette.is-hurt,
+body.reduced-motion .is-shaking-sm,
+body.reduced-motion .is-shaking-md,
+body.reduced-motion .is-shaking-lg {
   animation: none;
+}
+
+body.reduced-motion .scene-fade {
+  transition: none;
+}
+
+body.reduced-motion .story-overlay {
+  transition-duration: 60ms;
 }
 
 body.reduced-motion .effect,
@@ -220,6 +220,12 @@ opacity: 0.35;   filter: grayscale(1);
 }
 
+/* the fallen pose the dying animation settles into */
+.actor--dead .actor__body {
+  transform: scale(0.85) rotate(-6deg);
+  transform-origin: 50% 100%;
+}
+
 /* Effects & floating text */
 .effect {
   position: absolute;
@@ -419,3 +425,100 @@ .hud__toast--visible {
   opacity: 1;
 }
+
+/* Dark red edge pulse when the player takes a heavy hit (Renderer.hurtVignette). */
+.hurt-vignette {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  opacity: 0;
+  box-shadow: inset 0 0 90px 30px rgb(168 64 47 / 0.45);
+  z-index: 5;
+}
+
+.hurt-vignette.is-hurt {
+  animation: hurt-pulse 320ms ease-out;
+}
+
+/* Full-screen ink wash for map/screen transitions (Game.fadeThrough). */
+.scene-fade {
+  position: fixed;
+  inset: 0;
+  background: var(--ink);
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 200ms ease-out;
+  z-index: 40;
+}
+
+.scene-fade--black {
+  opacity: 1;
+  transition-duration: 150ms;
+}
+
+/* Focus-visible tooltip: the title text, readable without a mouse. */
+[data-tip] {
+  position: relative;
+}
+
+[data-tip]:focus-visible::after {
+  content: attr(data-tip);
+  position: absolute;
+  bottom: calc(100% + 6px);
+  left: 50%;
+  transform: translateX(-50%);
+  width: max-content;
+  max-width: 240px;
+  white-space: normal;
+  background: var(--ink-2);
+  color: var(--paper);
+  border: var(--border-subtle);
+  border-radius: 4px;
+  padding: 0.3rem 0.6rem;
+  font-size: calc(0.75rem * var(--text-scale));
+  font-style: normal;
+  z-index: 50;
+  pointer-events: none;
+}
+
+/* Touch controls: only on coarse pointers, big enough for thumbs. */
+.hud__touch {
+  display: none;
+  position: absolute;
+  right: 0.75rem;
+  bottom: 18%;
+  flex-direction: column;
+  gap: 0.5rem;
+  pointer-events: auto;
+}
+
+.hud__touch-button {
+  min-width: 64px;
+  min-height: 44px;
+  padding: 0.5em 0.9em;
+  background: rgb(24 21 17 / 0.85);
+  border: var(--border-subtle);
+  border-radius: 6px;
+  color: var(--paper);
+  font-size: calc(0.9rem * var(--text-scale));
+}
+
+.hud__touch-button--dim {
+  opacity: 0.35;
+}
+
+@media (pointer: coarse) {
+  .hud__touch {
+    display: flex;
+  }
+
+  .action-button {
+    font-size: calc(1rem * var(--text-scale));
+    padding: 0.7em 1.1em;
+    min-height: 44px;
+  }
+
+  .menu__button {
+    padding: 0.8em 1.6em;
+  }
+}
@@ -226,6 +226,63 @@ padding: 1.5rem 2rem;   min-width: 280px;
 }
 
+/* Story overlays (death, chapter complete): ink-dark, verses, one choice */
+.story-overlay {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgb(16 14 12 / 0.92);
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 600ms ease;
+  z-index: 32;
+}
+
+.story-overlay--visible {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.story-overlay__panel {
+  max-width: min(460px, 88vw);
+  text-align: center;
+  padding: 1.5rem 2rem;
+}
+
+.story-overlay__line {
+  color: var(--paper-dim);
+  font-style: italic;
+  line-height: 1.7;
+  margin: 0.35rem 0;
+}
+
+.story-overlay__panel .menu__nav {
+  margin-top: 1.4rem;
+  align-items: center;
+}
+
+.story-overlay__shards {
+  display: flex;
+  gap: 1.2rem;
+  justify-content: center;
+  margin: 0.8rem 0 0.4rem;
+}
+
+.story-overlay__shard {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.25rem;
+  font-size: 1.7rem;
+}
+
+.story-overlay__shard-name {
+  font-size: calc(0.72rem * var(--text-scale));
+  color: var(--paper-faint);
+}
+
 /* Full panels: inventory, character, journal, saves */
 .panel-overlay {
   position: absolute;
@@ -0,0 +1,352 @@+/**
+ * Procedural audio: every sound is synthesized with the Web Audio API —
+ * there are no audio files. The AudioContext is created lazily on the
+ * first user gesture (browser autoplay policy); until then, and in
+ * non-browser environments (tests), every method is a safe no-op.
+ */
+
+/** Master output ceiling; individual sounds carry their own gains. */
+const MASTER_LEVEL = 0.8;
+/** Ambient drone level — deliberately barely-there. */
+const DRONE_LEVEL = 0.05;
+/** Seconds for the drone pitch/gain glide when the region changes. */
+const DRONE_CROSSFADE_S = 1;
+
+interface Ambience {
+  /** Drone fundamental in Hz. */
+  base: number;
+  /** Bandpass center of the wind noise. */
+  wind: number;
+}
+
+/** Region name (or 'title') -> drone tuning. */
+const AMBIENCES: Record<string, Ambience> = {
+  title: { base: 36.71, wind: 240 }, // D1 — lower than any place in the world
+  'The Nameless Edge': { base: 55, wind: 380 }, // A1
+  'The Glasswood': { base: 82.41, wind: 660 }, // E2 — brighter, glassier air
+  'The Mire of Bells': { base: 65.41, wind: 480 }, // C2
+};
+
+const FALLBACK_AMBIENCE: Ambience = { base: 49, wind: 340 }; // G1
+
+interface Drone {
+  oscA: OscillatorNode;
+  oscB: OscillatorNode;
+  windFilter: BiquadFilterNode;
+  gain: GainNode;
+}
+
+interface ToneOpts {
+  freq: number;
+  type?: OscillatorType;
+  attack?: number;
+  decay?: number;
+  gain?: number;
+  delay?: number;
+  /** Glide to this frequency across the decay (for impacts). */
+  freqEnd?: number;
+}
+
+interface NoiseOpts {
+  filterType: BiquadFilterType;
+  freq: number;
+  q?: number;
+  attack?: number;
+  decay: number;
+  gain: number;
+  delay?: number;
+}
+
+export class AudioSystem {
+  private ctx: AudioContext | null = null;
+  private master: GainNode | null = null;
+  private noiseBuffer: AudioBuffer | null = null;
+  private drone: Drone | null = null;
+  private enabled = true;
+  private volume = 1;
+  private ambienceKey: string | null = null;
+
+  /** Call once at boot; the context is created on the first user gesture. */
+  attach(): void {
+    if (typeof window === 'undefined') return;
+    const init = (): void => {
+      this.initContext();
+      window.removeEventListener('pointerdown', init);
+      window.removeEventListener('keydown', init);
+    };
+    window.addEventListener('pointerdown', init);
+    window.addEventListener('keydown', init);
+  }
+
+  /** Sound on/off — immediate, and the context is suspended while off. */
+  setEnabled(on: boolean): void {
+    this.enabled = on;
+    if (!this.ctx || !this.master) return;
+    this.applyMasterGain();
+    if (on) void this.ctx.resume();
+    else void this.ctx.suspend();
+  }
+
+  /** Master volume, 0..1. */
+  setVolume(volume: number): void {
+    this.volume = Math.min(1, Math.max(0, volume));
+    this.applyMasterGain();
+  }
+
+  /** Ambient drone for a region (or 'title'); glides over ~1s on change. */
+  setAmbience(key: string): void {
+    if (key === this.ambienceKey) return;
+    this.ambienceKey = key;
+    const ctx = this.ctx;
+    if (!ctx || !this.master) return; // replayed by initContext()
+    const tuning = AMBIENCES[key] ?? FALLBACK_AMBIENCE;
+    const drone = (this.drone ??= this.buildDrone(tuning));
+    const t = ctx.currentTime;
+    const settle = DRONE_CROSSFADE_S / 3; // setTargetAtTime tau: ~settled in 3τ
+    drone.oscA.frequency.setTargetAtTime(tuning.base, t, settle);
+    drone.oscB.frequency.setTargetAtTime(tuning.base * 1.007, t, settle);
+    drone.windFilter.frequency.setTargetAtTime(tuning.wind, t, settle);
+    // Dip, then swell back — the world holds its breath between places.
+    drone.gain.gain.setTargetAtTime(DRONE_LEVEL * 0.2, t, settle * 0.6);
+    drone.gain.gain.setTargetAtTime(DRONE_LEVEL, t + DRONE_CROSSFADE_S * 0.5, settle);
+  }
+
+  // ---- combat --------------------------------------------------------------
+
+  /** Light hit: short filtered noise burst + low thump. */
+  hitLight(): void {
+    this.noise({ filterType: 'bandpass', freq: 1800, q: 0.8, decay: 0.09, gain: 0.22 });
+    this.tone({ freq: 170, freqEnd: 90, decay: 0.08, gain: 0.14 });
+  }
+
+  /** Heavy hit: lower, longer noise body + dropping sub thump. */
+  hitHeavy(): void {
+    this.noise({ filterType: 'lowpass', freq: 750, decay: 0.26, gain: 0.3 });
+    this.tone({ freq: 95, freqEnd: 52, decay: 0.3, gain: 0.26 });
+  }
+
+  /** Heal: two soft ascending bells (C5 -> E5). */
+  heal(): void {
+    this.bell(523.25, { gain: 0.1, decay: 0.9 });
+    this.bell(659.25, { gain: 0.1, decay: 1.1, delay: 0.12 });
+  }
+
+  /** Death knell: a single deep bell with a very long decay. */
+  deathKnell(): void {
+    this.bell(110, { gain: 0.22, decay: 2.4 });
+  }
+
+  /** Lower the ambient drone (death overlay) and bring it back. */
+  duckAmbience(ducked: boolean): void {
+    if (!this.ctx || !this.drone) return;
+    this.drone.gain.gain.setTargetAtTime(
+      ducked ? DRONE_LEVEL * 0.25 : DRONE_LEVEL,
+      this.ctx.currentTime,
+      0.4,
+    );
+  }
+
+  /** Player hurt: low noise thud + a dissonant minor second. */
+  playerHurt(): void {
+    this.noise({ filterType: 'lowpass', freq: 420, decay: 0.22, gain: 0.28 });
+    this.tone({ freq: 110, decay: 0.3, gain: 0.14 });
+    this.tone({ freq: 116.54, decay: 0.3, gain: 0.12 });
+  }
+
+  /** Calm / non-violent success: resolved major third (C4 + E4). */
+  calm(): void {
+    this.tone({ freq: 261.63, attack: 0.06, decay: 1.2, gain: 0.1 });
+    this.tone({ freq: 329.63, attack: 0.06, decay: 1.4, gain: 0.09, delay: 0.05 });
+  }
+
+  /** Condition applied: short high whisper of filtered noise. */
+  condition(): void {
+    this.noise({ filterType: 'highpass', freq: 4200, decay: 0.12, gain: 0.09 });
+    this.tone({ freq: 780, freqEnd: 520, decay: 0.12, gain: 0.05 });
+  }
+
+  // ---- UI ------------------------------------------------------------------
+
+  /** Dialogue advance: a very soft, very short bell. */
+  dialogueAdvance(): void {
+    this.bell(880, { gain: 0.045, decay: 0.35 });
+  }
+
+  /** Choice selected: a dry click. */
+  uiSelect(): void {
+    this.noise({ filterType: 'bandpass', freq: 2600, q: 1.4, decay: 0.03, gain: 0.12 });
+    this.tone({ freq: 1250, decay: 0.03, gain: 0.05 });
+  }
+
+  /** Panel open/close: two short tones, rising to open, falling to close. */
+  panel(open: boolean): void {
+    const first = open ? 392 : 493.88;
+    const second = open ? 493.88 : 392;
+    this.tone({ freq: first, decay: 0.09, gain: 0.08 });
+    this.tone({ freq: second, decay: 0.12, gain: 0.08, delay: 0.07 });
+  }
+
+  /** Toast: a soft tick. */
+  toast(): void {
+    this.tone({ freq: 1567.98, decay: 0.06, gain: 0.04 });
+  }
+
+  /** Name Echo / narration: ethereal slow-attack chord (A3 + C#4 + E4). */
+  echo(): void {
+    const notes = [220, 277.18, 329.63];
+    notes.forEach((freq, i) => {
+      this.tone({ freq, attack: 0.9, decay: 2.8, gain: 0.045, delay: i * 0.15 });
+      this.tone({ freq: freq * 1.003, attack: 0.9, decay: 2.8, gain: 0.03, delay: i * 0.15 });
+    });
+  }
+
+  /** Footstep: a barely-audible percussive tick. */
+  footstep(): void {
+    this.noise({ filterType: 'lowpass', freq: 900, decay: 0.035, gain: 0.045 });
+  }
+
+  // ---- internals -----------------------------------------------------------
+
+  private initContext(): void {
+    if (this.ctx || typeof window === 'undefined') return;
+    const Ctor =
+      window.AudioContext ??
+      (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+    if (!Ctor) return;
+    let ctx: AudioContext;
+    try {
+      ctx = new Ctor();
+    } catch {
+      return; // audio unavailable — stay silent
+    }
+    this.ctx = ctx;
+    this.master = ctx.createGain();
+    this.master.connect(ctx.destination);
+    this.applyMasterGain();
+    if (this.enabled) void ctx.resume();
+    else void ctx.suspend();
+    if (this.ambienceKey) {
+      const key = this.ambienceKey;
+      this.ambienceKey = null; // force setAmbience to apply
+      this.setAmbience(key);
+    }
+  }
+
+  private applyMasterGain(): void {
+    if (!this.ctx || !this.master) return;
+    const level = this.enabled ? MASTER_LEVEL * this.volume : 0;
+    this.master.gain.setTargetAtTime(level, this.ctx.currentTime, 0.03);
+  }
+
+  /** Persistent region drone: two detuned sines + wind noise with a slow LFO. */
+  private buildDrone(tuning: Ambience): Drone {
+    const ctx = this.ctx as AudioContext;
+    const gain = ctx.createGain();
+    gain.gain.value = 0;
+    gain.connect(this.master as GainNode);
+    const oscA = ctx.createOscillator();
+    oscA.type = 'sine';
+    oscA.frequency.value = tuning.base;
+    const oscB = ctx.createOscillator();
+    oscB.type = 'sine';
+    oscB.frequency.value = tuning.base * 1.007; // slow beating against oscA
+    const oscGain = ctx.createGain();
+    oscGain.gain.value = 0.5;
+    oscA.connect(oscGain);
+    oscB.connect(oscGain);
+    oscGain.connect(gain);
+    // Wind: looped noise through a bandpass, breathing via a slow LFO.
+    const wind = ctx.createBufferSource();
+    wind.buffer = this.getNoiseBuffer();
+    wind.loop = true;
+    const windFilter = ctx.createBiquadFilter();
+    windFilter.type = 'bandpass';
+    windFilter.frequency.value = tuning.wind;
+    windFilter.Q.value = 0.6;
+    const windGain = ctx.createGain();
+    windGain.gain.value = 0.35;
+    const lfo = ctx.createOscillator();
+    lfo.type = 'sine';
+    lfo.frequency.value = 0.09;
+    const lfoGain = ctx.createGain();
+    lfoGain.gain.value = 0.18;
+    lfo.connect(lfoGain);
+    lfoGain.connect(windGain.gain);
+    wind.connect(windFilter);
+    windFilter.connect(windGain);
+    windGain.connect(gain);
+    oscA.start();
+    oscB.start();
+    wind.start();
+    lfo.start();
+    // Fade in slowly — a drone should be noticed only when it stops.
+    gain.gain.setTargetAtTime(DRONE_LEVEL, ctx.currentTime, 1.5);
+    return { oscA, oscB, windFilter, gain };
+  }
+
+  /** One shared second of white noise, created on first use. */
+  private getNoiseBuffer(): AudioBuffer {
+    const ctx = this.ctx as AudioContext;
+    if (!this.noiseBuffer) {
+      const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
+      const data = buffer.getChannelData(0);
+      for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
+      this.noiseBuffer = buffer;
+    }
+    return this.noiseBuffer;
+  }
+
+  /** A struck bell: sine fundamental + one inharmonic partial, long decay. */
+  private bell(freq: number, opts: { gain?: number; decay?: number; delay?: number } = {}): void {
+    const gain = opts.gain ?? 0.18;
+    const decay = opts.decay ?? 1.2;
+    this.tone({ freq, gain, decay, delay: opts.delay });
+    this.tone({ freq: freq * 2.76, gain: gain * 0.3, decay: decay * 0.5, delay: opts.delay });
+  }
+
+  private tone(opts: ToneOpts): void {
+    const ctx = this.ctx;
+    const master = this.master;
+    if (!ctx || !master || !this.enabled) return;
+    const t = ctx.currentTime + (opts.delay ?? 0);
+    const attack = opts.attack ?? 0.005;
+    const decay = opts.decay ?? 0.3;
+    const osc = ctx.createOscillator();
+    osc.type = opts.type ?? 'sine';
+    osc.frequency.setValueAtTime(opts.freq, t);
+    if (opts.freqEnd !== undefined) {
+      osc.frequency.exponentialRampToValueAtTime(opts.freqEnd, t + attack + decay);
+    }
+    const gain = ctx.createGain();
+    gain.gain.setValueAtTime(0, t);
+    gain.gain.linearRampToValueAtTime(opts.gain ?? 0.2, t + attack);
+    gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
+    osc.connect(gain);
+    gain.connect(master);
+    osc.start(t);
+    osc.stop(t + attack + decay + 0.05);
+  }
+
+  private noise(opts: NoiseOpts): void {
+    const ctx = this.ctx;
+    const master = this.master;
+    if (!ctx || !master || !this.enabled) return;
+    const t = ctx.currentTime + (opts.delay ?? 0);
+    const attack = opts.attack ?? 0.003;
+    const src = ctx.createBufferSource();
+    src.buffer = this.getNoiseBuffer();
+    const filter = ctx.createBiquadFilter();
+    filter.type = opts.filterType;
+    filter.frequency.value = opts.freq;
+    filter.Q.value = opts.q ?? 1;
+    const gain = ctx.createGain();
+    gain.gain.setValueAtTime(0, t);
+    gain.gain.linearRampToValueAtTime(opts.gain, t + attack);
+    gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + opts.decay);
+    src.connect(filter);
+    filter.connect(gain);
+    gain.connect(master);
+    src.start(t);
+    src.stop(t + attack + opts.decay + 0.05);
+  }
+}
@@ -56,6 +56,22 @@ );   return allyAdjacent ? 2 : 0;
 }
 
+/**
+ * The condition an enemy's attack inflicts right now. Pack hunters (wolves)
+ * only bleed their prey while flanking — alone, the bite is just a bite.
+ */
+export function inflictedCondition(
+  attacker: ActorState,
+  defender: ActorState,
+  all: ActorState[],
+): { id: ConditionId; duration: number } | undefined {
+  if (attacker.kind !== 'enemy') return undefined;
+  const def = enemyDef(attacker.defId);
+  if (!def.appliesCondition) return undefined;
+  if (def.packTactics && flankingBonus(attacker, defender, all) === 0) return undefined;
+  return def.appliesCondition;
+}
+
 export function performAttack(
   attacker: ActorState,
   defender: ActorState,
@@ -69,6 +85,10 @@ const damage = Math.round(     computeDamage(attack, defense, options.mult ?? 1, rng) * incomingDamageMult(defender),
   );
   defender.hp = Math.max(0, defender.hp - damage);
+  // Being struck steadies the mind: the player gains 1 focus per attack taken.
+  if (defender.kind === 'player') {
+    defender.focus = Math.min(defender.maxFocus, defender.focus + 1);
+  }
   const brokenConditions = onActorHit(defender);
   if (defender.hp === 0) {
     defender.alive = false;
@@ -136,6 +156,8 @@ if (!rng.chance(chance)) return false;   target.faction = 'neutral';
   target.aggroed = false;
   removeCondition(target, 'afraid');
+  // Calming another steadies the mind: +1 focus.
+  actor.focus = Math.min(actor.maxFocus, actor.focus + 1);
   return true;
 }
 
@@ -1,9 +1,19 @@ import type { GridPos } from '../core/types';
 
+export type ShakeIntensity = 'sm' | 'md' | 'lg';
+
 export type EffectRequest =
   | { kind: 'impact'; pos: GridPos; glyph: string }
   | { kind: 'floatingText'; pos: GridPos; text: string; tone: 'damage' | 'heal' | 'word' }
-  | { kind: 'shake' };
+  | {
+      kind: 'flash';
+      actorId: string;
+      pos: GridPos;
+      tone: 'damage' | 'heal';
+      /** Attacker position, when known — the target recoils away from it. */
+      from: GridPos | null;
+    }
+  | { kind: 'shake'; intensity: ShakeIntensity };
 
 export type EffectHandler = (request: EffectRequest) => void;
 
@@ -32,7 +42,17 @@ word(pos: GridPos, text: string): void {     this.handler?.({ kind: 'floatingText', pos, text, tone: 'word' });
   }
 
-  shake(): void {
-    this.handler?.({ kind: 'shake' });
+  /** Brief hit flash (+ small recoil away from `from`) on the struck actor. */
+  hitFlash(actorId: string, pos: GridPos, from: GridPos | null): void {
+    this.handler?.({ kind: 'flash', actorId, pos, tone: 'damage', from });
+  }
+
+  /** Soft golden flash on the healed actor. */
+  healFlash(actorId: string, pos: GridPos): void {
+    this.handler?.({ kind: 'flash', actorId, pos, tone: 'heal', from: null });
+  }
+
+  shake(intensity: ShakeIntensity = 'md'): void {
+    this.handler?.({ kind: 'shake', intensity });
   }
 }
@@ -0,0 +1,30 @@+import type { GameState } from '../core/GameState';
+import { DIALOGUES } from '../data/dialogues';
+import { SHARDS } from '../data/shards';
+
+/**
+ * "Chapter complete" is honest about scope: the current content awards only
+ * some of the four Name Shards. Scanning dialogue effects for what can be
+ * earned keeps the threshold correct as new content ships — no fake endings.
+ */
+export function obtainableShardIds(): string[] {
+  const found = new Set<string>();
+  for (const tree of Object.values(DIALOGUES)) {
+    for (const node of Object.values(tree.nodes)) {
+      const effects = [
+        ...(node.effects ?? []),
+        ...(node.choices ?? []).flatMap((choice) => choice.effects ?? []),
+      ];
+      for (const effect of effects) {
+        if (effect.type === 'gainShard' && SHARDS[effect.shardId]) found.add(effect.shardId);
+      }
+    }
+  }
+  return [...found];
+}
+
+/** True when the player holds every shard the current content can award. */
+export function chapterComplete(state: GameState): boolean {
+  const obtainable = obtainableShardIds();
+  return obtainable.length > 0 && obtainable.every((id) => state.shards.includes(id));
+}
@@ -12,8 +12,9 @@ import { hasCondition } from './ConditionSystem'; 
 export type AiIntent =
   | { type: 'move'; path: GridPos[] }
-  | { type: 'attack'; targetId: string }
+  | { type: 'attack'; targetId: string; mult?: number }
   | { type: 'rangedAttack'; targetId: string }
+  | { type: 'push'; targetId: string }
   | { type: 'guard' }
   | { type: 'wait' };
 
@@ -173,7 +174,12 @@ ): AiIntent {   if (enemy.aggroed || manhattan(enemy.pos, ctx.player.pos) <= aggroRange) {
     enemy.aggroed = true;
     const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
-    if (adjacent) return adjacent;
+    if (adjacent) {
+      // Every third blow is a heavy swing; the fixed cadence is the tell.
+      enemy.routineStep = (enemy.routineStep ?? 0) + 1;
+      const mult = enemy.routineStep % 3 === 0 ? 1.5 : undefined;
+      return { type: 'attack', targetId: ctx.player.id, mult };
+    }
     const path = pathToward(enemy, ctx.player.pos, ctx.map, occupancy, moveRange);
     return path ? { type: 'move', path } : { type: 'wait' };
   }
@@ -241,7 +247,8 @@ return { type: 'wait' }; // winding down — its tell }
 
 // Crown soldier: disciplined advance; when hurt he alternates guard/advance
-// instead of flailing — order, even in retreat.
+// instead of flailing — order, even in retreat. Pressed while hurt, every
+// other contact is a shove to reopen distance.
 function crownSoldierAction(
   enemy: ActorState,
   moveRange: number,
@@ -249,9 +256,15 @@ attackRange: number,   ctx: AiContext,
   occupancy: Occupancy,
 ): AiIntent {
+  const hurt = enemy.hp < enemy.maxHp / 2;
   const adjacent = attackIfAdjacent(enemy, ctx.player, attackRange);
-  if (adjacent) return adjacent;
-  const hurt = enemy.hp < enemy.maxHp / 2;
+  if (adjacent) {
+    if (hurt) {
+      enemy.routineStep = (enemy.routineStep ?? 0) + 1;
+      if (enemy.routineStep % 2 === 0) return { type: 'push', targetId: ctx.player.id };
+    }
+    return adjacent;
+  }
   if (hurt) {
     enemy.routineStep = (enemy.routineStep ?? 0) + 1;
     if (enemy.routineStep % 2 === 0) return { type: 'guard' };
@@ -0,0 +1,13 @@+/**
+ * One-time onboarding hints, persisted as world flags: returns the text to
+ * show the first time, null afterwards. No framework — flags + toast.
+ */
+export function hintOnce(
+  flags: Record<string, boolean>,
+  flagId: string,
+  text: string,
+): string | null {
+  if (flags[flagId]) return null;
+  flags[flagId] = true;
+  return text;
+}
@@ -7,7 +7,7 @@ import { tileAt } from '../world/MapDefinition'; import type { Renderer } from '../rendering/Renderer';
 import type { AnimationQueue } from '../rendering/AnimationQueue';
 import type { EffectSystem } from './EffectSystem';
-import { applyCondition, removeCondition } from './ConditionSystem';
+import { applyCondition } from './ConditionSystem';
 
 export interface MovementDeps {
   getState: () => GameState;
@@ -74,10 +74,6 @@ if (applyCondition(actor, 'unstable')) {         this.deps.effects.word(actor.pos, 'Unstable');
         this.deps.renderer.refreshActor(actor);
       }
-    }
-    if (hazard === 'water' && removeCondition(actor, 'burning')) {
-      this.deps.effects.word(actor.pos, 'Extinguished');
-      this.deps.renderer.refreshActor(actor);
     }
   }
 }
@@ -1,4 +1,5 @@ import type { GameState } from '../core/GameState';
+import type { InputAction } from '../core/InputManager';
 import { el, clear } from '../utils/dom';
 import { conditionDef } from '../data/conditions';
 
@@ -29,6 +30,8 @@ private readonly actionBar: HTMLElement;   private readonly toastBox: HTMLElement;
   private toastTimer: number | null = null;
   private actionHandler: ((actionId: string) => void) | null = null;
+  private touchHandler: ((action: InputAction) => void) | null = null;
+  private readonly touchInteract: HTMLButtonElement;
 
   constructor(parent: HTMLElement) {
     this.root = el('div', 'hud');
@@ -54,10 +57,35 @@ this.actionBar = el('div', 'hud__actions');     this.toastBox = el('div', 'hud__toast');
     this.toastBox.setAttribute('role', 'status');
 
-    this.root.append(status, this.conditionsRow, context, this.turnOrderRow, this.actionBar, this.toastBox);
+    // Touch controls: hidden unless the pointer is coarse (CSS media query).
+    const touchBar = el('div', 'hud__touch');
+    this.touchInteract = this.touchButton('Act', () => this.touchHandler?.({ type: 'interact' }));
+    touchBar.append(
+      this.touchInteract,
+      this.touchButton('Bag', () => this.touchHandler?.({ type: 'openPanel', panel: 'inventory' })),
+      this.touchButton('Journal', () => this.touchHandler?.({ type: 'openPanel', panel: 'journal' })),
+    );
+
+    this.root.append(status, this.conditionsRow, context, this.turnOrderRow, this.actionBar, this.toastBox, touchBar);
     parent.append(this.root);
   }
 
+  private touchButton(label: string, onClick: () => void): HTMLButtonElement {
+    const button = el('button', 'hud__touch-button', label) as HTMLButtonElement;
+    button.type = 'button';
+    button.addEventListener('click', onClick);
+    return button;
+  }
+
+  onTouch(handler: (action: InputAction) => void): void {
+    this.touchHandler = handler;
+  }
+
+  /** The Act button only lights up when there is something to interact with. */
+  setTouchInteractVisible(visible: boolean): void {
+    this.touchInteract.classList.toggle('hud__touch-button--dim', !visible);
+  }
+
   onAction(handler: (actionId: string) => void): void {
     this.actionHandler = handler;
   }
@@ -75,7 +103,11 @@ clear(this.conditionsRow);     for (const active of player.conditions) {
       const def = conditionDef(active.id);
       const chip = el('span', 'condition-chip glyph', def.glyph);
-      chip.title = `${def.name} (${active.remaining}) — ${def.tooltip}`;
+      const label = `${def.name} (${active.remaining}) — ${def.tooltip}`;
+      chip.title = label;
+      chip.setAttribute('aria-label', label);
+      chip.dataset.tip = label;
+      chip.tabIndex = 0;
       this.conditionsRow.append(chip);
     }
   }
@@ -113,13 +145,13 @@ this.actionBar.append(button);     });
   }
 
-  toast(text: string): void {
+  toast(text: string, durationMs = 3200): void {
     this.toastBox.textContent = text;
     this.toastBox.classList.add('hud__toast--visible');
     if (this.toastTimer !== null) window.clearTimeout(this.toastTimer);
     this.toastTimer = window.setTimeout(() => {
       this.toastBox.classList.remove('hud__toast--visible');
-    }, 3200);
+    }, durationMs);
   }
 }
 
@@ -70,6 +70,9 @@ ): HTMLElement {     const def = itemDef(itemId);
     const row = el('div', 'inventory__row');
     row.title = def.description;
+    row.setAttribute('aria-label', `${def.name} — ${def.description}`);
+    row.dataset.tip = def.description;
+    row.tabIndex = 0;
     row.append(el('span', 'inventory__glyph glyph', def.glyph));
     const name = el('span', 'inventory__name', def.name);
     row.append(name);
@@ -100,6 +100,10 @@ const form = el('div', 'settings-form');   form.append(
     toggleRow('Reduced motion', draft.reducedMotion, (v) => (draft.reducedMotion = v)),
     toggleRow('Screen shake', draft.screenShake, (v) => (draft.screenShake = v)),
+    toggleRow('Sound', draft.sound, (v) => (draft.sound = v)),
+    sliderRow('Sound volume', draft.soundVolume, [0.25, 0.5, 0.75, 1], ['Quiet', 'Soft', 'Normal', 'Full'], (v) => {
+      draft.soundVolume = v;
+    }),
     sliderRow('Animation speed', draft.animationSpeed, [0.5, 1, 2], ['Fast', 'Normal', 'Slow'], (v) => {
       draft.animationSpeed = v;
     }),
@@ -184,6 +188,77 @@ element,     show: () => element.classList.add('pause-overlay--visible'),
     hide: () => element.classList.remove('pause-overlay--visible'),
     visible: () => element.classList.contains('pause-overlay--visible'),
+  };
+}
+
+export interface StoryOverlay {
+  element: HTMLElement;
+  show: () => void;
+  hide: () => void;
+  visible: () => boolean;
+}
+
+/** Death: a short verse over ink, then the choice to rise. */
+export function buildDeathOverlay(onRise: () => void): StoryOverlay {
+  const element = el('div', 'story-overlay story-overlay--death');
+  element.setAttribute('aria-label', 'You died');
+  const panel = el('div', 'story-overlay__panel');
+  panel.append(
+    el('p', 'story-overlay__line', 'The grave remembers you.'),
+    el('p', 'story-overlay__line', 'But it cannot keep what it cannot name.'),
+  );
+  const nav = el('nav', 'menu__nav');
+  nav.append(menuButton('Rise again', onRise));
+  panel.append(nav);
+  element.append(panel);
+  return {
+    element,
+    show: () => element.classList.add('story-overlay--visible'),
+    hide: () => element.classList.remove('story-overlay--visible'),
+    visible: () => element.classList.contains('story-overlay--visible'),
+  };
+}
+
+export interface EndingOverlay extends StoryOverlay {
+  showFor: (shards: { glyph: string; name: string }[]) => void;
+}
+
+/**
+ * Chapter complete: the current content's shards, carried. Not an ending —
+ * the world is not done forgetting.
+ */
+export function buildEndingOverlay(callbacks: {
+  onContinue: () => void;
+  onTitle: () => void;
+}): EndingOverlay {
+  const element = el('div', 'story-overlay story-overlay--ending');
+  element.setAttribute('aria-label', 'Chapter complete');
+  const panel = el('div', 'story-overlay__panel');
+  const shardRow = el('div', 'story-overlay__shards');
+  panel.append(
+    el('h2', 'menu__heading', 'What the road gave back'),
+    shardRow,
+    el('p', 'story-overlay__line', 'The shards you carry hum like bells that almost remember their sound.'),
+    el('p', 'story-overlay__line', 'The world is not done forgetting. You are not done walking.'),
+  );
+  const nav = el('nav', 'menu__nav menu__nav--row');
+  nav.append(menuButton('Keep walking', callbacks.onContinue), menuButton('Return to title', callbacks.onTitle));
+  panel.append(nav);
+  element.append(panel);
+  return {
+    element,
+    show: () => element.classList.add('story-overlay--visible'),
+    showFor: (shards) => {
+      clear(shardRow);
+      for (const shard of shards) {
+        const entry = el('span', 'story-overlay__shard');
+        entry.append(el('span', 'glyph', shard.glyph), el('span', 'story-overlay__shard-name', shard.name));
+        shardRow.append(entry);
+      }
+      element.classList.add('story-overlay--visible');
+    },
+    hide: () => element.classList.remove('story-overlay--visible'),
+    visible: () => element.classList.contains('story-overlay--visible'),
   };
 }
 
@@ -21,7 +21,7 @@ | 'water'   | 'hut'
   | 'unstable';
 
-export type HazardKind = 'water' | 'unstable' | 'fire';
+export type HazardKind = 'unstable' | 'fire';
 
 export interface TileDef {
   terrain: TerrainId;
@@ -75,7 +75,7 @@ terrain: 'water',     walkable: false,
     blocksSight: false,
     glyph: glyphOf(GLYPH_IDS.water),
-    hazard: 'water',
+    hazard: null, // unwalkable, so nothing ever stands on it to be extinguished
     label: 'Black water',
   },
   h: {
@@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest';
 import { Rng } from '../src/core/Rng';
-import { computeDamage, performAttack, performCalm, flankingBonus } from '../src/systems/CombatSystem';
+import { computeDamage, performAttack, performCalm, flankingBonus, inflictedCondition } from '../src/systems/CombatSystem';
 import { applyCondition } from '../src/systems/ConditionSystem';
 import { createEnemyState, createPlayerState } from '../src/entities/EntityFactory';
 
@@ -54,15 +54,6 @@ const vsUnstable = performAttack(player, wolfB, new Rng(3), [player, wolfB], {});     expect(vsUnstable.damage).toBeGreaterThan(normal.damage);
   });
 
-  it('breaks frozen when the target is struck', () => {
-    const player = createPlayerState('neutral', { x: 0, y: 0 });
-    const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
-    applyCondition(wolf, 'frozen');
-    const outcome = performAttack(player, wolf, new Rng(2), [player, wolf], {});
-    expect(outcome.brokenConditions).toContain('frozen');
-    expect(wolf.conditions.some((c) => c.id === 'frozen')).toBe(false);
-  });
-
   it('applies on-hit conditions to living targets', () => {
     const player = createPlayerState('neutral', { x: 0, y: 0 });
     const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
@@ -72,6 +63,68 @@ });     expect(outcome.conditionsApplied).toContain('staggered');
     expect(wolf.conditions.some((c) => c.id === 'staggered')).toBe(true);
   });
+
+  it('grants the player 1 focus when struck by an attack', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    player.focus = 2;
+    performAttack(wolf, player, new Rng(4), [wolf, player], {});
+    expect(player.focus).toBe(3);
+  });
+
+  it('caps focus gain from being hit at max focus', () => {
+    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    player.focus = player.maxFocus;
+    performAttack(wolf, player, new Rng(4), [wolf, player], {});
+    expect(player.focus).toBe(player.maxFocus);
+  });
+
+  it('does not grant focus to enemies when they are hit', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+    wolf.focus = 0;
+    performAttack(player, wolf, new Rng(4), [player, wolf], {});
+    expect(wolf.focus).toBe(0);
+  });
+});
+
+describe('inflictedCondition', () => {
+  it('wolf bite bleeds only while flanking in a pack', () => {
+    const wolfA = createEnemyState('ashWolf', { x: 0, y: 0 });
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    expect(inflictedCondition(wolfA, player, [wolfA, player])).toBeUndefined();
+    const wolfB = createEnemyState('ashWolf', { x: 2, y: 0 });
+    expect(inflictedCondition(wolfA, player, [wolfA, wolfB, player])).toEqual({
+      id: 'bleeding',
+      duration: 3,
+    });
+  });
+
+  it('archer arrows destabilize; rust machine fists burn; the lurker still drowns memory', () => {
+    const player = createPlayerState('neutral', { x: 1, y: 0 });
+    const archer = createEnemyState('crownArcher', { x: 0, y: 0 });
+    expect(inflictedCondition(archer, player, [archer, player])).toEqual({
+      id: 'unstable',
+      duration: 2,
+    });
+    const machine = createEnemyState('rustMachine', { x: 0, y: 0 });
+    expect(inflictedCondition(machine, player, [machine, player])).toEqual({
+      id: 'burning',
+      duration: 2,
+    });
+    const lurker = createEnemyState('mireLurker', { x: 0, y: 0 });
+    expect(inflictedCondition(lurker, player, [lurker, player])).toEqual({
+      id: 'forgotten',
+      duration: 2,
+    });
+  });
+
+  it('non-enemies never inflict conditions this way', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    const wolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+    expect(inflictedCondition(player, wolf, [player, wolf])).toBeUndefined();
+  });
 });
 
 describe('flankingBonus', () => {
@@ -107,6 +160,23 @@ expect(freshWolf.aggroed).toBe(false);       }
     }
     expect(calmed).toBe(true);
+  });
+
+  it('grants the player 1 focus on a successful calm, capped at max', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    player.focus = player.maxFocus - 1;
+    let calmed = false;
+    for (let seed = 0; seed < 50 && !calmed; seed++) {
+      const freshWolf = createEnemyState('ashWolf', { x: 1, y: 0 });
+      calmed = performCalm(player, freshWolf, 3, new Rng(seed));
+    }
+    expect(calmed).toBe(true);
+    expect(player.focus).toBe(player.maxFocus);
+    // A failed attempt grants nothing.
+    player.focus = 2;
+    const machine = createEnemyState('rustMachine', { x: 1, y: 0 });
+    expect(performCalm(player, machine, 10, new Rng(1))).toBe(false);
+    expect(player.focus).toBe(2);
   });
 
   it('never calms non-calmable enemies or distant targets', () => {
@@ -16,10 +16,10 @@ it('every binding condition has effect, glyph, tooltip and duration', () => {     const required = [
       'bleeding',
       'burning',
-      'frozen',
       'staggered',
       'afraid',
       'guarded',
+      'guarding',
       'silenced',
       'forgotten',
       'remembered',
@@ -65,14 +65,6 @@ expect(tick.damage).toBe(3);     expect(wolf.alive).toBe(false);
   });
 
-  it('frozen prevents the action and sets speed to 0', () => {
-    const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
-    applyCondition(wolf, 'frozen');
-    expect(speedOf(wolf)).toBe(0);
-    const tick = tickConditions(wolf);
-    expect(tick.preventsAction).toBe(true);
-  });
-
   it('staggered halves speed', () => {
     const wolf = createEnemyState('ashWolf', { x: 0, y: 0 });
     applyCondition(wolf, 'staggered');
@@ -92,6 +84,15 @@ expect(tick.focusDelta).toBe(1);     expect(player.focus).toBe(3);
   });
 
+  it('guarding raises defense like guarded and expires on the next turn', () => {
+    const player = createPlayerState('neutral', { x: 0, y: 0 });
+    applyCondition(player, 'guarding', 1);
+    expect(defenseOf(player)).toBeCloseTo(player.stats.defense * 1.5);
+    const tick = tickConditions(player);
+    expect(tick.expired).toContain('guarding');
+    expect(defenseOf(player)).toBeCloseTo(player.stats.defense);
+  });
+
   it('silenced blocks abilities', () => {
     const player = createPlayerState('neutral', { x: 0, y: 0 });
     applyCondition(player, 'silenced');
@@ -113,6 +114,6 @@ applyCondition(player, 'burning');     applyCondition(player, 'bleeding');
     expect(removeCondition(player, 'burning')).toBe(true);
     expect(player.conditions.map((c) => c.id)).toEqual(['bleeding']);
-    expect(removeCondition(player, 'frozen')).toBe(false);
+    expect(removeCondition(player, 'staggered')).toBe(false);
   });
 });
@@ -4,6 +4,8 @@ import { GRAVE_CLEARING } from '../src/data/maps/graveClearing'; import { OLD_ROAD } from '../src/data/maps/oldRoad';
 import { validateContent } from '../src/utils/validation';
 import { tileAt } from '../src/world/MapDefinition';
+import { ENEMY_IDS } from '../src/data/enemies';
+import { ITEM_IDS } from '../src/data/items';
 
 // Importing a map module runs defineMap, which throws on malformed rows —
 // so this suite failing at import time is itself the signal.
@@ -39,5 +41,15 @@ const grave = GRAVE_CLEARING.interactables.find((i) => i.id === 'yourGrave');     const tree = GRAVE_CLEARING.interactables.find((i) => i.id === 'deadTree');
     expect(tileAt(GRAVE_CLEARING, grave!.pos)?.terrain).toBe('grave');
     expect(tileAt(GRAVE_CLEARING, tree!.pos)?.terrain).toBe('deadTree');
+  });
+
+  it('formerly orphaned content is reachable: rust machine placed, bow and letter obtainable', () => {
+    const spawns = ALL_MAPS.flatMap((m) => m.enemies);
+    expect(spawns.some((s) => s.defId === ENEMY_IDS.rustMachine)).toBe(true);
+    const loot = ALL_MAPS.flatMap((m) => m.interactables)
+      .filter((i) => i.action.type === 'item')
+      .map((i) => (i.action as { itemId: string }).itemId);
+    expect(loot).toContain(ITEM_IDS.huntingBow);
+    expect(loot).toContain(ITEM_IDS.letterToTheDrowned);
   });
 });
@@ -70,6 +70,33 @@ expect(intent.type).toBe('wait');     expect(soldier.patrolIndex).toBe(1);
   });
 
+  it('hollow soldier telegraphs a heavy swing every third attack', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const soldier = createEnemyState('hollowSoldier', { x: 4, y: 3 });
+    const mults = [0, 1, 2, 3].map(() => {
+      const intent = decideEnemyAction(soldier, ctx(player, [soldier]));
+      return intent.type === 'attack' ? intent.mult : null;
+    });
+    expect(mults).toEqual([undefined, undefined, 1.5, undefined]);
+  });
+
+  it('crown soldier alternates attack and shove while hurt and pressed', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const soldier = createEnemyState('crownSoldier', { x: 4, y: 3 });
+    soldier.hp = 5; // below half of 18
+    const first = decideEnemyAction(soldier, ctx(player, [soldier]));
+    const second = decideEnemyAction(soldier, ctx(player, [soldier]));
+    expect(first.type).toBe('attack');
+    expect(second).toEqual({ type: 'push', targetId: 'player' });
+  });
+
+  it('crown soldier without wounds never shoves', () => {
+    const player = createPlayerState('neutral', { x: 4, y: 4 });
+    const soldier = createEnemyState('crownSoldier', { x: 4, y: 3 });
+    const intent = decideEnemyAction(soldier, ctx(player, [soldier]));
+    expect(intent.type).toBe('attack');
+  });
+
   it('archer fires from range with line of sight, closes in when blind', () => {
     const player = createPlayerState('neutral', { x: 4, y: 4 });
     const archer = createEnemyState('crownArcher', { x: 4, y: 1 });
@@ -0,0 +1,53 @@+import { describe, it, expect } from 'vitest';
+import { hintOnce } from '../src/systems/HintSystem';
+import { chapterComplete, obtainableShardIds } from '../src/systems/EndingSystem';
+import { createNewGameState } from '../src/entities/EntityFactory';
+import { SHARD_IDS, TOTAL_SHARDS } from '../src/data/shards';
+
+function freshState() {
+  return createNewGameState({
+    variantId: 'neutral',
+    mapId: 'graveClearing',
+    region: 'The Nameless Edge',
+    objective: 'Wake.',
+    playerSpawn: { x: 6, y: 3 },
+    seed: 1,
+  });
+}
+
+describe('hintOnce', () => {
+  it('shows the text once, then never again, and persists as a flag', () => {
+    const flags: Record<string, boolean> = {};
+    expect(hintOnce(flags, 'hintCombat', 'Fight smart.')).toBe('Fight smart.');
+    expect(flags.hintCombat).toBe(true);
+    expect(hintOnce(flags, 'hintCombat', 'Fight smart.')).toBeNull();
+  });
+
+  it('tracks hints independently per flag', () => {
+    const flags: Record<string, boolean> = {};
+    expect(hintOnce(flags, 'a', 'A')).toBe('A');
+    expect(hintOnce(flags, 'b', 'B')).toBe('B');
+    expect(hintOnce(flags, 'a', 'A')).toBeNull();
+  });
+});
+
+describe('chapterComplete', () => {
+  it('only the shards current content can award count as obtainable', () => {
+    const obtainable = obtainableShardIds();
+    expect(obtainable).toContain(SHARD_IDS.glasswood);
+    expect(obtainable).toContain(SHARD_IDS.mire);
+    // rust and coast exist as data but no content awards them yet
+    expect(obtainable.length).toBeLessThan(TOTAL_SHARDS);
+  });
+
+  it('is false with no shards and true once every obtainable shard is carried', () => {
+    const state = freshState();
+    expect(chapterComplete(state)).toBe(false);
+    state.shards.push(SHARD_IDS.glasswood);
+    expect(chapterComplete(state)).toBe(false); // still missing the mire shard
+    for (const id of obtainableShardIds()) {
+      if (!state.shards.includes(id)) state.shards.push(id);
+    }
+    expect(chapterComplete(state)).toBe(true);
+  });
+});