Monorepo for Aesthetic.Computer aesthetic.computer

Plan: L5 (Processing-style Lua) Support on Aesthetic Computer#

Context#

AC supports two piece languages: JavaScript (.mjs) and KidLisp (.lisp). We want to add Lua (.lua) as a third, enabling L5/Processing-style sketches to run natively in the browser. This uses Wasmoon — a Lua 5.4 VM compiled to WebAssembly (~130KB gzipped) — following the exact same integration pattern as KidLisp.

L5 is a Processing library for Lua (runs on Love2D desktop). We're not porting L5 itself — we're implementing the Processing API surface in Lua, bridged to AC's drawing primitives via Wasmoon's JS↔Lua interop.


Files to Create/Modify#

File Action
system/public/aesthetic.computer/dep/wasmoon/ Create — vendor Wasmoon ESM + WASM binary
system/public/aesthetic.computer/lib/l5.mjs Create — Lua runtime adapter (~400 lines)
system/public/aesthetic.computer/lib/disk.mjs Modify — import l5, add .lua detection + fallback
system/public/aesthetic.computer/disks/l5-hello.lua Create — test piece

Step 1: Vendor Wasmoon#

Install wasmoon via npm and copy the ESM bundle + WASM binary into dep/wasmoon/. The dep/ directory already holds vendored packages (three, gl-matrix, wasmboy, etc.).

dep/wasmoon/
  index.mjs        # ESM entry (LuaFactory, LuaEngine exports)
  glue.wasm        # Lua 5.4 VM compiled to WebAssembly

The WASM URL is constructed relative to the module:

const WASM_URL = new URL("../dep/wasmoon/glue.wasm", import.meta.url).href;

Step 2: Create lib/l5.mjs — The Lua Runtime Adapter#

Follows the KidLisp singleton pattern. Exports one function: module(source) → returns { boot, paint, sim, act, leave }.

2.1 Architecture#

l5.mjs
  ├── ensureFactory()     — lazy-init Wasmoon LuaFactory singleton
  ├── module(source)      — main export: compile Lua → lifecycle object
  ├── createDrawingState()— fill/stroke/transform state manager
  ├── injectConstants()   — PI, TWO_PI, CENTER, CORNER, etc.
  ├── injectMathGlobals() — sin, cos, random, lerp, map, dist, noise, etc.
  ├── injectDrawingAPI()  — background, fill, stroke, rect, circle, line, text, etc.
  ├── updateInputGlobals()— mouseX, mouseY, width, height, frameCount per frame
  └── dispatchEvent()     — AC events → Lua callbacks (keyPressed, mousePressed, etc.)

2.2 Drawing State#

Processing has stateful fill/stroke that AC doesn't — the adapter tracks:

{
  fillColor: [255,255,255,255],   // current fill RGBA
  strokeColor: [0,0,0,255],       // current stroke RGBA
  fillEnabled: true,               // noFill() toggles
  strokeEnabled: true,             // noStroke() toggles
  strokeWeight: 1,
  colorMode: "RGB",                // RGB | HSB | HSL
  colorMax: [255,255,255,255],     // for colorMode scaling
  rectMode: "CORNER",              // CORNER | CENTER | CORNERS | RADIUS
  ellipseMode: "CENTER",
  textSizeVal: 8,
  textAlignH: "LEFT",
  // Transform stack for push()/pop()
  currentTransform: { tx: 0, ty: 0 },
  transformStack: [],
  styleStack: [],
}

Shape drawing applies fill then stroke:

function drawShape($, state, fillFn, strokeFn) {
  if (state.fillEnabled) {
    $.ink(...state.fillColor);
    fillFn($);
  }
  if (state.strokeEnabled) {
    $.ink(...state.strokeColor);
    strokeFn($);
  }
}

2.3 Lifecycle Bridge#

L5 setup()          → AC boot($)    — called once
L5 draw()           → AC paint($)   — called every frame
L5 keyPressed() etc → AC act($)     — dispatched per event
L5 sim() (custom)   → AC sim($)     — if defined in Lua

