A developer's diary of pedagogical upgrades, encoding traps, hidden buttons, and star ratings that refused to appear
The Dot and Cross Diagram simulation is a free, open-access HTML5 interactive built with WebEJS (Easy JavaScript Simulations). It lets Singapore O-Level chemistry students drag-and-drop electrons to build covalent bond diagrams for 15 molecules — from simple H₂ all the way to the sulfite ion SO₃²⁻.
The simulation already worked. Students could place electrons, click Check, and get a Correct or Incorrect result. But it lacked depth: no real-world context, no molecular geometry, no hint about why chemistry matters beyond the exam, and no way for students to track their own progress.
This post documents the engineering journey of turning a functional simulation into a genuinely pedagogical tool — and every frustrating bug that stood in the way.
“The simulation worked. The question was whether it taught.” — The design challenge
Critical constraint: All changes must be made in _source.json, the WebEJS source file, so the teacher can re-upload it to the WebEJS online editor and regenerate index.html. We cannot just edit the compiled output.
WebEJS compiles a UTF-16 LE encoded JSON file (_source.json) into a self-contained index.html. The JSON has five top-level sections:
{
"information": { "HTMLHead": "..." }, // <head> content
"description": { "pages": [...] }, // documentation tabs
"model": {
"variables": { "pages": [...] }, // model state variables
"custom": { "pages": [...] }, // JavaScript functions
"initialization": { "pages": [...] }
},
"view": { "Tree": [...] }, // UI element tree
"metadata": { ... }
}
We wrote a Python script — modify_source.py — that reads from a clean backup (_source.json.bak), applies all patches programmatically, and writes the modified _source.json. This is idempotent: run it ten times, get the same result. It is also safe: the backup is never touched.
We added new String and int variables to the Var Table (whythismatters, moleculeshape, bondangle, shapedescription, moleculepolarity, streakcount) and patched each of the 15 option1()–option15() functions to set these values when a molecule is selected. We also added three description pages (How to Use, Background Theory, Molecule Guide) to the description.pages array.
${bondangle} directly in the Panel's HTML property, expecting WebEJS to interpolate model variables like a template literal. Instead, the browser rendered the literal string ${bondangle} on screen.
<script> block injected into information.HTMLHead calls window._model._userSerialize() every 500 ms and writes HTML into named <div> placeholder elements.
function _dcUpdate() {
var s = window._model._userSerialize();
var mi = document.getElementById('dc-molinfo');
if (mi) {
mi.innerHTML =
'<b>Shape:</b> ' + s.moleculeshape +
' | <b>Angle:</b> ' + s.bondangle;
}
}
json.dump(..., ensure_ascii=False) caused a UnicodeEncodeError when writing emoji characters (⭐, 📊) to a UTF-16 LE file. Python's codec cannot represent code points above U+FFFF as surrogate pairs in this mode.
ensure_ascii=True in json.dump. This serialises all non-ASCII characters as \uXXXX escape sequences, which the browser's JavaScript engine decodes correctly at runtime.
# Wrong
json.dump(data, f, ensure_ascii=False, indent=4)
# Correct
json.dump(data, f, ensure_ascii=True, indent=4)
HtmlArea view elements caused two problems: (a) each rendered as a fixed-height <iframe> with scrollbars, and (b) WebEJS renders view children once per description page, so four copies appeared on screen.
HtmlArea additions. The existing html element (type Panel) renders as an inline <div> in the main document. Dynamic dc-molinfo and dc-progress divs were placed as placeholders inside it. The polling script updates them directly.
_userSerialize()starsDisplay[16] (String array) and starsearned[16] (double array) stored in a separate variable page called “Stars & Progress”. These never appeared in _userSerialize()’s return object. Every polling cycle saw s.starsDisplay === undefined. All stars showed ○ forever.
starsCSV (initial value: "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0") in the main Var Table. _userSerialize() reliably returns plain scalar strings. The polling script splits on comma and converts each value to a star emoji display.
// computeStars() stores ratings as CSV
var parts = starsCSV.split(',');
parts[q] = String(newStars); // "3", "2", or "1"
starsCSV = parts.join(',');
// Polling script reads it back
var _csv = s.starsCSV.split(',');
var _n = parseInt(_csv[i]) || 0;
var _star = _n >= 3 ? '⭐⭐⭐' :
_n === 2 ? '⭐⭐' :
_n === 1 ? '⭐' : '○';
This was the most instructive bug of the project.
computeStars() was wired inside showCorrectWithMCQ(), which was called from the twoStateButtoncheck element's OffClick. It turned out that button had Display: none — it was invisible. The actual “Check 🤔” button users click is a separate, regular button element named check, and its OnClick never called computeStars() at all.
check button's OnClick at the score-recalculation block at the end: if scoreIndividual[question] === 1 (correct), call computeStars(); otherwise do attemptq++ to count the failed attempt. Single patch point, handles all 15 questions.
// Added at end of check button OnClick, before _update()
if (scoreIndividual[question] === 1) {
computeStars(); // award stars based on attemptq
} else {
attemptq++; // count wrong attempt for next try
}
computeStars(), stars finally appeared — but the streak counter showed “2 in a row” after completing just 1 molecule. computeStars() was being triggered twice per click (once from the check button, and once from the hidden twoStateButtoncheck path which could still fire under some conditions), and each call incremented streakcount.
streakcount only changes inside the if (current === 0) branch — i.e., only on the first completion of a question. Repeat calls for an already-completed question (where current > 0) become no-ops for the streak.
function computeStars() {
var current = parseInt(parts[q]) || 0;
if (current === 0) {
// First completion: update stars AND streak
parts[q] = String(newStars);
starsCSV = parts.join(',');
streakcount = (attemptq === 0) ? streakcount + 1 : 0;
} else if (newStars > current) {
// Re-done better: upgrade stars only, streak unchanged
parts[q] = String(newStars);
starsCSV = parts.join(',');
}
// Already done at same/lower level: no-op
}
After all seven rounds of iteration, the system works as follows:
_source.json.bak (clean backup, never modified)
|
v
modify_source.py (Python script, run once to apply all patches)
|
v
_source.json (upload this to WebEJS online editor)
|
v
index.html (WebEJS compiles and generates this)
Runtime flow in the browser:
Student clicks "Check"
|-- check button OnClick runs
| |-- validates electron placement
| |-- sets scoreIndividual[question] = 1 (if correct)
| |-- recalculates score
| |-- calls computeStars() [OUR PATCH]
| |-- parses starsCSV
| |-- updates CSV + streakcount (first time only)
|
Polling script (every 500ms)
|-- calls window._model._userSerialize()
|-- reads starsCSV, streakcount, question, moleculeshape, ...
|-- updates #dc-molinfo innerHTML (molecule info panel)
|-- updates #dc-progress innerHTML (star progress table)
The hidden-button bug was only found by grepping the compiled index.html and reading the view element definitions. The source JSON alone would not have revealed that twoStateButtoncheck had Display: none and that a different element named check was the real visible button.
WebEJS's _userSerialize() is designed to return simple model state for serialisation/embedding. It reliably serialises scalar strings and numbers. Array variables stored in secondary variable pages may be omitted. When bridging the model and a custom polling script, always use scalars — CSV strings, space-separated tokens, or JSON strings.
Any function that updates counters or scores may be called more than once due to event handler overlap in a complex compiled simulation. Build guard conditions into the function itself: “only do this if the state was previously zero / not yet completed”. This is far more robust than trying to audit every call site.
ensure_ascii=True
When writing JSON to a UTF-16 LE file in Python, use ensure_ascii=True. Emoji and other high-code-point characters become \uXXXX sequences in the JSON, which JavaScript decodes natively. Attempting ensure_ascii=False with surrogate-heavy characters will crash the encoder.
An ideal architecture would use WebEJS events or reactive bindings to update the DOM. In practice, the window._model._userSerialize() polling approach — 500 ms interval, with a first call at 800 ms after load — is simple, debuggable, and robust. The student never notices the delay; the simulation does not feel sluggish.
After uploading the patched _source.json and recompiling with the WebEJS editor, a student working through the simulation experiences:
“N₂’s triple bond is one of the strongest in chemistry. That is why 78% of the air you breathe does not react, keeping atmospheric conditions stable for life.” — Example “Why This Matters” text for Question 4
The total change required about 500 lines of Python in modify_source.py and zero manual editing of any JSON or HTML file. The simulation is open-source under CC-BY-SA-NC, hosted at sg.iwant2study.org, and freely usable by any teacher or student.
The biggest takeaway is not technical — it is pedagogical. A simulation that tells a student “Correct!” teaches very little. A simulation that responds with the molecular shape, the bond angle, the reason this molecule exists in your body or your food or your atmosphere, and a visible record of what you have accomplished — that is a tool that teaches.
Try the simulation:
iwant2study.org — Dot and Cross Diagram
More chemistry interactives:
sg.iwant2study.org/chemistry