FooGolf Lua API reference

Status: API specification v0.3, 2026-10-09. Implemented in slices - fw 0.8.509 (drill basics), 0.8.514 (segment physics, canvas, course, popup/box), 0.8.530 (round engine), 0.8.570 (online play, choice/toggle settings), 0.8.624 (everything the eight remaining ports needed: handicap, tees, audio.say, the make pattern / reticle / camera glide on the green, canvas.arc/image/scenery/grass, hud.move/widget_text/player_card, players.*, trends.read, util.*, ui.error/info) and 2026-10-09 (after 0.8.646): audio.sound{} and audio.note_hz, sounds synthesised on the device (12.1). The exact subset per module is tabled in foodoublebassesp32/docs/lua-runtime.md; anything marked (not implemented) below is still specification. Every facility here was derived from the built-in drills and games so that each of them can be written in Lua - and since 2026-09-26 each of them HAS been: FooGolf publishes a Lua twin of every native drill and game (the porting catalogue, porting-catalogue.md, records each). Numbers in this document (units, constants, limits) are the firmware’s real numbers, not placeholders. Things that are still a decision are marked (open).

Companion documents: developer-guide.md (start there) and porting-catalogue.md. Launch monitor drivers (the third extension kind, foogolf.monitor{}) have their own reference, monitor-drivers.md, with the Garmin R10 protocol worked through in garmin-r10-protocol.md; nothing in this document applies to a driver except section 0’s Lua notes.


0. Conventions

ThingConvention
Distancefeet, floats (hole_ft, leave_ft). The runtime never uses metres or yards.
Anglesdegrees. Face angle: positive = right (open for a right-hander), negative = left. Aim: degrees from the line to the cup, positive = right.
Club speedmetres per second (speed_mps, what the sensors measure). Ball speed in mph is derived: speed_mps * 2.23694 * impact_ratio.
Rotationdegrees per inch through the impact gate, positive = face opening, negative = closing.
Coordinates on the greenx lateral (+ right), y forward (+ toward the cup), origin at the ball’s start. The cup is at (0, hole_ft).
Colours0xRRGGBB integers.
Screen320 x 170 pixels, origin top-left.
Timenow_ms is the device’s millisecond clock (wraps after 49 days; subtract, never compare).
Stimpinteger 8..12.
Impact ratioball speed / club speed, float 1.40..1.80 (default 1.65). A property of the PLAYER and their putter - there is no device-wide value (fw 0.8.536).
StringsASCII only (the fonts carry no accents, Greek, or en/em dashes). %d, %s, %.1f are string.format.
Booleans returned by hookson_tick returns true while busy.
Optional argument tablesEvery function that takes {...} accepts missing keys and uses the documented default.

The Lua standard library available: math, string, table, utf8, coroutine, plus pairs/ipairs/select/tostring/tonumber/type/pcall/error/assert. Not available: io, os, require, load, dofile, debug, package. math.random is seeded from hardware entropy when the script starts.


1. foogolf - the manifest

A script declares exactly one experience at the top level and returns nothing.

local d = foogolf.drill{ ... }     -- a drill: current player, one number, trends
local g = foogolf.game{ ... }      -- a game: 1-4 players, a round, scoreboard

Both return the experience object on which you define hooks (section 2).

foogolf.action{ label = "New random map", run = function() ... return true end }   -- fw 0.8.528

A row of your own in the script’s menu, between Play / New and Settings (at most 2, label at most 23 chars). run is called from the menu whether or not your script is the active experience, so keep it to settings, course.new_seed(), ui.flash and the like - no drawing. Return true to start a new round at once (the menu’s Play), anything else to stay on the menu with its labels refreshed.

1.0 foogolf.store / recall / store_keys - persistent state (every kind, 2026-10-09)

Every extension kind - drills, games and launch monitor drivers alike - has the same small key/value store on the device, under the foogolf table so the API is identical everywhere:

ok, why = foogolf.store(key, value)   -- key: a string of 1-16 characters; value: a string (raw bytes
                                      -- allowed) of up to 512 bytes, or nil to delete. true, or
                                      -- false + "store full" | "value too long" | "key too long" |
                                      -- "store not open". A wrong type raises.
value   = foogolf.recall(key)         -- the string, or nil
keys    = foogolf.store_keys()        -- a table of the keys present (at most 32)

Budget: 2048 bytes of keys plus values per scope, 32 keys.

Scope and lifetime, by kind:

KindScopeSurvivesWiped by
drill, gamethe installed extension, by share codereboots, updates under the same code, the VM closing and reopeninguninstall
launch monitor driverthe paired link record (this driver + this pairing)reboots, reconnects, driver updates under the same codeUnpair, a new pairing, uninstall

Uninstall wipes the store completely, for every kind, flushed and pending values alike, so a reinstall starts empty; for a driver this holds even while its pairing record is kept. Never shared between extensions.

Flash is written lazily: store changes a table in RAM; the device flushes a dirty table to flash a few seconds later, when the VM closes (or the link drops), at uninstall and before a restart. Calling store on every shot or tick is fine; a value stored in the last moments before a power cut can be lost.

Public file, private values: the extension’s source is public on the store; the values live on the owner’s device only. Note that settings (section 11.3) remains the way to give a setting a row on the device’s menus; the store is for state the extension manages itself. (Firmware note: the store is a LittleFS file per scope under /lfs/kv, src/LUA/ExtensionKv; settings still persists its integers in NVS - moving it onto the store is a TODO recorded in monitor-drivers-design.md.)

1.1 Manifest fields (both kinds)

FieldTypeDefaultMeaning
id-ignoredThere is no id in the file (2026-09-16). The extension’s identity is its five-character share code, allocated by the store when the extension is first created and never changed; it names the trend log, the settings namespace and the analytics game. An id written here is ignored.
namestringrequiredMenu cell label and page title. At most 24 chars.
versioninteger1Bumped by you on every published change.
authorstring""Shown on the About page and the cloud listing.
spoken_namestringnameText the cloud synthesises into the name clip at publish time. The device speaks it when play starts.
speaks_stimpbooleantrueAppend ”. stimp N” to the opener. Set false when stimp has no effect on your experience (rotation-only drills).
announces_own_namebooleanfalsetrue = the runtime does NOT speak the name at on_start; you call audio.say_name() when your own pre-game page hands over.
shot_speech--Ignored since fw 0.8.672 (user, 2026-10-13): the device speaks nothing per shot and your extension owns every sound but the impact. Before 0.8.672 "device" (the default) spoke the Audio feedback cue before on_putt; set "none" if your extension must not double up on an older device.
abouttablerequiredThe About page, five sections (section 1.4).
resumablebooleandrill: false, game: trueWhether the menu offers Resume + New after leaving mid-way - including after the owner has played something ELSE (fw 0.8.570). Drills reset on Play.
keys_editingbooleantrueDeliver UP/DOWN as raw keys with auto-repeat (true) or as focus-move keys (false). Leave true.

1.2 Extra fields for drills

FieldTypeDefaultMeaning
trend{ label = string }requiredEnables the Trends menu row and trends.record. The key is id. label heads the graph (“Face angle”).

1.3 Extra fields for games

FieldTypeDefaultMeaning
players"active" or "current"“active”Seats come from the Players page’s active list (1-4) or just the current player.
onlinebooleanfalseThe round is shared across devices when this device hosts or is a guest in Online play (section 13).
scoring"device", "stroke", "match", "skins", "none"“device”"device" follows the device’s Scoring row (on Game settings, and on the game’s own Settings when it declares settings.shared("scoring")), the others fix it. "none" = the script keeps its own score.
handicapbooleanfalseThe first principles’ “handicap modifying” declaration (fw 0.8.624). true = the round engine rates the players: every r:record of a LOCAL seat with a real player record feeds that player’s handicap with the distance the putt was struck from and its leave (PuttStats_Record, the feed native Course play uses; a remote seat’s replay never does; a guest rates its own player from its own roll, as the native guest does). Declare it only when every putt is straight at the hole - no walls, no obstacles (Mini golf must not). A drill never rates anyone.

