Back to BDD Health Quest

README

BDD Health Quest

A browser-based health and adventure game built with React and TypeScript, fully tested using Behavior-Driven Development (BDD) with Cucumber.js and Playwright.

App Overview

About

BDD Health Quest is a small interactive game where you manage a player's health by taking steps, buying and eating donuts, and customising your player name. Health decays in real time, so you need to stay active to survive.

The project serves as a practical, hands-on example of Behavior-Driven Development. Every feature was specified in plain-English Gherkin scenarios before any code was written, and all 23 scenarios run as automated browser tests that click buttons and read the screen just like a real user would.

Features

  • Health Monitor -- Real-time health bar with decay, colour-coded status (Healthy/Warning/Critical/Dead), and Game Over detection
  • Steps Counter -- Increment, decrement, and reset steps; each step adds 10 HP
  • Donut Shop -- Buy donuts (max 5), eat them to restore 50 HP, with inventory management and error handling
  • Name Editor -- Modal-based player name editing with live validation (5-7 characters)
  • BDD Test Suite -- 23 Cucumber scenarios across 5 feature files, driven by Playwright

Quick Start

bash
# Install dependencies
npm install

# Start the dev server
npm run dev

# Run the BDD tests (requires dev server on port 5174)
npm run bdd

What is BDD and Why Does This Project Use It?

If you've never encountered BDD before, here's the short version: BDD is a way of writing tests in plain English before you write any code. The idea is that anyone -- a developer, a designer, a product manager -- can read and understand what the software is supposed to do, because the tests are written as human-readable scenarios rather than low-level code assertions.

Traditional testing might look like this:

javascript
expect(counter.value).toBe(0);
counter.increment();
expect(counter.value).toBe(1);

BDD testing looks like this:

gherkin
Scenario: Increment steps from 0 to 1
  Given I open the app
  Then I should see the count is 0
  When I click the increment button
  Then I should see the count is 1

Both test the same thing. But the second version reads like a user story. You can hand it to someone who's never written code and they'll understand what the app should do. That's the point.

How It Works in This Project

The BDD pipeline has three layers that connect together:

