Related explorations
→ How candle physics break dungeons
→ The hidden goblin workforce
→ My JavaScript descent into madness (this post)
Introduction | When Python Was Enough
This whole adventure started innocently.
I had built a small thermodynamic model in Python to estimate how fast a dungeon would heat up if you filled it with torches or candles. The kind of thing you do on a calm Sunday evening, because you suddenly wonder:
“How do RPG dungeons stay lit? And why aren’t adventurers dying from heatstroke?”
The Python model was elegant, clean, short. A few formulas, a loop, and a matplotlib plot. It smelled of science, order, and sanity. Then I had an idea. A dangerous one.
“What if readers could play with it online?”, “What if they could adjust the number of candles… in real time… on my website?”
And this, dear reader, is how I voluntarily walked into the JavaScript Dungeon with the support of ChatGPT from OpenAI, as it was my first real test with JS in a browser.
I survived. Barely. Let me show you the map.
Room I | The Language Itself (Abandon Clean Syntax, All Ye Who Enter Here)
Python is delightful. You import matplotlib, write three lines, and boom, graph.
JavaScript?
JavaScript is the language equivalent of a pile of enchanted scrolls written by wizards who died halfway through the spell.
Can it do everything? Yes.
Does it do everything comfortably? laughs in var/let/const scope issues
To draw a simple line, I had to:
- obtain a 2D canvas context like a nervous archaeologist,
- handle resizing,
- think about event ordering,
- manually draw axes, ticks, legends,
- and appease the Canvas Rendering Demon, who only awakens when the wrong property is touched.
A goblin caretaker would say:
GOBLIN TIP #42:
If you stare at the Canvas Context too long, it will stare back.
Room II | The Browser (A Medieval Laboratory With All Experiments Forbidden)
Running a simulation in Python is like working in a real lab:
- electricity works
- fire is allowed
- instruments behave
Running one in a browser is like working in a medieval stone cellar where half your tools are banned, the candles blow out when you breathe, and everything must pass a purity test.
The browser gently tells you:
“No file access.”
“No Python.”
“No plotting library.”
“Enjoy your 500-page JavaScript manual.”
So you start drawing everything yourself: Lines, shapes, pixels… Like a monk illuminating a manuscript.
Room III | WordPress (The Two-Headed Minotaur)
Now we reach the part where your torch starts flickering. I wrote my JavaScript with a lot of support from OpenAI ChatGPT because I was in easy mode. I injected it into my WordPress article using a custom HTML block. It worked perfectly in the editor preview.
But on the live website? Nothing.
A dark, silent canvas. The void. I checked the code.
Everything looked correct. Then I opened the browser inspector and found the monster:
if (tCurrentS <= candleLifetimeS && nCandles > 0)
WordPress had transformed my && into &&.
It had “sanitized” my JavaScript.
It had HTML-ified my logic operator.
JavaScript saw this and immediately died of syntax poisoning.
This creature is known as: WP-initiated Syntax Death (rare, but always fatal for scripts)
Room IV | Plugins: Summoning Heavy Machinery for a Candle Problem
To fix this, I installed a plugin. Then another. Then another. All for some lines of JavaScript.
Eventually, I discovered the spellbook known as WPCode, which lets you inject scripts, far away from WordPress’s overeager HTML sanitizer. Of course, after writing my beautiful snippet, I forgot to click “Activate”. The result?
ReferenceError: runDungeonSimulation is not defined
This is JavaScript’s way of saying:
“Your function doesn’t exist and maybe never existed.
Also, your hopes and dreams are invalid.”
Clicking “Activate” felt like defeating the dungeon boss.
Room V | Victory: A Working Dungeon Simulator
After all this suffering, the simulator finally came alive.
“This is the moment where the Goblin Caretaker looked at my code and said: ‘Yes, this will burn.’”
A fully interactive Dungeon Candle Thermodynamics Simulator running in JavaScript:
- Temperature over time
- Oxygen depletion
- Candle lifetime
- Livability index
- Immediate doom if candle count > 50
All drawn manually on a <canvas>, like a retro scientific instrument forged by a Goblin Artificer.
And honestly? It works beautifully.
Room VI | Reflection: Why Do We Do This to Ourselves?
Creating this toy reminded me of something important:
- Python is clean, rational, and structured.
- JavaScript is chaotic, improvised, and arcane.
- The browser is a dungeon full of traps.
- WordPress is a goblin minotaur that rewrites your spells behind your back.
And yet…
The web is the only place where anyone can play with your simulation instantly.
No environment. No installation. Just a browser, a canvas, and a reckless number of candles.
This chaos, this absurd stack of technologies, is also what makes it fun. I suffered… and then I smile when it works. Just like adventurer in a dungeon.
Appendix | The Scientific Part (Python Version)
For sanity, here is the clean and readable version of the model, written in Python. Nothing explodes here. No canvas demons. Just physics.
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 = 18.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()
Appendix | The Cursed JavaScript Mirror
Created with the support of ChatGPT / OpenAI. I don’t pretend to be a JS coder with just one tentative ;).
Warning: Reading this section without proper emotional preparation may cause confusion, temporal displacement, or spontaneous goblin transformation.
<script>
function simulateDungeon(params) {
const {
roomLength,
roomWidth,
roomHeight,
nCandles,
candlePowerW,
candleLifetimeH,
tOutsideC,
tInitialC,
airExchangePerHour,
o2InitialFraction,
o2OutsideFraction,
o2MinFraction,
simDurationH,
dtSeconds
} = params;
const rhoAir = 1.2; // kg/m3
const cpAir = 1000.0; // J/(kg*K)
const volume = roomLength * roomWidth * roomHeight; // m3
const mAir = rhoAir * volume;
const heatCapacityRoom = mAir * cpAir;
const simDurationS = simDurationH * 3600.0;
const candleLifetimeS = candleLifetimeH * 3600.0;
const kVent = airExchangePerHour / 3600.0; // 1/s
// O2
let o2Volume = o2InitialFraction * volume;
const o2ConsumptionM3PerHPerCandle = 0.008; // ~8 L/h
const o2ConsumptionM3PerSPerCandle = o2ConsumptionM3PerHPerCandle / 3600.0;
// Temperature
let tC = tInitialC;
let tK = tC + 273.15;
const tOutK = tOutsideC + 273.15;
const timesH = [];
const tempsC = [];
const o2FractionList = [];
const livabilityList = [];
const candlesLitList = [];
let tCurrentS = 0.0;
while (tCurrentS <= simDurationS + 1e-9) {
let candlesLit, totalCandlePower;
if (tCurrentS <= candleLifetimeS && nCandles > 0) {
candlesLit = nCandles;
totalCandlePower = nCandles * candlePowerW;
} else {
candlesLit = 0;
totalCandlePower = 0.0;
}
// Temperature update
const qIn = totalCandlePower * dtSeconds;
const dT_candles = qIn / heatCapacityRoom;
const dT_vent = -kVent * (tK - tOutK) * dtSeconds;
tK += dT_candles + dT_vent;
tC = tK - 273.15;
// O2 update
if (candlesLit > 0) {
const o2Consumed = candlesLit * o2ConsumptionM3PerSPerCandle * dtSeconds;
o2Volume = Math.max(0.0, o2Volume - o2Consumed);
}
const targetO2Volume = volume * o2OutsideFraction;
const dV_o2_vent = kVent * (targetO2Volume - o2Volume) * dtSeconds;
o2Volume += dV_o2_vent;
o2Volume = Math.min(Math.max(o2Volume, 0.0), volume);
const o2Fraction = o2Volume / volume;
// O2 score
let o2Score;
if (o2Fraction >= o2InitialFraction) {
o2Score = 1.0;
} else if (o2Fraction <= o2MinFraction) {
o2Score = 0.0;
} else {
o2Score = (o2Fraction - o2MinFraction) /
(o2InitialFraction - o2MinFraction);
}
// Temperature comfort score (still used in livability)
const comfyMin = 18.0;
const comfyMax = 27.0;
const hardMin = 0.0;
const hardMax = 40.0;
let tempScore;
if (tC >= comfyMin && tC <= comfyMax) {
tempScore = 1.0;
} else if (tC < comfyMin) {
if (tC <= hardMin) tempScore = 0.0;
else tempScore = (tC - hardMin) / (comfyMin - hardMin);
} else {
if (tC >= hardMax) tempScore = 0.0;
else tempScore = (hardMax - tC) / (hardMax - comfyMax);
}
const livability = Math.max(0, Math.min(1, o2Score * tempScore)) * 100.0;
timesH.push(tCurrentS / 3600.0);
tempsC.push(tC);
o2FractionList.push(o2Fraction);
livabilityList.push(livability);
candlesLitList.push(candlesLit);
tCurrentS += dtSeconds;
}
return { timesH, tempsC, o2FractionList, livabilityList, candlesLitList };
}
function drawDungeonChart(canvas, data, params) {
const ctx = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, w, h);
const marginLeft = 50;
const marginRight = 50;
const marginTop = 20;
const marginBottom = 30;
const plotW = w - marginLeft - marginRight;
const plotH = h - marginTop - marginBottom;
const { timesH, tempsC, o2FractionList, livabilityList, candlesLitList } = data;
const n = timesH.length;
if (n < 2) return;
const tMin = timesH[0];
const tMax = timesH[n - 1];
function xFromTime(t) {
return marginLeft + ((t - tMin) / (tMax - tMin)) * plotW;
}
// Left axis: percentages (0–100)
const candlesMax = Math.max(...candlesLitList, 1);
const candlesPct = candlesLitList.map(c => (c / candlesMax) * 100.0);
const o2Pct = o2FractionList.map(f => f * 100.0);
const livPct = livabilityList; // already 0–100
function yFromPerc(p) {
const frac = p / 100.0;
return marginTop + (1.0 - frac) * plotH;
}
// Right axis: temperature in °C
let tMinC = Math.min(...tempsC);
let tMaxC = Math.max(...tempsC);
// Ajout d'un peu de marge
const padding = 2.0;
tMinC = Math.floor(tMinC - padding);
tMaxC = Math.ceil(tMaxC + padding);
if (tMaxC <= tMinC) {
tMaxC = tMinC + 1;
}
function yFromTemp(T) {
const frac = (T - tMinC) / (tMaxC - tMinC);
return marginTop + (1.0 - frac) * plotH;
}
// Axes
ctx.strokeStyle = "#555";
ctx.lineWidth = 1;
ctx.beginPath();
// left axis
ctx.moveTo(marginLeft, marginTop);
ctx.lineTo(marginLeft, marginTop + plotH);
// bottom axis
ctx.lineTo(marginLeft + plotW, marginTop + plotH);
// right axis (for temperature)
ctx.moveTo(marginLeft + plotW, marginTop);
ctx.lineTo(marginLeft + plotW, marginTop + plotH);
ctx.stroke();
// Horizontal grid for % (0, 50, 100)
ctx.strokeStyle = "#222";
[0, 50, 100].forEach(v => {
const y = yFromPerc(v);
ctx.beginPath();
ctx.moveTo(marginLeft, y);
ctx.lineTo(marginLeft + plotW, y);
ctx.stroke();
ctx.fillStyle = "#888";
ctx.font = "10px system-ui, sans-serif";
ctx.fillText(v.toString(), marginLeft - 25, y + 3);
});
// Temperature ticks on right axis (tMinC, mid, tMaxC)
ctx.fillStyle = "#888";
ctx.font = "10px system-ui, sans-serif";
const tMidC = (tMinC + tMaxC) / 2.0;
[tMinC, tMidC, tMaxC].forEach(T => {
const y = yFromTemp(T);
const label = T.toFixed(0) + "°C";
ctx.fillText(label, marginLeft + plotW + 5, y + 3);
});
// Helper to draw a line (percent series)
function drawPercentSeries(values, color) {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xFromTime(timesH[0]), yFromPerc(values[0]));
for (let i = 1; i < n; i++) {
ctx.lineTo(xFromTime(timesH[i]), yFromPerc(values[i]));
}
ctx.stroke();
}
// Draw percent series (left axis)
drawPercentSeries(candlesPct, "#ffcc00"); // candles
drawPercentSeries(o2Pct, "#00bcd4"); // O2
drawPercentSeries(livPct, "#8bc34a"); // livability
// Draw temperature series (right axis)
ctx.strokeStyle = "#ff5722";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xFromTime(timesH[0]), yFromTemp(tempsC[0]));
for (let i = 1; i < n; i++) {
ctx.lineTo(xFromTime(timesH[i]), yFromTemp(tempsC[i]));
}
ctx.stroke();
// Labels
ctx.fillStyle = "#eee";
ctx.font = "12px system-ui, sans-serif";
ctx.fillText("time (h)", marginLeft + plotW / 2 - 20, h - 8);
// left axis label
ctx.save();
ctx.translate(15, marginTop + plotH / 2 + 20);
ctx.rotate(-Math.PI / 2);
ctx.fillText("percentage (%)", 0, 0);
ctx.restore();
// right axis label
ctx.save();
ctx.translate(w - 15, marginTop + plotH / 2 + 20);
ctx.rotate(-Math.PI / 2);
ctx.fillText("temperature (°C)", 0, 0);
ctx.restore();
// Legend
const legendItems = [
{ label: "Candles lit (% of initial)", color: "#ffcc00" },
{ label: "O\u2082 (%)", color: "#00bcd4" },
{ label: "Livability index (%)", color: "#8bc34a" },
{ label: "Temperature (°C)", color: "#ff5722" },
];
let lx = marginLeft + 10;
let ly = marginTop + 15;
legendItems.forEach(item => {
ctx.fillStyle = item.color;
ctx.fillRect(lx, ly - 8, 10, 10);
ctx.fillStyle = "#eee";
ctx.fillText(item.label, lx + 14, ly);
lx += 170;
});
}
function runDungeonSimulation() {
const params = {
roomLength: parseFloat(document.getElementById("roomLength").value) || 10,
roomWidth: parseFloat(document.getElementById("roomWidth").value) || 5,
roomHeight: parseFloat(document.getElementById("roomHeight").value) || 3,
nCandles: parseFloat(document.getElementById("nCandles").value) || 50,
candlePowerW: 40.0,
candleLifetimeH: parseFloat(document.getElementById("candleLifetime").value) || 3,
tOutsideC: 5.0,
tInitialC: 5.0,
airExchangePerHour: parseFloat(document.getElementById("airExchange").value) || 0.05,
o2InitialFraction: 0.18,
o2OutsideFraction: 0.21,
o2MinFraction: 0.12,
simDurationH: parseFloat(document.getElementById("simDuration").value) || 8,
dtSeconds: parseFloat(document.getElementById("dtSeconds").value) || 60
};
const data = simulateDungeon(params);
const canvas = document.getElementById("dungeonCanvas");
drawDungeonChart(canvas, data, params);
const lastIdx = data.timesH.length - 1;
const finalTemp = data.tempsC[lastIdx];
const finalO2 = data.o2FractionList[lastIdx] * 100.0;
const minO2 = Math.min(...data.o2FractionList) * 100.0;
const minLiv = Math.min(...data.livabilityList);
const steps = data.timesH.length;
const out = [];
out.push("Final temperature: " + finalTemp.toFixed(1) + " °C");
out.push("Final O₂: " + finalO2.toFixed(1) + " % (min: " + minO2.toFixed(1) + " %)");
out.push("Minimum livability index: " + minLiv.toFixed(1) + " %");
out.push("Number of simulation steps: " + steps + " (dt = " + params.dtSeconds + " s)");
document.getElementById("dungeonOutput").textContent = out.join("\n");
}
document.addEventListener("DOMContentLoaded", runDungeonSimulation);
</script>
Conclusion | Why I’ll Do It Again
Even though JavaScript took me on an unexpected adventure:
- through layers of browser weirdness,
- through WordPress filters,
- through plugin rituals,
- through stage bosses named “undefined function”…
I love the result. And you know what? Now that it works…
I’m already thinking of the next simulator.
Photo by Gabriel Heinzer