1.4 The About page

about = {
  skill_focus  = "Distance control and start line.",
  description  = "Two to four sentences ...",
  instructions = { "Open Ladder and choose Play.", "Putt to the distance shown.", ... },
  scoring      = "One point per station inside 3 ft ...",
}

The runtime renders the standard five-section page (Name from the manifest, then Skill Focus, Description, Instructions numbered, Scoring) in the house style: 14 pt body, amber 0xFFC107 section headings. All four strings are required; use "TODO" if a section is not written yet.


2. Hooks

Define these as functions on the object the manifest returned. All are optional except on_putt.

function d.on_start(resume)        -- the experience took the screen
function d.on_putt(shot)           -- a good swing was captured (section 3)
function d.on_tick(now_ms)         -- called while busy; return true to stay busy
function d.on_key(key)             -- "ok" | "up" | "down" | "back" | "left" | "right"
function d.on_stop()               -- HOME pressed or another experience selected
function d.on_setting(key, value)  -- a setting changed (menu, or host in Online play)
function g.on_mulligan(seat)       -- games: the device took a stroke back (section 10.3)
function d.on_round_over(round)    -- games: the round engine finished (section 10)
function g.state()                 -- online games: extra state to publish (section 13)
function g.apply_state(t)          -- online games: extra state arrived
function g.on_input(kind, t, from) -- online games, host only: a guest's non-putt input

2.1 Lifecycle in order

  1. Menu Play (or Resume, or boot restore of the persisted experience): the runtime clears the screen to black, loads the script’s settings, then calls on_start(resume). resume is true when the user chose Resume or the device rebooted into this experience. Draw your page here.
  2. Unless announces_own_name, the runtime speaks the opener (spoken_name + stimp) right after on_start returns.
  3. The device scans for swings. A captured good swing arrives as on_putt(shot). The device speaks nothing for it (fw 0.8.672): anything you draw, roll or say - audio.say_face_angle(shot.face_deg) included - happens inside on_putt and the ticks that follow.
  4. The runtime calls on_tick(now_ms) every frame (about 30 Hz) while any of these hold: your last on_tick returned true, a roll is playing, speech is playing, a flash or notice is up, a results countdown is running. When none holds the experience is idle: the scanner arms and the next putt can be captured. While idle on_tick is still called, but only about every 100 ms and never during a capture window (up to 0.7 s), so idle-time animation is coarse by design (scan-loop isolation is the device’s first rule).
  5. A putt while the results page is up restarts the round with that putt: the runtime calls on_start(false) and then on_putt(shot) (fw 0.8.624; every native drill behaves this way, and an online guest’s card waits for the host instead).
  6. HOME at any time: on_stop() then the menu. Your state is kept in memory but nothing of yours runs while stopped - no on_tick, no roll, no timers, no results countdown - so drop transient state (a roll in flight, a pause) in on_stop and rebuild the page from your round in on_start(true). A drill’s next Play calls on_start(false); the Resume row calls on_start(true) while a round is open (a round-over card closes it: the next start is on_start(false)). r.over tells you whether the round in memory is finished.

2.2 Time budget

A hook may run for at most 5 ms of interpreter time per call (about 10 000 simple operations on the device). A longer hook is aborted with an error page naming the hook - move the work into several ticks. Physics (putt.simulate) runs in C and does not count.


3. The shot table

Delivered to on_putt. All fields are read-only.

FieldTypeMeaning
speed_mpsnumberClub head speed at impact.
ball_mphnumberspeed_mps * 2.23694 * impact_ratio.
face_degnumberFace angle at the ball, + right / - left.
rotationnumberFace rotation rate through the gate, deg/in, + opening.
attack_degnumberAttack angle, degrees.
path_left, path_rightnumber 0..1Inner-sensor proportion at the left and right gate (the combined widget’s arrow). 0.5 = centred.
impact_rationumberThe ratio in effect for this putt: the seat’s player, else the current player.
stimpintegerDevice stimp at the time of the putt.
playertable or nilplayers.current() (the current player’s { id, name, handicap, impact_ratio }); nil when there is no player.
seatinteger or nilGames: which seat putted (1-based).
remotebooleanGames in Online play: this putt was struck on another device and is being replayed here (section 13).
time_msnumberDevice clock at capture.
idintegerMonotonic putt number since boot.

shot is also what the physics and the widget consume, so you pass it straight through: putt.simulate(shot, {...}), hud.widget(shot).


4. putt - physics

Deterministic and pure: the same inputs give the same trajectory on every device, which is what lets Online play replay another device’s putt locally. The model is the firmware’s one roll model and one cup-capture predicate.

4.1 putt.simulate(shot, opts) -> traj

local t = putt.simulate(shot, { hole_ft = 12 })
opts keyDefaultMeaning
hole_ftrequired unless courseCup distance straight ahead.
aim_deg0Added to shot.face_deg (mini golf style explicit aim).
from{x=0,y=0}Ball start (a continuation putt in a course).
stimp, impact_ratiofrom shotOverrides.
wallsnilSegment-table form (fw 0.8.514): a list of wall segments {x1, y1, x2, y2} in feet (screen convention: x right, y down). With walls the engine rolls the ball through the segment table instead of the straight line; from is then required, hole_ft is not used, and the angle is aim_deg + shot.face_deg measured from +x, clockwise positive. Walls are two-sided, half_ft thick each side of the line (default putt.wall_half_ft = 0.2, the drawn band), with round ends.
from{x=0,y=0}Ball start (a continuation putt).
cupnil{x, y} of the cup in the segment form (omit for a table with no cup).
half_ft0.2Wall half-thickness in the segment form.
sample_ms50Spacing of traj.points.

4.2 The trajectory table

FieldTypeMeaning
outcomestring"holed", "lipped_out", "short", "long", "wide". lipped_out = entered the cup mouth and was not captured (rim reflection); short = stopped before the front rim on its line; wide = passed the cup at least as far sideways as it went past it; long otherwise.
holedbooleanoutcome == "holed".
entered_cupbooleanholed or lipped out.
sidestring or nil"left" / "right" for a miss, by the sign of miss.x.
cuestringThe one-word verdict the built-in drills speak: "good", "short", "left", "right", "long" (Close-in tiers, section 4.4).
rest{x, y}Resting position in feet from the ball start.
miss{x, y}Resting position relative to the cup (rest.x, rest.y - hole_ft).
leave_ftnumberDistance from the cup at rest, 0 when holed.
inside_3ftbooleanleave_ft <= 3 (the lag drills’ station rule).
roll_ftnumberStraight-line rollout ignoring the cup (putt.roll_ft).
duration_snumberRoll time including the sink.
liptable or nilOnly when lipped_out: { entry_speed_fps, exit_deg, exit_speed_fps }. Exit keeps 25 % of the entry speed, in the firmware’s rim-reflection direction.
bounceslistSegment form only: { {t, x, y, wall = i}, ... } (wall is the 1-based index into walls).
lipped_outbooleanSegment form: entered the cup mouth and was rejected at least once (the closed-form path reports it as outcome).
travel_ftnumberSegment form: path length actually rolled, bounces included.
pointslist{ {t, x, y, v}, ... } sampled every sample_ms, v in ft/s.
t:at(t_s)methodInterpolated x, y, v at time t_s. Use it to animate on your own canvas.

