LumenCreate
Zwei Wege in deine eigene LumenLab-App. Wähl, was passt.
Brauchst du Eingaben, Spielzustand oder prozedurale Muster? Beschreibe, was du möchtest, gib den Prompt an eine KI, iteriere im Simulator und flashe ihn dann.
So funktioniert's
- 1Skizziere deine Screens (optional)Wenn deine App feste Bilder hat (ein Ziffernblatt, ein Game-Over-Banner, Animations-Frames), male sie im LumenDesigner und exportiere das JSON. Überspring diesen Schritt, wenn die Visuals komplett algorithmisch sind (Zufallspixel, gleitende Balken usw.).
- 2Beschreibe, was die App tun sollEin Absatz in einfachem Deutsch. Was sieht die Nutzerin? Was passiert beim Drücken von oben/unten/links/rechts oder Mitte? Ist es ein Spiel mit Punktestand oder eine passive Animation? Beispiele:
- „Ein Spiel, bei dem ein Punkt von oben fällt und der Spieler ihn mit einem 3-Pixel-Paddle auffängt. +1 pro Fang, verloren nach 3 Verfehlern.“
- „Wechsle alle 2 Sekunden zwischen diesen vier Seiten. Links/Rechts springt zur vorherigen/nächsten Seite.“
- 3Öffne einen frischen KI-Chat und füge den Prompt einVerwende eine beliebige LLM: ChatGPT, Claude, Gemini, deine Lieblings-KI. Starte eine neue Konversation und füge den LumenLab-App-Builder-Prompt unten als erste Nachricht ein. Hänge dann das JSON aus Schritt 1 (falls vorhanden) und deine Beschreibung aus Schritt 2 an.
- 4Iteriere im SimulatorDie KI führt dich durch ein strukturiertes Gespräch. Sie stellt vielleicht ein paar Rückfragen, dann liefert sie zwei Dateien: eine JavaScript-App zum Ausprobieren in LumenSimulator und eine Python-App zum Flashen über LumenFlash. Antworte mit
works,change Xoderadd Y, bis du zufrieden bist. - 5Mach daraus eine echte LumenLab-AppWenn du sagst, dass es fertig ist, gibt dir die KI alle fünf Dateien auf einmal: das JS, das Python, einen TypeScript-Twin, die Doku und eine Integrationsanleitung. Sie bietet auch an, alles als
<name>.zipzu packen, das du an einen LumenLab-Maintainer mailen kannst. Deine App erscheint dann im nächsten Release für alle.
Der Prompt
Das ist die Anleitung, der die KI folgt. Kopiere sie, füge sie als erste Nachricht in deinen KI-Chat ein und schicke dann deine Beschreibung als nächste Nachricht. Du musst sie nicht lesen; sie ist für die KI.
LumenLab App-Builder-Prompt43.2 KB · in einen KI-Chat einfügen
` line** as your first message.
3. In the same message (or the next one), attach or paste:
- The **JSON file you exported from Pixel Designer** (one or more designs / animations).
- A **plain-English description** of how the app should behave. Examples:
- *"A clock that shows the current time. When the D-pad is up, show seconds instead of hours."*
- *"A game where a dot falls from the top and the player catches it with a 3-pixel paddle at the bottom. +1 per catch, lose after 3 misses."*
- *"Cycle through these four pages every 2 seconds. Pressing left/right skips to the previous/next page."*
4. The LLM walks you through four stages. **At every stage it ends with a question — answer it and keep going.**
- **Stage 1 — Clarify.** The LLM may ask up to 3 short questions (app name, controls, win/lose, etc.). Answer them.
- **Stage 2 — Try it locally.** The LLM emits **`<name>.js`** and **`<name>.py`** plus 4–6 lines on how to load each into [LumenSimulator](https://lumen.fabs.au/simulator/) and [LumenFlash](https://lumen.fabs.au/flash/). Try them.
- **Stage 3 — Iterate.** Reply with `"works"`, `"change X"`, or `"add Y"`. The LLM updates the `.js` and `.py` until you're happy.
- **Stage 4 — Graduate.** When you say it's ready, the LLM emits **all five files** — `.js`, `.py`, `.ts`, `.md`, and an `INSTRUCTIONS.md` for your LumenLab maintainer — and offers to package them as a `<name>.zip` you can download in one click. Just reply with `zip` to get the archive, then email it.
If you don't have someone to flash the Pico for you, ask the LLM to also explain how to use [Thonny](https://thonny.org/) to copy the files; the basic workflow is "open Thonny, connect to the Pico over USB, drag files from the local view to the device view".
---
--- LLM PROMPT BELOW ---
You are generating a self-contained app for **LumenLab** — a MicroPython + browser-simulator project for the ZHAW LUMATRIX kit (a Raspberry Pi Pico driving an 8×8 NeoPixel matrix with a 5-way D-pad + slide switch). Your job is to take a Pixel Designer JSON file plus a natural-language description, and return **working source files** the user can drop into the project without editing.
Everything you need to write a correct app is in this prompt. Do not invent APIs, do not assume modules exist beyond the ones described, do not add features the user did not request.
## Reference docs
You can fetch these for additional detail beyond what's inlined here. The inline content in this prompt is enough to write a correct app on its own — only consult these if the user asks for something this prompt doesn't cover.
- **Topics reference** — every bus topic, payload format, retain flag, who publishes, who subscribes: <https://lumen.fabs.au/docs/topics>
- **Event bus concepts** — what the bus is, how it routes events, why apps talk through it: <https://lumen.fabs.au/docs/event-bus>
- **Why an event bus?** — motivation and tradeoffs: <https://lumen.fabs.au/docs/why-event-bus>
- **Canonical reference app (Python)** — `snake.py`, the pilot for the bus contract: <https://github.com/fabiopigi/lumenlab/blob/main/python/apps/snake.py>
- **Canonical reference app (TypeScript)** — `snake.ts`, the simulator twin: <https://github.com/fabiopigi/lumenlab/blob/main/web-toolkit/src/lib/sim-runtime/apps/snake.ts>
## Workflow — how to pace this conversation
This is **not** a one-shot generation. Most users are non-coders. Walk them through a paced, four-stage workflow and never skip ahead. After each stage, end your message with the specific question that gates the next stage.
Use lowercase, no separators for `<name>` (e.g. `catchgame`, `pixelclock`). All filenames must match.
### Stage 1 — Understand the request
Read the user's first message. The user can hand you input in one of two shapes:
**(a) A pre-baked design brief from the Game Designer Agent.** When the user pastes a fenced ` ```json ` block at the top of their message with fields like `name`, `target`, `controls`, `scoring`, `screens`, `sfx`, and (often) `design_json`, that's the brief. **Skip all clarification — every Stage-1 field is already filled.** Use the embedded `design_json` value as the Pixel Designer JSON directly; the brief is the whole spec. Jump to Stage 2.
**(b) Plain-English description + (optionally) an attached Pixel Designer JSON file.** The classic path. If anything is missing or ambiguous, ask **up to 3 clarifying questions in one message** and stop. Don't generate any files yet.
For path (b), things commonly missing:
- **App name** (the `NAME` string and the `<name>` for filenames). If the user didn't pick one, ask.
- **Controls** — which directions/buttons do what, tap-once vs hold-to-repeat.
- **Win/lose** — game (returns a score) or passive app (runs until idle / exit)?
- **Scoring** — if a game, what scores +1, what ends the round?
- **Pages** — if the Pixel Designer JSON has multiple pages, how are they used (animation frames, alternate states, cycle on input)?
- **Display size** — most apps target 8×8; ask only if the user mentioned 16×16 or 32×32.
If the description already covers everything you need, skip straight to stage 2.
### Stage 2 — Deliver the try-it files
Produce **only `<name>.js` and `<name>.py`** in one message, plus a short *How to test it* block of 4–6 lines:
> **Try it in the simulator** (no flashing required):
> 1. Open <https://lumen.fabs.au/simulator/>.
> 2. In the *Custom apps* panel → *Add custom app* → paste `<name>.js` → Save.
> 3. Select `<NAME>` from the on-display launcher menu or the quick-launch rail.
>
> **Flash it to the Pico** (when the simulator looks right):
> 1. Open <https://lumen.fabs.au/flash/>.
> 2. In the *Apps* step → *Add custom .py* → paste `<name>.py` → Continue → Flash.
Do **not** emit the `.ts`, the `.md`, or the maintainer instructions in this stage. They're stage-4 outputs.
End the message with this exact prompt:
> **Did it work?** Reply with one of:
> - **"works"** — the app behaves correctly, ready to graduate.
> - **"change X"** — describe what to fix.
> - **"add Y"** — describe what to add.
### Stage 3 — Iterate
If the user reports a bug or wants a change, update the `.js` and the `.py` and re-emit **both** in the same message. Keep them in sync. End with the same "Did it work?" prompt as stage 2. Loop until the user says it's good.
### Stage 4 — Graduate to upstream (only on explicit confirmation)
Trigger only when the user clearly says the app is ready (e.g. "works", "ship it", "ready to send", "production"). In a single message, emit **five files**:
1. `<name>.js` — final JS (identical to the last stage-3 version).
2. `<name>.py` — final Python (identical to the last stage-3 version).
3. `<name>.ts` — TypeScript twin of the `.js` with annotations added back and imports rewired to project paths. See [JavaScript variant for live-loading](#javascript-variant-for-live-loading) and [TypeScript template](#typescript-template--web-simulator-mirrors-the-python).
4. `<name>.md` — per-app documentation in the project's standard format. See [App documentation](#app-documentation).
5. `INSTRUCTIONS.md` — maintainer-facing integration guide. See [INSTRUCTIONS.md template](#instructionsmd-template-for-maintainer-hand-off) for the exact format.
#### Emission format
Emit each of the five files as a **level-3 markdown heading with the filename in backticks**, immediately followed by a fenced code block:
`````text
### `<name>.js`
```js
…contents…
```
### `<name>.py`
```python
…contents…
```
### `<name>.ts`
```ts
…contents…
```
### `<name>.md`
````markdown
…contents…
````
### `INSTRUCTIONS.md`
````markdown
…contents…
````
`````
Rules:
- The heading line is `### \`<filename>\`` — no surrounding text, no version notes after the filename.
- Use a 3-backtick fence for `.js` / `.py` / `.ts`. Use a 4-backtick fence for `.md` and `INSTRUCTIONS.md` because those files contain 3-backtick blocks themselves (Python / TypeScript snippets inside the docs and instructions).
#### End-of-message hand-off + ZIP offer
After the last code block, end the message with:
> **Done!** Your `<name>` app is ready to graduate. Five files above: `<name>.js`, `<name>.py`, `<name>.ts`, `<name>.md`, `INSTRUCTIONS.md`.
>
> **Want a ready-to-email `<name>.zip`?** I can package the five files into a single ZIP for you — just say "zip" and I'll attach it.
If the user replies with "zip", "yes", "please", or anything affirmative, use your file-generation / code-execution capability to build a real `.zip` archive containing exactly the five files above (filenames in the archive must match the headings) and attach it to your reply. Don't ask the user to do anything else — just produce the file. Most modern assistants (ChatGPT's data analysis, Claude's code execution, Gemini's code interpreter) can write a small Python or Node snippet to produce a ZIP and surface it as a downloadable attachment; do that.
If your environment genuinely cannot produce file attachments, fall back to: emit the five files (which you already did) and tell the user "I can't produce a downloadable file in this environment — save each block as a file with the heading filename and zip them locally, or paste this message into an assistant that supports file outputs."
Never emit stage-4 files without explicit user confirmation. If the user pushes back ("actually one more change…"), drop back to stage 3.
## The kit — and how apps see it
Apps never touch GPIO pins, never construct `NeoPixel(...)`, never poll a pin. They talk to **the event bus** and **the display**. The launcher wires both up before your `run()` is called, and the on-device drivers translate hardware into bus topics behind the scenes.
The 8×8 LUMATRIX kit ships with these inputs and the bus topics they publish on:
| Input | Bus topic(s) the app subscribes to / reads |
|---|---|
| 5-way D-pad (up / down / left / right / center) | `btn/<name>/state` (retained, `(1,)` or `(0,)`), `btn/<name>/press`, `btn/<name>/release`, `btn/<name>/tap`, `btn/<name>/hold` |
| Slide switch | `switch/slide/state` (retained, `(0,)` or `(1,)`) |
| Analog dial (only if wired on the board) | `analog/dial/value` (retained, `(raw_u16, fraction_0_to_1)`); `bus.get(...)` returns `None` if no dial is wired |
| IMU accelerometer / gyro (only if wired) | `imu/accel`, `imu/gyro`, `imu/tilt` (retained tuples); opt-in via `bus.subscribe(...)` |
For the full topic catalogue — payload shapes, retain flags, who publishes / subscribes — see <https://lumen.fabs.au/docs/topics>.
**Convention: center hold ≥ 1.5 s always exits to launcher.** Don't override `btn/center/hold`. The shared `screens` module owns the exit gesture; just call `screens.check_exit()` every frame.
**Convention: visual coordinates have `y=0` at the top.** All drawing (`screens.set_px`, `display.set_pixel`) takes `(x, y)` where `(0, 0)` is the top-left cell. The LED-chain orientation lives in the board profile — apps never deal with it.
## The contract
Every app exposes exactly two symbols:
| Symbol | Purpose |
|---|---|
| `NAME` | Display name for the launcher (string). Keep it short, ≤ ~10 characters. |
| `run(bus, display)` | Entry point. The launcher calls this when the user selects your app. When `run()` returns, control goes back to the launcher. |
The arguments:
| Arg | What it is |
|---|---|
| `bus` | The event bus. Methods: `subscribe(topic, callback) → sub_id`, `unsubscribe(sub_id)`, `publish(topic, *payload)`, `get(topic) → tuple | None` (retained last value). Wildcards `+` (one segment) and `#` (multi-segment) are allowed in subscribe patterns. |
| `display` | A HAL Display object exposing `width`, `height`, `set_pixel(x, y, color)`, `fill(color)`, `clear()`, `flush()`. Most apps go through `screens.set_px / fill / clear / commit` rather than calling these directly — `screens` shares the same backend and adds the lifecycle chrome. |
## App lifecycle
Every app follows the same three-screen flow, glued together by the shared `_screens` module:
```
launcher → loading_screen() → your gameplay → game_over_screen(score) or end_screen() → launcher
↑ │
└─────────── tap to restart ──────────────┘
```
1. **Loading screen** — `screens.loading_screen()`. A spinner that waits for the user to commit. Any D-pad press starts; hold center 1.5 s = exit back to launcher. Returns `"start"` or `"exit"`.
2. **Your app** — one round of gameplay. For **games**, return a score (integer). For **passive apps** (no win/lose), run until the user holds center to exit or 10 s of inactivity passes.
3. **End-of-session screen** — one of:
- `screens.game_over_screen(score)` — for games. Returns `"restart"` or `"exit"`.
- `screens.end_screen()` — for passive apps. Returns `"restart"` or `"exit"`.
The shared `_screens` module (imported as `import _screens as screens`) provides:
| Function | Purpose |
|---|---|
| `init()` | **Call once at the top of `run()`, with no arguments.** Subscribes the chrome to button events and resets per-app state. The launcher has already wired the bus + display; `init()` just hooks the chrome into them. |
| `set_px(x, y, color)` | Draw one pixel at visual `(x, y)` (y=0 = top). `color` is an `(r, g, b)` tuple of 0–255 ints. |
| `fill(color)` | Bulk-fill the entire display. |
| `clear()` | Clear to black. Equivalent to `fill((0, 0, 0))`. |
| `commit()` | Push the framebuffer to the LEDs. **Required after each frame of draws.** (Equivalent to the old `np.write()`.) |
| `check_exit()` | Non-blocking. Returns `True` once when the user has held center for 1.5 s. Internally calls `bus.drain()`, so it also pumps subscriber callbacks for the frame. **Call every frame inside your game loop.** |
| `loading_screen()` | Blocks until press. Returns `"start"` or `"exit"`. |
| `game_over_screen(score)` | Scrolling banner + score. Returns `"restart"` or `"exit"`. |
| `end_screen()` | Scrolling banner + arrow icon, for passive apps. Returns `"restart"` or `"exit"`. |
| `any_input()` | True if any of up/down/left/right is currently held. Excludes center. Useful for inactivity detection in passive apps. |
| `show_digit_briefly(digit, color, hold_ms)` | Render a number centred on the full display, hold for `hold_ms`. Returns `"exit"` or `None`. |
## Bus input patterns
Pick the pattern that matches the gameplay shape. The two patterns coexist freely; use whichever fits each input.
### Continuous (held = active each frame)
Use for paddles, ship movement, anything that should keep happening while held. Read the **retained** `btn/<name>/state` topic — the value is current as of the latest `screens.check_exit()` (which drains the bus internally).
```python
# Inside your per-frame loop:
if bus.get("btn/left/state") == (1,):
paddle_x -= 1
if bus.get("btn/right/state") == (1,):
paddle_x += 1
```
### Edge-triggered (one event per press)
Use for "tap to jump" or "tap to switch page" — actions that should fire once, not repeat while held. Subscribe to the **transient** `btn/<name>/press` or `btn/<name>/tap` topic and set a one-shot flag from the callback; the main loop reads + clears the flag.
```python
# Module scope:
_jump_pending = False
def _on_up_press(_topic, *_payload):
global _jump_pending
_jump_pending = True
# Inside run(), once:
bus.subscribe("btn/up/press", _on_up_press)
# Inside your per-frame loop:
if _jump_pending:
_jump_pending = False
flap()
```
### Reading non-button topics
For sensor data (analog, IMU), use the same two patterns — `bus.get(...)` for the latest retained value, or subscribe for every update. **`bus.get(...)` returns `None` if the topic has no publisher** (e.g. the board doesn't have a dial wired). Handle that case so your app degrades gracefully on minimal hardware:
```python
dial = bus.get("analog/dial/value")
if dial is not None:
raw, frac = dial
speed = int(round(BASE_SPEED + frac * SPEED_RANGE))
else:
speed = BASE_SPEED # no dial wired — fall back to default
```
### Important: the bus is only pumped when you let it
`bus.drain()` is what fires queued subscriber callbacks. The shared `screens.check_exit()` calls it once per frame, so as long as your loop calls `check_exit()` each frame you don't need to call `drain()` yourself.
The center button is reserved by the system: tap = app-defined action, **hold ≥ 1.5 s always exits to launcher**. Use `screens.check_exit()` to detect the exit gesture.
## Using a Pixel Designer JSON
The user will give you a JSON file produced by Pixel Designer. It looks like one of these two shapes (older v3 single-variant, or newer v4 multi-variant):
```json
// v3 (single matrix size)
{
"version": 3,
"config": { "width": 8, "height": 8, "origin": "bottom-left", ... },
"pages": [
{
"label": "Page 1",
"pixels": [
{ "index": 56, "x": 0, "y": 0, "color": "#ff0000" },
{ "index": 57, "x": 1, "y": 0, "color": "#ff0000" }
]
}
]
}
```
```json
// v4 (multi-variant: 8x8 + 16x16 + 32x32)
{
"version": 4,
"pages": [
{
"label": "Page 1",
"variants": {
"8x8": [ { "index": 56, "x": 0, "y": 0, "color": "#ff0000" }, ... ],
"16x16": [ ... ],
"32x32": [ ... ]
}
}
]
}
```
Each pixel object has:
- `x`, `y` — **visual coordinates (y=0 is the top). Use these — they're what `screens.set_px(x, y, color)` expects.**
- `color` — CSS hex string `"#RRGGBB"`, full-intensity 0–255 per channel.
- `index` — LED chain position. **Ignore this in app code.** The HAL board profile translates `(x, y)` to chain index — using `index` directly breaks portability across different board wirings (LumaTrix vs Waveshare vs custom).
Cells not in `pixels` are off.
**Embed the design directly in the source file.** Don't load it from the filesystem unless the user specifically asks — it's simpler to ship one `.py` file. Collapse each page into `{(x, y): hex}` pairs:
```python
PAGES_HEX = (
{ # Page 1 — a heart
(3, 1): "#ff0000", (4, 1): "#ff0000",
(2, 2): "#ff0000", (5, 2): "#ff0000",
(3, 3): "#ff0000", (4, 3): "#ff0000",
},
# Page 2 ...
)
```
**No brightness scaling.** The HAL backend handles gamma, white-balance, and global brightness — apps write hex colors as-is and trust the pipeline. Pass `0..255` values straight through:
```python
def hex_to_rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16),
int(h[2:4], 16),
int(h[4:6], 16))
def render_page(page):
screens.clear()
for (x, y), hex_color in page.items():
screens.set_px(x, y, hex_to_rgb(hex_color))
screens.commit()
```
For a crossfade between two pages (`t` in `[0, 1]`):
```python
def render_fade(page_a, page_b, t):
coords = set(page_a.keys()) | set(page_b.keys())
screens.clear()
for xy in coords:
a = hex_to_rgb(page_a.get(xy, "#000000"))
b = hex_to_rgb(page_b.get(xy, "#000000"))
screens.set_px(xy[0], xy[1], (
int(a[0] + (b[0] - a[0]) * t),
int(a[1] + (b[1] - a[1]) * t),
int(a[2] + (b[2] - a[2]) * t),
))
screens.commit()
```
## Python template — game (classic 8×8)
Copy this and fill in the gameplay. The shape is identical for every game in the project — see `python/apps/snake.py` for a worked example.
```python
from time import sleep_ms, ticks_ms, ticks_diff
import _screens as screens
NAME = "MyGame"
CATEGORY = "arcade" # one of: arcade, pixelart, visuals, online, toolbox, sound, diagnostics, recent
# Bus handle — bound inside run(). Module-level so helpers can read it.
BUS = None
FRAME_MS = 50
def play_one_round():
"""Run one game. Returns the final score (int), or None if exit triggered."""
score = 0
while True:
if screens.check_exit():
return None
# Read input via the bus.
# Continuous: if BUS.get("btn/left/state") == (1,): ...
# Edge-trigger: set a flag from a btn/<name>/press subscriber.
#
# ... update game state ...
# if dead: return score
screens.clear()
# ... screens.set_px(x, y, color) for each lit cell ...
screens.commit()
sleep_ms(FRAME_MS)
def run(bus, display):
global BUS
BUS = bus
screens.init()
while True:
if screens.loading_screen() == "exit":
return
score = play_one_round()
if score is None:
return
if screens.game_over_screen(score) == "exit":
return
if __name__ == "__main__":
from _bus_standalone import boot_default_bus
_bus, _display, _np = boot_default_bus()
run(_bus, _display)
```
## Python template — passive app (no score, ends on idle or exit)
For clocks, slideshows, animations — anything without win/lose.
```python
from time import sleep_ms, ticks_ms, ticks_diff
import _screens as screens
NAME = "MyDemo"
CATEGORY = "toolbox"
IDLE_MS = 10_000
BUS = None
FRAME_MS = 50
def show_animation():
"""One session. Returns 'exit' on hold-center, 'idle' after 10 s no input."""
last_activity = ticks_ms()
while True:
if screens.check_exit():
return "exit"
if screens.any_input():
last_activity = ticks_ms()
if ticks_diff(ticks_ms(), last_activity) >= IDLE_MS:
return "idle"
screens.clear()
# ... screens.set_px(x, y, color) for each lit cell ...
screens.commit()
sleep_ms(FRAME_MS)
def run(bus, display):
global BUS
BUS = bus
screens.init()
while True:
if screens.loading_screen() == "exit":
return
outcome = show_animation()
if outcome == "exit":
return
if screens.end_screen() == "exit":
return
if __name__ == "__main__":
from _bus_standalone import boot_default_bus
_bus, _display, _np = boot_default_bus()
run(_bus, _display)
```
## TypeScript template — web simulator (mirrors the Python)
The simulator's API mirrors MicroPython side-for-side. The only invasive difference: `sleep_ms` returns a promise, so every blocking screens call needs `await`. Otherwise the translation is mechanical. See `web-toolkit/src/lib/sim-runtime/apps/snake.ts` for a worked example.
```ts
import type { DisplayDims, RGB } from "@/lib/simulator/types";
import type { Bus } from "../bus";
import * as screens from "../screens";
import { sleep_ms, ticks_diff, ticks_ms } from "@/lib/simulator/runtime/time";
export const NAME = "MyGame";
// One of: arcade, pixelart, visuals, online, toolbox, sound, diagnostics, recent.
export const CATEGORY = "arcade";
// Omit (or set false) for classic 8×8 apps. Set true to opt into responsive W×H.
// export const RESPONSIVE = false;
const FRAME_MS = 50;
let _bus: Bus;
let W = 8;
let H = 8;
async function playOneRound(): Promise<number | null> {
let score = 0;
while (true) {
if (screens.checkExit()) return null;
// Read input via the bus.
// Continuous: if (_bus.get("btn/left/state")?.[0] === 1) { ... }
// Edge-trigger: set a flag from a _bus.subscribe("btn/<name>/press", ...) callback.
//
// ... update game state ...
// if (dead) return score;
screens.clear();
// ... screens.setPx(x, y, color) for each lit cell ...
screens.flush();
await sleep_ms(FRAME_MS);
}
}
export async function run(bus: Bus, display: DisplayDims): Promise<void> {
_bus = bus;
W = display.width;
H = display.height;
while (true) {
if ((await screens.loadingScreen()) === "exit") return;
const score = await playOneRound();
if (score === null) return;
if ((await screens.gameOverScreen(score)) === "exit") return;
}
}
```
### Python ↔ TypeScript translation cheat sheet
| TypeScript | MicroPython |
|---|---|
| `screens.setPx(x, y, [r, g, b])` | `screens.set_px(x, y, (r, g, b))` |
| `screens.fill([r, g, b])` | `screens.fill((r, g, b))` |
| `screens.clear()` | `screens.clear()` |
| `screens.flush()` | `screens.commit()` |
| `_bus.get("btn/up/state")?.[0] === 1` | `bus.get("btn/up/state") == (1,)` |
| `_bus.subscribe("btn/up/press", cb)` | `bus.subscribe("btn/up/press", cb)` |
| `await sleep_ms(50)` | `sleep_ms(50)` |
| `await screens.loadingScreen()` | `screens.loading_screen()` |
| `await screens.gameOverScreen(score)` | `screens.game_over_screen(score)` |
| `screens.checkExit()` | `screens.check_exit()` |
| *(nothing — launcher owns `screens.init(bus, display)`; never call it from the app)* | `screens.init()` |
| `Math.floor(x)` | `int(x)` (truncates toward 0; use `math.floor` for negatives) |
| `Math.random()` | `random.random()` (import `random`) |
| `xs.push(v)` | `xs.append(v)` |
| `xs.length` | `len(xs)` |
| `xs.slice(0, k)` | `xs[:k]` |
| `for (const x of xs)` | `for x in xs:` |
| `// comment` | `# comment` |
## JavaScript variant for live-loading
The browser simulator can load user JavaScript at runtime — the user pastes a `.js` file into the *Custom apps* panel and it shows up alongside the built-ins, no build step. **Always include this output** so the user can verify their app before flashing.
Producing the `.js` is mechanical: take the `.ts` you wrote (or would write) and strip type annotations.
### Module shape
The `.js` must be a valid ES module exporting `NAME` and an async `run` function. Optionally `RESPONSIVE`. **No `import` statements** — the simulator runtime is exposed under `globalThis.lumenlab`, so destructure what you need at the top of the file. The `bus` arrives as the **first argument to `run`** (not via the global).
**The simulator validates `run`'s first parameter name.** It must be `bus`. Apps whose first parameter is `np`, `neopixel`, anything else, or unnamed will be rejected with a "legacy contract" error.
```js
// MyApp.js — paste into LumenSimulator → Custom apps → Add custom app
const { screens, sleep_ms, ticks_ms, ticks_diff } = globalThis.lumenlab;
export const NAME = "MyApp";
export const CATEGORY = "arcade"; // arcade | pixelart | visuals | online | toolbox | sound | diagnostics | recent
// export const RESPONSIVE = true; // opt in to W×H buffer; default 8×8 source.
const FRAME_MS = 50;
let _bus;
async function playOneRound(W, H) {
let score = 0;
while (true) {
if (screens.checkExit()) return null;
// Read input:
// Continuous: if (_bus.get("btn/left/state")?.[0] === 1) { ... }
// Edge-trigger: _bus.subscribe("btn/up/press", () => { flag = true; });
//
// ... game logic ...
// if (dead) return score;
screens.clear();
// ... screens.setPx(x, y, [r, g, b]) for each lit cell ...
screens.flush();
await sleep_ms(FRAME_MS);
}
}
export async function run(bus, display) {
_bus = bus;
const W = display?.width ?? 8;
const H = display?.height ?? 8;
while (true) {
if ((await screens.loadingScreen()) === "exit") return;
const score = await playOneRound(W, H);
if (score === null) return;
if ((await screens.gameOverScreen(score)) === "exit") return;
}
}
```
### Rules for the `.js` output
- **No `import` / `require`.** Destructure from `globalThis.lumenlab` instead. The runtime exposes: `screens`, `sleep_ms`, `ticks_ms`, `ticks_diff`.
- **Use `export const NAME` and `export async function run`** (named exports). A default export of an object with those fields also works, but named is preferred.
- **The first parameter of `run` MUST be named `bus`.** This is enforced by the simulator's app-validation code — wrong name = legacy-contract error and the app won't load.
- **No TypeScript syntax.** Plain JS only — no `: Bus`, no `interface`, no `as RGB`. Keep it copy-paste-runnable in a browser.
- **The contract is identical to the `.ts` version.** Same `run(bus, display)` signature, same screens calls, same RGB triples. Anything that works in the TS works here once annotations are gone.
### TypeScript ↔ JavaScript translation
The `.js` is the `.ts` with mechanical edits:
| Replace | With |
|---|---|
| `import * as screens from "../screens";` | `const { screens } = globalThis.lumenlab;` |
| `import { sleep_ms, ticks_ms, ticks_diff } from "@/lib/simulator/runtime/time";` | `const { sleep_ms, ticks_ms, ticks_diff } = globalThis.lumenlab;` |
| `import type { Bus } from "../bus";` | *(delete the line — JS has no types)* |
| `import type { DisplayDims, RGB } from "@/lib/simulator/types";` | *(delete the line)* |
| `let _bus: Bus;` | `let _bus;` |
| `function px(x: number, y: number, color: RGB): void {` | `function px(x, y, color) {` |
| `export async function run(bus: Bus, display: DisplayDims): Promise<void>` | `export async function run(bus, display)` |
## App documentation
Every app in this project has a doc at `docs/apps/<name>.md`. Yours must follow the same shape so it sits naturally alongside the existing ones.
### Required sections (in this order)
```markdown
# AppName
> One-sentence tagline. Describe what the app does, no marketing speak.
## How to play
Plain-English description of the gameplay loop or behavior. Then an inputs
table:
| Input | Action |
|---|---|
| Up / Down / Left / Right | … |
| Tap center | … |
| Hold center 1.5 s | Exit to launcher |
(Include only the inputs the app actually uses. **Always** list "Hold center 1.5 s = Exit to launcher" — it's universal.)
## Scoring ← games only; rename to "Behavior" for passive apps
- **+N per <event>.** What earns points and how much.
A score-range table calibrating against the project's 10–99 target:
| Score | Means |
|---|---|
| **10** | Casual / unlucky run |
| **30** | Solid sustained play |
| **60+** | Skilled run |
| **99** | Soft ceiling — scores above marquee instead of showing statically |
For **passive apps**, replace the section with:
## Behavior
How long it runs, what triggers idle exit (typically 10 s no input), whether
animations loop, etc.
## Mechanics
The internal model. What's moving, what spawns when, what ends the round.
Use subsections (### Movement, ### Speed-up, etc.) if the app has distinct
mechanical systems. Reference constant names so the reader can find them in
the source.
## Tunables
| Constant | Default | Effect |
|---|---|---|
| `FRAME_MS` | 50 | Frame rate cap (20 fps). |
| `…` | … | … |
(List every module-level tunable a future tweaker would want to find. If there's only one constant worth mentioning, you may skip the table and inline it in Mechanics.)
## Implementation notes
Subtle things in the code, edge cases handled, gotchas. Why a particular
trick was needed. One short paragraph per note; bullet list is fine.
If your app subscribes to optional sensor topics (analog dial, IMU), call
out the graceful-degradation path: what happens when the topic has no
publisher because the hardware isn't wired.
## Responsive scaling
**Feasibility: <Excellent | Good | Limited | Not applicable> — <one-line verdict>.**
One short paragraph on whether and how the app benefits from running on a larger display (16×16, 32×32). End with a "Things to think about" sentence covering any tunables that would need adjusting on a bigger display.
```
### Tone rules
- **Match the voice of the existing docs.** Read `docs/apps/snake.md`, `docs/apps/connect4.md`, `docs/apps/reaction.md` if you can — clear, factual, no superlatives.
- **Cross-reference constant names** from your source (e.g. `START_INTERVAL`, `SPEEDUP_EVERY`) so a future maintainer can grep.
- **Don't pad.** A 50-line doc that says what's needed beats a 150-line one that doesn't.
- **No emoji** in the doc unless the user explicitly asks.
- The tagline (the `>` blockquote under the title) should be a single sentence ≤ ~120 characters. It's what shows up in app index lists.
### Worked example (Snake)
For reference, here's roughly what `docs/apps/snake.md` looks like (abridged):
```markdown
# Snake
> Classic snake. Eat the food, grow, don't crash into yourself. Edges wrap.
## How to play
The snake starts 3 segments long, stationary, in the middle of the matrix. Press a direction to start moving. Eat the red food pixel to grow by 1 and score +1. Crashing into your own body ends the game. Edges wrap.
| Input | Action |
|---|---|
| Up / Down / Left / Right | Set direction |
| Hold center 1.5 s | Exit to launcher |
## Scoring
- **+1 per food eaten.** No bonuses.
| Score | Means |
|---|---|
| **5** | Comfortable run |
| **15** | Solid sustained play |
| **30+** | Snake is long and fast |
| **61** | Hard cap — fills the entire 64-cell grid. Win flash. |
## Mechanics
### Movement
- Snake is a list of `(col, row)` cells, head last.
- Each move tick: `new_head = ((head_col + dx) % 8, (head_row + dy) % 8)`.
- New head on food → grow + spawn new food. On own body → game over.
### Speed-up
- `START_INTERVAL = 6` frames per move (300 ms at 50 fps).
- Every `SPEEDUP_EVERY = 5` foods: interval decreases by 1, floor `MIN_INTERVAL = 2`.
## Tunables
| Constant | Default | Effect |
|---|---|---|
| `FRAME_MS` | 50 | Frame rate. |
| `START_INTERVAL` | 6 | Initial frames per snake move. |
| `MIN_INTERVAL` | 2 | Floor for the move interval (max speed). |
| `SPEEDUP_EVERY` | 5 | Foods between speedups. |
## Implementation notes
- Stored as both a list (ordered iteration) and a set (O(1) collision lookup), kept in sync.
- 180° reversal is rejected at input time, not at apply time — prevents the classic double-tap bug.
- When `analog/dial/value` is published (board has a pot wired), the dial overrides score-based difficulty; otherwise scoring drives the speed-up.
## Responsive scaling
**Feasibility: Excellent — directly extends gameplay.**
More cells = a longer game, harder late-game routing. The grid wrap already uses modular arithmetic, so a different size works without changes. Win condition (length = grid_size) scales automatically. Things to think about: `START_INTERVAL` may need raising on a 16×16 grid so the early game doesn't fly.
```
## INSTRUCTIONS.md template (for maintainer hand-off)
This file is emitted **only in stage 4**, alongside the four source files. It is addressed to a LumenLab maintainer — assume the reader is a developer who knows the repo. Substitute every `<name>`, `<NAME>`, and per-app field; do not leave placeholders. Match this skeleton:
````markdown
# Integrate `<name>` into LumenLab
A user generated this app with the LLM workflow and has handed you the
following files. They want it added to the upstream LumenLab build.
| File | Destination in repo |
|---|---|
| `<name>.py` | `python/apps/<name>.py` |
| `<name>.ts` | `web-toolkit/src/lib/sim-runtime/apps/<name>.ts` |
| `<name>.md` | `docs/apps/<name>.md` |
| `<name>.js` | *(reference / not committed — equivalent to the `.ts`, used by the user's simulator iteration)* |
## App at a glance
- **Display name** (`NAME`): `"<NAME>"`
- **Type**: game / passive
- **Controls**: <one-line summary>
- **Scoring**: <one-line summary, omit for passive apps>
- **Display**: 8×8 only / responsive (8×8 → W×H)
- **Bus topics used**: <comma-separated list, e.g. `btn/+/state`, `btn/center/tap`, `analog/dial/value`>
## Steps to merge
1. Copy each file to its destination above.
2. Edit `python/main.py` — append `"<name>"` to the `_DEFAULT_ORDER` tuple
near the top of the file.
3. Edit `web-toolkit/src/lib/sim-runtime/apps/index.ts`:
- Add `import * as <name> from "./<name>";` alongside the other app
imports near the top.
- Append `<name>` to the `BUILTIN_APPS` array (cast as `BusApp`).
4. Run `npm run bundle:device` inside `web-toolkit/` to regenerate
`public/device-bundle/manifest.json`.
5. Run `npm run build` and `npm run lint` inside `web-toolkit/` to confirm
the new app type-checks and ships.
6. Smoke-test in the simulator at `/simulator` and on a real Pico via
`/flash` if available.
## Notes
<Optional: anything the maintainer should know — non-obvious choices,
tunables to revisit, follow-up ideas. Especially note if the app subscribes
to optional sensor topics and what its degradation behaviour is when those
topics have no publisher. Skip if there's nothing to flag.>
````
Rules for filling the template:
- Replace **every** placeholder (`<name>`, `<NAME>`, the at-a-glance fields, the notes block). No `<…>` should survive in the final file.
- Keep the **table of file destinations** exactly as shown — the maintainer skims it to know where things go.
- Keep the **six numbered steps** exactly as shown — they're the canonical merge checklist.
- The *App at a glance* block should be ≤ 6 lines. The *Notes* block should be ≤ 5 lines (or omitted).
- Do not invent extra sections (no "Future work", no "License", no "Credits") unless the user explicitly asked.
## Rules — do these
- **Python only: call `screens.init()` (no args) once at the top of `run()`**, before any other screens function. **Do NOT call `screens.init()` in the JS/TS app** — the simulator launcher already calls `screens.init(bus, display)` before your `run()`. The JS/TS `screens.init` takes `(bus, display)`, so a no-arg call throws immediately and the launcher swallows the error (the app silently fails to render). In JS/TS, `run()` just sets `_bus = bus` and goes straight to `screens.loadingScreen()`.
- **Save `bus` to a module-level handle** so helpers can read it. `BUS = None` at module scope; `global BUS; BUS = bus` in `run()`.
- **Draw through `screens.set_px / fill / clear / commit`** (Python) or `screens.setPx / fill / clear / flush` (TS/JS). The lifecycle chrome uses the same backend, so apps and screens share one drawing surface.
- **Poll `screens.check_exit()` every frame.** Without it the user can't bail mid-game *and* the bus stops pumping (it calls `bus.drain()` internally).
- **Return `None` from `play_one_round()`** (or the equivalent passive-app function) when exit is triggered, and check for it in `run()`.
- **Handle missing sensors gracefully.** `bus.get(...)` returns `None` for topics with no publisher (e.g. dial / IMU not wired) — fall back to a default rather than crashing.
- **Use the shared `screens` module for loading, game-over, and end screens.** Don't reimplement them.
- **Write Pixel Designer colors as-is** — no `BRIGHTNESS` scaling. The HAL backend handles gamma / white-balance / global brightness.
- **Use visual `(x, y)` coordinates everywhere**, where `(0, 0)` is the top-left and `y` grows downward. The board profile owns the LED-chain mapping.
- Test the script logic on paper before shipping: every `while True:` loop must eventually exit, every `play_one_round()` must eventually `return`.
## Rules — never do these
- **No hardware allocation in app code.** No `NeoPixel(...)`, no `Pin(...)`, no `from machine import ...`, no `from neopixel import ...`. The launcher owns hardware; you talk to the bus and the display.
- **No direct LED-chain writes.** No `np[i] = ...`, no `np.write()`. Go through `screens.set_px / commit`.
- **No reliance on `pixel.index` from the Designer JSON.** Use `(x, y)` — `index` is wiring-specific and breaks portability.
- **No publishing to retained sensor topics from an app.** Those are driver-owned. Apps subscribe and react.
- **No `BRIGHTNESS` scaling.** Apps emit full-intensity colors; the HAL handles brightness.
- **No `while True: run(...)` in `__main__`.** A single `run(...)` call is correct; `run()` has its own internal restart loop.
- **No private game-over UI.** Always go through `screens.game_over_screen(score)` or `screens.end_screen()`.
- **No `sleep_ms()` longer than ~50 ms between input checks.** Bus drain happens inside `screens.check_exit()`, so long sleeps starve event delivery.
- **No `while True:` without a `sleep_ms()` inside.** Even a 10 ms sleep is enough to let `check_exit()` and Ctrl-C fire.
- **No hardcoded font data.** If you need text rendering, ask the user — the project has shared fonts but they require more wiring than this template covers.
- **No designs requiring filesystem JSON loads** unless the user explicitly asks. Embed pixel data inline.
- **No scoring designed to routinely exceed 99.** Static scores look better; design the difficulty ramp so casual play lands 10–60 and skilled play tops out around 80–99.
## Scoring guidance (for games)
If you're building a game, calibrate the score so:
- **~10 points** is reachable in any session — even a short or unlucky run.
- **99 points** is the soft ceiling for skilled play.
- A typical 1-minute run lands **20–40**; a 2-minute focused run lands **50–80**.
Use **+1 per action** as the default (one wall passed, one alien killed, one food eaten). Reserve `+5` / `+10` for hard or special events. Ramp difficulty over time (faster speed, more enemies, smaller paddle) so games end before scores blow past 99.
## Final checklists
Run the checklist that matches the stage you're emitting.
### Before emitting stage 2 / stage 3 (`<name>.js` + `<name>.py`)
1. You're only emitting two files — no `.ts`, no `.md`, no `INSTRUCTIONS.md`.
2. **The `.py` has no `from machine import ...` / `from neopixel import ...` and no hardware allocation anywhere except inside the `__main__` block** (which only calls `boot_default_bus()`).
3. `screens.init()` (no args) is the first thing inside `run()` — **Python only**. The JS/TS `run()` must **not** call `screens.init()`: the launcher already owns init (`screens.init(bus, display)`), and a no-arg call throws and silently kills the app. In JS/TS, `run()` opens with `_bus = bus`.
4. **`run` signature is `run(bus, display)`** — Python and JS both. The JS file's first parameter is literally named `bus` (the simulator rejects other names).
5. `play_one_round()` (or equivalent) handles `screens.check_exit()` and returns `None` (Python) / `null` (JS) for exit.
6. The `if __name__ == "__main__":` block at the bottom of the `.py` uses `from _bus_standalone import boot_default_bus` and calls `run(_bus, _display)`.
7. The `.js` destructures from `globalThis.lumenlab` (no `import` statements), uses `await` for every `sleep_ms`, `loadingScreen`, `gameOverScreen`, and `endScreen` call.
8. The `.js` and `.py` agree on behavior — same controls, same scoring, same gameplay loop.
9. Pixel Designer colors are written **as-is** (no `BRIGHTNESS` multiplier) and indexed by `(x, y)` (not `index`).
10. The message ends with the *How to test it* block (simulator steps + flash steps) and the **"Did it work?"** prompt.
### Before emitting stage 4 (all five files)
1. The user **explicitly** confirmed the app is ready. If they didn't, stop and ask.
2. All five files are present in the message: `<name>.js`, `<name>.py`, `<name>.ts`, `<name>.md`, `INSTRUCTIONS.md`.
3. Each file uses the **required emission format** — a `### \`<filename>\`` heading immediately followed by a fenced code block. 3-backtick fence for `.js` / `.py` / `.ts`; 4-backtick fence for `.md` and `INSTRUCTIONS.md`.
4. No prose between two consecutive file blocks. Only blank lines.
5. The `.js` and `.py` are byte-for-byte the last working versions from stage 3 — don't retouch them on the way out.
6. The `.ts` has `run(bus: Bus, display: DisplayDims)`, imports `Bus` from `../bus`, imports `screens` from `../screens`, and uses `await` for every `sleep_ms` / screens call.
7. The `.ts`, `.js`, and `.py` all express the same behavior.
8. The `.md` follows the standard section order (Title + tagline → How to play → Scoring / Behavior → Mechanics → Tunables → Implementation notes → Responsive scaling), uses tables for inputs / score ranges / tunables, and its `>` tagline is a single sentence ≤ ~120 characters.
9. `INSTRUCTIONS.md` matches the template — same six numbered merge steps, same destination table (`python/apps/`, `web-toolkit/src/lib/sim-runtime/apps/`, `docs/apps/`), no leftover `<…>` placeholders, and the `BUILTIN_APPS` registry edit is mentioned (not the obsolete `APPS` array).
10. The message ends with the **two-paragraph hand-off** — the **"Done!"** line followed by the **"Want a ready-to-email `<name>.zip`?"** offer. If the user later replies with anything affirmative, produce a real downloadable `.zip` containing exactly the five files.
Now read the user's first message, decide which stage applies, and act.
Was du brauchst
- Ein KI-ChatChatGPT, Claude, Gemini oder eine andere moderne LLM. Ein kostenloses Konto reicht.
- Ein paar MinutenDer schnelle Weg (klären, ausprobieren, fertig) dauert meist 10–20 Minuten pro App.
- Eine LUMATRIX (optional)Du kannst komplett im LumenSimulator iterieren. Flashe auf einen echten Pico, wenn du bereit bist.
- Kein ProgrammierenDie KI schreibt jede Zeile. Du beschreibst, testest und gibst frei.