Back to LightWrite S.P.A.R.K. Chat Log  •  06/15/2026 Open App
S.P.A.R.K. with AI — Development Dialog

Building LightWrite

How a 2022 student demo became a two-way communication simulator —
including two real bugs caught and fixed.

For Students — How to Read This Page

This is the real conversation that rebuilt the 2022 Stranger Things light app. Watch for S.P.A.R.K. banners that label each stage, Prompt Critique boxes analysing what made each request strong or weak, and Design Decision boxes explaining technical choices.

Two real bugs — a push/pop accumulation error and an aspect-ratio distortion — were caught during this session. Both are documented here so you can learn to spot them yourself.

🎯
S — Set Goal Identify what you want to build and why. The original app existed; the goal here was to extend it meaningfully, not just port it.
klp

A student wrote a simulation of the phenomena in Stranger Things where light bulbs turn on to spell words, one letter at a time. I want to create a similar HTML/CSS/Bootstrap 5/JavaScript p5.js app that does similarly, but let’s have the communication go two ways: a user can type on the keyboard to turn on letters, but imagine if you, Claude, are in the Upside Down and want to communicate with us. I’ll provide a set of words/phrases we often use at TNT you can select randomly to spell out.

The app is ‘LightWrite’ and has a subfolder: LightWrite2026. Files: lightWrite.html, lightWriteSketch.js, lightWriteScript.js, lightWriteStyles.css, and lightWriteSettings.html. If we have lightWriteCover.html we can use the mindFlayerBG image. I have provided the livingRoomXmasLights canvas background picture. Adjust the canvas size so there is no visual distortion. This app will be part of our S.P.A.R.K. development series as well as a Processing and Simulation app in explore.html. I really like the chatlog you developed for Yahtzee — it’s included here as an example to follow.

Prompt Critique — Rich and Comprehensive

This is an excellent scope-setting prompt. Let’s break it down:

  • States the starting artifact clearly — “A student wrote a simulation…” establishes there is existing code to study and respect.
  • Defines the extension precisely — two-way communication: human keyboard input and Claude autonomous mode. That’s a clear product requirement, not a vague wish.
  • Names all the files up front — specifying lightWriteSketch.js, lightWriteScript.js, etc. saves multiple back-and-forth rounds about structure.
  • Flags the distortion issue proactively — “adjust the canvas size so there is no visual distortion” is a technical constraint that would have been caught at review time anyway. Flagging it early saves a rebuild.
  • Points to a style template — “I really like the chatlog you developed for Yahtzee” gives the AI a concrete format to match. No ambiguity about tone, structure, or depth.

What could be stronger: The folder name “LightWrite2006” is a likely typo for “LightWrite2026”. On a longer project, ambiguous names cause extra round-trips. Small but worth catching.

GitHub Copilot

Before writing a line, I read the existing sketch.js and index.html. Two problems I found immediately:

  1. The canvas is square (500×500) but the image is 959×598 (ratio 1.604:1). The background is being horizontally compressed by about 18%. Every letter position is consequently mis-mapped. This is the distortion you flagged.
  2. lightUp() calls translate() without push()/pop(). Each call adds to the current transform. When multiple letters need to glow simultaneously (required for two-way mode), the second letter is drawn at Position A + Offset B, not at Position B. The original works only because it lights one letter per frame via a key-held check.

Both are fixed in the 2026 rebuild. The naming will be LightWrite2026-06-15/ to match TNT’s date-stamped convention.

Design Decision — Read Before You Write

Both bugs were invisible from the original description. The aspect-ratio issue only shows if you check the image dimensions; the push/pop issue only surfaces when more than one letter is active simultaneously — which the original never does. An AI-assisted rebuild that does not read the original code first will inherit its bugs. Reading the existing files was the prerequisite, not an optional courtesy.

Architecture & Two-Way Design
💬
P — Prompt Focused follow-up prompts that address one design question each. The two-way architecture required clear answers before a single file was created.
klp

For the name, I earlier asked Ask Copilot for suggestions and the top pick was LightWrite — clean, intuitive, works as a page title, a function name (lightWrite("HELP")), and a class name. Let’s use that.

