FooGolf developers
Documentation › docs/lua/api-reference.md

FooGolf Lua API reference

Status: API specification v0.2, 2026-09-26. 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) and 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). 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.


0. Conventions

Thing Convention
Distance feet, floats (hole_ft, leave_ft). The runtime never uses metres or yards.
Angles degrees. Face angle: positive = right (open for a right-hander), negative = left. Aim: degrees from the line to the cup, positive = right.
Club speed metres per second (speed_mps, what the sensors measure). Ball speed in mph is derived: speed_mps * 2.23694 * impact_ratio.
Rotation degrees per inch through the impact gate, positive = face opening, negative = closing.
Coordinates on the green x lateral (+ right), y forward (+ toward the cup), origin at the ball's start. The cup is at (0, hole_ft).
Colours 0xRRGGBB integers.
Screen 320 x 170 pixels, origin top-left.
Time now_ms is the device's millisecond clock (wraps after 49 days; subtract, never compare).
Stimp integer 8..12.
Impact ratio ball 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).
Strings ASCII only (the fonts carry no accents, Greek, or en/em dashes). %d, %s, %.1f are string.format.
Booleans returned by hooks on_tick returns true while busy.
Optional argument tables Every 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.1 Manifest fields (both kinds)

Field Type Default Meaning
id - ignored There 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.
name string required Menu cell label and page title. At most 24 chars.
version integer 1 Bumped by you on every published change.
author string "" Shown on the About page and the cloud listing.
spoken_name string name Text the cloud synthesises into the name clip at publish time. The device speaks it when play starts.
speaks_stimp boolean true Append ". stimp N" to the opener. Set false when stimp has no effect on your experience (rotation-only drills).
announces_own_name boolean false true = 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 "device" or "none" "device" "device": the user's Settings > Audio feedback cue (face angle or club speed) is spoken before on_putt; anything you say chains behind it. "none": you own every sound.
about table required The About page, five sections (section 1.4).
resumable boolean drill: false, game: true Whether 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_editing boolean true Deliver UP/DOWN as raw keys with auto-repeat (true) or as focus-move keys (false). Leave true.

1.2 Extra fields for drills

Field Type Default Meaning
trend { label = string } required Enables the Trends menu row and trends.record. The key is id. label heads the graph ("Face angle").

1.3 Extra fields for games

Field Type Default Meaning
players "active" or "current" "active" Seats come from the Players page's active list (1-4) or just the current player.
online boolean false The 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 Games row's Scoring cell, the others fix it. "none" = the script keeps its own score.
handicap boolean false The 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). If shot_speech == "device", the face-angle or club-speed cue starts playing before on_putt is called. Anything you draw, roll or say 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.

Field Type Meaning
speed_mps number Club head speed at impact.
ball_mph number speed_mps * 2.23694 * impact_ratio.
face_deg number Face angle at the ball, + right / - left.
rotation number Face rotation rate through the gate, deg/in, + opening.
attack_deg number Attack angle, degrees.
path_left, path_right number 0..1 Inner-sensor proportion at the left and right gate (the combined widget's arrow). 0.5 = centred.
impact_ratio number The ratio in effect for this putt: the seat's player, else the current player.
stimp integer Device stimp at the time of the putt.
player table or nil players.current() (the current player's { id, name, handicap, impact_ratio }); nil when there is no player.
seat integer or nil Games: which seat putted (1-based).
remote boolean Games in Online play: this putt was struck on another device and is being replayed here (section 13).
time_ms number Device clock at capture.
id integer Monotonic 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 key Default Meaning
hole_ft required unless course Cup distance straight ahead.
aim_deg 0 Added to shot.face_deg (mini golf style explicit aim).
from {x=0,y=0} Ball start (a continuation putt in a course).
stimp, impact_ratio from shot Overrides.
walls nil Segment-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).
cup nil {x, y} of the cup in the segment form (omit for a table with no cup).
half_ft 0.2 Wall half-thickness in the segment form.
sample_ms 50 Spacing of traj.points.

4.2 The trajectory table

Field Type Meaning
outcome string "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.
holed boolean outcome == "holed".
entered_cup boolean holed or lipped out.
side string or nil "left" / "right" for a miss, by the sign of miss.x.
cue string The 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_ft number Distance from the cup at rest, 0 when holed.
inside_3ft boolean leave_ft <= 3 (the lag drills' station rule).
roll_ft number Straight-line rollout ignoring the cup (putt.roll_ft).
duration_s number Roll time including the sink.
lip table or nil Only 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.
bounces list Segment form only: { {t, x, y, wall = i}, ... } (wall is the 1-based index into walls).
lipped_out boolean Segment form: entered the cup mouth and was rejected at least once (the closed-form path reports it as outcome).
travel_ft number Segment form: path length actually rolled, bounces included.
points list { {t, x, y, v}, ... } sampled every sample_ms, v in ft/s.
t:at(t_s) method Interpolated 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)

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 key Default Meaning
hole_ft required Cup distance. Sets the camera.
balls {} Previous resting balls { {x, y, inside = bool}, ... } (green when inside, red when not; up to 20).
ball nil The live ball {x, y} if not at the start.
halo true The 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.
flag true Draw the flag; "faint" = 50 % pole/pennant and no cup (the Lag putting settled view).
start_marker not halo The 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).
pattern nil List 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_ms 0 When 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 key Default Meaning
hole_ft required As putt.simulate.
balls {} Previous balls to keep on the green.
on_done nil Completion callback.
flash nil Colour to flash at rest (0xFFD700 gold for holed, 0x00FF00, 0x44AAFF), 600 ms, or {color, ms}.
instant device.instant_feedback() Force instant or rolling. Games pass false (games always roll).
overlay, pattern as show Kept during the roll.
widget nil A 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.

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 key Default Meaning
holes 9 1..18.
par 2 Number, 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.
tees nil "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_putt false Every putt ends the seat's hole (Close-in).
pick_up nil Stroke cap per hole; the seat is marked holed at the cap (r:record returns picked_up = true).
scoring "device" "device" follows the Games row's Scoring cell; "stroke", "match", "skins", "none" fix it. Solo match play / skins play against a Par seat built from each hole's par.
min_ft, max_ft 4, 30 The 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")   -- pull the device-wide rows into your submenu
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. 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 field Default Meaning
kind "hole" What it is, for the analyst: pin, station, hole, ... Lower-case a-z0-9_, at most 11 characters.
hole 0 Hole or station number, 1-based. 0 = not applicable.
stroke 0 Which 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()    -- Drills row "Feedback (instant|real time)"
device.scoring_type()        -- "stroke"|"match"|"skins" (Games row Scoring cell)
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

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" | "putt"
audio.fanfare()               -- the boot fanfare (a round-over celebration)
audio.rattle(speed01)         -- (not implemented; the green's own sink plays it)
audio.busy()                  -- speech or a tone is still playing
audio.stop()

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.


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:

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.


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

Limit Value
Script source 64 KB
Installed extensions / slots 16 (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 slice 5 ms
Settings per script 8, 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 document 1600 bytes of JSON including the round
Seats 4
Holes 18
Previous balls on the green 20
Make pattern points 32
Labels on the HUD 16
Trend entries per player per drill 256 (the file halves itself)
analytics.target distance > 0 and <= 300 ft; kind 11 chars
audio.say parts per call 20
Fonts 8, 12, 14, 18, 24, 32, 48

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.