Dungeon Candle Simulator

Related explorations
→ How candle physics break dungeons (this post)
The hidden goblin workforce
My JavaScript descent into madness

After running a physically super-accurate simulation of dungeon lighting, I can confidently confirm that the greatest threat to adventurers is not monsters, traps or necromancers…

it’s thermal runaway in an unventilated stone chamber filled with 200 small, little, innocent candles!

Realistically, the only thing left alive would be a goblin caretaker, sitting in a corner, sweaty, inhaling fumes, and wondering why every hero keeps passing out before reaching room 2.

This small JavaScript-adapted code lets us play with the number of candles and the “viability” of the Dungeon. For a pretty small 10x5m room, if we have more than 15 candles without a proper aeration… it becomes quickly a mess 🙂

Dungeon Candle Simulator

Adjust the parameters and hit Run simulation to see how the dungeon heats up, loses oxygen, and becomes unlivable. Left axis: percentages. Right axis: temperature in °C.

Left axis: % of candles lit, O₂ (%), livability (%). Right axis: temperature (°C).


NB: ACH is the number of times the volume of the room is changed per hour. 1 means, all the volume of the room is replaced each hour. A very bad ventilated dungeon will have 0.05 (20h to change all the air), a laboratory, something around 5 to 10.

I have coded the initial script in Python and used AI to translate it into a JavaScript browser-ready widget. It doesn’t necessarily work well on all browsers.

If you want to have a look at the Python script and propose any modifications, feel free to copy the code below and play with it. It’s for fun and isn’t expected to provide real value when measured in a real dungeon… well, what is a real dungeon?

Bellow: Image from the game Skyrim (Bethesda Software, Microsoft) and the mod Temple of Kruziik. At least 16 candles are visible… that’s a lot… But It’s so beautiful. 😉

import math
import matplotlib.pyplot as plt