Prompt Critique — Cross-Session Reference

This is a clean, authoritative decision prompt: it names the choice, cites where the decision was made (Ask Copilot), and explains why (works as title, function, and class). No ambiguity, no debate required. Cross-referencing prior sessions like this is a good habit — it keeps context from being re-litigated in the current conversation.

GitHub Copilot

Good choice. The function-name test is a useful one: a name that works as both lightWrite.html and lightWrite(message, delayMs) stays consistent across the codebase and is self-documenting at every occurrence. Going with LightWrite throughout.

The Aspect-Ratio Fix
GitHub Copilot

The aspect-ratio fix is two lines of arithmetic:

var IMG_W = 959;
var IMG_H = 598;

/* In setup(): */
_cw = Math.min(container.clientWidth, 700);
_ch = Math.floor(_cw * IMG_H / IMG_W);   /* = _cw × 0.6236 */

For a 700px container, the canvas becomes 700×436 instead of 500×500. The letter coordinates scale proportionally:

/* In _lightUp(): */
var px = x * _cw / IMG_W;
var py = y * _ch / IMG_H;

Every glow lands exactly on its bulb in the background image, at every viewport width.

Design Decision — Aspect Ratio Is a Data Constraint, Not a Style Choice

The square canvas was not a stylistic decision; it was an uncaught assumption. When the image is 959×598 and the canvas is 500×500, the image is stretched horizontally by a factor of 500/959 ÷ 500/598 ≈ 1.19. The letter coordinates were measured on the unstretched image, so they land in the wrong place on the stretched one.

The lesson: any time you place elements at coordinates measured from an asset (image, video, map), the canvas dimensions must match the asset’s aspect ratio. If they do not, the coordinate system is wrong for every element, and the error accumulates with every pixel of distortion.

The push/pop Bug
🔍
A — Analyze Before accepting the original code, analyze it for correctness. Both bugs below were found by reading the source, not by running tests.
GitHub Copilot

The original lightUp():

function lightUp(x, y, clr){
    translate(x*width/959, y*height/598);   // ← no push() before this
    fill(255, 50, 30, 25);
    for(let i = 0; i < 40; i+=2){ circle(0, 0, i); }
    // ← no pop() here
}

In p5.js, translate() moves the coordinate origin relative to its current position. Without push() and pop(), each call to lightUp() moves the origin further in the direction of the last translation. If lightUp() is called twice in one frame (letters A and B both active), letter B is drawn at:

origin + A.offset + B.offset   /* wrong */

instead of:

origin + B.offset               /* correct */

The fix: wrap every lightUp() in push()/pop(), which saves and restores the transform matrix around each call.

function _lightUp(x, y, clr) {
    push();                              // ← save transform
    translate(x * _cw / IMG_W,
              y * _ch / IMG_H);
    /* draw glow rings... */
    pop();                               // ← restore transform
}
Design Decision — push/pop Is Not Defensive; It Is Correct

Students sometimes think push()/pop() is “defensive coding” — a safety habit for when things might go wrong. It is actually the only correct pattern for any function that modifies the transform state (translate, rotate, scale) and is called more than once per frame.

The original code “worked” because keyIsDown() was checked inside draw() and only one condition could be true at a time in practice — but that was an accidental constraint, not a design one. The moment two letters are active (which two-way mode requires by definition), the bug manifests.

Rule: if a function uses translate(), rotate(), or scale(), it must begin with push() and end with pop(), always, regardless of whether you currently call it more than once per frame.

Claude Mode Architecture
🔁
R — Refine The Claude-mode architecture required several deliberate choices. Each is documented below because each is a teaching point in its own right.
klp

How does Claude mode work exactly? I want to understand the timing so I can explain it to students.

Prompt Critique — Explanation-First Prompt

Clean and intentional. Asking “how does this work?” rather than “make it work” is the right question when you need to explain code to students. The answer forces the AI to document the mechanism, which becomes a ready-made teaching resource. This is the K (Know) habit applied mid-session.

GitHub Copilot

Claude mode uses async/await with a minimal sleep() utility:

function sleep(ms) {
    return new Promise(function(resolve) { setTimeout(resolve, ms); });
}

For each phrase, _claudeTypePhrase() loops through every character:

activeLetters.add(ch);      // light the bulb
await sleep(ms);            // hold it lit
activeLetters.delete(ch);   // extinguish it
await sleep(ms * 0.35);     // brief dark gap

The p5.js draw() loop runs at 30fps independently of this. It simply reads the activeLetters Set each frame and lights up whatever is in it. The async function and the draw loop share state through activeLetters — they run concurrently without interfering.

This is the producer/consumer pattern: the async function produces active letters; the draw loop consumes them visually. Neither knows about the other’s timing.

Design Decision — Shared State Over Callbacks

An alternative design would have the async function call a p5 function directly. This was rejected for two reasons:

  1. Separation of concerns. The sketch (lightWriteSketch.js) knows how to draw. The controller (lightWriteScript.js) knows what to communicate. Neither should know about the other’s internals.
  2. Testability. You can test the Claude timing logic by checking what gets added/removed from activeLetters without running p5 at all.

The shared Set is the deliberate coupling point — minimal, clearly named, and easy to reason about. Both files declare their relationship to it in comments at the top.

Post-Launch Bug — Wrong Image Path
🔁
R — Refine (Field Test) A third bug surfaced the first time the app was loaded in a browser. This one was not found by reading code — it required a live test to trigger. This is why the Refine stage matters even after the Analyze stage.
klp

When I load lightWrite.html, I never see a canvas, even when I select ‘Lights Only’ in the settings. At the bottom of the page there is a ‘Loading…’ message but nothing ever happens.

Prompt Critique — Good Problem Report

Clean symptom description: what was expected (canvas), what appeared instead (Loading…), and the observation that nothing changes. The detail about “Lights Only” is useful because it rules out the text-display code as the cause. One improvement: opening DevTools (F12) and reporting the console error would have pointed directly at the failing resource and shortened the diagnosis. When an app hangs silently, the console is always the first place to look.

GitHub Copilot

The “Loading…” div is p5.js’s built-in preload indicator. It shows while preload() is running and disappears only when every loadImage() call completes. When one fails silently (no error callback), p5.js hangs in preload indefinitely — setup() never runs, so no canvas is ever created.

The broken call was:

loadImage('images/livingRoomXmasLights.png')   // relative to LightWrite2026-06-15/

That folder has no images/ subfolder. The PNG lives in the legacy sThings folder. The correct path:

'../_LegacyTNT/ProcessingShowcase/sThings2022-11-11/images/livingRoomXmasLights.png'

Two additional changes were made alongside the path fix:

  • Added a success callback that sets _imgLoaded = true, so draw() knows the image is ready.
  • Added an error callback that logs a warning and lets the app continue — the canvas now renders with a dark fallback background if the image is ever missing, instead of hanging silently.
Design Decision — Fail Gracefully, Never Silently

The original loadImage() call with no error callback is a pattern that fails invisibly. p5.js does not throw an exception when an image 404s — it simply never resolves the preload promise. Adding function() { console.warn('…'); } as the third argument gives you a visible signal in DevTools instead of an app that appears to be thinking forever.

The lesson: every file-loading call in a browser app should have both a success callback and an error callback. This applies to fetch(), XMLHttpRequest, dynamic <img> elements, and p5.js loadImage() alike. Graceful failure is a professional expectation, not an optional polish.

The third take-away for students (updating the K stage): the path to an asset is code. A wrong path is a bug, even though it contains no syntax errors and no logic errors. A browser cannot tell you “this file does not exist” in a way that crashes the application — it silently returns a 404. Testing on the live file system is the only way to catch path bugs. Always test a new app in a browser before declaring it done.

Updated Takeaway — Four Things Every Student Should Know

1. push()/pop() is not optional. (see above)

2. Aspect ratio is a coordinate contract. (see above)

3. Shared state is a valid integration pattern. (see above)

