Building LightWrite
How a 2022 student demo became a two-way communication simulator —
including two real bugs caught and fixed.
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.
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.
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.
Before writing a line, I read the existing sketch.js and index.html.
Two problems I found immediately:
- 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.
lightUp()callstranslate()withoutpush()/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.
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.
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.
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.
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 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.
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 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
}
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.
How does Claude mode work exactly? I want to understand the timing so I can explain it to students.
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.
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.
An alternative design would have the async function call a p5 function directly. This was rejected for two reasons:
- 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. - Testability. You can test the Claude timing logic by checking what
gets added/removed from
activeLetterswithout 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.
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.
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.
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, sodraw()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.
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.
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 | What 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/*
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.
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?
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.
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.
Two principles in one exchange:
- 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
ifblock; in the refactored version it requires adding one line to the object. - Use
constfor values that do not change. This is not a style preference — it is documentation.consttells 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. Reservelet(orvarin 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.
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?
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.
Removed from three files simultaneously:
lightWrite.html— mode bar reduced to two buttonslightWriteSettings.html— mode radio group reduced to two options; description updatedlightWriteScript.js—startClaude()now always setshumanMode = false;stopClaude()always setshumanMode = true;setMode()useshumanMode = (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.
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.
Even at slow, I have trouble deciphering Claude’s message without the text window. Let’s add an ‘Extremely Slow’ setting: 2000ms?
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.
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?
Agreed — self-contained is better than cross-folder dependency. Created
LightWrite2026-06-15/images/ and copied both assets:
livingRoomXmasLights.png— canvas background (used bylightWriteSketch.js)mindFlayerBG.jpg— hero and cover page background (used bylightWriteStyles.css)
Updated three path references: preload() in lightWriteSketch.js
and both background declarations in lightWriteStyles.css.
The legacy folder’s copies are untouched.
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).