4.3 Model summary (so you can explain a verdict to a player)

  • Rollout: ball speed in mph v, roll_ft = v^2 / (2 * (a0(stimp) + 0.0645 * v)), with a0 = 0.804 at stimp 8, 0.580 at 10, 0.433 at 12 (linear between, clamped). Fitted to GSPro within 1.5 %.
  • Deceleration is constant along the line: decel = v0^2 / (2 * roll_ft).
  • Capture at the cup: at the moment the line is closest to the cup centre, offset^2 + (speed / 32)^2 < r^2 with r = 0.177 ft (4.25 in cup). A putt dying at the front lip still drops; a putt at 3 ft/s or more needs to be nearly dead centre.
  • A rejected entry reflects off the rim and keeps 25 % of its speed.
  • Cup radius 0.177 ft, ball radius 0.070 ft: putt.cup_radius_ft, putt.ball_radius_ft.

4.4 Helpers

putt.roll_ft(shot)                       -- straight rollout, feet
putt.make_distance_ft(shot)              -- longest cup distance this exact putt would drop at; 0 = none ("Missed")
putt.would_hole(shot, hole_ft)           -- boolean, no trajectory needed
putt.would_capture(roll_ft, off_ft, hole_ft, stimp)   -- 1-D: a putt with this rollout, this far off the centre line
putt.make_pattern(hole_ft, stimp)        -- outline of every resting spot a dropped ball could have reached: list of {x,y} (x lateral, y forward), up to 32 points, for green.show{ pattern }
putt.at(traj, t_s)                       -- x, y, v interpolated from traj.points (segment form); also traj:at(t_s)
putt.aim_path{ walls, from, cup, aim_deg, max_ft = 60, legs = 3, half_ft }   -- the preview: list of {x,y} points (start first), plus at_cup and len_ft; each leg leaves a wall with the physics' direction change, not a mirror
putt.cue(traj)                           -- the spoken tier for any trajectory (same as traj.cue)
putt.dominant_miss(traj)                 -- "left"|"right"|"short"|"long" by the larger axis of traj.miss (the Lag putting rule)

traj.cue tiers, in order: holed = good; roll_ft < hole_ft - cup_radius = short; if a straight putt at this pace would have dropped = left/right; else |miss.x| >= miss.y = left/right, otherwise long.


5. course - mini golf holes (fw 0.8.514)

The firmware’s procedural course generator, exposed as a helper that returns a hole in the engine’s own format: the walls are segments, the fairway is a list of cells for the script to paint, and nothing in the engine cares where the lines came from (a script may hand-lay its own).

local hole = course.generate(seed, index)   -- deterministic; index 1-based
hole.par            -- 1..3 from the playability model
hole.tee            -- {x, y} feet          hole.cup   -- {x, y}
hole.walls          -- list of {x1, y1, x2, y2}: the drawn-band centrelines, offset 0.2 ft outside the fairway edge
hole.cells          -- list of {x, y, diag}: fairway cells (2 ft squares, top-left corner); diag 0 whole, 1..4 = the NE/SE/SW/NW half is wall
hole.bounds         -- {x0, y0, x1, y1} feet, fairway plus the wall bands
hole.route_ft       -- length of the generator's own solve from the tee
hole.aim_deg        -- the solve's launch angle from the tee (screen convention)
hole.cell_ft, hole.wall_ft                  -- 2 and 0.4
course.default_aim(x, y)                    -- deg, route_ft from any point on the LAST generated hole (straight in when clear, else via the best waypoint)
course.seed()                               -- the device's course seed (Mini golf's own; minted when unset, so both cells show the same course)
course.new_seed()                           -- mint another (Settings > Regenerate course)
course.prefetch(seed, index, count)         -- generate those holes on core 0 into the runtime's course cache (each hole of a seed is generated once and kept; the par model costs seconds on the device); course.generate serves from the cache (fw 0.8.521)

putt.simulate(shot, { walls = hole.walls, cup = hole.cup, from = ball, aim_deg = aim }) rolls the ball through it; putt.aim_path{...} previews it. Wall physics: restitution 0.65 normal, 0.92 tangential, round corners, the common cup predicate and 25 % rim rebound; 2 ms steps capped at 15 s; the ball stops below 0.03 ft/s.


6. green - the perspective putting green page

The shared first-person green every rolling drill and game uses: a 30 ft green, the cup on the left, the 3 ft dashed ring, a flag, previous balls, the pulsing waiting-ball halo, the approach-zoom camera during a roll, the sink animation and cup rattle. You describe the scene; the runtime draws it into a full-screen canvas.

6.1 green.show(opts)

Shows (or re-renders) the idle view.

opts keyDefaultMeaning
hole_ftrequiredCup distance. Sets the camera.
balls{}Previous resting balls { {x, y, inside = bool}, ... } (green when inside, red when not; up to 20).
ballnilThe live ball {x, y} if not at the start.
halotrueThe halo on the waiting ball AND on a resting ball (Lag putting’s native keeps it on the resting ball; fw 0.8.626 - before, a resting ball never had one). A picture drawn by green.show is static, so the halo sits at its rest size, the same pixels every time; it breathes only while green.roll animates.
flagtrueDraw the flag; "faint" = 50 % pole/pennant and no cup (the Lag putting settled view).
start_markernot haloThe grey start dot. Its default follows the natives: shown only when no waiting ball sits on it (fw 0.8.626; it was always on before, and its rim showed under the ball).
patternnilList of {x,y} from putt.make_pattern drawn as the translucent make-pattern polygon over the scene (and over the roll when passed to green.roll). Absent = none.
animate_ms0When a green is already up and hole_ft changed, the camera glides from the old distance to the new one over this many ms (ease-out; Lag putting’s 850 on a new target). Busy time.
overlay-(not implemented) a Lua drawing callback; pattern and green.reticle cover the built-in overlays.

6.2 green.roll(shot, opts) -> traj

Simulates and animates the putt on the green. Returns the trajectory at once; opts.on_done(traj) is called when the ball has settled (or the moment after the next tick when Feedback is set to instant, in which case the ball is drawn straight at its rest and nothing rolls).

opts keyDefaultMeaning
hole_ftrequiredAs putt.simulate.
balls{}Previous balls to keep on the green.
on_donenilCompletion callback.
flashnilColour to flash at rest (0xFFD700 gold for holed, 0x00FF00, 0x44AAFF), 600 ms, or {color, ms}.
instantdevice.instant_feedback()Force instant or rolling. Games pass false (games always roll).
overlay, patternas showKept during the roll.
widgetnilA shot to show the combined widget bottom-right during the roll.

The roll is busy time: on_tick keeps being called, OK or BACK on the page ends the roll early (the ball is placed at its rest), HOME stops the experience.

6.3 Others