flowchart LR A["Feature Files
(Gherkin)
features/*.feature"] -->|"matched by
Cucumber.js"| B["Step Definitions
(TypeScript)
tests/steps/*.ts"] B -->|"drives"| C["Browser
(Playwright)
Headless Chromium"] C -->|"interacts with"| D["React App
(localhost:5174)"] style A fill:#f3e8ff,stroke:#7c3aed style B fill:#e0f2fe,stroke:#0284c7 style C fill:#fef3c7,stroke:#d97706 style D fill:#dcfce7,stroke:#16a34a

Here's a concrete example of how one line flows through the system:

sequenceDiagram participant F as Feature File participant C as Cucumber.js participant S as Step Definition participant P as Playwright participant App as React App F->>C: "When I click buy donut" C->>S: Matches step function S->>P: page.click('[aria-label="buy donut"]') P->>App: Clicks the Buy Donut button App->>App: State updates (donuts: 0 โ†’ 1) F->>C: "Then I should have 1 donut" C->>S: Matches assertion step S->>P: page.textContent('[aria-label="donut count"]') P->>App: Reads the DOM text App-->>P: "You have 1 donut" P-->>S: Returns text S->>S: Assert text contains "1 donut" โœ“

Layer 1: Feature files (features/*.feature) describe what should happen using the Gherkin language. Each file covers one area of the app -- donuts, steps, health, name validation, or integration between them. These files contain no code at all.

Layer 2: Step definitions (tests/steps/*.ts) are the glue. Each Given, When, or Then line in a feature file maps to a TypeScript function that tells the browser what to do. For example, "When I click buy donut" maps to a function that calls page.click('[aria-label="buy donut"]').

Layer 3: The browser (Playwright) is the actual execution layer. Cucumber.js reads the feature files, matches each line to a step definition, and the step definition drives a real headless Chromium browser that loads the app, clicks buttons, fills in forms, and reads text off the page.

When you run npm run bdd, all three layers come together: Cucumber reads the plain English, calls the matching TypeScript functions, and Playwright drives the browser. If the app behaves as the feature file describes, the test passes.

Architecture Deep Dive

The game itself is a React app with a clean separation of concerns. Here's how the pieces fit together and why each one exists.

The State Layer: GameContext

Everything in the game flows through a single React Context called GameContext. It holds five pieces of state:

  • health (number) -- your current HP, starts at 500
  • maxHealth (number) -- the highest your health has ever been
  • steps (number) -- your step count
  • donuts (number) -- how many donuts you're carrying
  • playerName (string) -- your display name
The Context also provides functions to modify that state: incrementSteps, buyDonut, consumeDonut, addHealth, and so on. Every component in the app reads from and writes to this same shared context, which means there's one source of truth for the entire game state.

stateDiagram-v2 state GameContext { health: number = 500 maxHealth: number = 500 steps: number = 0 donuts: number = 0 playerName: string = "Anonymous" } state Actions { incrementSteps decrementSteps resetSteps buyDonut consumeDonut addHealth setPlayerName } Actions --> GameContext: modify state GameContext --> Components: provide state

This pattern is important for testing. Because all state is centralised, when a test clicks the "Buy Donut" button and then checks the donut count, there's no ambiguity about where that count comes from.

The Component Layer

The UI is split into small, focused components, all connected through the shared GameContext:

flowchart TD App["App"] GP["GameProvider
wraps everything in context"] GC["GameContent"] HM["HealthMonitor
reads: health, maxHealth"] ST["Steps
reads: steps
calls: increment, decrement, reset"] DN["Donuts
reads: donuts
calls: buyDonut, consumeDonut"] NE["NameEditor
reads: playerName
calls: setPlayerName"] CTX["GameContext
(shared state)"] App --> GP --> GC GC --> HM GC --> ST GC --> DN GC --> NE HM -.->|useGame| CTX ST -.->|useGame| CTX DN -.->|useGame| CTX NE -.->|useGame| CTX style CTX fill:#fef3c7,stroke:#d97706 style HM fill:#dcfce7,stroke:#16a34a style ST fill:#e0f2fe,stroke:#0284c7 style DN fill:#f3e8ff,stroke:#7c3aed style NE fill:#fee2e2,stroke:#dc2626

Each game component calls the useGame() hook to get exactly the state and actions it needs. HealthMonitor only reads health and maxHealth. Steps reads steps and uses incrementSteps, decrementSteps, and resetSteps. They don't know about each other.

But they're connected through the context. When Steps calls incrementSteps(), that function also adds 10 HP to health. HealthMonitor automatically re-renders because health changed. The components are independent but the state ties them together.

The Real-Time Mechanic: Health Decay

A custom hook called useHealthDecay runs a timer that ticks every second, reducing health by 1 HP. This is what gives the game its urgency -- if you just sit there, your health slowly drains to zero and you get a Game Over.

flowchart LR Timer["setInterval
(every 1 second)"] -->|"-1 HP"| Health["health state"] Health -->|"> 300"| Healthy["๐ŸŸข Healthy"] Health -->|"151-300"| Warning["๐ŸŸ  Warning"] Health -->|"1-150"| Critical["๐Ÿ”ด Critical"] Health -->|"= 0"| Dead["๐Ÿ’€ Game Over"] style Healthy fill:#dcfce7,stroke:#16a34a style Warning fill:#fef3c7,stroke:#d97706 style Critical fill:#fee2e2,stroke:#dc2626 style Dead fill:#fca5a5,stroke:#991b1b

The decay is implemented as a setInterval inside a useEffect. It reads the decay rate and interval from gameConfig.ts, so the timing is configurable in one place rather than scattered through the code.

The Configuration Layer

All magic numbers live in constants/gameConfig.ts:

SettingValuePurpose
INITIAL_HEALTH500Starting HP
HEALTH_DECAY_RATE1HP lost per tick
DONUT_HEALTH50HP restored per donut
STEP_HEALTH10HP gained per step
MAX_DONUTS5Inventory limit
NAME_MIN_LENGTH5Shortest valid name
NAME_MAX_LENGTH7Longest valid name
This centralisation matters for both the app and the tests. If you change MAX_DONUTS from 5 to 10, both the app logic and the feature files should reflect that.

The Validation Layer

All validation logic lives in utils/validation.ts, separate from the UI components. This includes:

  • Name validation -- checks length against the config thresholds and returns a structured result with isValid and error fields
  • Health status -- maps HP to a status string (Healthy, Warning, Critical, Dead)
  • Health colour -- maps HP to a display colour (green, orange, red)
  • Health percentage -- calculates bar width as a percentage of max health
Pulling validation out of components means it can be unit tested independently and reused across different parts of the UI.

How the BDD Tests Map to the Architecture

Each feature file targets a specific area of the game. The integration feature file is special -- it tests the connections between components:

flowchart TD subgraph "Feature Files" CF["counter.feature
3 scenarios"] DF["donuts.feature
7 scenarios"] HF["health-monitor.feature
5 scenarios"] NF["name-validation.feature
5 scenarios"] IF["integration.feature
3 scenarios"] end subgraph "App Components" ST2["Steps"] DN2["Donuts"] HM2["HealthMonitor"] NE2["NameEditor"] end CF -->|tests| ST2 DF -->|tests| DN2 HF -->|tests| HM2 NF -->|tests| NE2 IF -->|tests| ST2 IF -->|tests| DN2 IF -->|tests| HM2 style IF fill:#fef3c7,stroke:#d97706 style CF fill:#e0f2fe,stroke:#0284c7 style DF fill:#f3e8ff,stroke:#7c3aed style HF fill:#dcfce7,stroke:#16a34a style NF fill:#fee2e2,stroke:#dc2626

Feature FileWhat It Exercises
counter.featureSteps state + increment/decrement/reset actions
donuts.featureDonut state + buy/eat actions + inventory limits
health-monitor.featureHealth display + decay + max health + game over
name-validation.featureName editor modal + validation rules + save/cancel
integration.featureCross-component effects (steps affect health, etc.)
The integration tests are where BDD really shines. A scenario like "Taking steps increases health" verifies that clicking the step button in the Steps component causes the HealthMonitor component to show a higher number. That's testing the real user experience, not isolated units.

Tech Stack

LayerTechnology
FrontendReact 18, TypeScript
BuildVite
TestingCucumber.js, Playwright
LintingESLint

Project Structure

shell
src/
  App.tsx                          # Root component
  components/
    game/
      HealthMonitor.tsx            # Health bar and status display
      Steps.tsx                    # Step counter controls
      Donuts.tsx                   # Donut shop with buy/eat
    modals/
      NameEditor.tsx               # Player name editing modal
    ui/
      Modal.tsx                    # Reusable modal component
      Button.tsx                   # Reusable button component
  context/
    GameContext.tsx                 # Central game state provider
  hooks/
    useGame.ts                     # Game context consumer hook
    useHealthDecay.ts              # Real-time health decay hook
  constants/
    gameConfig.ts                  # All game configuration values
  types/
    game.types.ts                  # TypeScript type definitions
  utils/
    validation.ts                  # Name validation, health utilities

features/                          # Gherkin feature files
  counter.feature
  donuts.feature
  health-monitor.feature
  integration.feature
  name-validation.feature

tests/
  steps/                           # Cucumber step definitions
  support/                         # Test hooks and world setup

BDD Test Coverage

FeatureScenariosWhat's tested
Steps Counter3Increment, decrement, reset
Donut Shop7Buy, eat, limits, errors, inventory management
Health Monitor5Initial values, decay, max health, game over
Name Validation5Default name, modal, too short/long, valid save
Integration3Steps + health, donuts + health, combined actions
Total: 23 scenarios, 109 steps

User Guide

For detailed instructions on gameplay, controls, and strategy, see the User Guide.

License

MIT

ยฉ 2026 Jonathan Leahy ยท v0.8.3