Skip to content

Repository files navigation

Diving Simulator

A 2D HTML5 Canvas diving simulator implementing the Bühlmann ZHL-16C decompression algorithm with multi-gas support, realistic bubble physics, and a technical wrist dive computer-style HUD. Dive any of four authored sites — Shore, Reef, Wreck and Cave — including overhead (wreck/cave) environments with torch, guideline and current mechanics.

Quick Start

Open src/diving-simulator.html in a browser. No build step or server required.

Controls

Diving

Key Action
/ W Inflate BCD (ascend)
/ S Vent BCD (descend)
/ A · / D Fin kick left / right (horizontal swim)
T Toggle torch (cave / wreck)
F Fast-forward at a deco / safety stop (10× game time — game time already runs at 3× real time, so this is 30× real time while active)
16 Switch tank (during dive)
I Gas info overlay (Tec / CCR)
H / ? Toggle help overlay
ESC Close overlay
Enter Start dive / Reset

Vertical movement uses asymmetric acceleration: ascent ramps at 2 m/s² and descent at 3.33 m/s², both reaching max velocity in exactly 3 real seconds of sustained keypress. Releasing the key decelerates at 4 m/s². The fin-kick keys (/A and /D) drive horizontal swimming, and currents (when present) push the diver — hold a kick to swim against them.

The fin-kick style depends on the dive mode: recreational dives use a flutter kick (legs alternate up and down); technical and CCR dives use a frog kick (legs sweep symmetrically with a glide pause), the standard kick for trim and silt control.

Dive mode (Rec / Tec / CCR) and dive site (Shore / Reef / Wreck / Cave) are chosen on the gas-setup screen before the dive.

On-screen controls (touch & desktop)

On-screen buttons let you play without a keyboard. The four navigation buttons are laid out as a WASD cross in the bottom-left (ascend on top, descend below it, left/right flanking descend). On touch devices they show arrow glyphs (▲ ◄ ▼ ►); on a non-touch desktop they show W / A / S / D and respond to mouse press-and-hold. Context buttons (Help, Torch, Gas info, Fast-forward, tank switch, and the CCR Bailout / setpoint controls) appear on the right as the dive state requires.

Gas Setup Screen

Key Action Mode
/ O₂ fraction ±1% Rec + Tec
PgUp / PgDn Tank pressure ±10 bar Rec + Tec
/ He fraction ±1% Tec only
[ / ] AMV ∓/± 1 L/min (min 8, max 25 L/min) Tec only
, / . Tank size ∓/± 1 L Tec only
TAB Cycle selected tank tab Tec only
+ / - Add / remove tank (up to 6) Tec only
g / Shift+G GF Low +5 / −5 (range 30–100%) Tec only
f / Shift+F GF High +5 / −5 (range 30–100%) Tec only
M Switch mode (Rec / Tec / CCR) Any
14 (Rec) / 18 (Tec) Select a gas preset Rec + Tec

CCR mode uses 15 for diluent presets and [/]/,/. for setpoint / diluent volume instead of AMV/tank-size — see CCR Mode Controls below.

CCR Mode Controls

Key Action
[ / ] Decrease / Increase setpoint (during dive)
B Bailout to open circuit (irreversible)
15 Select diluent preset (in gas setup)
I Gas info overlay (Tec/CCR modes)
ESC Close overlay

Features

Decompression Model

  • Full Bühlmann ZHL-16C with 16 tissue compartments (N₂ + He)
  • Gradient Factors — GF Low / GF High support (default 35/75, a typical technical-diving default; 100/100 = pure unmodified Bühlmann). Range 30–100%. Affects ceiling, NDL, and deco schedule via linear interpolation from GF Low → GF High. Displayed on dive computer when not 100/100.
  • Real-time NDL, ceiling, TTS, and GTR calculations
  • Multi-gas support with up to 6 configurable tanks (Nitrox / Trimix)
  • PO₂ monitoring with hypoxia (< 0.16 bar) and hyperoxia warnings
  • Adaptive safety stop — 5 minutes if maxDepth > 30m or NDL dropped below 5 minutes at any point during the dive, otherwise 3 minutes. Active window is 2.4–8.3m (fast-forward is available while holding in that band). Missing safety stop shows a yellow warning on the post-dive screen (not a game over).