Before each paint() call, input globals are updated:

engine.global.set("mouseX", $.pen?.x ?? 0);
engine.global.set("mouseY", $.pen?.y ?? 0);
engine.global.set("width", $.screen?.width ?? 128);
engine.global.set("height", $.screen?.height ?? 128);
engine.global.set("frameCount", frameCount++);

2.4 Async Note#

Unlike lisp.module() which is synchronous, l5.module() is async (Wasmoon engine creation requires await). The loading path in disk.mjs already uses await for dynamic imports, so this fits naturally.


Step 3: Full L5 → AC API Mapping#

Lifecycle#

L5 Function AC Equivalent Notes
setup() boot($) Runs once on load
draw() paint($) Every frame
keyPressed() act($) on keyboard:down:*
keyReleased() act($) on keyboard:up:*
mousePressed() act($) on touch
mouseReleased() act($) on lift
mouseMoved() act($) on move
mouseDragged() act($) on draw
mouseClicked() Not mapped v1 Need tap detection
mouseWheel() Not mapped v1

Screen & Background#

L5 AC Notes
background(r,g,b) $.wipe(r,g,b) Also supports single gray value
clear() $.wipe(0,0,0,0) Transparent clear
size(w,h) $.resolution(w,h) Set canvas resolution

Color State#

L5 AC Notes
fill(r,g,b,a) Sets state.fillColor, applied via $.ink() before each shape
noFill() state.fillEnabled = false
stroke(r,g,b,a) Sets state.strokeColor
noStroke() state.strokeEnabled = false
strokeWeight(w) state.strokeWeight = w Used for line thickness
colorMode(mode, max...) Internal state Converts HSB/HSL → RGB for $.ink()
lerpColor(c1,c2,t) num.blend(c1,c2,t) or manual lerp
color(r,g,b,a) Returns [r,g,b,a] table Used as color value
red(c) / green(c) / blue(c) / alpha(c) Extract from color array

Shape Drawing#

L5 AC Notes
point(x,y) $.plot(x,y) Uses stroke color
line(x1,y1,x2,y2) $.line(x1,y1,x2,y2) Uses stroke color
rect(x,y,w,h) $.box(x,y,w,h) filled + $.box(x,y,w,h,"outline") stroked Respects rectMode
square(x,y,s) Delegates to rect(x,y,s,s)
circle(x,y,d) $.circle(x,y,d/2) L5 uses diameter, AC uses radius
ellipse(x,y,w,h) $.oval(x,y,w/2,h/2) Respects ellipseMode
triangle(x1,y1,...) $.tri(x1,y1,x2,y2,x3,y3)
quad(x1,y1,...x4,y4) $.poly([x1,y1,...])
arc(x,y,w,h,start,stop) $.pie(x,y,w/2,start,stop) Approximate
beginShape() / vertex(x,y) / endShape(mode) Collect vertices → $.shape(verts) or $.poly(verts)

Text#

L5 AC Notes
text(str,x,y) $.write(str, x, y) Uses fill color
textSize(s) state.textSizeVal = s AC bitmap font is fixed; pass as scale option
textAlign(h,v) state.textAlignH/V Internal tracking
textWidth(str) $.text.width(str)

Transforms#

L5 AC Notes
translate(x,y) state.currentTransform.tx += x Coordinate offset applied to all shapes
push() state.push() Saves transform + style state
pop() state.pop() Restores transform + style state
resetMatrix() Reset transform to {tx:0, ty:0}
rotate(angle) Not supported v1 Would need software matrix transform
scale(sx,sy) Not supported v1 Same limitation

Math#

