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()