CCR Mode (Closed Circuit Rebreather)

A full CCR dive mode simulating closed-circuit rebreather operations:

Gas System:

  • O₂ cylinder and diluent cylinder with independent pressure tracking
  • PO₂ control loop: metabolic O₂ consumption with solenoid injection toward setpoint
  • Diluent auto-add on descent to maintain loop volume
  • CO₂ scrubber with countdown timer

Diluent Presets:

Slot Mix Use Case
1 Air (21/0) Shallow recreational
2 Trimix 21/35 Moderate technical
3 Trimix 15/45 Deep technical
4 Trimix 10/70 Ultra-deep technical
5 Heliox 10/90 Extreme-depth / narcosis-critical

Decompression Integration:

  • Bühlmann deco uses dynamic loop gas fractions: fO₂ = PO₂ / P_ambient
  • GTR reports O₂ cylinder endurance (not diluent)

HUD Elements:

  • Setpoint and actual PO₂ display
  • O₂ bar and diluent bar pressure indicators
  • Scrubber remaining time

Failure Modes:

Failure Trigger Delay
Hypoxia PO₂ < 0.16 bar 30 dive-seconds
Hyperoxia PO₂ > 1.6 bar 30 dive-seconds
CO₂ breakthrough Scrubber depleted 180 dive-seconds

Bailout: Press B to bail out to open circuit. This is irreversible — the diver switches to breathing the diluent gas as an OC supply.

Breathing Cycle & Bubbles

Bubble emission follows a physiological breathing cycle state machine:

Inhale (2s) → Exhale (1.5s) → Pause (0.5s) → repeat
  • Breathing bubbles emit only during the exhale phase, rising from the diver's mouth
  • BCD exhaust bubbles emit during fast ascent (>5 m/min), appearing from the BCD position with smaller radius and faster rise speed

AMV (Actual Minute Volume)

Gas consumption is driven by a configurable AMV rate (default 15 L/min, range 8–25). AMV directly affects:

  • Gas consumption: consumption = AMV × ambient_pressure
  • GTR (Gas Time Remaining): recalculated each frame using current AMV

Adjust AMV with [ and ] in the advanced gas setup screen. Higher AMV simulates heavier breathing (e.g., exertion, stress); lower AMV simulates relaxed, efficient breathing.

Dive Computer Display

The HUD is styled after a modern technical wrist dive computer:

  • Titanium-gray bezel with inner shadow
  • 6 horizontal data zones with alternating dark backgrounds
  • Depth as the dominant centered element (42px)
  • Ascent rate bar indicator (6px, color-coded)
  • 3-column data grid: MAX / AVG / ASC rate + AMV / GTR / TTS
  • Red-tinted zone background when decompression obligation exists
  • Compact 9px labels for minimal visual clutter

Physics

  • Time acceleration: 3× (1 real second = 3 dive seconds)
  • Max ascent rate: 25 m/min (a runaway over-inflated BCD can exceed the barotrauma threshold below)
  • Max descent rate: 20 m/min
  • Barotrauma threshold: 18 m/min sustained for 10 dive-seconds
  • Maximum depth: 300 m
  • Hypoxia threshold (open circuit): PO₂ < 0.16 bar for 10+ dive-seconds → game over. CCR uses a separate 30-second threshold — see the CCR Failure Modes table above.

Dive Sites

Four authored sites, each playable with any dive mode, selected on the gas-setup screen:

