Add an atmospheric hazard model
import HazardExposureCalculator from ‘../../../components/HazardExposureCalculator.astro’;
verifiedAgainst 6e0f847 · verifiedOn 2026-09-10.
In Voxamine, hazards are never spawned entities. The primary threat to survival is not a wandering monster; it is the physical and chemical consequences of your own ambition (“The Lab is the Monster”, MASTERPLAN.md §31). When an enclosed kiln or smelting furnace runs with insufficient air, incomplete carbon combustion releases carbon monoxide ($CO$).
Unlike games that use a simplistic damage = concentration × time timer, Voxamine implements genuine peer-reviewed toxicological equations. This guide walks through authoring a full atmospheric hazard model from control-volume concentration down to physiological blood uptake using the Coburn-Forster-Kane (CFK) differential model.
What you will build
Section titled “What you will build”The Carbon Monoxide Inhalation & Carboxyhemoglobin ($COHb$) Uptake Model:
- Atmosphere Source: Gases vented from unsealed or relief-valved reaction domains enter a bounded control volume in
AtmosphericDomainRegistry. - Ideal-Gas Conversion: Converts physical gas moles ($n_{\text{CO}}$) into volumetric parts per million (ppmv) using $p_i = \frac{n_i R T}{V}$ in
AtmosphericConcentrationMath. - Nonlinear CFK Uptake: Integrates fractional hemoglobin binding ($COHb$) over time, accounting for the reversible Haldane equilibrium ($M = 218$) where $CO$ competes with $O_2$.
- Sourced Reference Baseline:
CarbonMonoxideExposureProfile.ReferenceRestingAdult, derived from published ATSDR and EPA AEGL-2 documentation. - Authoritative Vitals Impact: Updates
PlayerVitals.CarbonMonoxideLoadand scalesBloodOxygen($SpO_2$ carrying capacity proxy). When $COHb > 40%$, severe motor ataxia and collapse trigger. - Realistic Biological Clearance: In clean air ($0\text{ ppm}$), $COHb$ clears progressively according to the natural 4–5 hour biological elimination half-life rather than vanishing instantly.
Where this sits
Section titled “Where this sits”Hazard modeling bridges chemistry simulation, atmospheric transport, and authoritative player survival:
flowchart TD
VESSEL["VesselDomain\nreaction solver emits vented CO moles"] --> ATMOS["AtmosphericDomainRegistry\nsparse 3D control volume (V, T, outdoor air changes)"]
ATMOS --> PPM["AtmosphericConcentrationMath.TryCalculatePartsPerMillion()\nconverts moles to dry-air ppmv via ideal gas law"]
subgraph Chemistry ["VoxelSandbox.Chemistry"]
PPM --> CFK["CarbonMonoxideExposureMath.TryAdvance()\nintegrates nonlinear CFK differential equation"]
PROFILE["CarbonMonoxideExposureProfile\nsourced physiological parameters (ATSDR-CO-CFK-2012)"]
PROFILE --> CFK
end
CFK --> VITALS["PlayerVitals.TryApplyCarbonMonoxideExposure()\nupdates CarbonMonoxideLoad & BloodOxygen"]
VITALS --> HUD["PlayerAssayRuntime / GameplayHudRuntime\nvisual dimming, motor ataxia & collapse cues"]
classDef core fill:#1e1e24,stroke:#e58a63,stroke-width:1px,color:#f8f9fa;
classDef chem fill:#0c4f48,stroke:#6fd8c6,stroke-width:1px,color:#f8f9fa;
classDef ui fill:#252a36,stroke:#8c98a8,stroke-width:1px,color:#f8f9fa;
class VESSEL,ATMOS,VITALS core;
class PPM,CFK,PROFILE chem;
class HUD ui;
Invariants
Section titled “Invariants”- No linear
dose = ppm × secondsshortcuts: Biological toxicokinetics are nonlinear. Inhaling $50\text{ ppm}$ for 10 hours does not have the same physiological effect as inhaling $500\text{ ppm}$ for 1 hour. The CFK differential equation captures the true saturation plateau and Haldane equilibrium. - No invented sensory cues for odorless toxins: Carbon monoxide is strictly colorless, odorless, and non-irritating. The game never invents green smoke particles or magical coughing cues. Players recognize $CO$ through process awareness (reducing furnaces in enclosed spaces), indirect bio-indicators (canary equivalent), or instruments.
- Finite substepping: Large frame deltas (e.g. during sleep, waiting, or background simulation) are integrated in internal 60-second sub-steps, clamped to an 8-hour maximum per advance to prevent runaway loops.
- Authoritative vitals ownership: UI components only read
PlayerVitals.CarbonMonoxideLoadandPlayerVitals.BloodOxygen; presentation code must never compute or overwrite physiological state.
Before you start
Section titled “Before you start”Reading manifest
Section titled “Reading manifest”Read these files at pinned commit 6e0f847 before authoring:
| # | File | Symbol / What to extract |
|---|---|---|
| 1 | Assets/_Game/Scripts/Chemistry/AtmosphericConcentrationMath.cs |
Ideal-gas conversion from moles, volume, and temperature to dry-air ppmv. |
| 2 | Assets/_Game/Scripts/Chemistry/CarbonMonoxideExposureMath.cs |
CarbonMonoxideExposureProfile struct and CarbonMonoxideExposureMath.TryAdvance discrete CFK integrator. |
| 3 | Assets/_Game/Scripts/World/Environment/AtmosphericDomainRegistry.cs |
Control-volume geometry (volumeCubicMeters, temperatureKelvin, air changes). |
| 4 | Assets/_Game/Scripts/Player/PlayerVitals.cs |
CarbonMonoxideLoad, BloodOxygen, and TryApplyCarbonMonoxideExposure. |
| 5 | Assets/_Game/Tests/EditMode/Chemistry/CarbonMonoxideExposureMathTests.cs |
Unit test fixture verifying AEGL-2 60-minute reference bands and clean-air clearance. |
| 6 | Docs/CHEMISTRY_SOURCES.md |
Evidence citation for ATSDR-CO-CFK-2012. |
Intake decisions
Section titled “Intake decisions”| Decision | Choice | Why |
|---|---|---|
| Primary Toxin | Carbon Monoxide ($CO$) | Signature early pyrometallurgical killer produced by solid-fuel reduction. |
| Transport Scale | Discrete control volume ($15\text{ m}^3$ room) | Avoids expensive per-voxel CFD while honoring real ventilation and door openings. |
| Physiological Model | Coburn-Forster-Kane (CFK) | Gold standard peer-reviewed differential equation for carboxyhemoglobin kinetics. |
| Reference Profile | 70 kg resting adult | Standard ATSDR toxicological baseline ($V_b = 5500\text{ mL}$, $\dot{V}_A = 13200\text{ mL/min}$, $M = 218$). |
| Sub-step Cadence | $60\text{ s}$ per step | Balances integration stability with computational performance. |
| Clearance Mechanism | Haldane displacement ($0\text{ ppm}$) | Naturally reproduces the 320-minute clean-air biological half-life. |
The files, in order
Section titled “The files, in order”Step 1: Control Volume Concentration Conversion
Section titled “Step 1: Control Volume Concentration Conversion”In Assets/_Game/Scripts/Chemistry/AtmosphericConcentrationMath.cs, convert vented moles into parts per million by volume (ppmv) using the ideal gas law:
$$p_i = \frac{n_i R T}{V}, \quad \text{ppmv} = \frac{p_i}{p_{\text{ref}}} \times 10^6$$
namespace VoxelSandbox.Chemistry{ public static class AtmosphericConcentrationMath { public const double PartsPerMillion = 1000000d;
public static bool TryCalculatePartsPerMillion( double gasMoles, double volumeCubicMeters, double temperatureKelvin, double referencePressurePascals, out double partsPerMillion) { partsPerMillion = 0d; if (!IsFiniteNonNegative(gasMoles) || !IsFinitePositive(volumeCubicMeters) || !IsFinitePositive(temperatureKelvin) || !IsFinitePositive(referencePressurePascals)) { return false; }
double partialPressurePascals = gasMoles * ChemicalConstants.MolarGasConstantJoulesPerMolKelvin * temperatureKelvin / volumeCubicMeters; double result = partialPressurePascals / referencePressurePascals * PartsPerMillion; if (!IsFiniteNonNegative(result)) { return false; }
partsPerMillion = result; return true; }
private static bool IsFiniteNonNegative(double value) => !double.IsNaN(value) && !double.IsInfinity(value) && value >= 0d; private static bool IsFinitePositive(double value) => IsFiniteNonNegative(value) && value > 0d; }}Step 2: The Sourced CFK Physiological Profile
Section titled “Step 2: The Sourced CFK Physiological Profile”In Assets/_Game/Scripts/Chemistry/CarbonMonoxideExposureMath.cs, define the physiological parameters of the exposed subject.
Every number must be finite, physically bounded, and tied to an evidence citation ID:
namespace VoxelSandbox.Chemistry{ public readonly struct CarbonMonoxideExposureProfile { public const string ReferenceRestingAdultSourceId = "ATSDR-CO-CFK-2012";
public CarbonMonoxideExposureProfile( double bloodVolumeMilliliters, double endogenousCarbonMonoxideMillilitersPerMinute, double haldaneCoefficient, double lungDiffusingCapacityMillilitersPerMinuteTorr, double humidifiedBarometricPressureTorr, double alveolarVentilationMillilitersPerMinute, double maximumOxygenBindingMillilitersPerMilliliterBlood, double capillaryOxygenPartialPressureTorr, double initialCarboxyhemoglobinFraction, string sourceId) { // ... argument range validation ... BloodVolumeMilliliters = bloodVolumeMilliliters; EndogenousCarbonMonoxideMillilitersPerMinute = endogenousCarbonMonoxideMillilitersPerMinute; HaldaneCoefficient = haldaneCoefficient; LungDiffusingCapacityMillilitersPerMinuteTorr = lungDiffusingCapacityMillilitersPerMinuteTorr; HumidifiedBarometricPressureTorr = humidifiedBarometricPressureTorr; AlveolarVentilationMillilitersPerMinute = alveolarVentilationMillilitersPerMinute; MaximumOxygenBindingMillilitersPerMilliliterBlood = maximumOxygenBindingMillilitersPerMilliliterBlood; CapillaryOxygenPartialPressureTorr = capillaryOxygenPartialPressureTorr; InitialCarboxyhemoglobinFraction = initialCarboxyhemoglobinFraction; SourceId = sourceId; }
public static CarbonMonoxideExposureProfile ReferenceRestingAdult => new( bloodVolumeMilliliters: 5500d, endogenousCarbonMonoxideMillilitersPerMinute: 0.007d, haldaneCoefficient: 218d, lungDiffusingCapacityMillilitersPerMinuteTorr: 30d, humidifiedBarometricPressureTorr: 713d, alveolarVentilationMillilitersPerMinute: 13200d, maximumOxygenBindingMillilitersPerMilliliterBlood: 0.2d, capillaryOxygenPartialPressureTorr: 100d, initialCarboxyhemoglobinFraction: 0.0075d, sourceId: ReferenceRestingAdultSourceId); }}Step 3: The Discrete CFK Numerical Integrator
Section titled “Step 3: The Discrete CFK Numerical Integrator”In the same file, implement the Coburn-Forster-Kane differential equation:
$$\frac{d[\text{COHb}]}{dt} = \frac{\dot{V}{\text{CO}}}{V_b} + \frac{P{I,\text{CO}} - \frac{[\text{COHb}] \cdot P_{c,O_2}}{[\text{HbO}_2] \cdot M}}{V_b \cdot \beta}$$
Where $\beta = \frac{1}{D_L} + \frac{P_B - P_{\text{H}_2\text{O}}}{\dot{V}_A}$.
public static class CarbonMonoxideExposureMath { public const double SecondsPerIntegrationStep = 60d; public const double PartsPerMillionPerTorr = 1316d; public const double MaximumIntegrationSeconds = 8d * 60d * 60d;
public static bool TryAdvance( double carboxyhemoglobinFraction, double inhaledCarbonMonoxidePartsPerMillion, double elapsedSeconds, in CarbonMonoxideExposureProfile profile, out double updatedCarboxyhemoglobinFraction) { updatedCarboxyhemoglobinFraction = carboxyhemoglobinFraction; if (!IsFiniteFraction(carboxyhemoglobinFraction) || !IsFiniteNonNegative(inhaledCarbonMonoxidePartsPerMillion) || !IsFiniteNonNegative(elapsedSeconds) || elapsedSeconds > MaximumIntegrationSeconds) { return false; }
if (elapsedSeconds == 0d) return true;
double beta = 1d / profile.LungDiffusingCapacityMillilitersPerMinuteTorr + profile.HumidifiedBarometricPressureTorr / profile.AlveolarVentilationMillilitersPerMinute; if (!IsFinitePositive(beta)) return false;
double inhaledPartialPressureTorr = inhaledCarbonMonoxidePartsPerMillion / PartsPerMillionPerTorr; double remainingSeconds = elapsedSeconds; double fraction = carboxyhemoglobinFraction;
while (remainingSeconds > 0d) { double stepSeconds = remainingSeconds > SecondsPerIntegrationStep ? SecondsPerIntegrationStep : remainingSeconds; double coHbConcentration = fraction * profile.MaximumOxygenBindingMillilitersPerMilliliterBlood; double oxygenHemoglobinConcentration = profile.MaximumOxygenBindingMillilitersPerMilliliterBlood - coHbConcentration; if (oxygenHemoglobinConcentration <= 0d) { updatedCarboxyhemoglobinFraction = 1d; return true; }
double removalPressure = coHbConcentration * profile.CapillaryOxygenPartialPressureTorr / (oxygenHemoglobinConcentration * profile.HaldaneCoefficient); double ratePerMinute = profile.EndogenousCarbonMonoxideMillilitersPerMinute / profile.BloodVolumeMilliliters + (inhaledPartialPressureTorr - removalPressure) / (profile.BloodVolumeMilliliters * beta); double nextConcentration = coHbConcentration + ratePerMinute * (stepSeconds / 60d); fraction = Clamp01(nextConcentration / profile.MaximumOxygenBindingMillilitersPerMilliliterBlood); remainingSeconds -= stepSeconds; }
updatedCarboxyhemoglobinFraction = fraction; return true; }
private static bool IsFiniteNonNegative(double value) => !double.IsNaN(value) && !double.IsInfinity(value) && value >= 0d; private static bool IsFiniteFraction(double value) => IsFiniteNonNegative(value) && value < 1d; private static bool IsFinitePositive(double value) => IsFiniteNonNegative(value) && value > 0d; private static double Clamp01(double value) => value <= 0d ? 0d : value >= 1d ? 1d : value; }Step 4: Wire Exposure into PlayerVitals.cs
Section titled “Step 4: Wire Exposure into PlayerVitals.cs”In Assets/_Game/Scripts/Player/PlayerVitals.cs, connect inhaled concentration to authoritative player physiology:
public bool TryApplyCarbonMonoxideExposure(float concentrationPartsPerMillion, float elapsedSeconds) { if (!IsFiniteNonNegative(concentrationPartsPerMillion) || !IsFiniteNonNegative(elapsedSeconds) || !CarbonMonoxideExposureMath.TryAdvance( CarbonMonoxideLoad, concentrationPartsPerMillion, elapsedSeconds, CarbonMonoxideExposureProfile.ReferenceRestingAdult, out double updatedLoad)) { return false; }
CarbonMonoxideLoad = (float)updatedLoad; BloodOxygen = NormalBloodOxygen * (1f - CarbonMonoxideLoad); return true; }Because BloodOxygen directly scales with $(1 - \text{CarbonMonoxideLoad})$, elevated blood poisoning immediately reduces oxygen delivery, triggering visual peripheral tunneling and motor ataxia in the player presentation systems.
Step 5: Author Validation Tests in CarbonMonoxideExposureMathTests.cs
Section titled “Step 5: Author Validation Tests in CarbonMonoxideExposureMathTests.cs”In Assets/_Game/Tests/EditMode/Chemistry/CarbonMonoxideExposureMathTests.cs, assert conformance to published toxicological reference bands:
[Test] public void ReferenceAdult_OneHourAtAeGlTwoConcentrationProducesThePublishedFourPercentReferenceBand() { CarbonMonoxideExposureProfile profile = CarbonMonoxideExposureProfile.ReferenceRestingAdult; Assert.That( CarbonMonoxideExposureMath.TryAdvance(profile.InitialCarboxyhemoglobinFraction, 83d, 60d * 60d, profile, out double coHb), Is.True);
// NIST/AEGL's 70 kg adult 60-minute AEGL-2 derivation targets 4% COHb Assert.That(coHb, Is.EqualTo(0.04d).Within(0.003d)); }
[Test] public void ZeroCarbonMonoxide_ClearsAnElevatedLoadTowardTheProfileBaselineInsteadOfInstantlyHealing() { CarbonMonoxideExposureProfile profile = CarbonMonoxideExposureProfile.ReferenceRestingAdult; Assert.That(CarbonMonoxideExposureMath.TryAdvance(0.2d, 0d, 60d * 60d, profile, out double afterOneHour), Is.True);
// Clearance occurs progressively toward baseline, never instantaneously Assert.That(afterOneHour, Is.GreaterThan(profile.InitialCarboxyhemoglobinFraction)); Assert.That(afterOneHour, Is.LessThan(0.2d)); }Interactive Exposure Simulator
Section titled “Interactive Exposure Simulator”Experiment with the exact Coburn-Forster-Kane differential equations. Adjust the atmospheric $CO$ concentration in parts per million and the exposure duration to observe $COHb$ blood uptake and clinical symptom tiers:
Verify
Section titled “Verify”1. Run EditMode Tests Headlessly
Section titled “1. Run EditMode Tests Headlessly”Execute the Chemistry and Hazard test categories headlessly:
/home/soulwax/Unity/Hub/Editor/6000.6.0f1/Editor/Unity \ -batchmode -nographics \ -projectPath . \ -runTests \ -testPlatform EditMode \ -testCategory Chemistry \ -testResults Logs/chemistry-hazard-tests.xml \ -logFile Logs/chemistry-hazard.logConfirm all CFK uptake, source existence, and clearance assertions pass with zero failures.
2. In-Game Functional Verification
Section titled “2. In-Game Functional Verification”- Place a charcoal roasting hearth inside a sealed stone enclosure ($V \approx 16\text{ m}^3$).
- Ignite the furnace and stand beside it without ventilation.
- Observe
CarbonMonoxideLoadclimbing gradually on the debug overlay (F3). - At $COHb > 20%$, observe peripheral visual darkening; at $COHb > 40%$, observe motor stumbling and involuntary collapse.
- Exit into open outdoor air and verify that recovery requires several in-game hours rather than clearing immediately.
Now do your own
Section titled “Now do your own”Follow this checklist whenever adding a new atmospheric hazard:
- Identify the biological mechanism:
- Asphyxiant ($CO_2$, $N_2$): Oxygen displacement, fractional inspired $P_{O_2}$.
- Competitive metabolic poison ($CO$): Reversible hemoglobin binding via CFK.
- Irritant / Corrosive ($SO_2$, $Cl_2$, $HF$): Mucosal acid generation, concentration threshold with duration-dependent tissue necrosis.
- Declare a sourced exposure profile:
- Define volume distribution, baseline clearance rates, and source citation ID in
CHEMISTRY_SOURCES.md.
- Define volume distribution, baseline clearance rates, and source citation ID in
- Use discrete sub-stepping:
- Never allow large time deltas to integrate in a single step; break into stable 30–60 second intervals.
- Wire to authoritative player state:
- Store the physiological load in
PlayerVitals; let presentation react read-only.
- Store the physiological load in
- Implement realistic half-life elimination:
- Never clear a toxic burden instantly; model hepatic or pulmonary clearance over time.
Pitfalls
Section titled “Pitfalls”Pitfall 1: Assuming linear dose = ppm × time
Section titled “Pitfall 1: Assuming linear dose = ppm × time”- Symptom: Exposure to mild smoke for hours accumulates to lethal levels, or high concentrations kill instantly without modeling biological uptake lag.
- Cause: Ignoring the saturation ceiling and reversible Haldane equilibrium.
- Fix: Use the nonlinear CFK integrator (
CarbonMonoxideExposureMath.TryAdvance).
Pitfall 2: Inventing magical cues for odorless toxins
Section titled “Pitfall 2: Inventing magical cues for odorless toxins”- Symptom: Carbon monoxide displays glowing green smoke particles or warning sound effects.
- Cause: Violating the three core rules of hazard design (“The Lab is the Monster”,
MASTERPLAN.md§31). - Fix: Keep $CO$ strictly invisible. Warning comes from combustion knowledge, instruments, or physiological symptoms (headache, vision dimming).
Pitfall 3: Infinite loops on large delta time
Section titled “Pitfall 3: Infinite loops on large delta time”- Symptom: The game hangs during sleep, fast-forwarding, or time skip.
- Cause: Running
while (remainingSeconds > 0)whenelapsedSecondsis hundreds of thousands of seconds. - Fix: Clamp integration time to
MaximumIntegrationSeconds($8\text{ hours}$) and reject unbounded intervals.
Pitfall 4: Instantaneous healing in clean air
Section titled “Pitfall 4: Instantaneous healing in clean air”- Symptom: Stepping through an open doorway immediately resets $COHb$ to zero.
- Cause: Setting
CarbonMonoxideLoad = 0whenever atmospheric concentration is zero. - Fix: When $CO = 0\text{ ppm}$, continue integrating CFK with $P_{\text{CO}} = 0$; elimination will naturally follow the authentic 320-minute clearance curve.
User-contributed notes
Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works:
**bold**,*italic*,`code`, and links.Notes policy
Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked
nofollow.