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

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

  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. 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() 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)

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.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.

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:

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.