L5 AC / JS Notes
abs, ceil, floor, round, sqrt, pow, exp, log Math.* Direct delegation
sq(x) x * x
min, max Math.min/max
sin, cos, tan, asin, acos, atan, atan2 Math.*
radians(deg) deg * PI / 180 Also in num.radians
degrees(rad) rad * 180 / PI Also in num.degrees
random(min?, max?) Math.random() scaled
constrain(v,lo,hi) num.clamp(v,lo,hi)
dist(x1,y1,x2,y2) num.dist(x1,y1,x2,y2)
lerp(a,b,t) num.lerp(a,b,t)
map(v,iL,iH,oL,oH) num.map(v,iL,iH,oL,oH)
noise(x,y?,z?) num.perlin(x,y) z dimension ignored v1
norm(v,lo,hi) (v-lo)/(hi-lo)
fract(x) x - Math.floor(x)
randomSeed(s) No-op v1
noiseSeed(s) No-op v1
randomGaussian() Box-Muller transform

Input Globals (updated each frame)#

L5 Source Notes
mouseX, mouseY $.pen.x, $.pen.y
pmouseX, pmouseY Previous frame's pen position Tracked internally
movedX, movedY mouseX - pmouseX Computed
mouseIsPressed $.pen.drawing
mouseButton From event data LEFT/RIGHT/CENTER
key From keyboard event Last key pressed
keyCode Char code of key
keyIsPressed Any key currently held Tracked in keyState map
width, height $.screen.width/height
frameCount Internal counter
deltaTime Computed from frame timing
focused true Always focused in AC

Constants#

L5 Value
PI Math.PI
HALF_PI Math.PI / 2
QUARTER_PI Math.PI / 4
TWO_PI / TAU Math.PI * 2
DEGREES / RADIANS Mode strings
RGB / HSB / HSL Color mode strings
LEFT / CENTER / RIGHT Alignment strings
TOP / BOTTOM / BASELINE Vertical alignment
CORNER / CORNERS / RADIUS Shape mode strings
CLOSE For endShape(CLOSE)

Environment#

L5 AC Notes
frameRate(fps) $.fps(fps)
cursor() / noCursor() $.cursor()
loop() / noLoop() / isLooping() Internal flag; skip draw() calls when noLoop
redraw() $.needsPaint()
print(...) / println(...) console.log(...)
millis() performance.now()
day(), month(), year(), hour(), minute(), second() new Date() methods

Image (basic support)#

L5 AC Notes
loadImage(url) $.get.picture(url) Async — returns promise
image(img,x,y) $.paste(img,x,y)
get(x,y) Read from $.screen.pixels
set(x,y,c) $.ink(c); $.plot(x,y)

Not Mapped (v1 scope exclusions)#

  • rotate() / scale() — needs software matrix (significant effort)
  • bezier() / curve() — not in AC
  • loadFont() — AC has fixed bitmap fonts
  • loadVideo() / video playback — not in AC
  • filter() effects — partial (blur/invert exist)
  • createGraphics() — could map to $.painting() later
  • smooth() / noSmooth() — AC is pixel-native
  • save() / loadStrings() / file I/O — different paradigm
  • blend() modes — partial support
  • tint() / noTint() — not directly available
  • applyMatrix() — needs full matrix support

Step 4: Modify disk.mjs#

4.1 Add import (near line 68)#

import * as l5 from "./l5.mjs";

