Skip to content
Edit on GitHub

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.


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.CarbonMonoxideLoad and scales BloodOxygen ($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.

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;
  1. No linear dose = ppm × seconds shortcuts: 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.
  2. 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.
  3. 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.
  4. Authoritative vitals ownership: UI components only read PlayerVitals.CarbonMonoxideLoad and PlayerVitals.BloodOxygen; presentation code must never compute or overwrite physiological state.

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.
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.

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));
}

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:


Execute the Chemistry and Hazard test categories headlessly:

Terminal window
/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.log

Confirm all CFK uptake, source existence, and clearance assertions pass with zero failures.

  1. Place a charcoal roasting hearth inside a sealed stone enclosure ($V \approx 16\text{ m}^3$).
  2. Ignite the furnace and stand beside it without ventilation.
  3. Observe CarbonMonoxideLoad climbing gradually on the debug overlay (F3).
  4. At $COHb > 20%$, observe peripheral visual darkening; at $COHb > 40%$, observe motor stumbling and involuntary collapse.
  5. Exit into open outdoor air and verify that recovery requires several in-game hours rather than clearing immediately.

Follow this checklist whenever adding a new atmospheric hazard:

  1. 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.
  2. Declare a sourced exposure profile:
    • Define volume distribution, baseline clearance rates, and source citation ID in CHEMISTRY_SOURCES.md.
  3. Use discrete sub-stepping:
    • Never allow large time deltas to integrate in a single step; break into stable 30–60 second intervals.
  4. Wire to authoritative player state:
    • Store the physiological load in PlayerVitals; let presentation react read-only.
  5. Implement realistic half-life elimination:
    • Never clear a toxic burden instantly; model hepatic or pulmonary clearance over time.

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) when elapsedSeconds is 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 = 0 whenever 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.

  1. Loading notes…