green.flash(color, ms)               -- feedback disc over the current view (600 ms default, holed 1200)
green.reticle(x, y, color)           -- the sight reticle over a resting ball (Lag putting's settled view); green.reticle() clears it, and so does the next show()
green.to_screen(x, y)                -- world feet -> screen px under the current camera frame
green.frame()                        -- the current camera frame { scale, fwd_max }

Colours the page uses, for your overlays to match: green outer 0x1A6004, inner 0x2E8B22, cup 0x111111, ring white, inside ball 0x40C040, miss 0xFF0000, flag 0xE03020.


7. canvas - draw your own picture

For drills whose picture is not a putting green (the circle-edge spread of Face angle, the stripes of Arc strength, the first-person strip of Make distance, the mini golf course). One full-screen 320 x 170 RGB565 canvas in PSRAM, allocated when you first call canvas.begin and freed when the experience stops. Coordinates are floats; curves are drawn with fractional coverage anti-aliasing (the firmware’s standing rule: never int-snap a curve).

canvas.begin(bg)                              -- (re)create the canvas, fill with bg, put it under the labels; returns false if PSRAM is short
canvas.fill(color)
canvas.disc(x, y, r, color, opa)              -- AA filled disc; opa 0..255, default 255
canvas.ring(x, y, r, width, color, opa)
canvas.line(x1, y1, x2, y2, width, color, opa, cap)   -- cap "round" (default) | "square" (runs half the width past each end: flush right-angle corners) | "butt"
canvas.dashed_line(x1, y1, x2, y2, width, on, off, color, opa)
canvas.arc(cx, cy, r, a0_deg, a1_deg, width, color, opa, cap)   -- clockwise from 3 o'clock (y down); a full circle when the sweep reaches 360; one coverage value per pixel (a translucent arc never beads); cap "round" (default) | "square" | "butt" shapes the ends
canvas.rect(x, y, w, h, color, opa)           -- fractional edges, AA coverage
canvas.rect(x, y, w, h, color, opa, radius | { radius=, shadow=, shadow_x=, shadow_y=, shadow_opa=, shadow_color= })
                                              -- with a 7th argument (fw 0.8.626): the device's OWN rectangle
                                              -- renderer - integer corners, a corner radius ("circle" for a
                                              -- disc) and the blurred drop shadow LVGL objects carry. What a
                                              -- native page draws its rounded discs and raised bands with.
canvas.polygon(points, color, opa)            -- {{x,y},...} filled, even-odd, AA rows
canvas.polygon(points, color, opa, "lvgl")    -- the device's own polygon renderer instead (integer corners; fw 0.8.626)
canvas.polyline(points, width, color, opa, rounded)   -- the device's own line renderer (an lv_line): width px, flat ends unless rounded (fw 0.8.626)
canvas.text(x, y, text, size, color, align)   -- size 8|12|14|18|24|32|48; align "left"|"center"|"right"
canvas.image(name, x, y, zoom, angle_deg)     -- the built-in "putter_clubhead" asset with its pivot at (x, y); zoom in LVGL units (256 = 1:1; Arc strength uses 18); rotated about the pivot
canvas.path(d, {fill=, stroke=, width=1, opa=255, x=0, y=0, scale=1, angle=0})
                                              -- SVG path data (fw 0.8.516): d is the <path d="..."> string
                                              -- (M L H V C S Q T A Z, absolute + relative); filled even-odd
                                              -- across all subpaths and/or stroked, both anti-aliased; the
                                              -- placement translates / scales / rotates the path's own
                                              -- coordinates. Paste the d attribute from Inkscape or
                                              -- Illustrator. Limits: 2048 flattened points, 64 subpaths;
                                              -- an unparsable d raises an error naming canvas.path.
                                              -- This is the image story: pictures are vector, in the
                                              -- script text, never uploaded files (user decision 2026-09-15).
canvas.scenery([theme])                       -- paint the Settings > Visual settings scenery (or theme index 1..N) over the whole canvas, sky above y = 85
canvas.grass(y0, y1 [, "texture" | color])    -- rows y0..y1 in the Grass settings BASE colour (default), the baked grass texture Swing path uses ("texture"), or a colour
canvas.hspan(y, x0, x1, color, opa)           -- one row's span [x0, x1) with fractional ends: the primitive every AA row of a native drill is built from (fw 0.8.626)
canvas.ellipse(cx, cy, rx, ry, color, opa)    -- AA filled ellipse with the natives' cap-row fade (Make distance's cup, rings and ball; fw 0.8.626)
canvas.vline(x, y0, y1, color, opa)           -- a vertical hairline at fractional x: two columns weighted by the fraction, row coverage at the ends (fw 0.8.626)
canvas.arc_band(xo, y_mid, r, thick, dy_lo, dy_hi, color, opa)
                                              -- the LEFT-hand arc of the circle centred (xo, y_mid) between the rows y_mid - dy_hi and
                                              -- y_mid - dy_lo, cut flat on those rows - the circle-edge drills' history band (fw 0.8.626)
canvas.trace(x0, ys, thick, color [, dash [, col_w]])
                                              -- a column trace (fw 0.8.626): ys[i] is the (already rounded) row at column x0 + i - 1; each
                                              -- column a col_w (default 2) x thick block bridged to the previous column's row, drawn with
                                              -- the device's rectangle renderer; dash > 0 gates the columns on the ABSOLUTE x (dash on,
                                              -- dash off). Arc strength's arcs and dashed guides.
canvas.save()                                 -- keep a copy of the current canvas as a backdrop (one copy; costs 109 KB)
canvas.restore()                              -- memcpy the saved backdrop back (a static scene under a moving ball)
canvas.restore_rect(x, y, w, h)               -- the same for one patch (the ball's old position)
canvas.invalidate(x, y, w, h)                 -- redraw only this patch on the TFT and the cast
canvas.width, canvas.height                   -- 320, 170

Pixel parity with the natives (fw 0.8.626). Every anti-aliased primitive above is the firmware’s own routine (src/UI/AaDraw.h, ONE copy shared with the native drills), and the LVGL-rendered ones (rect with a 7th argument, polygon with "lvgl", polyline, trace, text, image) call the device’s renderer as a native page does. A twin that draws with the same primitive and the same numbers puts down the same pixels - the 2026-09-27 comparison in foodoublebassesp32/docs/lua-runtime.md shows every FooGolf twin bit-identical to its native. Two numbers that matter when you transcribe C++: Lua numbers are 32-bit floats here (so math.sqrt, math.cos are the C sqrtf, cosf), and the order of a float expression is the order of its rounding - keep the native’s.

Per-frame cost matters for the LAN and support cast: redraw and invalidate only what moved (a rolling ball’s patch), the way Mini golf does. A full-screen invalidate every frame is legal but costs the cast a whole frame each time.

PSRAM. If the canvas cannot be allocated (PSRAM is close to its limit with a cast running) canvas.begin returns false and every draw call is a no-op; the runtime shows a red “No PSRAM for the picture” note and your script keeps running. Never stall on it.


8. hud - labels and the standard widgets

LVGL text objects layered over the canvas or the green. They survive canvas redraws. Fonts: 12, 14, 18, 24, 32, 48.

hud.title(text)                     -- top-left 18 pt white (the house position for the drill name / hole line)
hud.value(text, size)               -- top-right big readout (48 pt default; 32 for games)
hud.label{ id = "score", text = "7.5", x = 160, y = 60, size = 32, color = 0xFFFFFF, align = "center", width = nil }
hud.set("score", "8.0")             -- update by id; nil text removes
hud.move("score", x, y)             -- reposition (the Face angle labels track the picture)
hud.remove(id)
hud.clear()                         -- every label
hud.indicators(hits, done, total)   -- bottom-left tick/cross/dash row (green tick, red cross, grey dash), wraps
hud.widget(shot, corner)            -- the combined shot readout (section 8.1); corner "br" (default) | "bl" | "tr" | "tl"; hud.widget(nil) removes
hud.widget_corner{ points = {{x,y},...}, avoid = {x, y, r} }   -- the corner a rolling ball's screen path touches least, never over the cup (fw 0.8.518)
hud.widget_text(shot)               -- the widget's formatted strings {face, speed, rotation} for your own labels (Swing path's 48 pt readouts)
hud.text_width(text, size)          -- w, h in px of a string in that label font (fw 0.8.624) - place or collision-test your own labels
hud.strike(id, on)                  -- (not implemented; an untethered device leaves the round, nothing is struck through)

