diff --git a/CLAUDE.md b/CLAUDE.md
index f2339f6..b286b16 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,6 +64,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
+- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx
index fcf5b21..92a73af 100644
--- a/apps/web/src/components/combatant-row.tsx
+++ b/apps/web/src/components/combatant-row.tsx
@@ -1,4 +1,4 @@
-import type { CombatantId } from "@initiative/domain";
+import { type CombatantId, deriveHpStatus } from "@initiative/domain";
import { X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { cn } from "../lib/utils";
@@ -127,10 +127,12 @@ function CurrentHpInput({
currentHp,
maxHp,
onCommit,
+ className,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onCommit: (value: number) => void;
+ className?: string;
}) {
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
const prev = useRef(currentHp);
@@ -161,7 +163,7 @@ function CurrentHpInput({
value={draft}
placeholder="HP"
disabled={maxHp === undefined}
- className="h-7 w-[7ch] text-center text-sm tabular-nums"
+ className={cn("h-7 w-[7ch] text-center text-sm tabular-nums", className)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -181,6 +183,7 @@ export function CombatantRow({
onAdjustHp,
}: CombatantRowProps) {
const { id, name, initiative, maxHp, currentHp } = combatant;
+ const status = deriveHpStatus(currentHp, maxHp);
return (
{/* Initiative */}
@@ -219,6 +223,10 @@ export function CombatantRow({
{
if (currentHp === undefined) return;
const delta = value - currentHp;
diff --git a/packages/domain/src/__tests__/hp-status.test.ts b/packages/domain/src/__tests__/hp-status.test.ts
new file mode 100644
index 0000000..31b9a5c
--- /dev/null
+++ b/packages/domain/src/__tests__/hp-status.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { deriveHpStatus } from "../hp-status.js";
+
+describe("deriveHpStatus", () => {
+ it("returns healthy when currentHp >= maxHp / 2", () => {
+ expect(deriveHpStatus(10, 20)).toBe("healthy");
+ expect(deriveHpStatus(15, 20)).toBe("healthy");
+ expect(deriveHpStatus(20, 20)).toBe("healthy");
+ });
+
+ it("returns bloodied when 0 < currentHp < maxHp / 2", () => {
+ expect(deriveHpStatus(9, 20)).toBe("bloodied");
+ expect(deriveHpStatus(1, 20)).toBe("bloodied");
+ expect(deriveHpStatus(5, 20)).toBe("bloodied");
+ });
+
+ it("returns unconscious when currentHp <= 0", () => {
+ expect(deriveHpStatus(0, 20)).toBe("unconscious");
+ });
+
+ it("returns unconscious with negative HP", () => {
+ expect(deriveHpStatus(-5, 20)).toBe("unconscious");
+ });
+
+ it("returns undefined when maxHp is undefined", () => {
+ expect(deriveHpStatus(10, undefined)).toBeUndefined();
+ });
+
+ it("returns undefined when currentHp is undefined", () => {
+ expect(deriveHpStatus(undefined, 20)).toBeUndefined();
+ });
+
+ it("returns undefined when both are undefined", () => {
+ expect(deriveHpStatus(undefined, undefined)).toBeUndefined();
+ });
+
+ it("handles maxHp=1 (no bloodied state possible)", () => {
+ expect(deriveHpStatus(1, 1)).toBe("healthy");
+ expect(deriveHpStatus(0, 1)).toBe("unconscious");
+ });
+
+ it("handles maxHp=2 (1/2 is healthy, not bloodied)", () => {
+ expect(deriveHpStatus(1, 2)).toBe("healthy");
+ expect(deriveHpStatus(0, 2)).toBe("unconscious");
+ });
+
+ it("handles odd maxHp=21 (10 is bloodied since 10 < 10.5)", () => {
+ expect(deriveHpStatus(10, 21)).toBe("bloodied");
+ expect(deriveHpStatus(11, 21)).toBe("healthy");
+ });
+
+ it("returns healthy when currentHp exceeds maxHp", () => {
+ expect(deriveHpStatus(25, 20)).toBe("healthy");
+ });
+});
diff --git a/packages/domain/src/hp-status.ts b/packages/domain/src/hp-status.ts
new file mode 100644
index 0000000..c45d93b
--- /dev/null
+++ b/packages/domain/src/hp-status.ts
@@ -0,0 +1,11 @@
+export type HpStatus = "healthy" | "bloodied" | "unconscious";
+
+export function deriveHpStatus(
+ currentHp: number | undefined,
+ maxHp: number | undefined,
+): HpStatus | undefined {
+ if (currentHp === undefined || maxHp === undefined) return undefined;
+ if (currentHp <= 0) return "unconscious";
+ if (currentHp < maxHp / 2) return "bloodied";
+ return "healthy";
+}
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index 00d0d5b..2e8628e 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -18,6 +18,7 @@ export type {
TurnAdvanced,
TurnRetreated,
} from "./events.js";
+export { deriveHpStatus, type HpStatus } from "./hp-status.js";
export {
type RemoveCombatantSuccess,
removeCombatant,
diff --git a/specs/013-hp-status-indicators/checklists/requirements.md b/specs/013-hp-status-indicators/checklists/requirements.md
new file mode 100644
index 0000000..1119020
--- /dev/null
+++ b/specs/013-hp-status-indicators/checklists/requirements.md
@@ -0,0 +1,36 @@
+# Specification Quality Checklist: HP Status Indicators
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-03-05
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
+- FR-001 defines the exact thresholds for each status, making the boundary conditions fully testable.
+- Edge cases cover odd max HP values, min max HP (1 and 2), over-max HP, and unset max HP.
diff --git a/specs/013-hp-status-indicators/data-model.md b/specs/013-hp-status-indicators/data-model.md
new file mode 100644
index 0000000..98ba4b4
--- /dev/null
+++ b/specs/013-hp-status-indicators/data-model.md
@@ -0,0 +1,46 @@
+# Data Model: HP Status Indicators
+
+**Feature**: 013-hp-status-indicators
+**Date**: 2026-03-05
+
+## Entities
+
+### HpStatus (derived type -- not stored)
+
+A discriminated string union representing the health state of a combatant.
+
+| Value | Condition | Description |
+|-------|-----------|-------------|
+| `"healthy"` | `currentHp >= maxHp / 2` | Combatant is above half HP |
+| `"bloodied"` | `0 < currentHp < maxHp / 2` | Combatant is below half HP but still conscious |
+| `"unconscious"` | `currentHp <= 0` | Combatant is at or below zero HP |
+
+**Derivation rules**:
+- Only meaningful when both `currentHp` and `maxHp` are defined
+- Returns `undefined` when either value is missing (HP tracking not enabled)
+- Evaluation order: check `unconscious` first (currentHp <= 0), then `bloodied` (currentHp < maxHp / 2), else `healthy`
+
+### Combatant (existing -- unchanged)
+
+No modifications to the `Combatant` interface. HP status is computed on demand from existing fields:
+
+| Field | Type | Used for status |
+|-------|------|----------------|
+| `currentHp` | `number \| undefined` | Compared against thresholds |
+| `maxHp` | `number \| undefined` | Defines the "half HP" bloodied threshold |
+
+## State Transitions
+
+HP status changes are a side effect of existing HP operations (adjustHp, setHp). No new state transitions are introduced.
+
+```text
+healthy ──[take damage below half]──> bloodied
+bloodied ──[take damage to 0]──────> unconscious
+unconscious ──[heal above 0]───────> bloodied or healthy
+bloodied ──[heal above half]───────> healthy
+healthy ──[take massive damage]────> unconscious (skip bloodied)
+```
+
+## Storage Impact
+
+None. HP status is derived at render time from existing persisted `currentHp` and `maxHp` values. No localStorage schema changes.
diff --git a/specs/013-hp-status-indicators/plan.md b/specs/013-hp-status-indicators/plan.md
new file mode 100644
index 0000000..4ede6f3
--- /dev/null
+++ b/specs/013-hp-status-indicators/plan.md
@@ -0,0 +1,64 @@
+# Implementation Plan: HP Status Indicators
+
+**Branch**: `013-hp-status-indicators` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md)
+**Input**: Feature specification from `/specs/013-hp-status-indicators/spec.md`
+
+## Summary
+
+Add visual HP status indicators to combatant rows. A pure domain function derives the HP status ("healthy", "bloodied", "unconscious") from currentHp and maxHp. The UI applies color treatments: amber for bloodied HP text, red + muted row for unconscious/dead combatants. No new stored state -- status is computed on render.
+
+## Technical Context
+
+**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
+**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons)
+**Storage**: N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged)
+**Testing**: Vitest (unit tests for domain function, existing layer boundary test)
+**Target Platform**: Modern browsers (Vite dev server, production build)
+**Project Type**: Web application (monorepo: domain + application + web adapter)
+**Performance Goals**: Synchronous render -- indicator updates in the same React render cycle as HP changes
+**Constraints**: Domain layer must remain pure (no UI imports); adapter layer handles all visual presentation
+**Scale/Scope**: Single new domain function + UI changes to one component (combatant-row)
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+| Principle | Status | Notes |
+|-----------|--------|-------|
+| I. Deterministic Domain Core | PASS | `deriveHpStatus` is a pure function: same inputs always produce the same output. No I/O, randomness, or clocks. |
+| II. Layered Architecture | PASS | Domain exports the pure function; UI adapter calls it in the component. No reverse dependency. |
+| III. Agent Boundary | N/A | No agent involvement in this feature. |
+| IV. Clarification-First | PASS | No ambiguity -- thresholds are defined in spec (bloodied = 0 < currentHp < maxHp/2, unconscious = currentHp <= 0). |
+| V. Escalation Gates | PASS | All behavior is within spec scope. |
+| VI. MVP Baseline Language | PASS | Assumptions section uses "MVP baseline does not include" language. |
+| VII. No Gameplay Rules in Constitution | PASS | Bloodied/unconscious thresholds are in feature spec, not constitution. |
+| Development Workflow | PASS | `pnpm check` must pass before commit. Domain testable via pure-function assertions. |
+
+No violations. Complexity Tracking section not needed.
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/013-hp-status-indicators/
+├── plan.md # This file
+├── research.md # Phase 0 output
+├── data-model.md # Phase 1 output
+├── quickstart.md # Phase 1 output
+└── tasks.md # Phase 2 output (/speckit.tasks command)
+```
+
+### Source Code (repository root)
+
+```text
+packages/domain/src/
+├── hp-status.ts # NEW: deriveHpStatus pure function + HpStatus type
+├── __tests__/hp-status.test.ts # NEW: unit tests for deriveHpStatus
+├── index.ts # MODIFIED: export new function and type
+
+apps/web/src/components/
+├── combatant-row.tsx # MODIFIED: apply status-based styling
+```
+
+**Structure Decision**: Follows existing monorepo layout. New domain function in its own module (consistent with adjust-hp.ts, set-hp.ts pattern). UI changes confined to the existing combatant-row component. No new application-layer code needed since status is derived at render time, not via a use case.
diff --git a/specs/013-hp-status-indicators/quickstart.md b/specs/013-hp-status-indicators/quickstart.md
new file mode 100644
index 0000000..9d70df3
--- /dev/null
+++ b/specs/013-hp-status-indicators/quickstart.md
@@ -0,0 +1,46 @@
+# Quickstart: HP Status Indicators
+
+**Feature**: 013-hp-status-indicators
+
+## Overview
+
+Add a pure domain function `deriveHpStatus` and apply visual status indicators (color treatments) to combatant rows in the encounter tracker.
+
+## Key Files
+
+| File | Action | Purpose |
+|------|--------|---------|
+| `packages/domain/src/hp-status.ts` | CREATE | Pure function `deriveHpStatus` + `HpStatus` type |
+| `packages/domain/src/__tests__/hp-status.test.ts` | CREATE | Unit tests for all status thresholds and edge cases |
+| `packages/domain/src/index.ts` | MODIFY | Export `deriveHpStatus` and `HpStatus` |
+| `apps/web/src/components/combatant-row.tsx` | MODIFY | Call `deriveHpStatus` and apply conditional CSS classes |
+
+## Implementation Steps
+
+1. **Domain function** (`packages/domain/src/hp-status.ts`):
+ - Define `type HpStatus = "healthy" | "bloodied" | "unconscious"`
+ - Implement `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined`
+ - Return `undefined` if either input is `undefined`
+ - Check `currentHp <= 0` first (unconscious), then `currentHp < maxHp / 2` (bloodied), else healthy
+
+2. **Tests** (`packages/domain/src/__tests__/hp-status.test.ts`):
+ - Healthy: currentHp >= maxHp/2
+ - Bloodied: 0 < currentHp < maxHp/2
+ - Unconscious: currentHp <= 0 (including negative)
+ - Undefined inputs: returns undefined
+ - Edge cases: maxHp=1, maxHp=2, odd maxHp, currentHp > maxHp
+
+3. **Export** (`packages/domain/src/index.ts`):
+ - Add `export { type HpStatus, deriveHpStatus } from "./hp-status.js"`
+
+4. **UI styling** (`apps/web/src/components/combatant-row.tsx`):
+ - Import `deriveHpStatus` from `@initiative/domain`
+ - Call it with combatant's `currentHp` and `maxHp`
+ - Apply to HP text: `text-amber-400` for bloodied, `text-red-400` for unconscious
+ - Apply to row: `opacity-50` for unconscious combatants
+
+## Validation
+
+```bash
+pnpm check # Must pass (knip + format + lint + typecheck + test)
+```
diff --git a/specs/013-hp-status-indicators/research.md b/specs/013-hp-status-indicators/research.md
new file mode 100644
index 0000000..eb99254
--- /dev/null
+++ b/specs/013-hp-status-indicators/research.md
@@ -0,0 +1,60 @@
+# Research: HP Status Indicators
+
+**Feature**: 013-hp-status-indicators
+**Date**: 2026-03-05
+
+## R1: HP Status Derivation Approach
+
+**Decision**: Implement as a pure domain function `deriveHpStatus(currentHp, maxHp)` returning a discriminated union type `HpStatus = "healthy" | "bloodied" | "unconscious"`.
+
+**Rationale**:
+- The status is a derived value, not stored state. Computing it on demand avoids synchronization issues and keeps the data model unchanged.
+- A standalone pure function in the domain layer is consistent with the project's architecture (e.g., `adjustHp`, `setHp` are each in their own module).
+- Returning a simple string union (not an object or class) is the lightest representation and easy to pattern-match in the UI.
+
+**Alternatives considered**:
+- Storing status as a field on `Combatant`: Rejected -- introduces redundant state that must stay in sync with HP changes. Violates principle of derived data.
+- Computing in the UI layer only: Rejected -- violates constitution principle I (domain logic must be pure domain functions) and makes the logic untestable without a UI.
+
+## R2: Bloodied Threshold
+
+**Decision**: Bloodied when `0 < currentHp < maxHp / 2` (strict less-than for both bounds, floating-point division).
+
+**Rationale**:
+- Follows D&D 4e/5e convention where "bloodied" means below half HP.
+- Using strict less-than means at exactly half HP the combatant is still "healthy" -- this is the most common TTRPG interpretation.
+- Floating-point division handles odd maxHp values correctly (e.g., maxHp=21: bloodied at 10 since 10 < 10.5).
+
+**Alternatives considered**:
+- `currentHp <= maxHp / 2`: Would make combatants at exactly half HP appear bloodied. Less standard.
+- `Math.floor(maxHp / 2)`: Would make maxHp=21 bloodied at 10 (same result) but maxHp=20 bloodied at 10 (also same). No practical difference but adds unnecessary complexity.
+
+## R3: Visual Treatment Approach
+
+**Decision**: Color the HP text and optionally mute the entire row for unconscious combatants:
+- **Healthy**: Default foreground color (no change)
+- **Bloodied**: Amber/orange HP text (`text-amber-400`) -- warm danger signal
+- **Unconscious**: Red HP text (`text-red-400`) + reduced row opacity (`opacity-50`) -- clearly out of action
+
+**Rationale**:
+- Coloring the HP number is the most targeted indicator -- it draws the eye to the relevant data point.
+- Row opacity reduction for unconscious combatants provides a strong "out of action" signal without removing the row (GM may still need to reference it).
+- Amber and red are universally understood warning/danger colors and already used in the project (red for damage mode in quick-hp-input, `--color-destructive: #ef4444`).
+- No new icons needed -- color alone is sufficient for two states and avoids visual clutter.
+
+**Alternatives considered**:
+- Full row background color: Too visually heavy, would conflict with the active-turn indicator (blue left border + bg-accent/10).
+- Strikethrough on name for unconscious: Considered but opacity reduction is more modern and less ambiguous (strikethrough could imply "removed").
+- Badge/pill indicator: Adds UI elements and visual weight. Color on existing text is simpler.
+
+## R4: Function Signature & Return for Undefined HP
+
+**Decision**: Return `undefined` when maxHp is not set (status is not applicable). Function signature: `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined`.
+
+**Rationale**:
+- When maxHp is undefined, HP tracking is disabled for that combatant. No status can be derived.
+- Returning `undefined` (not a fourth status value) keeps the type clean and matches the "no HP tracking" semantic.
+- The UI simply skips styling when the result is `undefined`.
+
+**Alternatives considered**:
+- Separate `"none"` status: Adds a value that is semantically different from the three health states. `undefined` is more idiomatic TypeScript for "not applicable".
diff --git a/specs/013-hp-status-indicators/spec.md b/specs/013-hp-status-indicators/spec.md
new file mode 100644
index 0000000..505293e
--- /dev/null
+++ b/specs/013-hp-status-indicators/spec.md
@@ -0,0 +1,98 @@
+# Feature Specification: HP Status Indicators
+
+**Feature Branch**: `013-hp-status-indicators`
+**Created**: 2026-03-05
+**Status**: Draft
+**Input**: User description: "When a combatant's health is lower than half, the creature is considered bloodied, which makes the UI show an indicator. If a combatant is at <=0 HP the creature is unconscious/dead, which should also be shown. Visual treatment at implementer's discretion to fit the existing modern UI style."
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Bloodied Indicator (Priority: P1)
+
+As a game master running an encounter, I want to see at a glance which combatants are bloodied (below half their maximum HP) so I can narrate the battle's intensity and make tactical decisions without mental math.
+
+**Why this priority**: The bloodied condition is a core D&D/TTRPG concept that GMs check frequently during combat. Providing a visual indicator eliminates the need to compare current HP vs max HP for every combatant each round.
+
+**Independent Test**: Can be fully tested by setting a combatant's max HP and reducing current HP below half, then verifying the visual indicator appears. Delivers immediate at-a-glance awareness.
+
+**Acceptance Scenarios**:
+
+1. **Given** a combatant with max HP set to 20 and current HP at 10, **When** the combatant row is displayed, **Then** the HP area shows a bloodied visual indicator (e.g., amber/orange color treatment on the HP value)
+2. **Given** a combatant with max HP set to 20 and current HP at 9, **When** the combatant row is displayed, **Then** the bloodied indicator is visible
+3. **Given** a combatant with max HP set to 20 and current HP at 11, **When** the combatant row is displayed, **Then** no bloodied indicator is shown (normal appearance)
+4. **Given** a combatant with max HP set to 21 (odd number) and current HP at 10, **When** the combatant row is displayed, **Then** the bloodied indicator is shown (10 < 21/2 = 10.5)
+5. **Given** a combatant with no max HP set, **When** the combatant row is displayed, **Then** no status indicator is shown regardless of current HP value
+
+---
+
+### User Story 2 - Unconscious/Dead Indicator (Priority: P1)
+
+As a game master, I want to see which combatants have reached 0 HP or below so I can immediately identify who is down without scanning HP numbers.
+
+**Why this priority**: Equally critical to bloodied -- knowing who is unconscious or dead is essential for combat flow and takes priority over other status information.
+
+**Independent Test**: Can be fully tested by reducing a combatant's current HP to 0 or below and verifying the visual indicator appears.
+
+**Acceptance Scenarios**:
+
+1. **Given** a combatant with max HP set to 20 and current HP at 0, **When** the combatant row is displayed, **Then** the HP area shows an unconscious/dead visual indicator (e.g., red color treatment and/or a skull/death icon)
+2. **Given** a combatant with max HP set to 20 and current HP at -5, **When** the combatant row is displayed, **Then** the unconscious/dead indicator is shown (negative HP still counts as down)
+3. **Given** a combatant with max HP set to 20 and current HP at 1, **When** the combatant row is displayed, **Then** the unconscious/dead indicator is NOT shown (1 HP is bloodied, not dead)
+4. **Given** a combatant at 0 HP whose HP is then healed above 0, **When** the HP changes, **Then** the unconscious/dead indicator is removed and replaced with the appropriate status (bloodied or healthy)
+
+---
+
+### User Story 3 - Status Transitions (Priority: P2)
+
+As a game master adjusting HP during combat, I want the visual indicators to update in real time as I apply damage or healing so I always have an accurate picture of the battlefield.
+
+**Why this priority**: Indicator transitions are a natural consequence of the first two stories but need explicit attention to ensure smooth, responsive visual feedback.
+
+**Independent Test**: Can be tested by using the quick HP input to deal damage across thresholds and observing indicator changes.
+
+**Acceptance Scenarios**:
+
+1. **Given** a combatant at full HP (20/20), **When** I deal 11 damage (reducing to 9/20), **Then** the indicator transitions from healthy to bloodied
+2. **Given** a bloodied combatant (5/20), **When** I deal 5 damage (reducing to 0/20), **Then** the indicator transitions from bloodied to unconscious/dead
+3. **Given** an unconscious combatant (0/20), **When** I heal 15 HP (restoring to 15/20), **Then** the indicator transitions from unconscious/dead to healthy (skipping bloodied since 15 > 10)
+
+---
+
+### Edge Cases
+
+- What happens when max HP is 1? At 1/1 HP the combatant is healthy; at 0/1 HP they are unconscious/dead. There is no bloodied state since 0 < 0.5 rounds to unconscious/dead.
+- What happens when max HP is 2? At 1/2 HP the combatant is healthy (1 is not less than 2/2 = 1); at 0/2 they are unconscious/dead. Bloodied only applies when currentHp < maxHp / 2 (strict less-than with exact division).
+- What happens when current HP exceeds max HP (e.g., temporary HP or manual override)? The combatant shows healthy status -- no special indicator for over-max.
+- What happens when max HP is set but current HP has not been modified? Current HP equals max HP by default, so the combatant shows healthy status.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: System MUST derive a combatant's HP status from their current HP and max HP values: "healthy" (currentHp >= maxHp / 2), "bloodied" (0 < currentHp < maxHp / 2), or "unconscious" (currentHp <= 0)
+- **FR-002**: System MUST display a visual indicator on the combatant row when a combatant is bloodied -- the HP value text MUST use a warm/amber color to signal danger
+- **FR-003**: System MUST display a visual indicator on the combatant row when a combatant is unconscious/dead -- the HP value text MUST use a red/destructive color and the row MUST appear visually muted (reduced opacity or strikethrough name) to signal the combatant is out of action
+- **FR-004**: System MUST NOT show any status indicator when max HP is not set for a combatant
+- **FR-005**: System MUST update the visual indicator immediately when current HP changes (no delay or animation lag)
+- **FR-006**: The HP status derivation MUST be a pure domain-level computation with no UI dependencies
+
+### Key Entities
+
+- **HP Status**: A derived value (not stored) computed from a combatant's currentHp and maxHp. Possible values: "healthy", "bloodied", "unconscious". Only meaningful when maxHp is defined.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: Users can identify bloodied combatants at a glance without reading HP numbers -- 100% of combatants below half HP display a distinct visual treatment
+- **SC-002**: Users can identify unconscious/dead combatants at a glance -- 100% of combatants at 0 HP or below display a distinct visual treatment that differs from the bloodied indicator
+- **SC-003**: Visual indicators update within the same interaction frame as HP changes -- no perceptible delay between HP adjustment and indicator update
+- **SC-004**: Status indicators are visually consistent with the existing UI design system (colors, spacing, typography match the established style)
+
+## Assumptions
+
+- "Bloodied" uses the D&D 4e/5e convention: strictly below half max HP (not at exactly half)
+- The unconscious/dead label is intentionally combined into one state -- the system does not distinguish between unconscious and dead (this is a GM decision, not a system decision)
+- No sound effects or animation beyond color/opacity changes for MVP baseline
+- MVP baseline does not include a tooltip or label explaining what "bloodied" means
+- The status is purely visual/derived -- it does not affect game mechanics or stored data
diff --git a/specs/013-hp-status-indicators/tasks.md b/specs/013-hp-status-indicators/tasks.md
new file mode 100644
index 0000000..ee59f5b
--- /dev/null
+++ b/specs/013-hp-status-indicators/tasks.md
@@ -0,0 +1,122 @@
+# Tasks: HP Status Indicators
+
+**Input**: Design documents from `/specs/013-hp-status-indicators/`
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
+
+**Tests**: Included -- domain function requires unit tests per project convention.
+
+**Organization**: Tasks grouped by user story. US1 and US2 share the same domain function (foundational), then diverge at the UI layer.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
+- Include exact file paths in descriptions
+
+---
+
+## Phase 1: Foundational (Domain Function + Tests)
+
+**Purpose**: Create the pure domain function that all user stories depend on
+
+**CRITICAL**: No UI work can begin until this phase is complete
+
+- [x] T001 Create `HpStatus` type and `deriveHpStatus` pure function in `packages/domain/src/hp-status.ts` -- returns `"healthy" | "bloodied" | "unconscious" | undefined` based on `currentHp` and `maxHp` inputs. Check `currentHp <= 0` first (unconscious), then `currentHp < maxHp / 2` (bloodied), else healthy. Return `undefined` when either input is `undefined`.
+- [x] T002 Write unit tests for `deriveHpStatus` in `packages/domain/src/__tests__/hp-status.test.ts` covering: healthy (currentHp >= maxHp/2), bloodied (0 < currentHp < maxHp/2), unconscious (currentHp <= 0), unconscious with negative HP, undefined when maxHp undefined, undefined when currentHp undefined, undefined when both undefined, edge case maxHp=1 (no bloodied state possible), edge case maxHp=2 (1/2 is healthy not bloodied), odd maxHp=21 (10 is bloodied since 10 < 10.5), currentHp exceeding maxHp (healthy).
+- [x] T003 Export `deriveHpStatus` and `HpStatus` type from `packages/domain/src/index.ts` -- add `export { type HpStatus, deriveHpStatus } from "./hp-status.js";`
+
+**Checkpoint**: `pnpm check` passes. Domain function is tested and exported.
+
+---
+
+## Phase 2: User Story 1 - Bloodied Indicator (Priority: P1) + User Story 2 - Unconscious/Dead Indicator (Priority: P1)
+
+**Goal**: Apply visual status indicators to the combatant row -- amber HP text for bloodied, red HP text + muted row for unconscious/dead
+
+**Independent Test**: Set a combatant's max HP, reduce current HP below half -- verify amber text. Reduce to 0 -- verify red text and muted row. Heal above half -- verify normal appearance restored.
+
+### Implementation
+
+- [x] T004 [US1] [US2] Modify `apps/web/src/components/combatant-row.tsx` to import `deriveHpStatus` from `@initiative/domain` and call it with the combatant's `currentHp` and `maxHp` at the top of the `CombatantRow` component. Store result in a `status` variable.
+- [x] T005 [US1] [US2] In `apps/web/src/components/combatant-row.tsx`, apply conditional CSS classes based on `status`: (1) On the `CurrentHpInput` wrapper/value -- add `text-amber-400` when bloodied, `text-red-400` when unconscious. (2) On the row's outer `` -- add `opacity-50` when unconscious. Ensure the active-turn border styling still takes precedence for active combatants. Use the existing `cn()` utility for conditional class merging.
+
+**Checkpoint**: `pnpm check` passes. Bloodied and unconscious indicators are visible in the UI.
+
+---
+
+## Phase 3: User Story 3 - Status Transitions (Priority: P2)
+
+**Goal**: Ensure visual indicators update in real time as HP changes through quick HP input, direct entry, and +/- controls
+
+**Independent Test**: Use the quick HP input to deal damage across thresholds (healthy -> bloodied -> unconscious) and heal back. Verify indicator changes instantly with no flicker or delay.
+
+### Implementation
+
+- [x] T006 [US3] Verify in `apps/web/src/components/combatant-row.tsx` that `deriveHpStatus` is called with the current combatant props (not stale state) so status updates in the same render cycle as HP changes. No additional code should be needed if T004-T005 are implemented correctly -- this task is a manual verification and visual QA pass.
+
+**Checkpoint**: All transitions (healthy->bloodied->unconscious->healthy) work smoothly.
+
+---
+
+## Phase 4: Polish & Cross-Cutting Concerns
+
+- [x] T007 Run `pnpm check` to validate all automated checks pass (knip, format, lint, typecheck, test)
+- [x] T008 Verify layer boundary test still passes (domain must not import from React/UI) in `packages/domain/src/__tests__/layer-boundaries.test.ts`
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Foundational (Phase 1)**: No dependencies -- can start immediately
+- **US1+US2 (Phase 2)**: Depends on Phase 1 completion (needs domain function)
+- **US3 (Phase 3)**: Depends on Phase 2 completion (needs UI indicators in place)
+- **Polish (Phase 4)**: Depends on all phases complete
+
+### Within Phases
+
+- T001 before T002 (need function to test it)
+- T002 before T003 (tests pass before exporting)
+- T004 before T005 (import before styling)
+
+### Parallel Opportunities
+
+- T001 and T002 can be done together if writing tests alongside implementation (TDD)
+- US1 and US2 are implemented together in Phase 2 since they modify the same file and component
+
+---
+
+## Parallel Example: Phase 1
+
+```bash
+# T001 and T002 can be written together (TDD style):
+Task: "Create deriveHpStatus in packages/domain/src/hp-status.ts"
+Task: "Write tests in packages/domain/src/__tests__/hp-status.test.ts"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First (Phase 1 + Phase 2)
+
+1. Complete Phase 1: Domain function + tests + export
+2. Complete Phase 2: UI styling for both bloodied and unconscious
+3. **STOP and VALIDATE**: Manually test all scenarios from spec
+4. Run `pnpm check`
+
+### Full Delivery
+
+1. Phase 1: Domain function (T001-T003)
+2. Phase 2: UI indicators (T004-T005)
+3. Phase 3: Transition verification (T006)
+4. Phase 4: Final validation (T007-T008)
+
+---
+
+## Notes
+
+- US1 (bloodied) and US2 (unconscious) are combined into Phase 2 because they modify the same component and the domain function handles both statuses. Implementing them separately would require touching the same lines twice.
+- T006 is a verification task, not a code change -- if the React component re-renders on prop changes (which it does by default), transitions work automatically.
+- Total: 8 tasks across 4 phases. Minimal scope -- no new application layer, no storage changes.