Site Character Max depth Overhead?
Shore Gentle sandy slope from a beach entry, seagrass, boulders, a small sunken boat + anchor landmark ~32 m No
Reef Flat-topped seamount (mesa) with coral gardens, gorgonians, sponges and fish; steep flanks to the abyss open No
Wreck ZENOBIA-inspired Ro-Ro ferry on its side: multi-deck hull with cars/lorries, cargo, engine room; three marked penetration points (bow / hatch / stern); a large bower anchor lies on the seabed off the bow ~68 m Yes
Cave Cenote: a brown-limestone entrance that forks into a shallow upper tunnel and a deep lower tunnel descending to a ~100 m grey-rock cathedral, rejoining before the surface shaft ~106 m Yes

Overhead environments (wreck & cave) add confined-space mechanics:

  • Limited line-of-sight — the wreck hides everything outside a bubble around the diver behind its solid steel hull; the cave is near-black without a torch.
  • Torch (T) — lights a cone in the cave and widens the visibility bubble in the wreck.
  • Guideline — a breadcrumb line is laid automatically while overhead, marking the way back to open water.
  • Bad-air pockets — unbreathable gas traps (e.g. the cave's upper tunnel) where you cannot surface.
  • Overhead game-over notice — dying in an overhead environment shows a safety callout about the dangers of wreck/cave diving (training, continuous guideline, rule of thirds).

Environment

  • Boat / buoy / pond surface markers per site; warm cenote sky and jungle rim above the cave
  • Currents — many dives feature a horizontal current that pushes the diver; counter it with A/D fin kicks
  • Site-aware marine life (reef fish, turtles, rays, sharks; no sharks at shore/cave)
  • Depth-graded water and rock: warm shallows cooling to dark grey/black with depth

Testing

Open src/diving-simulator-tests.html in a browser to run the legacy client's automated test suite. Tests cover decompression math, gas consumption, PO₂ calculations, breathing cycle, AMV bounds, and gameAPI integration.

The same suite runs headless under Playwright via npm run test:e2e (it loads the test harness and asserts window.testResults). The migration client has an independent Vitest unit suite.

The extracted model lives in src/core/. It owns typed, immutable tissue and life-support state plus deterministic fixed-step updates, but it is not yet authoritative in the shipped game. The pure DivePlanner in src/planner/ runs forecasts from copied state at a separately scheduled cadence and has a typed same-origin Worker boundary. Versioned serialization and legacy-v2 migration live in src/save/; storage is injected through a small local key/value port. Renderer and HUD work consumes the immutable, derived snapshots in src/presentation/. tests/parity/ drives both paths from the frozen WP-01 traces while the legacy client remains the behavioral oracle.

The migration save contract is intentionally local-only. It supports browser reload, process recreation, corruption rejection, and future schema detection on one device. Accounts, cloud backup, conflict resolution, and cross-device synchronization are out of scope and no related client or dependency is included.

Development & CI

The production game remains the plain HTML/CSS/JS client during the migration. A separate TypeScript/Vite bootstrap at the repository root provides the new client without changing src/diving-simulator.html or its script order. Install tooling with npm install, then:

New TypeScript UI copy is keyed in src/app/i18n/catalog.ts; direct user-facing literals are rejected by ESLint. Locale-aware depth, pressure, duration, and gas-fraction formatting lives in src/app/i18n/formatters.ts. This establishes the EN/DE boundary early without claiming that the full product translation is complete.

Command What it does
npm run dev Starts the new Vite migration client with source maps
npm run dev:legacy Serves the unchanged legacy client at src/diving-simulator.html
npm run build Type-checks and creates the migration client production bundle in dist/
npm run typecheck Runs strict TypeScript checks without emitting files
npm run lint Lints TypeScript, legacy JavaScript, tests, scripts, and configuration
npm test Runs TypeScript unit tests, pure-core parity, then the full legacy Playwright suite
npm run test:unit Runs the TypeScript unit suites outside parity
npm run test:legacy Runs the focused legacy Playwright smoke test
npm run test:parity Compares the extracted pure core with deterministic legacy fixtures
npm run test:e2e Builds and tests the migration Worker plus the full legacy Playwright suite
npm run test:perf Captures the opt-in performance baseline
npm run screenshots Captures review screenshots (phone + desktop, setup + in-dive) to screenshots/ via scripts/screenshots.mjs
npm run interior:check Issue #124 optics guard: measures the legacy client's overhead interiors and fails if one has gone flat (CI-gated)
npm run interior:update Re-records that guard's reference frames. Deliberately does not touch its thresholds — see the note below

Interior optics thresholds (issue #124)

npm run interior:check enforces per-scene bands on per-pixel chroma (mean(max(R,G,B) - min(R,G,B))) and mean luminance for the torch-lit wreck and cave interiors, plus two open-water control scenes. It exists because those interiors drifted to near-neutral grey twice — once per renderer surface — and nothing caught either.

Alongside the means sit variance floors on luminance and on chroma. Means are blind to arrangement: one flat fill of rgb(41,61,73) satisfies the mean bands of all four wreck scenes at once. The floors require the frame to have structure in brightness and in colour, which no single fill can do.

Two things about it are deliberate and easy to undo by accident:

  • The thresholds live in scripts/interior-optics-check.mjs, not in a recorded manifest. --update re-records the reference frames only. Thresholds derived from whatever is currently on screen describe the current state, and if the current state is the shortfall, recording it locks the shortfall in — which is the sequencing trap #124 explicitly warns about. If a change to the interiors is intended, move the specific band and say why.
  • The reference frames are review artefacts, not a pixel diff. Playwright's frames are deterministic per platform but not across them, and there is no linux reference set here. The enforced half is the statistics — which do travel: the same scenes measured on Windows and on ubuntu-24.04 agree exactly on every interior, while their pixels do not agree at all.

CI pipelines (GitHub Actions):

  • .github/workflows/pr.yml — runs on every pull request to main: migration build/type-check → lint/license checks → migration unit tests → legacy browser tests → review screenshots (uploaded as artifacts). It does not deploy.
  • .github/workflows/deploy.yml — runs the same dual-client checks on push to main (i.e. after a PR is merged), then deploys the legacy client to Cloudflare Pages until the migration cutover.

So a PR is fully checked (and produces screenshots for review) before merge, and deployment only happens once the change lands on main.

Dev container

.devcontainer/ defines a Linux container on the Playwright image, pinned to the playwright-core version in package-lock.json, with Node pinned to the version the workflows install. Open the repository in it with Dev Containers: Reopen in Container.

It reproduces CI's toolchain — Ubuntu 24.04, Node 22 with npm 10, the same Chromium build — so lint, type-check, unit, parity and e2e behave as they do on ubuntu-24.04. On a machine with no local Node install it is the only way to run them at all.

It does not reproduce CI's pixels, and that was measured rather than assumed: npm run pixi:visual-check inside the container fails against the committed linux frames by a max channel delta of 230 over 10.46% of pixels — the same magnitude issue #133 measured between Windows and Linux — while the frame statistics agree with both reference sets. The OS and the browser build match the runner, so what differs is the host CPU that Chromium's software rasteriser generates code for. Record the linux reference set in CI; use the container for everything else.

Do not capture performance in it either. Software rendering, so npm run test:perf and npm run wp06:perf produce numbers that are not comparable with the committed baselines — run those on the host or in CI.

The pins are not on an honour system. npm run devcontainer:check compares them against CI: the image version and PLAYWRIGHT_IMAGE_VERSION against playwright-core in package-lock.json, the image's distro suffix against the runner label, and the node feature against .nvmrc. pr.yml runs it, so a Dependabot bump that leaves the container behind turns its own PR red instead of surfacing whenever someone next rebuilds. It has two stricter modes: --toolchain, which pr.yml uses after setup-node so CI proves it resolved the pin rather than something merely compatible with it, and --container, which post-create.sh uses to also catch a container built before the pins it is now being checked against.

.nvmrc holds an exact version, not a major. 22 is a range: both actions/setup-node and the container's node feature resolve it to whatever 22.x they find, and two resolutions a week apart can ship different npm builds — the same drift this guard exists to catch, one level down. Because Node bundles npm, pinning Node exactly pins npm too, and NPM_BY_NODE in scripts/devcontainer-check.mjs records which npm comes with it so the check can assert that as well.

Nothing bumps .nvmrc automatically — Dependabot does not read it. Moving it is a deliberate edit, and the check fails until NPM_BY_NODE gains a row for the new version, which is the prompt to look at what npm came along.

It fails closed. A version it cannot resolve to an exact major.minor.patch — a bare major, lts/*, a node-version-file that is missing or holds a range, a setup-node step that names no version at all — is an error rather than a skipped file, because the failure that matters here is the one where nothing looks wrong.

This is also why the toolchain workflows run on ubuntu-24.04 rather than ubuntu-latest. A moving label is not a pin: ubuntu-latest migrates to Ubuntu 26 from 19 October 2026, which would change the OS under the committed linux reference frames without a commit touching them, and -noble and -jammy ship the same Playwright on different Ubuntu releases, so the version alone does not say which. release-label.yml stays on ubuntu-latest — it runs none of the toolchain, and the check ignores workflows that never install Node.

Opening it on Windows. Dev Containers needs a Docker daemon it can reach. With Docker Desktop, Dev Containers: Reopen in Container works straight from a Windows window. With Docker Engine installed inside WSL2 and no Desktop, it does not — open the folder in WSL first (WSL: Connect to WSL, or code . from the distro), then reopen in the container from there.

Where the clone lives. Inside the WSL2 filesystem is the faster option: a /mnt/c bind mount pays 9p/drvfs on every file, which npm ci and Vite both feel. A clone under C:\ does work — post-create.sh handles the two ways that mount breaks git, declaring the worktree safe and putting core.hooksPath in the container's global config, because /mnt/c reports a fixed owner that no chown can move and husky's write to .git/config is silently discarded there without failing npm ci. Expect the "chmod on .git/config.lock" error on create; the script says so and the pre-commit gate still runs.

gameAPI

The simulator exposes window.gameAPI for programmatic access and testing:

// Read-only
gameAPI.depth           // current depth (m)
gameAPI.maxDepth        // max depth reached
gameAPI.diveTime        // elapsed dive time (dive-seconds)
gameAPI.ascentRate      // current ascent rate (m/min)
gameAPI.breathPhase     // 'inhale' | 'exhale' | 'pause'
gameAPI.breathTimer     // time remaining in current breath phase
gameAPI.hypoxiaTime     // accumulated hypoxia exposure (dive-seconds)
gameAPI.safetyStopNeeded     // whether a safety stop is required
gameAPI.safetyStopRemaining  // seconds remaining on safety stop
gameAPI.safetyStopCountdownStarted // whether the countdown has been locked in
gameAPI.safetyStopComplete   // whether safety stop was completed

// Read-write
gameAPI.amvRate         // AMV rate (clamped 8–25 L/min)
gameAPI.gfLow           // GF Low (clamped 30–100%)
gameAPI.gfHigh          // GF High (clamped 30–100%)
gameAPI.gameState       // 'gas-setup' | 'surface' | 'diving' | 'gameover' | 'post-dive'
gameAPI.activeTank      // active tank index

// Functions
gameAPI.calculateSafetyStopDuration()  // returns adaptive stop duration in seconds

License

The code in this repository is licensed under the MIT License.

The deployed game (src/, what pages deploy src/ ships) bundles no third-party code — every npm package in package.json is dev/CI tooling (ESLint, Playwright, husky) and is never shipped. The only third-party asset shipped is the Barlow Semi Condensed typeface (src/fonts/*.woff2), self-hosted for GDPR reasons (see issue #29) and licensed under the SIL Open Font License 1.1.

The Bühlmann ZHL-16C decompression coefficients (ZHL16C_N2/ZHL16C_HE in src/constants.js) are published scientific data, independently transcribed from standard reference tables — see the source comment above those tables for the specific reference.

About

A 2D HTML5 Canvas diving simulator with the Buhlmann ZHL-16C decompression algorithm, multi-gas, CCR mode, and a technical wrist dive computer-style HUD.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages