Pages

Monday, March 30, 2026

From Simulation to Learning Tool: Improving a WebEJS Chemistry HTML5 App A developer's diary of pedagogical upgrades, encoding traps, hidden buttons, and star ratings that refused to appear

 ๐Ÿงช Open Educational Resources • Chemistry • HTML5 Simulations

From Simulation to Learning Tool: Improving a WebEJS Chemistry HTML5 App

A developer's diary of pedagogical upgrades, encoding traps, hidden buttons, and star ratings that refused to appear

๐Ÿ“… March 2026⏱ 12 min read๐ŸŽซ Singapore O-Level Chemistry

The Starting Point

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

What We Wanted to Add

๐ŸŽฏLearning ObjectivesCollapsible panel listing the five core O-Level outcomes for covalent bonding
๐Ÿ“‹Valence Reference TableQuick-lookup table: atom, group, electrons, and stability rule
๐Ÿ”ฌMolecule Info PanelLive VSEPR shape, bond angle, and polarity after each correct answer
๐ŸŒReal-World ContextOne paragraph explaining why each molecule matters outside the exam hall
Star Progress Tracker1–3 stars per molecule based on how many attempts it took to get it right
๐Ÿ”ฅStreak CounterMotivational feedback for consecutive first-attempt correct answers
⚠️

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.

Understanding the WebEJS Architecture

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.

The Engineering Journey: A Timeline of Struggles

Feature

1. Adding static content — the easy part

We added new String and int variables to the Var Table (whythismattersmoleculeshapebondangleshapedescriptionmoleculepolaritystreakcount) 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.

BugFix

2. Template literals don’t interpolate in Panel HTML

❌ Struggle
The first attempt used ${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.
✅ Solution
Switched to a JavaScript polling architecture: a <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;
  }
}
BugFix

3. UTF-16 + emoji = encoding crash

❌ Struggle
Writing the modified JSON back to disk with 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.
✅ Solution
Used 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)
BugFix

4. HtmlArea renders as <iframe> — and appears four times

❌ Struggle
Adding dynamic content as 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.
✅ Solution
Removed all 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.
BugFix

5. Array variables are not returned by _userSerialize()

❌ Struggle
The progress tracker used 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.
✅ Solution
Replaced both arrays with a single scalar String variable 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 ? '⭐'  : '○';
BugFix

6. The visible “Check” button was not the one we patched

This was the most instructive bug of the project.

❌ Struggle
After all the fixes above, stars still showed ○ despite completing molecules. 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.
✅ Solution
Patched the visible 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
}
BugFix

7. Streak counter doubling on every correct answer

❌ Struggle
With the check button now calling 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.
✅ Solution
Made the streak update idempotentstreakcount 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
}

The Final Architecture

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)

Key Lessons Learned

1. Read the compiled output, not just the source

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.

2. Prefer scalar variables over arrays for cross-context data

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.

3. Make side-effect functions idempotent

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.

4. UTF-16 and emoji: always use 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.

5. Polling is a pragmatic solution for read-only DOM bridging

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.

What the Students Now See

After uploading the patched _source.json and recompiling with the WebEJS editor, a student working through the simulation experiences:

  1. collapsible Learning Objectives panel that reminds them of the five things they are working toward
  2. Valence Electron Quick Reference table (collapsible) listing every atom they will encounter
  3. After each correct diagram, a Molecule Info panel appears instantly showing VSEPR shape, bond angle, and polarity
  4. real-world “Why This Matters” paragraph — for example, water’s bent polar shape creating hydrogen bonds, or nitrogen’s triple bond explaining why 78% of the air does not react
  5. live progress table showing all 15 molecules with ○ / ⭐ / ⭐⭐ / ⭐⭐⭐ ratings
  6. streak counter (“๐Ÿ”ฅ 3 in a row!”) for consecutive first-attempt successes
  7. Three new description pages: How to Use, Background Theory, and a Molecule Guide reference table covering all 15 structures with difficulty tiers
“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

Closing Thoughts

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

No comments:

Post a Comment