8.1 The combined shot readout

hud.widget(shot) places the standard widget at the standard spot: bottom right, 6 px in from the edge, 76 px tall so it clears the ball’s row on the green. It shows the face angle in 24 pt (“Straight”, “L0.2”, “R1.1”), the club speed as mph or as feet of roll (the user’s Settings > Speed display choice), the rotation rate (“+0.35 deg/in”), and the path/lie arrow from path_left/path_right scaled by the user’s Skill level. The material colour, shadow and top-light are the device’s tuned look; you cannot restyle it and you should not draw your own - players read the same widget in every experience. A Browser sim game gets the same widget from foogolf.js (api 6, web-games.md) with no call at all, under the same rule.

Call it once per putt; it persists until you call it again or clear the HUD. During a green.roll pass the shot as widget = shot so it rides on the rolling page too.


9. ui - pages, flashes, results, menu

ui.results{ title = "Ladder", rows = { {"Score:", "7/10"}, {"Putts:", "23"} }, bg = 0x000000, restart_ms = 10000, on_restart = fn }

The standard “Drill over” page: title 32 pt white, “Drill over” 32 pt 0xFE4D26, two label/value rows in 24 pt, and the shared auto-restart countdown (“Restarting in Ns”, 10 s everywhere - a user decision). Any key but HOME restarts at once. on_restart is called before on_start(false). For games use ui.results_card.