4. The path to an asset is code — and a wrong path is a silent bug. Unlike a typo in JavaScript, a bad file path produces no syntax error and no crash. The browser returns a 404 silently. The only defences are: (a) always test in a real browser, (b) always add error callbacks to asset-loading calls, and (c) open DevTools before declaring an app done. The third bug in this project was invisible until the first browser test. That is not a failure of code review — it is a reminder that code review and browser testing are different tools that catch different categories of error.

File Structure & Integration
💡
K — Know Consolidate what was built, how it integrates into the TNT site, and what students should take away.
What This Session Produced
FileWhat it does
lightWriteSketch.js p5.js sketch: correct aspect ratio, push/pop fix, improved glow rings, shares activeLetters and humanMode globals
lightWriteScript.js Controller: 33 TNT phrases, async Claude loop, mode switching, settings persistence (localStorage)
lightWriteStyles.css Upside Down dark theme: blood-red accents, glow text shadow, canvas wrapper, mode bar, received text display, settings page
lightWrite.html Main app: mode selector, p5 canvas, received text display, Claude controls, collapsible instructions
lightWriteCover.html Cover page: mindFlayer background, About section, links to all sub-pages including legacy 2022 original
lightWriteSettings.html Settings: mode, speed, show/hide text, custom phrase, built-in phrase list; persisted to localStorage
lightWriteSPARKchatlog.html This document — full S.P.A.R.K. development log including both bugs
explore.html S.P.A.R.K., Processing, and Simulations offcanvases updated; Processing card features LightWrite
news.html Entry 016 added for LightWrite

Original files untouched: _LegacyTNT/ProcessingShowcase/sThings2022-11-11/*

Three Things Every Student Should Take Away

1. push()/pop() is not optional. Any p5.js function that calls translate(), rotate(), or scale() must wrap its body in push()/pop(). Every time. No exceptions. The original code worked accidentally; the correct code works by design.

2. Aspect ratio is a coordinate contract. When you place elements at coordinates measured from an asset, the canvas must match that asset’s proportions. A square canvas for a landscape image is a bug, even if nothing looks obviously wrong at first glance.

3. Shared state is a valid integration pattern. The p5.js sketch and the controller script are coupled through exactly two variables: activeLetters and humanMode. That minimal, well-named interface keeps both files independently readable and testable. When you design a shared-state boundary, name it clearly and document it at both ends.

Post-Launch Refinements
🔁
R — Refine (Continued) Several improvements were made after the initial launch. Each is documented here because each teaches a distinct design or engineering lesson.
klp

I’m curious about how you identified the coordinates of the lightbulbs — how did you discover the LETTER_DATA? And why is it var and not const?

Prompt Critique — Two Questions, Both Worth Asking

Both questions are excellent and address different layers of the code: the first is about how domain knowledge was obtained (a process question), and the second is about correct use of a JavaScript keyword (a correctness question). Bundling related questions like this is efficient, as long as both are about the same file or topic — which they are here.

GitHub Copilot

Where do the coordinates come from?

They come directly from the original sketch.js written by David M. in 2022. The original code has a series of if(keyIsDown(65)) lightUp(240, 157, "grn"); lines — one per letter, A through Z. Those numbers were measured by David by clicking on the actual livingRoomXmasLights.png image and recording the pixel position of each physical Christmas light bulb in the photograph, in the original 959×598 coordinate space.

In the 2026 rebuild, I moved those 26 hard-coded if statements into a single LETTER_DATA object so the coordinate and color for each letter can be looked up by key rather than checked in sequence. The data is identical; only the structure changed. This is the data-over-logic refactoring pattern: if you have 26 nearly-identical blocks of code that differ only in their values, replace them with a data structure and one loop or lookup.

/* Original: 26 separate if-blocks */
if(keyIsDown(65)) lightUp(240, 157, "grn");
if(keyIsDown(66)) lightUp(322, 161, "blu");
...

/* Refactored: one lookup */
var data = LETTER_DATA[ch];
if (data) { _lightUp(data.x, data.y, data.c); }

Why var instead of const?

That was an oversight — and a good catch. LETTER_DATA is never reassigned. It is defined once and only read thereafter. const is the correct declaration for any value that will not be reassigned. Using var here signals “this might change” to a reader, which is misleading. It has been corrected to const.

