KidLisp.com Real-Time Update: State Preservation Report#
Problem#
When editing code on kidlisp.com, any change — even tweaking a single number in an expression — triggers a full reset of the interpreter state: the graphic buffer (layer0) is cleared, all user-defined variables (globalDef) are wiped, frameCount resets to 0, (once ...) blocks re-fire, melodies restart, and timers reset. This creates a jarring experience where accumulated visual state vanishes on every keystroke.
Current Architecture#
The Two Update Paths#
KidLisp already has two distinct update mechanisms — the infrastructure for state-preserving updates exists but isn't used for normal editing:
| Path | Message Type | Interpreter Method | State Preserved? |
|---|---|---|---|
| Full Reload (current default) | kidlisp-reload |
module(source) → reset() |
No — everything wiped |
| Slide Update (drag-number only) | kidlisp-slide |
slideUpdate(source) |
Yes — variables, timers, once flags kept |
Full Reload Flow (what happens now on every edit)#
Monaco editor change
→ 1s debounce
→ updatePreview()
→ postMessage({ type: 'kidlisp-reload', code })
→ boot.mjs forwards as { type: 'piece-reload' }
→ disk.mjs calls $commonApi.reload() → $commonApi.load() → lisp.module(source)
module(source) at kidlisp.mjs:3466 calls reset() at kidlisp.mjs:1999, which destroys:
this.ast— clearedthis.globalDef— reset to{}(all user(def ...)variables gone)this.frameCount— back to 0this.onceExecuted— cleared (all(once ...)blocks re-execute)this.localEnvStore— resetthis.melodies/this.melodyTimers— clearedthis.lastSecondExecutions/this.instantTriggersExecuted— clearedthis.layer0— set tonullwhen source changedthis.bakes— cleared when source changed- Performance caches (
functionCache,mathCache, etc.) — cleared
Slide Update Flow (what already works for number dragging)#
Slide mode drag
→ postMessage({ type: 'kidlisp-slide', code })
→ boot.mjs forwards as { type: 'piece-slide' }
→ disk.mjs calls lisp.slideUpdate(source)
slideUpdate(source) at kidlisp.mjs:1895 is much lighter:
- Re-parses source into fresh AST
- Updates
currentSourcetracking - Clears
layer0.pixels(fills with 0 for fresh render) - Clears bake layers
- Preserves:
globalDef,onceExecuted,frameCount, timers, melodies, event handlers
Proposed Solution: Smart Reload#
Core Idea#
Instead of always sending kidlisp-reload, the editor should detect the nature of a change and choose the appropriate update path. A "minor edit" (changing a value within an existing expression) should use the slide-like path; a "structural edit" (adding/removing expressions, changing function names) should use the full reload.
Implementation Plan#
1. New Message Type: kidlisp-smart-reload#
Add a third message type that carries metadata about the change:
// In kidlisp.com index.html — updatePreview()
sendToIframe({
type: 'kidlisp-smart-reload',
code: code,
changeType: detectChangeType(lastSentCodeSource, code), // 'value' | 'structural'
codeId: codeId,
createCode: needsNewCode,
authToken: acToken,
enableTrace: true
});
2. Change Detection in the Editor#
Add an AST-level diff function to the editor. Parse both old and new source (KidLisp's parser is fast) and compare structure:
function detectChangeType(oldSource, newSource) {
if (!oldSource || !newSource) return 'structural';
try {
// Use KidLisp's parser (or a lightweight copy) to get ASTs
const oldAST = parseKidLisp(oldSource);
const newAST = parseKidLisp(newSource);
// If expression count changed → structural
if (oldAST.length !== newAST.length) return 'structural';
// Walk both ASTs comparing structure (function names, nesting)
// but ignoring literal values (numbers, strings, colors)
if (structureMatches(oldAST, newAST)) return 'value';
return 'structural';
} catch (e) {
return 'structural'; // Parse error → full reload to show error
}
}
function structureMatches(a, b) {
if (typeof a !== typeof b) return false;
if (typeof a === 'number' || typeof a === 'string') {
// Both are atoms — if they're function names, compare; if numeric values, skip
if (typeof a === 'number' && typeof b === 'number') return true; // values can differ
// For strings: color literals (#fff), number-like strings are "values"
if (isValueLiteral(a) && isValueLiteral(b)) return true;
return a === b; // function names must match
}
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((el, i) => structureMatches(el, i === 0 ? b[0] : b[i]));
}
3. Interpreter: New softReload(source) Method#
Add a method between module() and slideUpdate() that:
- Re-parses the source (like
slideUpdate) - Preserves
globalDefuser variables (likeslideUpdate) - Preserves
onceExecuted(likeslideUpdate) - Preserves
frameCount(likeslideUpdate) - Preserves melodies and timers
- Clears
layer0.pixelsfor fresh visual render (likeslideUpdate) - Also runs precompile on the new AST (unlike current
slideUpdate) - Updates performance caches that depend on AST structure
// New method in kidlisp.mjs KidLisp class
softReload(source) {
if (!source) return;
try {
const parsed = this.parse(source);
// Deep copy + precompile (like module() does)
this.ast = JSON.parse(JSON.stringify(parsed));
this.precompile(this.ast);
this.currentSource = source;
// Clear visual state for fresh render
if (this.layer0 && this.layer0.pixels) {
this.layer0.pixels.fill(0);
}
if (this.bakes) {
this.bakes = [];
this.currentBakeIndex = -1;
}
// Clear caches that depend on AST shape
this.functionCache.clear();
this.globalEnvCache = null;
// Preserve: globalDef, onceExecuted, frameCount,
// melodies, timers, localEnvStore, tapper, drawer
} catch (e) {
console.warn('⚡ Soft reload parse error:', e.message);
}
}
4. Wire It Up in boot.mjs and disk.mjs#
boot.mjs — handle the new message:
} else if (event.data?.type === "kidlisp-smart-reload") {
const { code, changeType, codeId, createCode, authToken, enableTrace } = event.data;
window.__acCurrentKidlispCode = code;
if (changeType === 'value') {
// Value-only change → soft reload (preserve state)
window.acSEND({
type: "piece-soft-reload",
content: { source: code }
});
} else {
// Structural change → full reload
window.acSEND({
type: "piece-reload",
content: { source: code, codeId, createCode, authToken, enableTrace }
});
}
}
disk.mjs — handle the new internal message:
if (type === "piece-soft-reload") {
if (content.source) {
lisp.softReload(content.source);
}
return;
}
Key Files to Modify#
| File | Change |
|---|---|
| kidlisp.mjs | Add softReload() method (~30 lines) |
| boot.mjs | Handle kidlisp-smart-reload message (~15 lines) |
| disk.mjs | Handle piece-soft-reload message (~5 lines) |
| index.html | Add detectChangeType() + modify updatePreview() (~60 lines) |
What Gets Preserved (Value Changes)#
| State | Preserved? | Notes |
|---|---|---|
globalDef (user variables) |
Yes | (def x 10) keeps its value |
frameCount |
Yes | Animations continue from current frame |
onceExecuted |
Yes | (once ...) blocks don't re-fire |
melodies / melodyTimers |
Yes | Music keeps playing |
tapper / drawer |
Yes | Input handlers persist |
layer0 pixels |
No — cleared | Fresh visual render with new values |
bakes |
No — cleared | Baked composites regenerate |
| AST + precompiled data | No — rebuilt | New code takes effect |
| Performance caches | No — cleared | Rebuild from new AST |
What Triggers Full Reload (Structural Changes)#
- Adding or removing a top-level expression
- Changing a function name (e.g.,
circle→box) - Changing expression nesting depth
- Adding/removing arguments to a function call
- Parse errors (to display the error state)
Quick Win: Simpler Alternative#
If the full smart-reload approach feels too complex initially, there's a simpler first step:
Just use slideUpdate for all edits when the expression count hasn't changed.
In the onDidChangeModelContent handler at index.html:16804, instead of always calling updatePreview() (which sends kidlisp-reload), add a lightweight top-level expression count check:
// In the debounced handler
const oldParens = countTopLevelExpressions(lastCode);
const newParens = countTopLevelExpressions(currentCode);
if (oldParens === newParens && oldParens > 0) {
// Same structure → slide update (preserve state)
sendToIframe({ type: 'kidlisp-slide', code: currentCode });
} else {
// Different structure → full reload
updatePreview();
}
lastCode = currentCode;
function countTopLevelExpressions(code) {
if (!code) return 0;
let count = 0, depth = 0;
for (const ch of code) {
if (ch === '(') { if (depth === 0) count++; depth++; }
else if (ch === ')') depth--;
}
return count;
}
This doesn't require any interpreter changes — it reuses the existing kidlisp-slide → slideUpdate() path. It's less precise (won't detect when you change a function name without changing structure) but covers the most common case: tweaking a number or color value.
Edge Cases to Handle#
-
(def ...)value changes: If you change(def speed 5)to(def speed 10), the soft reload preserves the oldglobalDef.speed = 5. The new(def speed 10)would re-execute and overwrite it — so this actually works correctly sincedefis evaluated each frame. -
(once ...)with changed body: If you edit code inside a(once ...)block, the soft reload skips it becauseonceExecutedis preserved. This is the desired behavior for value tweaks (you don't want initialization to re-run), but might surprise users who change structural code insideonce. The full AST diff approach handles this by detecting structural changes insideonceblocks. -
Error recovery: If a soft reload's parse fails, fall back to full reload to show the error state properly.
-
$codesubstitution: Themodule()path handles$codefetching. The soft reload path should skip this (it's a live-editing context, not a code-loading context). -
Resolution changes:
(half),(third),(fourth)affect canvas size. If these are added/removed, a full reload is needed. The expression-count heuristic catches this naturally.
Recommendation#
Start with the Quick Win (expression-count heuristic using existing kidlisp-slide). It's ~20 lines of editor-side code, zero interpreter changes, and covers the primary use case: tweaking values while preserving accumulated graphics and animation state. Graduate to the full softReload() approach later if finer-grained control is needed.