FooGolf developers
Documentation › docs/lua/script-store-design.md

Lua script store - design v0.1 (2026-09-15)

Status: built 2026-09-15 - the user took all three recommendations in section 6 (separate stack + public sign-up, install by share code, SVG path data), and phases 1-3 shipped the same day: stack foogolf-extensions (us-east-1, foogolf-scripts until the 2026-09-16 rename) + site https://extensions.foogolf.com, fw 0.8.516 (staging) with the Scripts row, Install by code / Remove, and canvas.path. Section 7 records what exists; phase 4 (boot-time update check, online-play version match) and phase 5 are still to do. Companion to developer-guide.md section 7 and foodoublebassesp32/docs/lua-runtime.md.

Renamed script -> extension, end to end (user decisions 2026-09-16, fw 0.8.548 wording + fw 0.8.549 wire): every surface says extension - the device's Extensions row and Settings > Extensions settings, https://extensions.foogolf.com, the admin dashboard's Extensions page - and so does every machine name, because nothing had been published yet and the user chose to get the terminology right from the beginning. The old foogolf-scripts stack was deleted and foogolf-extensions created in its place (its table, source bucket and Cognito pool were empty, so nothing was migrated); scripts.foogolf.com was dropped, not redirected. What moved: stack and every resource name, the routes (/extensions, /extensions/{extension_id}, and the device's GET /e/{code} + /e/{code}/version), the extension_id field, the X-Extension-* install headers, the device's /lfs/extensions directory (0.8.549 deletes the old /lfs/scripts once), the analytics game: "extension:<id>", the C++ names ExtensionStore / ExtensionFetch / ExtensionsConfig.h, and the repo directories extension-store/, lambdas/extensions_api/, lambdas/dash_extensions/. Sections 1-6 below are the original design and still say "script" - read it as the old word for the same thing; section 7 records what is actually deployed. Record: foodoublebassesp32/docs/menu-housekeeping-2026-09-16.md; current ids in foogolf-ota-cloud/docs/operations.md.

1. Where scripts live today (fw 0.8.514)

2. Assets: vector only, in the script text (decision proposed)

No per-script file uploads. Everything a script draws is described in its own source: the canvas API is already a vector API (AA disc, ring, line, dashed line, arc, rect, polygon, text) plus two built-in bitmaps (putter_clubhead, ball).

"SVG" should mean SVG path data, not SVG files. A full SVG renderer on the device (parser, CSS, gradients, ThorVG-class rasteriser as in LVGL 9) is ~300 KB of flash and heavy at 240 MHz; LVGL 8 here has none. What fits:

canvas.path(d, {fill=color, stroke=color, width=1, opa=255,
                x=0, y=0, scale=1, angle=0})

d is the SVG <path d="..."> mini-language (M L H V C S Q T A Z, absolute + relative). The runtime flattens curves to line segments and feeds the existing AA polygon / line fills, ~300 lines of C++. Authors can paste path data straight out of Inkscape or Illustrator, and a whole drawing stays a few KB of text inside the 256 KB script heap. This is the one addition the API needs for "images"; raster uploads are out of scope.

Why not raster: the numbers do allow ~35-50 JPEGs of 800 x 600, but a JPEG decoder, a per-script asset quota, an asset pack installer with its tmp-beside-live flash reserve, and 0.2-1 MB of PSRAM per decoded image are a subsystem of their own, and the script sandbox has no file access by design. Vector costs none of that.

3. Cloud side (proposed)

A separate small SAM stack, foogolf-scripts, not the admin dashboard: the dashboard's Cognito pool and pages show the whole fleet, so public self-sign-up cannot be turned on there. Same region, same account, own resources so it can be torn down or costed alone.

4. Device side (proposed)

5. Phases

  1. Firmware: LittleFS script loading + dynamic slots + Scripts row + Install by code (stubbed against a static S3 object first).
  2. Cloud: foogolf-scripts stack + site, publish / list / get.
  3. canvas.path (SVG path data).
  4. Boot-time update check, Remove, online-play version match.
  5. Later: browser simulator, spoken-name synthesis, account-linked push to own devices over the control channel.

7. As built (2026-09-15)

Cloud - foogolf-ota-cloud/script-store/ (own SAM project, stack foogolf-extensions, us-east-1, review-first deploy; ops notes in foogolf-ota-cloud/docs/operations.md):

Device (fw 0.8.516):

8. Moderated public listing (2026-09-15, user request, deployed the same day)

An author can never make an extension public. Rules:

Smoke (scripts/store-smoke.py, PASS 2026-09-16 against the renamed stack): publish without a description -> 400; publish with a request -> requested, absent from /public; the dashboard Lambda approves and lists it; then the usual fetch / version / delete steps.

9. Nothing embedded - the store is the only source (fw 0.8.550, user decision 2026-09-16)

The user asked for Mini golf (Lua) to be published on the public site as a public extension "so that we can practice uploading it onto a machine", and for every Lua constant compiled into the ESP32 image to go, "so that the only source for extensions will now be the cloud". As built the same day:

10. Check for updates at boot + default extensions (fw 0.8.551 + cloud, user request 2026-09-16)

The request: a "check for updates" the device calls at startup after the audio pack, passing the ids of every Lua extension it uses; the server answers with version information, or the lack of the extension if it has been taken down; the device updates its local copies with progress ("1 of 3 extensions updated"). And a default flag managed through the support dashboard: every device installs a default extension, and default extensions cannot be removed from the device.

Wire (store API, open route like the install):

POST /e/check   {"installed": [{"c": "CFZW5", "v": 14}, ...], "fw": "0.8.551", "dev": "<device id>"}
200             {"installed": [{"c": "CFZW5", "id": "minigolf_l", "v": 15, "name": ..., "kind": "game",
                                "sha256": ..., "default": false},
                               {"c": "ZZZZZ", "gone": true}],
                 "defaults":  [{"c": ..., "id": ..., "v": ..., "name": ..., "kind": ..., "default": true}, ...]}

Codes, not manifest ids, identify an extension on the wire (a manifest id is unique only per author; the code is global). defaults is every row whose default flag is set - a table scan with a filter, fine for a table of dozens; revisit with a GSI if the store ever holds thousands. The device's re-fetches carry X-FooGolf-Update: 1 so they are not counted as installs.

Default flag: the dashboard's Extensions page (ExtensionsView.jsx) has a Default checkbox on every table (a confirm before ticking) and a "Default extensions" table; foogolf-dash-extensions PATCH accepts {default: bool} (alone or with listing) and GET /extensions?state=default. The store API refuses an author's DELETE of a default extension (409 is_default) - support clears the flag first. A default extension need not be publicly listed: the flag alone installs it everywhere.

Device (fw 0.8.551): src/LUA/ExtensionUpdate builds the job list from the answer - gone -> remove, a different version -> fetch + install, a changed flag alone -> ExtensionStore::setDefault (no download), every cloud default the device lacks -> fetch + install flagged - and runs it with "Updating|Installing|Removing N of M" progress; the summary reads "Up to date" / "None installed" / "2 of 2 extensions updated" / "1 installed, 1 removed" / "1 of 3 failed". At boot it is the Extensions highlight right after the audio pack (skipped under a pending firmware offer, as the pack is; never a held page - a failure is a red line and the next boot retries); the same run is Settings > Extensions settings > Check for updates. After a change the Lua slots reload and the active experience follows its extension by manifest id (App::applyExtensionChanges). ExtensionStore::Entry::isDefault (index field d, kept across a manual re-install) makes the Remove list say "NAME (default)" and its row show a refusal page instead of the confirm.

Ownership: every extension lives under exactly one author account (the row key is <sub8>-<manifest id>; there are no orphans), and FooGolf's own extensions live under support@foogolf.com. scripts/transfer-extension.py <CODE> --to <email> [--create] moves one (new row + S3 copies, code / version / listing / default kept). Mini golf (Lua) was moved there on 2026-09-16.

Deployed 2026-09-16: both stacks (review-first changesets: store ApiFn + Api + a CheckUpdates permission; dashboard DashExtensionsFn + Api), the dashboard frontend synced + invalidated; the flag toggle verified end to end through the moderation Lambda and /e/check. Firmware 0.8.551 on staging, bench verify pending.

11. Source editor on the site (2026-09-16, user request)

"There's no way for me to look at its source. I would like a big text area that I can actually look at the source in and edit it and save my changes; then any device will see those changes and update its version." Built the same day on the My extensions page: View / edit the source on every card opens the Lua in a 65 vh monospace editor (SourceEditor in App.jsx; the text comes from GET /e/<code> without the device header, so it is not counted as an install). Save as new version (or Ctrl+S) POSTs the text through the ordinary publish route with the extension's existing description - the store compiles it first and a syntax error shows in the editor with nothing changed - and the row's version bumps under the same share code, so every device that has it installed fetches it at its next boot update check (section 10) or Check for updates. Closing with unsaved changes asks first. The manifest version inside the file is not touched by an edit; the row version is what devices follow.

12. The share code IS the id; every source is open (2026-09-16, user decisions)

What happened: the user copied Mini golf (Lua)'s source into Publish to make a new extension and it came back with the ORIGINAL's share code - the row key was <owner sub>-<manifest id>, so the same author publishing the same id was, by design, "a new version". The user's rules:

As built (cloud + site the same day, fw 0.8.553):

Not changed: ownership (one account per extension, FooGolf's under support@foogolf.com), moderation, the default flag, the update check.

6. Decisions needed from the user (decided 2026-09-15: all three recommendations)

  1. Separate foogolf-scripts stack + site with public sign-up (recommended) versus a Scripts page inside the admin dashboard (would need per-user scoping of every existing view first).
  2. Install by share code on the device (recommended for v1, no account linking needed) versus linking a device to an account and pushing from the web.
  3. canvas.path with SVG path data as the image story (recommended) versus no vector import at all (authors draw with primitives only).