def simulate_dungeon(
    room_length_m=5.0,
    room_width_m=4.0,
    room_height_m=3.0,
    n_candles=50,
    candle_power_W=40.0,
    candle_lifetime_h=4.0,
    t_outside_C=10.0,
    t_initial_C=10.0,
    air_exchange_per_hour=0.1,
    o2_initial_fraction=0.18,
    o2_outside_fraction=0.21,
    o2_min_fraction=0.12,
    sim_duration_h=6.0,
    dt_s=60.0,          # <-- PAS DE TEMPS en secondes
):
    """
    Simple dungeon model:
    - Well-mixed air
    - Candles heat the air and consume O2
    - Ventilation mixes with outside air
    - We compute temperature, O2 fraction, and a livability score
    """

    # --- Constants ---
    rho_air = 1.2          # kg/m³
    cp_air = 1000.0        # J/(kg·K)

    # --- Geometry & air mass ---
    volume = room_length_m * room_width_m * room_height_m   # m³
    m_air = rho_air * volume                                # kg
    heat_capacity_room = m_air * cp_air                     # J/K

    # --- Time ---
    sim_duration_s = sim_duration_h * 3600.0
    candle_lifetime_s = candle_lifetime_h * 3600.0

    # Ventilation coefficient (1/s)
    k_vent = air_exchange_per_hour / 3600.0

    # --- Oxygen model (in m³ of O2) ---
    o2_volume = o2_initial_fraction * volume
    o2_consumption_m3_per_h_per_candle = 0.008   # ~8 L/h per candle
    o2_consumption_m3_per_s_per_candle = o2_consumption_m3_per_h_per_candle / 3600.0

    # --- Initial temperature ---
    t_C = t_initial_C
    t_K = t_C + 273.15
    t_out_K = t_outside_C + 273.15

    # --- Result arrays ---
    times_h = []
    temps_C = []
    o2_fraction_list = []
    livability_list = []
    candles_lit_list = []

    # --- Time loop ---
    t_current_s = 0.0
    while t_current_s <= sim_duration_s:
        # Are candles still burning?
        if t_current_s <= candle_lifetime_s and n_candles > 0:
            total_candle_power = n_candles * candle_power_W
            candles_lit = n_candles
        else:
            total_candle_power = 0.0
            candles_lit = 0

        # ----- TEMPERATURE -----
        # Heat from candles
        q_in = total_candle_power * dt_s      # J in this time step
        dT_candles = q_in / heat_capacity_room

        # Ventilation cooling/heating toward outside
        dT_vent = -k_vent * (t_K - t_out_K) * dt_s

        # Update temperature
        t_K += dT_candles + dT_vent
        t_C = t_K - 273.15

        # ----- OXYGEN -----

        # 1) Consumption by candles
        if candles_lit > 0:
            o2_consumed = candles_lit * o2_consumption_m3_per_s_per_candle * dt_s
            o2_volume = max(0.0, o2_volume - o2_consumed)

        # 2) Ventilation mixing with outside air
        target_o2_volume = volume * o2_outside_fraction
        dV_o2_vent = k_vent * (target_o2_volume - o2_volume) * dt_s
        o2_volume += dV_o2_vent
        o2_volume = max(0.0, min(o2_volume, volume))

        o2_fraction = o2_volume / volume

        # ----- LIVABILITY SCORE -----

        # O2 score: 1 at initial O2, 0 at o2_min_fraction
        if o2_fraction >= o2_initial_fraction:
            o2_score = 1.0
        elif o2_fraction <= o2_min_fraction:
            o2_score = 0.0
        else:
            o2_score = (o2_fraction - o2_min_fraction) / (
                o2_initial_fraction - o2_min_fraction
            )

        # Temperature score: 1 between 18 and 27°C, 0 at 0°C and 40°C
        comfy_min = 15.0
        comfy_max = 27.0
        hard_min = 0.0
        hard_max = 40.0

        if comfy_min <= t_C <= comfy_max:
            temp_score = 1.0
        elif t_C < comfy_min:
            if t_C <= hard_min:
                temp_score = 0.0
            else:
                temp_score = (t_C - hard_min) / (comfy_min - hard_min)
        else:   # t_C > comfy_max
            if t_C >= hard_max:
                temp_score = 0.0
            else:
                temp_score = (hard_max - t_C) / (hard_max - comfy_max)

        livability = max(0.0, min(1.0, o2_score * temp_score)) * 100.0

        # ----- Store results -----
        times_h.append(t_current_s / 3600.0)
        temps_C.append(t_C)
        o2_fraction_list.append(o2_fraction)
        livability_list.append(livability)
        candles_lit_list.append(candles_lit)

        # Increment time
        t_current_s += dt_s

    return {
        "times_h": times_h,
        "temps_C": temps_C,
        "o2_fraction": o2_fraction_list,
        "livability": livability_list,
        "candles_lit": candles_lit_list,
    }


if __name__ == "__main__":

    # >>> règlage le pas de temps <<<
    dt_s = 60.0      # 60 s = 1 minute

    result = simulate_dungeon(
        room_length_m=10.0,
        room_width_m=5.0,
        room_height_m=3.0,
        n_candles=120,
        candle_power_W=40.0,
        candle_lifetime_h=3.0,
        t_outside_C=5.0,
        t_initial_C=5.0,
        air_exchange_per_hour=0.05,
        o2_initial_fraction=0.18,
        o2_outside_fraction=0.21,
        o2_min_fraction=0.12,
        sim_duration_h=8.0,
        dt_s=dt_s,
    )

    times = result["times_h"]
    temps = result["temps_C"]
    o2_percent = [f * 100.0 for f in result["o2_fraction"]]
    livability = result["livability"]
    candles_lit = result["candles_lit"]

    # --- Plot ---
    plt.figure(figsize=(10, 6))

    plt.plot(times, candles_lit, label="Lit candles (count)")
    plt.plot(times, o2_percent, label="O₂ (%)")
    plt.plot(times, temps, label="Temperature (°C)")
    plt.plot(times, livability, label="Livability index (%)")

    plt.xlabel("Time (hours)")
    plt.ylabel("Value")
    plt.title("Dungeon Candle Simulation")
    plt.legend()
    plt.grid(True)
    plt.tight_layout()
    plt.show()