4.2 Add .lua detection (after KidLisp block ~line 7563, before the else at ~7639)#

} else if (
  (path && path.endsWith(".lua")) ||
  (sourceToRun.trim().startsWith("--") && /function\s+(setup|draw)\s*\(/.test(sourceToRun))
) {
  sourceCode = sourceToRun;
  originalCode = sourceCode;
  pieceMetadata = { code: slug || "l5", trustLevel: "l5", anonymous: true };
  send({ type: "boot-log", content: `compiling lua ...` });
  loadedModule = await l5.module(sourceToRun);
  send({ type: "boot-file", content: { filename: path, source: sourceCode.slice(0, 8000) } });
  if (devReload) store["publishable-piece"] = { slug, source: sourceToRun, ext: "lua" };
}

4.3 Extend fallback chain (~line 7736)#

After .mjs 404, try .lua before .lisp:

.mjs (404) → .lua (404) → .lisp (404) → 404 piece

Step 5: Error Handling#

  • Compile errors: engine.doString() throws → return a module with paint() that displays the error on screen in red text
  • Runtime errors: Each lifecycle call wrapped in try/catch, logged with "L5" prefix
  • Infinite loops: v1 documents the limitation; v2 could use Lua debug hooks for instruction-count limits
  • Cleanup: leave() calls engine.global.close() to free WASM memory

Step 6: Verification#

  1. npm run site — start dev server
  2. Create disks/l5-hello.lua with basic setup()/draw() using background, fill, circle, text
  3. Navigate to localhost:8888/l5-hello — verify it renders
  4. Test input: click to add circles, verify mouseX/mouseY/mousePressed() work
  5. Test push()/pop()/translate() with nested transforms
  6. Test error display: intentionally break Lua syntax, verify error shows on screen
  7. Test fallback: delete .mjs, verify .lua loads via the fallback chain
  8. Test hot reload: edit .lua file, verify changes appear

Step 7: L5 Documentation + Showcase Rollout#

Goal#

Ship a graspable proof page that shows what L5 API surface AC supports, what is partial, and what is not yet implemented.

7.1 Extend Existing /docs System First (lowest-risk)#

Use the current docs pipeline (docs command → /docsdocs.js) and add an L5 section there first:

  • Add docs.api.l5 in system/netlify/functions/docs.js
  • Add index links under a new <h2>L5 (Lua)</h2> section
  • Add entries like:
    • overview
    • lifecycle
    • graphics
    • color
    • text
    • math
    • input
    • environment
    • compatibility (full/partial/unsupported table)
    • unsupported (explicit v1 exclusions)

Resulting URLs:

  • /docs/l5:overview
  • /docs/l5:compatibility
  • etc.

7.2 Add a Dedicated /l5 Landing Page (show-off page)#

Create a static HTML page following AC frontend conventions (single-file page, no framework):

  • system/public/l5.aesthetic.computer/index.html

Page sections:

  1. Hero: “L5 on Aesthetic Computer”
  2. “Run now” links (sample pieces)
  3. Compatibility matrix (L5 API → AC API mapping)
  4. “What’s different from Processing/Love2D”
  5. Live code snippets (Lua examples)
  6. CTA: “open docs”, “open prompt”, “try example”

Style direction:

  • Reuse AC fonts (Berkeley Mono, YWFT Processing) and color token pattern from frontend style guide
  • Responsive layout for desktop/mobile
  • Keep everything in one HTML file unless it exceeds practical size

7.3 Routing#

Add redirects in system/netlify.toml:

  • from = "/l5"/l5.aesthetic.computer/index.html
  • from = "https://l5.aesthetic.computer"/l5.aesthetic.computer/index.html
  • from = "https://l5.aesthetic.computer/*"/l5.aesthetic.computer/index.html

Optional aliases:

  • /L5/l5 (case convenience)

7.4 Prompt/Command Integration#

Add quick entry commands in system/public/aesthetic.computer/disks/prompt.mjs:

  • l5docsout:/docs/l5:overview
  • l5 (or l5learn) → out:/l5

Keep existing docs command unchanged.

7.5 Single Source of Truth for Compatibility#

Avoid hand-maintaining the matrix in two places.

Create one source object/file (for example system/public/aesthetic.computer/lib/l5-reference.mjs) with:

  • name
  • l5Sig
  • acEquivalent
  • status (full, partial, unsupported)
  • notes

Use it to render:

  • /docs L5 entries
  • /l5 compatibility table

This prevents docs drift as runtime support changes.

7.6 Accuracy Guardrails (important)#

Do not claim “full L5 parity” in v1.

Label unsupported/partial APIs clearly (e.g. rotate, scale, bezier, file/video APIs). The page should be a trustable status board, not marketing-only copy.


Step 8: Verification for Docs/Showcase#

  1. Run npm start
  2. Verify:
    • /docs shows new “L5 (Lua)” section
    • /docs/l5:overview and /docs/l5:compatibility render correctly
    • /docs.json includes api.l5
  3. Verify /l5 loads and works on desktop + mobile breakpoints
  4. Verify prompt commands:
    • l5docs
    • l5 (or chosen alias)
  5. Confirm every compatibility claim matches real runtime behavior
  6. Run npm test before merge