Note that const on an object does not make the object’s contents immutable — you could still add or change a property. It only prevents the variable from being rebound to a different value. That is sufficient here: no code in LightWrite ever tries to replace LETTER_DATA with a new object.

Design Decision — Data Over Logic + const Over var

Two principles in one exchange:

  1. Data over logic. When you have N similar code blocks that differ only in their values, replace them with a data structure (array or object) and a single algorithm. The result is shorter, easier to extend, and much easier to test. Adding a new letter to the original app required adding an if block; in the refactored version it requires adding one line to the object.
  2. Use const for values that do not change. This is not a style preference — it is documentation. const tells the reader and the JavaScript engine that this binding will not change. The engine can optimize accordingly, and the reader does not have to scan for reassignment. Reserve let (or var in older code) for values that genuinely change during execution.

The original David M. code used var throughout — which was standard practice in 2022 and still works. The 2026 rebuild upgrades the top-level constants to const where appropriate.

Design Decision — Removing “Both” Mode
klp

I don’t see the value of having a ‘Both’ option. Can you remove it and make sure the app functions properly with each singleton modality?

Prompt Critique — Clean Feature Removal Request

Concise and decisive. “I don’t see the value” is a legitimate product reason to remove a feature — you don’t need to justify it further. The instruction “make sure the app functions properly with each singleton modality” is a useful constraint: it asks for removal and verification, not just deletion. This is good prompting habit: specify what must still work after the change.

GitHub Copilot

Removed from three files simultaneously:

  • lightWrite.html — mode bar reduced to two buttons
  • lightWriteSettings.html — mode radio group reduced to two options; description updated
  • lightWriteScript.jsstartClaude() now always sets humanMode = false; stopClaude() always sets humanMode = true; setMode() uses humanMode = (newMode === 'human')

The previous implementation had a subtle bug in “Both” mode where Claude’s extinguish logic tested activeLetters.has(ch) — which was always true because Claude had just added the letter. This caused every Claude-typed letter to stay permanently lit. That bug was separately fixed before Both was removed. Both the bug and the simplification are documented here because the bug is instructive: it is an example of a condition that is logically vacuous — always true by construction.

Design Decision — Fewer Modes Is Cleaner

The original three-mode design (Human / Claude / Both) was added speculatively. “Both” turned out to be confusing to use and introduced a non-trivial coordination problem between the async Claude loop and the synchronous keyboard handler. Removing it simplified the codebase, eliminated the bug surface, and left two modes that are both clearly defined and independently testable. The lesson: features that seem like they might be useful are not always worth the complexity they introduce. Ship the minimum and add only what earns its place.

Refinement — Speed Options & Asset Self-Containment
klp

Even at slow, I have trouble deciphering Claude’s message without the text window. Let’s add an ‘Extremely Slow’ setting: 2000ms?

GitHub Copilot

Added. The speed map is now:

var SPEED_MS = { xslow: 2000, slow: 400, medium: 180, fast: 85 };

“Extremely Slow” appears first in the settings radio group because it is the most useful for first-time users trying to read letters on the wall without the text display.

klp

The images for this app should be in an images subfolder inside the root folder for the app rather than in a separate legacy folder. Can you move them?

GitHub Copilot

Agreed — self-contained is better than cross-folder dependency. Created LightWrite2026-06-15/images/ and copied both assets:

  • livingRoomXmasLights.png — canvas background (used by lightWriteSketch.js)
  • mindFlayerBG.jpg — hero and cover page background (used by lightWriteStyles.css)

Updated three path references: preload() in lightWriteSketch.js and both background declarations in lightWriteStyles.css. The legacy folder’s copies are untouched.

Design Decision — Self-Contained Apps Are Portable

An app that references assets in a sibling folder creates an invisible dependency: if the sibling folder is moved, renamed, or deleted, the app breaks silently. Keeping assets inside the app’s own folder makes the app independently portable — copy the folder anywhere and it works. This is the same principle that motivates keeping CSS, scripts, and images in subfolders within a project rather than shared at a higher level, except when sharing is intentional and documented (as it is for TNT’s root-level styles/ and scripts/ folders).