## 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 Objectives Collapsible panel listing the five core O-Level outcomes for covalent bonding
📋 Valence Reference Table Quick-lookup table: atom, group, electrons, and stability rule
🔬 Molecule Info Panel Live VSEPR shape, bond angle, and polarity after each correct answer
🌍 Real-World Context One paragraph explaining why each molecule matters outside the exam hall
⭐ Star Progress Tracker 1–3 stars per molecule based on how many attempts it took to get it right
🔥 Streak Counter Motivational 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 (`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.

Bug Fix

#### 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;
      }
    }

Bug Fix

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

Bug Fix

#### 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.
Bug Fix

#### 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 ? '⭐'  : '○';

Bug Fix

#### 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
    }

Bug Fix

#### 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 idempotent: `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
    }

## 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. A collapsible Learning Objectives panel that reminds them of the five things they are working toward
2. A 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. A 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. A live progress table showing all 15 molecules with ○ / ⭐ / ⭐⭐ / ⭐⭐⭐ ratings
6. A 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