FooGolf Lua API reference
Status: API specification v0.1, 2026-09-14; implemented in slices - fw
0.8.509 (drill basics), 0.8.514 (segment physics, canvas, course, popup/box,
a second script slot, Mini golf in Lua) - the exact subset per module:
foodoublebassesp32/docs/lua-runtime.md; the rest is specification. Every facility here was derived from the seven
drills and two games shipped in C++ (fw 0.8.508) so that each of them can be
written in Lua without an extension - the porting catalogue
(porting-catalogue.md) is the proof. 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. |
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
- 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).resumeis true when the user chose Resume or the device rebooted into this experience. Draw your page here. - Unless
announces_own_name, the runtime speaks the opener (spoken_name+ stimp) right afteron_startreturns. - The device scans for swings. A captured good swing arrives as
on_putt(shot). Ifshot_speech == "device", the face-angle or club-speed cue starts playing beforeon_puttis called. Anything you draw, roll or say happens insideon_puttand the ticks that follow. - The runtime calls
on_tick(now_ms)every frame (about 30 Hz) while any of these hold: your laston_tickreturned 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 idleon_tickis 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). - HOME at any time:
on_stop()then the menu. Your state is kept in memory but nothing of yours runs while stopped - noon_tick, no roll, no timers, no results countdown - so drop transient state (a roll in flight, a pause) inon_stopand rebuild the page from your round inon_start(true). A drill's next Play callson_start(false); the Resume row callson_start(true)while a round is open (a round-over card closes it: the next start ison_start(false)).r.overtells 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() for a drill; the seat's player record for a game. |
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)
- Rollout: ball speed in mph
v,roll_ft = v^2 / (2 * (a0(stimp) + 0.0645 * v)), witha0= 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^2withr = 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}, up to 26 points, for green.overlay
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 | Pulsing halo on the waiting ball (size from Settings > Visual settings). |
flag |
true | Draw the flag; "faint" = 50 % pole/pennant and no cup (the Lag putting settled view). |
start_marker |
true | The grey start dot. |
overlay |
nil | function(cv, frame) called after every render with the page's canvas and the camera frame; use green.to_screen(frame, x, y) and the canvas.* calls to draw on top (make pattern, reticle, notes). |
pattern |
nil | List of {x,y} from putt.make_pattern drawn as the translucent make-pattern polygon (convenience for the commonest overlay). |
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; cleared by the next show()
green.to_screen(frame, x, y) -- world feet -> screen px for an overlay
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) -- clockwise from 3 o'clock
canvas.rect(x, y, w, h, color, opa, radius)
canvas.polygon(points, color, opa) -- {{x,y},...} filled
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) -- built-in assets: "putter_clubhead", "ball"
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 > Scenery backdrop (or a named theme) into the canvas, sky above y = 85
canvas.grass(y0, y1, color) -- the Grass settings base colour band
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
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
hud.strike(id, on) -- strike-through a label (a player not following)
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) -- full-screen held notice, then back to your page ("Putt ignored\nwaiting for Bob on Ferret")
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.scorecard(round) -- the classic club card for a round (section 10.4); any key returns
ui.number{ title = "Target", value = 12, min = 2, max = 120, step = 1, on_accept = function(v) end }
ui.choice{ title = "Look", options = {"Classic","Stripes"}, selected = 1, on_pick = function(i) end }
ui.keyboard{ title = "Name", initial = "", max = 16, on_done = function(text) end } -- text nil = cancelled
ui.info(title, body) -- a scrolling content page with a green heading
ui.error(text) -- red held error page (a failure must always end on an error page)
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 in 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. Still to come (the Golf course
port): tees, deck, lock-step online seats, hud.player_card,
hud.hole_line, ui.scorecard, r:restart, r:remaining_ft.
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 |
"usndp" |
(not yet) "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 a subset); "random": one per hole in [settings.min_ft, settings.max_ft]; a list of feet; or function(h) -> ft. Games that use a course pass nil. |
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. |
deck |
nil | (not yet) A list the host deals and guests adopt unchanged. |
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 }
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: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) -- (not yet) bottom-left: the queue above, the current seat's one-line card
hud.hole_line(r) -- (not yet) "Hole 3/9" (+ "This hole: 2 skins")
ui.scorecard(r) -- (not yet) classic club card: holes as rows, Feet, Par, SI, A-D columns
10.5 Handicaps (open)
The device rates players from Golf course rounds only (user decision
2026-09-14: drills are practice, games rate players, Mini golf never does).
A scripted game therefore has no handicap feed in v0.1; r.seats[i].handicap
is read-only. Whether a published, reviewed script may opt in is an open
decision.
11. players, trends, settings, device, analytics
11.1 players
players.current() -- { id, name, handicap (number|nil), impact_ratio }
players.active() -- list, leaderboard order (ascending handicap, unrated last), at most 4
players.count()
The Players page is device UI; scripts never add, rename or activate players.
11.2 trends
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 (for an in-page sparkline)
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")
device.online_role() -- "off"|"host"|"guest"
device.following() -- guest synced with its host
device.now() -- ms clock
device.log(fmt, ...) -- a line in the Env log (Settings > Advanced > 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(...) -- 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) -- "point three five" | "zero" | "negative"
audio.say_cue(cue) -- "good"|"short"|"left"|"right"|"long"|"missed"|"negative"
audio.say_name() -- the opener (spoken_name + stimp per the manifest) - for announces_own_name scripts
audio.tone(name) -- "hit" | "miss" | "holed" | "game_over" | "key" | "putt"
audio.fanfare() -- the boot fanfare (a round-over celebration)
audio.rattle(speed01) -- the cup rattle
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 and the colour words;
numbers 0..150 (whole), one-decimal numbers ("two point five"), two-decimal
numbers spoken digit by digit ("one point six five"). Parts are separated by
a 300 ms pause. 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:
- 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 youron_puttis ever called. - The host scores every putt. A guest's putt is sent to the host, which
receives it as
on_putt(shot)withshot.remote = trueandshot.seatset. 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)withshot.remote = true,shot.seat, the striker'simpact_ratioandball_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, thenr:recordas usual. On a guest,r:recordreturns"pending"and changes nothing: the runtime overwrites the round with the host's verdict, scores, hole and turn when they arrive, and callsapply_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 ing.apply_state(t)on the other devices.apply_stateis 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 fromr.*. 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 afterr:record, and coalesced 300 ms afternet.publish(). - Guest to host inputs beyond putts:
net.send(kind, t)on a guest arrives asg.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 yourstate()(mini golf publishesadev+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 asshot.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]carriesdevice(""for this one) andmine.- 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.
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)
util.ft_text(ft) -- "12 ft" / "2.5 ft" under 30 as the device formats it
util.face_text(deg) -- "0" | "L2.5" | "R0.3" with the degree sign
util.shuffle(list) -- Fisher-Yates in place
15. Limits at a glance
| Limit | Value |
|---|---|
| Script source | 64 KB |
| 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 |
| Labels on the HUD | 24 |
| 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.