Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 — cleared
  • this.globalDef — reset to {} (all user (def ...) variables gone)
  • this.frameCount — back to 0
  • this.onceExecuted — cleared (all (once ...) blocks re-execute)
  • this.localEnvStore — reset
  • this.melodies / this.melodyTimers — cleared
  • this.lastSecondExecutions / this.instantTriggersExecuted — cleared
  • this.layer0 — set to null when source changed
  • this.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 currentSource tracking
  • 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 globalDef user variables (like slideUpdate)
  • Preserves onceExecuted (like slideUpdate)
  • Preserves frameCount (like slideUpdate)
  • Preserves melodies and timers
  • Clears layer0.pixels for fresh visual render (like slideUpdate)
  • 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., circlebox)
  • 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-slideslideUpdate() 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#

  1. (def ...) value changes: If you change (def speed 5) to (def speed 10), the soft reload preserves the old globalDef.speed = 5. The new (def speed 10) would re-execute and overwrite it — so this actually works correctly since def is evaluated each frame.

  2. (once ...) with changed body: If you edit code inside a (once ...) block, the soft reload skips it because onceExecuted is 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 inside once. The full AST diff approach handles this by detecting structural changes inside once blocks.

  3. Error recovery: If a soft reload's parse fails, fall back to full reload to show the error state properly.

  4. $code substitution: The module() path handles $code fetching. The soft reload path should skip this (it's a live-editing context, not a code-loading context).

  5. 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.