ui.flash(text, ms, color)           -- 2.5 s overlay in 24 pt (default amber 0xFFC107); the page underneath stays live
ui.notice(text, ms, color, size)    -- (not implemented) full-screen held notice; ui.box is the runtime's own notice
ui.box(text, ms, color)             -- a translucent centred box over your picture (mini golf's "Holed!")
ui.popup{ rows = {"Cancel", "Repeat aim"}, on_pick = function(i) end }   -- the blue selector, UP/DOWN/OK, BACK closes
ui.menu()                           -- leave to the menu (what OK does in most drills)

At most 6 rows are yours. The device may append one of its own after them - the Mulligan row, whenever your game has an on_mulligan hook and the round has a stroke to take back (section 10.3) - so your on_pick(i) indices always stay 1..#rows and a row you did not write never reaches your callback. House rule for a round-engine game: open ui.popup on OK and let the device put Mulligan in it. Offer only rows that would do something; build them conditionally rather than ignoring a pick.

ui.scorecards()                     -- the scorecard browser (section 10.4) - the one scorecard page a Lua round has
ui.number{...} / ui.choice{...} / ui.keyboard{...}   -- (not implemented) settings.* rows are the input pages
ui.info(title, body)                -- a scrolling content page with a green heading (from a foogolf.action row; it returns to the menu itself)
ui.error(text)                      -- red held error page naming the script (a failure must always end on an error page); stops the experience

Every page swap that follows a key press is deferred by the runtime, so it is safe to call these from on_key.


10. round - the round engine for games

The engine behind Golf course and Mini golf: 1-4 seats, holes with a tee distance or a par, honour and away-player turn order, lock-step turns in Online play, stroke/match/skins scoring, the player card, the turn queue, the scorecard and the results card. A game that fits this shape gets multiplayer, scoring and every card for free; a game that does not can keep its own tables and use only ui.*.

Implemented: fw 0.8.530 (the Mini golf port) round.new with holes / par / seats / pick_up / one_putt / scoring, every read in 10.2, r:record / r:undo / r:announce_turn / r:place, hud.score_strip and ui.results_card; fw 0.8.570 lock-step online seats; fw 0.8.624 (the Course play port) tees, r:tee_ft, hud.player_card and the handicap feed. Not implemented, and not needed by any port: deck (Close-in stayed a drill with a local deck), hud.hole_line (it is hud.title(r:hole_text())), ui.scorecard(r) (the browser ui.scorecards() is the one card), r:restart, r:remaining_ft (the third value of r:ball).

10.1 round.new(opts) -> r

opts keyDefaultMeaning
holes91..18.
par2Number, or function(h) -> par (mini golf: the hole’s own par). The function is asked lazily and its answer cached; return nil for a hole you do not know yet (not generated) and the engine uses 2 uncached.
teesnil"usndp": the fixed 18-distance set {2,2,3,4,5,6,8,9,11,14,16,18,21,24,28,33,40,52} shuffled once (a shorter round takes the first holes of the shuffle); "random": one per hole in [min_ft, max_ft] (two more round.new keys, defaults 4 and 30); or a list of feet (repeated if shorter than the round). With tees every ball starts every hole at (0, 0) with that far to go (r:ball reads it; r:place is not needed), the dealt distances ride to online guests, and r:tee_ft(h) reads them. Games that use a course pass nothing (Mini golf).
seats"active""active": the Players page’s active list (1-4); "current": the current player only. A device with no players seats “Player”.
one_puttfalseEvery putt ends the seat’s hole (Close-in).
pick_upnilStroke cap per hole; the seat is marked holed at the cap (r:record returns picked_up = true).
scoring"device""device" follows the device’s Scoring row; "stroke", "match", "skins", "none" fix it. Solo match play / skins play against a Par seat built from each hole’s par.
min_ft, max_ft4, 30The range tees = "random" deals from.
deck-(not implemented) tees is the one dealt thing a round has.

One round per script: a second round.new replaces the first.

10.2 Reading the round

r.hole            -- current hole, 1-based; r.holes; r.scoring ("stroke" | "match" | "skins" | "none")
r.turn            -- seat index on turn (nil once the round is over)
r.seats[i]        -- { name, color (palette int), color_name ("red"...), id, following = true }
                  --   (following is ALWAYS true since fw 0.8.623: a device that untethers leaves the round)
r:strokes(seat, h)     -- h defaults to the current hole
r:holed(seat); r:teed(seat)
r:ball(seat)      -- x, y, rem_ft: where the seat's ball is and its distance to go (what r:record / r:place stored)
r:par(h)
r:tee_ft(h)       -- the dealt distance of hole h (round.new{ tees }), nil for a round without tees
r:order()         -- the turn queue: un-teed in honour order, then the furthest not-holed, then holed
r:my_turn()       -- true when the seat on turn belongs to this device (always true offline)
r:complete()      -- every seat holed on every hole
r:score_text(seat, short)   -- "E", "+3", "2 up", "1DN", "3 pt", "2 sk" per the scoring type
r:leading(seat)   -- true when the seat leads (the strip colours it red 0xC0392B, others blue 0x2C6FBB)
r:to_par(seat)    -- stroke play, completed holes only (a hole with no strokes = sat out, skipped)
r:total(seat)     -- strokes over the round
r:skins_value()   -- what the current hole is worth (1 outside skins); r:skins_halved() true right after a halved hole
r:hole_text()     -- "Hole 3/9   Par 2" (+ "   2 skins" when the hole is worth more) for hud.title
r:results()       -- ordered list of { seat, name, text, total, winner = bool } (best first) for the results card

10.3 Driving the round

r:place(seat | nil, x, y, rem_ft)   -- put a ball (nil = every ball) somewhere with its distance to go: the tee at a hole's start
r:record{ seat = s, holed = t.holed, x = t.rest.x, y = t.rest.y, leave_ft = ft }   -> outcome, picked_up

Counts one stroke for the seat (default: the seat on turn), stores the leave (the next stroke’s distance, which decides the away player), advances the turn or the hole, and returns "next", "hole_done" (the engine is already on the next hole: place the balls and draw it) or "round_over", plus whether that stroke was a pick-up. Honour on hole 1 is dealt by the runtime - random, alternating devices in Online play (a user rule); later holes go fewest-strokes-first with stable ties; within a hole the furthest ball plays next.

r:announce_turn{ distance = false }   -- "hole N . red player [. 12 feet]"; colour words are the seat palette
r:undo() -> seat | false             -- un-count the last stroke and put that ball back (mulligan); the seat is on turn again

r:undo() takes back the last stroke played, wherever the round has since got to. If that stroke finished a hole it un-advances the hole too, so re-read r.hole after calling it - the round can be on a different hole than it was a line earlier. Every seat’s ball, holed / teed state and the honour order come back with it, and the seat that played the stroke is on turn. It is one stroke deep (a second call in a row returns false), and it refuses once the round is over, because a finished round has already filed its scorecard. The handicap sample the putt fed is not withdrawn - the putter really did swing; it is the score that is given back.

The device offers a mulligan for you: on_mulligan(seat)

You do not have to write a Mulligan row, and you should not. Define

function g.on_mulligan(seat)   -- seat is 1-based; the round is ALREADY rewound

and the device appends its own Mulligan row to any ui.popup you open while there is a stroke to take back. It does the rollback itself and then calls your hook so you can put your own presentation back - your per-seat aim, your drawn ball - and redraw. The pick never reaches your on_pick, and the row never appears when there is nothing to undo, so a player is never offered something that does nothing.

Defining the hook is the opt-in: without it the row is never offered, because only you know where you drew your ball. Because the rollback may have moved the round back a hole, redraw from the round - reload your hole geometry and do not re-place the balls; their restored positions are the whole point.

function g.on_mulligan(seat)
  if last then aims[seat] = last.aim end   -- aim as that putt was played
  last = nil
  loadHole()                               -- r.hole may have moved back
  hudUpdate()
  drawIdle()
end

10.4 Standard pictures for a round

hud.score_strip(r, y)        -- mini golf's strip at y (default 150): one dot + score per seat, leader red; hud.score_strip(nil) removes it
hud.label{ id = "turn", text = "Bob   Shot 2", x = 312, y = 4, align = "right", dot = r.seats[r.turn].color }   -- a 10 px seat dot left of the text
ui.results_card(r, { title =, bg =, restart_ms = })   -- the round-over card ("Round over - <Scoring>"): every hole, par, each seat's strokes, totals, score; winner in gold; the 10 s countdown then on_start(false)
ui.scorecards()              -- fw 0.8.533: the scorecard page - the round in play, then the last 8 rounds since the restart, each titled by its scoring, with the Golf course scorecard's keys (middle keys scroll, BACK older, RIGHT newer, HOME/OK close). Offer it as the menu row: foogolf.action{ label = "Scorecard", run = function() ui.scorecards() end }
hud.player_card(r)           -- fw 0.8.624: Course play's bottom-left card - the turn queue above (next to hit lowest), then the seat on turn's line: white hole box, seat dot, name, score box (red leading / blue trailing), "Shot N"; rebuilt by the runtime as the round moves; hud.player_card(nil) removes it
hud.hole_line(r)             -- (not implemented) it is hud.title(r:hole_text())
ui.scorecard(r)              -- (not implemented) ui.scorecards() is the one card

10.5 Handicaps

Decided by the first principles (2026-09-26): a game declares whether it is handicap modifying - foogolf.game{ handicap = true } (section 1.3). The engine then rates the players from r:record exactly as native Course play does, for local seats only, and the trend / analytics rules are unchanged. A drill never rates anyone, and a game must not claim it unless every putt is a straight, realistic putt at the hole. The user guide warns owners that a careless game making the claim can distort a handicap the way a careless extension can distort their analytics.


11.1 players (fw 0.8.624)

players.current()   -- { id, name, handicap (number|nil - nil until the player has a rated putt), impact_ratio }, or nil
players.active()    -- list of the same, in the Players page's active order, at most 4
players.count()     -- how many are active

The Players page is device UI; scripts never add, rename or activate players.

trends.record(value)         -- append the drill's ONE number for the current player; call it once at "drill over"
trends.read(n)               -- the last n {when, value} for the current player, oldest first (n <= 64; fw 0.8.624)

The trend key is the extension’s share code. The device’s Trends row (player pick, graph with the raw line in blue and the smoothed line in gold, axis extending below zero for net scores, Delete trend data) is attached to the menu automatically. A game never records a trend.

11.3 settings

Declared once at the top level; each declaration adds a row to the experience’s Settings submenu, persists the value on the device, and (in Online play) rides in the host’s config so guests play the same settings.

settings.number{ key = "min_ft", label = "Min distance", unit = "ft", min = 4, max = 120, default = 4, step = 1 }
settings.choice{ key = "look", label = "Appearance", options = {"Classic", "Stripes", "Clean"}, default = 2 }
settings.toggle{ key = "yellow", label = "Hole style", on = "Yellow", off = "Cup lines", default = true }
settings.shared("skill_level", "stimp", "shots_per_drill", "feedback")   -- pull the device-wide rows into your submenu
settings.shared("stimp", "scoring")   -- a game that scores by the device's Scoring row shows it on its Settings (fw 0.8.673)
settings.action{ label = "Regenerate course", run = function() ... end }

settings.min_ft            -- read (numbers, 1-based option index for choice, boolean for toggle)
settings.set("min_ft", 6)  -- write from the script (an UP/DOWN override that should persist); calls on_setting

Limits: at most 8 declared settings per script, keys [a-z0-9_] at most 11 chars, values are integers (choice = index, toggle = 0/1); a choice takes at most 6 options of at most 15 characters. The row label shows the live value after a colon (“Min distance: 4 ft”, “Background: Green”) - the house rule since 2026-09-16. A choice row opens the device’s list page; a toggle flips in place. settings.shared takes "stimp", "shots_per_drill", "feedback" (the Drill feedback row: Instant / Real time, read with device.instant_feedback() - every drill that rolls a ball declares it, since firmware 0.8.665; older firmware refuses the name) and "skill_level" (no row: Path/lie difficulty lives on Game settings only). An experience that declares no settings has no Settings row. Changing a setting by hand on a guest device unsyncs it from the host, exactly as for the built-in experiences.

In Online play a host’s settings ride to its guests (fw 0.8.570): the first four settings.number / choice / toggle values go out with the host’s experience, keyed by the order you declared them, together with a hash of your extension’s identity - a guest running a different extension applies nothing. Declare the settings that matter to the shared round first, and do not reorder them between versions while a group might be mixing them.

Implemented since fw 0.8.570: settings.choice, settings.toggle. settings.action is not - a menu row is foogolf.action{}.

11.5 analytics - the user’s own shot-data endpoint (fw 0.8.530)

If the owner has set up Settings > Analytics settings (a server of their own, on their own network — see the user guide), the device POSTs each shot they aimed at a known target to it, once, as JSON. A script opts into that by declaring what the player is aiming at.

analytics.enabled()                -- true when the owner has configured an endpoint
analytics.target(ft, opts)         -- declare the standing target
analytics.target(nil)              -- clear it: nothing is forwarded again until you declare one
opts fieldDefaultMeaning
kind"hole"What it is, for the analyst: pin, station, hole, … Lower-case a-z0-9_, at most 11 characters.
hole0Hole or station number, 1-based. 0 = not applicable.
stroke0Which stroke this is at that target, 1-based. 0 = not applicable.

ft must be > 0 and <= 300; anything else is an error, not a clamp. Omitted opts fields go back to their defaults on every call, so pass the whole table each time.

Declare the target when you DEAL it, not when the putt arrives. The device asks for the target immediately before on_putt runs — that is the only moment it is still the target the player was aiming at. In practice the natural home is wherever the distance or the stroke count changes, which is usually the same function that redraws the page:

local function draw()
  green.show{ hole_ft = hole_ft }
  hud.value(util.ft_text(hole_ft))
  analytics.target(hole_ft, { kind = "pin", hole = 1, stroke = putts + 1 })
end

The target is cleared for you on activation and on every restart, so a script that forgets to re-declare goes quiet instead of reporting a stale distance.

What a script can and cannot do here. You choose which shots are reported and what they were aimed at. You cannot send a message yourself, you cannot send more than one message per shot, and you cannot invent a measurement: the speeds, angles, player and device all come from the device. The payload’s game field is "extension:<your id>", so an extension can never be mistaken for — or impersonate — a built-in game in the owner’s data.

Do not declare a target the player is not actually putting straight at. A mini-golf-style hole where the ball banks off walls has no straight target, and neither does a drill about face angle alone; the built-in games make exactly the same judgement (Mini golf and the face-angle drills forward nothing). A script that reports nonsense targets quietly ruins the owner’s own data set, which is why the user guide warns owners about running scripts at all.


11.4 device - the device-wide settings, read-only

device.stimp()               -- 8..12
device.impact_ratio()        -- 1.40..1.80, the CURRENT PLAYER's (fw 0.8.536: no device-wide value)
device.shots_per_drill()     -- 3..20 (Settings > Game settings)
device.instant_feedback()    -- "Drill feedback: Instant | Real time" (the drill's Settings, settings.shared("feedback"))
device.scoring_type()        -- "stroke"|"match"|"skins" (the Scoring row: the game's Settings, settings.shared("scoring"))
device.skill_level()         -- 1..4 and device.skill_name(); device.path_lie_sensitivity() = 2|5|8|12
device.speed_display()       -- "mph"|"ft"
device.name()                -- the device's cloud label ("Ferocious ferret"; fw 0.8.624)
device.skill_name()          -- the Skill level's word (fw 0.8.624)
device.online_role()         -- (not implemented) net.role() is the same answer
device.following()           -- (not implemented) a seat listed in r.seats is tethered
device.now()                 -- ms clock
device.log(text)             -- ONE string into the Env log (format it yourself); Settings > Dev > Logging;
                             -- on the emulator a black line in the page's log pane (fw 0.8.642), as is print()

12. Audio

The device has a fixed vocabulary of speech clips plus tones; scripts can say anything the vocabulary spells, and the cloud synthesises spoken_name (and, open, a short list of extra words declared in the manifest) at publish time.

audio.say(...)                -- fw 0.8.624: words and numbers in order: audio.say("left", 2.5) ; audio.say("hole", 3, "red", 12, "feet")
audio.say_distance(ft)        -- "twelve feet" (1..250)
audio.say_face_angle(deg)     -- "straight" | "left two point five"
audio.say_rotation(rate)      -- the magnitude: "point three five" | "one point two" | "zero" (say "negative" with audio.say_cue)
audio.say_cue(cue)            -- "good"|"short"|"left"|"right"|"long"|"missed"|"negative"
audio.say_name()              -- (not implemented; spoken_name synthesis is still open, so the opener is the stimp alone)
audio.tone(name)              -- "hit" | "miss" | "holed" | "game_over" | "key" | "fanfare"
audio.fanfare()               -- the boot fanfare (a round-over celebration)
audio.rattle(speed01)         -- (not implemented; the green's own sink plays it)
audio.sound{...} -> started   -- fw 0.8.647: a sound of your own, synthesised on the device (12.1)
audio.note_hz("C#5") -> hz    -- 554.37; A0..G9 with # or b, an error on anything else
audio.busy()                  -- speech, a tone or a sound is still playing
audio.stop()                  -- silences everything: speech, tones and sounds

Vocabulary for audio.say: left right straight long short good missed negative now point hundred feet mph stimp hole player impact ratio speed and the colour words red blue green yellow orange purple white pink; numbers 0..150 (whole), a number with an exact tenth is read to one decimal (“two point five”), any other fraction to two decimals digit by digit (“one point six five”). Every argument is a part separated by a 300 ms pause, except a unit word (feet, mph, point) which joins the number before it; an unknown word is an error. At most 20 clips per call. A say while speech is playing chains behind it; a tone plays at once and is not queued. The runtime holds the experience busy until speech drains (capped at 2.5 s past your last on_tick == false), so a verdict is never cut off by the next capture window.

Rule from the built-in drills: draw first, then speak. Start the cue from the tick after your redraw, so the picture is on the TFT when the word plays.

12.1 audio.sound{...} -> started

A sound of your own. The device cannot carry a library of clips (the speech pack already fills its share of PSRAM and flash sits at the update ceiling), so you describe a sound with a handful of parameters - the vanilla set every retro effect generator shares (sfxr, ZzFX, PICO-8) - and the device synthesises it the moment you call, into a transient buffer the mixer plays. Nothing is shipped, nothing stays resident. Several sounds at once are several mixer voices: call it three times in one hook and the three play together, mixed with any speech.

audio.sound{ wave = "square", hz = "E5", duration = 0.08, release = 0.03 }        -- a key click
audio.sound{ wave = "saw", hz = 2000, hz_end = 200, slide = "exp", duration = 0.25 }   -- a laser
audio.sound{ wave = "noise", lowpass = 800, duration = 0.8, decay = 0.5, sustain = 0.1, release = 0.25 }  -- a thud
audio.sound{ wave = "triangle", notes = { { "C5", 0.12 }, { "E5", 0.12 }, { "G5", 0.3 } } }   -- a jingle
audio.sound{ hz = 220, duration = 1.5, vibrato_hz = 5.5, vibrato_cents = 40, attack = 0.3, release = 0.5 }  -- a pad

Returns true when the sound started and false when it was dropped: the mixer had fewer than two free voices (one is always kept for speech, so at most three of your sounds play at once), the buffer could not be allocated, or the speaker is off. A dropped sound is never an error; a bad spec always is.

fielddefaultrangemeaning
wave"sine"sine square triangle saw noisethe oscillator
duty0.50.05..0.95the square wave’s high fraction
hz44020..10000, or a note namethe pitch (every note’s pitch when notes is given)
hz_endnil20..10000, or a note nameslide to this pitch over the sound; nil = no slide. With notes, every note slides by the ratio hz_end / hz
slide"linear"linear explinear in Hz, or equal cents per second
vibrato_hz00..20vibrato rate
vibrato_cents00..200vibrato depth, peak deviation
attack0.005secondsrise to full
decay0secondsfall from full to sustain
sustain10..1the level held until the release
release0.02secondsthe fade that ends every note; attack + decay + release must fit every note
duration0.20.01..3the length in seconds; ignored when notes is given
gain0.30..1loudness: 1 is 0.6 of full scale, 0.3 sits at the speech clips’ loudness; keep the sum near 0.5 when layering
lowpassnil100..11000a one-pole lowpass cutoff in Hz; nil = off
seed1integerthe noise is deterministic for a seed
notesnil1..32 of { pitch, seconds }a melody on this instrument: { "C5", 0.1 } or { 523.25, 0.1 }, each 0.01..3 s, 3 s in all; the envelope re-triggers per note, the phase runs on

Every field is optional and every value is validated, never clamped: a value out of range or of the wrong type is a Lua error naming the field and its range (audio.sound: duration must be 0.01..3), so a typo is never a quietly different sound. A pitch is a number in Hz or a note name; the device’s own cues sit at C5, E5, G5 (audio.tone("hit")), so a sound near those blends with the house style.

The rules.

  • A sound holds your experience busy the way speech does (audio.busy() reads true; the busy hold is capped at 2.5 s) and holds the shot scanner until the speaker is silent: nobody can putt while a sound plays. Sounds are for events - a verdict, a bounce, a key - never a per-tick stream, and a sound longer than a second needs a reason.
  • A sound dies like any voice: a capture start cuts every sound; so does audio.stop(), and so does any utterance that replaces rather than chains (say_face_angle, say_rotation, say_cue("missed"), say_cue("negative")). say, say_distance and the other cues chain and leave your sound playing.
  • One sound at a time per call, up to three at once. A fourth call returns false; so does a call while two utterances are queued. Check the result when it matters.
  • Square and saw waves are not band-limited: above about 3 kHz they alias, which is part of the retro sound. Keep the gains modest when layering: the mixer clamps, it does not compress.
  • Not in this version, by design: a handle to stop one sound (audio.stop() stops all), stereo, reverb, custom wavetables, streaming or per-sample audio from Lua.

12.2 audio.note_hz(name) -> hz

The equal-temperament pitch of a note name, A4 = 440: audio.note_hz("C#5") is 554.37. A letter A..G, an optional # or b, one octave digit 0..9; anything else is an error. The same parser reads the note names inside audio.sound.


13. Online play for games

Implemented in fw 0.8.570 (src/GAME/LuaOnline.cpp; the firmware record is foodoublebassesp32/docs/lua-runtime.md), with the three exceptions noted at the end of this section.

When the manifest says online = true and the device is hosting or a synced guest, the runtime shares the round:

  • Seats come from every device’s active players (host first), capped at 4, each with a fixed palette colour that is identical on every device.
  • Lock-step turns: r:my_turn() is false when the seat on turn belongs to another device; a putt captured then is dropped with the standard “Putt ignored, waiting for NAME on DEVICE” notice before your on_putt is ever called.
  • The host scores every putt. A guest’s putt is sent to the host, which receives it as on_putt(shot) with shot.remote = true and shot.seat set. The host publishes the round state twice: immediately (so every device replays the same roll in parallel from the same physics inputs) and again with the verdict, scores and next turn.
  • Guests replay: every other device gets the same on_putt(shot) with shot.remote = true, shot.seat, the striker’s impact_ratio and ball_mph, and - when the striker’s device had sent one - shot.aim, the launch aim in degrees. Run your normal roll from that seat’s ball, then r:record as usual. On a guest, r:record returns "pending" and changes nothing: the runtime overwrites the round with the host’s verdict, scores, hole and turn when they arrive, and calls apply_state (below), which is where you redraw. A guest must NOT schedule its own hole change or turn cue off a "pending" result - that is the host’s to deal.
  • Roster changes (a device leaving, a player activated before the first putt, a guest that stops following) are handled by the runtime with the standard notices, strike-throughs and sit-outs; you only ever read r.seats.
  • Your extra state. Everything not in the round (a mini golf aim, a house rule) goes in g.state() - a FLAT table of numbers, strings and booleans - and comes back in g.apply_state(t) on the other devices. apply_state is also the runtime’s “the round moved under you, redraw” call: it fires on a guest whenever a host state has been applied, with an empty table when you publish no state of your own, and it is the one place a guest should rebuild its picture from r.*. Your state must serialise to at most 192 bytes of JSON and the whole document to 1600; the runtime logs “state too large” and publishes the round without it. Publishing is automatic after r:record, and coalesced 300 ms after net.publish().
  • Guest to host inputs beyond putts: net.send(kind, t) on a guest arrives as g.on_input(kind, t, from_device) on the host. The kinds are the ones the wire carries: "aim" { deg }, "reset_aim", "repeat_aim", "mulligan"; anything else is an error. Inputs are sequenced and the host applies them in order - put your own sequence number in your state() (mini golf publishes adev + aseq) so a guest can skip the echo of its own older aim. An aim sent this way is remembered as the aim of the seat on turn and rides with that device’s next putt as shot.aim, so a bank shot replays identically even if the last aim step has not reached the host yet.
  • net.role() ("host" / "guest" / "off"), net.is_host(), net.publish(), net.device_name(). r.seats[i] carries device ("" for this one) and mine.
  • The Mulligan row is the device’s, wherever it is picked: a guest’s pick reaches the host, which takes the stroke back, republishes and calls its own on_mulligan(seat). Do not implement it yourself.
  • The round-over card: an online guest holds its card until the host deals the next round (the auto-restart countdown is skipped), so do not restart on a timer of your own.

Rounds that are not in Online play behave as a private round; a guest that stops following drops to a private round at once (user rule: an unfollowing guest affects nobody).

Not implemented (fw 0.8.570): handicaps from a scripted round (there are none at all - see 10.5), sit-outs and strike-through for a device that has stopped following, and net.send with an arbitrary payload - the wire’s input record has fixed fields, so a new kind needs firmware and cloud work. Two devices must have the same extension installed: the runtime matches it by the share code’s hash, not by menu position, and a guest whose extension differs plays privately.


A Browser sim web game plays the same round in the browser (golfclaude/docs/browser-sim-design.md, “Seated rounds”, 2026-10-09): the roll and the verdict are the page’s, the device’s WebRound keeps the seats, the turn and the card; the turn rules it shares with this runtime live in OnlineRound (nextTurn / holeComplete / computeOrder / startHole).


14. Timers and misc

timer.after(ms, fn) -> id     -- one-shot; fires between captures, even while the drill is idle (at most 4 live); implemented in fw 0.8.510
timer.cancel(id)
timer.every(ms, fn) -> id     -- repeating; implemented in fw 0.8.514
util.clamp(v, lo, hi)  util.round(v)  util.lerp(a, b, t)  util.ease_out_cubic(t)  util.smoothstep(t)   -- all fw 0.8.624 but clamp / round
util.ft_text(ft)              -- "12 ft" / "2.5 ft" under 30 as the device formats it
util.face_text(deg)           -- "Straight" | "L2.5" | "R0.3" - the combined widget's own words (fw 0.8.624)
util.shuffle(list)            -- Fisher-Yates in place from hardware entropy; returns the list (fw 0.8.624)

15. Limits at a glance

LimitValue
Script source64 KB
Installed extensions / slots16 (fw 0.8.624; 6 installed / 8 slots before)
Script heap (PSRAM)256 KB, plus one 109 KB canvas (and one saved backdrop)
Hook time slice5 ms
Settings per script8, keys 11 chars, integer values
Menu rows per script (foogolf.action)2
Rounds per script (round.new)1 live at a time; 4 seats, 18 holes
Online state document1600 bytes of JSON including the round
Seats4
Holes18
Previous balls on the green20
Make pattern points32
Labels on the HUD16
Trend entries per player per drill256 (the file halves itself)
analytics.target distance> 0 and <= 300 ft; kind 11 chars
audio.say parts per call20
audio.sound3 s a sound, 32 notes, 3 sounds at once (one mixer voice is kept for speech)
Fonts8, 12, 14, 18, 24, 32, 48
foogolf.store per extension2048 bytes, 32 keys of 16 chars, values of 512 bytes (2026-10-09)

16. Errors

A Lua error in a hook ends on a red error page naming the hook, the line and the message (the device rule: a failure must always end on an error page), and the experience stops. Errors are also written to the Env log and, once the cloud side exists, to the script’s page on the dashboard. Use pcall around anything speculative; use device.log liberally while developing.

Rendered from golfclaude/docs/lua/api-reference.md at 8ef01dd, 2026-10-26. The Markdown in the repository is the source; this page follows it on every push.