Writing FooGolf drills and games in Lua - developer's introduction
Status: v0.1, 2026-09-14 - a specification, first slice running. FooGolf's
built-in drills and games are C++ today. The plan is to re-implement every
one of them in Lua, publish those scripts as open-source examples, and let
anyone write and share their own on top of the same facilities. This guide
and the api-reference.md beside it describe those facilities. They were
designed by examining every shipped drill and game (porting-catalogue.md
records what each one needs), so that all of them can be written with what
is documented here. Firmware 0.8.509 runs the first slice: the drill in
section 3 shipped embedded in the image as the Drills-row cell "Inside three"
(until 0.8.549; since 0.8.550 nothing is embedded - every extension, this
one included, is installed from extensions.foogolf.com by share code, see
script-store-design.md section 9),
on a runtime that implements the manifest, the hooks, shot, the
putt helpers, green.show/roll/flash, hud.title/value/indicators/widget,
audio.say_*/tone, settings.number/shared, trends.record,
ui.results/flash/menu, device.* and util.ft_text/clamp/round. The
exact implemented subset per module is tabled in
foodoublebassesp32/docs/lua-runtime.md; everything else here is still
specification, and a detail that is still a decision is marked (open).
Read this first, then keep the reference open while you write.
1. What you can build
FooGolf is a putting launch monitor: four sensors in a mat measure the putter head as it passes, and the device (a 320 x 170 colour screen with six buttons and a speaker) turns each swing into club speed, face angle, face rotation, attack angle and path/lie. A script turns those numbers into an experience. There are two kinds, and the device treats them differently (a user rule from 2026-09-14):
| Drill | Game | |
|---|---|---|
| Who plays | The current player only | 1-4 active players, on one device or across devices |
| What it produces | ONE number per round, kept as a trend the player can graph | A scoreboard: holes, strokes, a scorecard, a results card |
| Menu row | Drills | Games |
| Multiplayer | No | Yes, through Online play |
| Handicaps | Never | Never for scripts (only the built-in Golf course rates players) |
| Ends with | The "Drill over" page and a 10 s auto-restart | The results card / scorecard and a 10 s auto-restart |
Every built-in experience fits one of those shapes: Face angle, Face rotation, Arc strength, Swing path and lie, Lag putting, Close-in putting and Make distance are drills; Golf course (stroke / match / skins) and Mini golf are games.
2. How the device runs your script
Five facts explain almost every rule in the reference.
- The sensors come first. A swing is captured by a tight loop that must never be disturbed (the firmware's first rule). Your script is called only between captures: every frame while it is busy (rolling a ball, speaking, holding a flash), and about every 100 ms when it is idle. Any hook that runs longer than 5 ms is stopped.
- One experience owns the screen. The device's menu, Wi-Fi pages,
updates and so on are the device's. When the user picks your experience
from the menu, the runtime hands you a black screen and calls
on_start. HOME always takes it back. - A putt is an event. A good swing arrives as
on_putt(shot). Nothing else changes the numbers; there is no polling. - Physics is the device's. The roll model and cup capture are one shared, deterministic model. You ask for a trajectory; you never integrate motion yourself. That is what makes replays on other devices match.
- Pictures, speech and settings are shared parts. The putting green, the combined shot readout, the results page, the trend graph, the scorecard, the settings rows and the speech vocabulary are the device's own, so every experience looks and sounds like it belongs. Your script describes; the runtime draws.
3. Your first drill, line by line
"Inside three": ten putts at one random distance; score a point for every putt that finishes inside 3 ft or drops.
-- inside_three.lua
local d = foogolf.drill{
name = "Inside three",
version = 1,
author = "FooGolf",
trend = { label = "Inside three" },
about = {
skill_focus = "Distance control.",
description = "Ten putts to one pin at a random distance. A putt that stops inside the 3 ft ring, or drops, scores a point.",
instructions = { "Choose Play.", "Note the distance top right.", "Putt; the ball rolls and the result is spoken.", "After ten putts the score is recorded as a trend." },
scoring = "One point per putt inside 3 ft or holed, out of ten.",
},
}
settings.number{ key = "min_ft", label = "Min distance", unit = "ft", min = 4, max = 120, default = 4 }
settings.number{ key = "max_ft", label = "Max distance", unit = "ft", min = 4, max = 120, default = 30 }
settings.shared("stimp")
local SHOTS = 10
local hole_ft, score, putts, balls
local function draw()
green.show{ hole_ft = hole_ft, balls = balls }
hud.title("Inside three")
hud.value(util.ft_text(hole_ft))
hud.indicators(balls, #balls, SHOTS) -- {inside=...} entries count as hits
end
function d.on_start(resume)
hole_ft = math.random(settings.min_ft, settings.max_ft)
score, putts, balls = 0, 0, {}
draw()
audio.say_distance(hole_ft) -- chains after the spoken name
end
function d.on_putt(shot)
hud.widget(shot)
green.roll(shot, {
hole_ft = hole_ft, balls = balls, widget = shot,
on_done = function(t)
putts = putts + 1
local hit = t.holed or t.inside_3ft
if hit then score = score + 1 end
balls[#balls + 1] = { x = t.rest.x, y = t.rest.y, inside = hit }
draw()
green.flash(t.holed and 0xFFD700 or (hit and 0x00FF00 or 0x44AAFF))
audio.say_cue(t.cue) -- "good" / "short" / "left" ...
if putts == SHOTS then
trends.record(score)
ui.results{ title = "Inside three",
rows = { {"Score:", score .. "/" .. SHOTS}, {"Distance:", util.ft_text(hole_ft)} } }
audio.tone("game_over") -- tones block: the page is painted first
end
end,
})
end
function d.on_key(key)
if key == "ok" then ui.menu() end
end
What the runtime did for you, without a line of code:
- Put an Inside three cell in the Drills row with Play, Settings (Min distance, Max distance, Stimp), Trends and About.
- Spoke "Inside three, stimp 10" when play started, then your distance.
- Played the user's face-angle or club-speed cue before each
on_putt. - Rolled the ball on the perspective green (or placed it instantly if the Drills row's Feedback cell says instant), with the camera, the sink and the cup rattle.
- Held the drill "busy" while the ball rolled and the word played, so the next swing could not cut either off.
- Drew the "Drill over" page, counted down 10 s, and restarted by calling
on_start(false). - Appended your score to the player's trend file, graphed under Trends.
4. The things every author asks
4.1 "How do I get the ball's trajectory, and whether it lipped out?"
local t = putt.simulate(shot, { hole_ft = 12 })
t.outcome -- "holed" | "lipped_out" | "short" | "long" | "wide"
t.holed, t.entered_cup, t.side -- side = "left"/"right" on a miss
t.rest.x, t.rest.y -- where it stopped, feet from the start
t.miss.x, t.miss.y, t.leave_ft -- relative to the cup
t.lip.entry_speed_fps, t.lip.exit_deg -- only when lipped_out
t.points[i].t, .x, .y, .v -- the path every 50 ms
local x, y, v = t:at(0.75) -- interpolate for your own animation
t.cue -- the word the built-in drills would speak
putt.simulate is pure: call it as often as you like, on any hypothetical
distance ("would this putt have dropped from 8 ft?" is
putt.would_hole(shot, 8)). green.roll calls it for you and plays the
result. The model is the firmware's (stimp roll curve fitted to GSPro, the
cup-capture rule offset^2 + (speed/32)^2 < r^2, a 25 % rebound off the
rim); the reference summarises it so you can explain a verdict.
Two useful derived numbers the built-in drills lean on:
putt.make_distance_ft(shot)- the longest cup distance this exact putt would have dropped at (0 = it would never drop; Make distance's "Missed").putt.make_pattern(hole_ft)- the outline of every spot a dropped ball could have come to rest at, to overlay on the green (Close-in putting).
4.2 "How do I show the combined shot widget?"
hud.widget(shot)
That is all. The runtime places the device's standard readout bottom-right
(face angle, speed as mph or feet per the user's setting, rotation rate and
the path/lie arrow scaled by the user's Skill level). Call it once per putt.
To have it on the rolling page too, pass widget = shot to green.roll.
You cannot restyle it: players read the same widget in every experience.
4.3 "How do I add a settings menu?"
Declare settings at the top level. Each one becomes a row in your experience's Settings submenu (with the live value in brackets), is stored on the device, and rides to guest devices in Online play:
settings.number{ key = "target", label = "Target", unit = "ft", min = 2, max = 120, default = 12 }
settings.choice{ key = "look", label = "Appearance", options = {"Classic", "Stripes"}, default = 2 }
settings.toggle{ key = "coat", label = "Hole style", on = "Yellow", off = "Cup lines", default = true }
settings.shared("skill_level", "stimp", "shots_per_drill") -- the device-wide rows
settings.action{ label = "Regenerate course", run = function() ... end }
-- read: settings.target ; write: settings.set("target", 15) ; react: d.on_setting(key, value)
Device-wide values you should read rather than duplicate: device.stimp(),
device.impact_ratio() (the current player's - there is no device-wide
impact ratio), device.shots_per_drill(),
device.instant_feedback(), device.scoring_type(), device.skill_level().
4.4 "How are scores tracked?"
A drill produces one number. Keep your own counters, and when the round
is over call trends.record(value) once. The device does the rest: the
Trends row, the player picker, the graph (raw in blue, smoothed in gold, the
axis extends below zero for net scores), and "Delete trend data" on the
Players page. Examples of the one number in the built-in drills: a 0..10
consistency score, stations hit out of ten, net good minus miss, total feet
of made putts.
A game keeps a round. Create it, record strokes, and read scores from it:
local r = round.new{ holes = 9, par = 2, tees = "usndp" }
-- each putt:
local ev = r:record{ seat = r.turn, holed = t.holed, leave_ft = t.leave_ft }
r:score_text(seat) -- "E", "+2", "2 up", "3 skins" per the Scoring cell
hud.player_card(r) -- the standard bottom-left card and turn queue
ui.scorecard(r) -- the classic club card
if ev == "round_over" then ui.results_card(r) end
The round engine deals honour, rotates the away player, handles stroke, match and skins scoring, and shares everything across devices in Online play. Handicaps are not fed by scripts (open).
4.4a "What about a mulligan?"
Do not write a Mulligan row. The device offers one for you - just define the hook:
function g.on_mulligan(seat) -- 1-based seat; the round is ALREADY rewound
if last then aims[seat] = last.aim end -- our aim as that putt was played
last = nil
loadHole() -- r.hole may have moved BACK a hole
hudUpdate()
drawIdle()
end
Whenever your game has this hook and the round has a stroke to take back, the
device appends its own Mulligan row to any ui.popup you open, rolls the
round back itself, and then calls the hook so you can put your own
presentation right. The pick never reaches your on_pick, and the row is not
shown when there is nothing to undo.
Three things to get right:
- Redraw from the round, and reload your hole. A mulligan takes back the
last stroke wherever the round has got to - including the putt that
finished a hole, which puts the round back on the previous one. So re-read
r.holeand reload your geometry. Never re-place the balls: the round has already restored them, and those positions are the point. - Drop your transient state, the same things you drop in
on_stop- a roll in flight, a pending pause, the shot readout. - Build your own rows conditionally. If a row would do nothing, leave it out rather than ignoring the pick - the device holds itself to that for the Mulligan row, and a player should never pick something that silently does nothing.
Defining the hook is the opt-in. Without it no mulligan is offered, because
only your script knows where it drew its ball. If you want a mulligan without
the device's row - from a foogolf.action row, say - call r:undo() yourself;
it returns the seat, or false when there is nothing to take back.
4.5 "How do I draw my own picture?"
When the perspective green is not your picture (a spread on a circle edge, stripes, a first-person strip, a course), draw into the canvas:
canvas.begin(0x000000)
canvas.arc(160, 85, 59, 200, 340, 2, 0xFFFFFF, 110)
canvas.dashed_line(52, 85, 300, 85, 0.6, 6, 4, 0xFFFFFF, 130)
canvas.disc(x, y, 3, 0xFF8C00)
hud.label{ id = "score", text = "7.5", x = 174, y = 81, size = 32, align = "center" }
canvas.invalidate(x - 4, y - 4, 8, 8) -- only the patch that changed
Curves are anti-aliased with fractional coverage (never int-snapped, a
standing rule). Redraw and invalidate only what moved: the device screen is
also streamed to a browser (LAN cast and the support cast), and a whole
frame per tick is what makes a cast stutter. For a static scene under a
moving ball, canvas.save() once and canvas.restore() per frame.
To animate a roll on your own canvas, take t = putt.simulate(...) and call
t:at(elapsed_s) each tick; keep returning true from on_tick until
elapsed_s >= t.duration_s.
4.6 "How do I speak?"
audio.say_cue(t.cue) -- "good" / "short" / "left" ...
audio.say_distance(26) -- "twenty six feet"
audio.say("hole", 3, "red", 12, "feet")
audio.say_face_angle(shot.face_deg) -- "left two point five"
audio.tone("holed") ; audio.fanfare()
The device speaks from a fixed clip vocabulary (numbers, directions,
verdicts, colours, units); spoken_name in your manifest is synthesised in
the cloud when you publish. Draw first, then speak; a say chains behind
whatever is already playing, and the runtime holds your drill busy until the
speech has drained.
4.7 "How do I make a multiplayer game?"
local g = foogolf.game{ name = "Ladder", online = true, ... }
local r
function g.on_start(resume)
r = r or round.new{ holes = 9, tees = {3, 5, 8, 12, 16, 20, 25, 30, 40} }
green.show{ hole_ft = r:remaining_ft(r.turn) } ; hud.player_card(r) ; r:announce_turn()
end
function g.on_putt(shot)
green.roll(shot, { hole_ft = r:remaining_ft(shot.seat), instant = false,
on_done = function(t)
local ev = r:record{ seat = shot.seat, holed = t.holed, leave_ft = t.leave_ft }
if ev == "round_over" then ui.results_card(r) else g.on_start(true) end
end })
end
With online = true the runtime does the rest when the device hosts or is a
synced guest: one roster across devices (host's players first, four seats,
fixed colours), lock-step turns ("Putt ignored, waiting for Bob on Ferret"
before your on_putt is ever called), the host scoring every putt, every
other device replaying the same roll from the same numbers, notices when a
device leaves, strike-throughs and sit-outs for a device that stops
following. Anything beyond the round that the other devices must see goes in
g.state() / g.apply_state(t); guest inputs beyond putts go through
net.send. The whole shared document must fit 1600 bytes.
4.8 "How do I send shots to the owner's analytics server?"
Some owners run a server of their own on their home network and have the
device POST every shot they aimed at a target to it, so they can analyse their
putting however they like (Settings > Analytics settings; the owner's guide
is golfclaude/docs/user-guide.md). Your script joins in by saying what the
player is aiming at:
analytics.target(hole_ft, { kind = "pin", hole = 1, stroke = putts + 1 })
Declare it where the distance or the stroke count changes — usually your draw
function — because the device asks for the target immediately before
on_putt. analytics.target(nil) clears it, and the target is cleared for you
on activation and restart, so forgetting to re-declare makes the script go
quiet rather than report a stale distance. analytics.enabled() tells you
whether the owner has an endpoint at all, if you want to skip the bookkeeping.
You declare the target and nothing else: one message per shot, sent by the
device, carrying the measurements the device made. You cannot send a message
yourself and you cannot invent a number. The payload's game is
"script:<your id>".
Only declare a target the player is genuinely putting straight at. No target is the right answer for a mini-golf hole where the ball banks off walls, for a drill about face angle alone, or for anything where "the target" is really "as far as you can". The built-in games make the same call — Mini golf and the face-angle drills forward nothing. Reporting a nonsense target does not break the device; it quietly ruins the data set the owner is keeping about their own putting, and that is the kind of thing they installed your script trusting you not to do. See House rules below and section 11.5 of the API reference.
5. House rules
These are decisions the device's users have already made; the runtime enforces the ones it can, and the rest are conventions a published script is expected to follow.
- Drills are single player, one number, and have trends. Games have scoreboards. Do not add a leaderboard to a drill or a trend to a game.
- The results page restarts in 10 s. Every experience, no exceptions. Any key but HOME restarts at once.
- OK during play leaves to the menu in a drill (unless your drill uses OK for something visible, like fast-forwarding a roll or opening a popup); BACK is free; HOME is the device's.
- Draw first, then speak. Start a spoken cue from the tick after the redraw, never inside the same call that changed the picture.
- A failure must always end on an error page. Never swallow a problem
into a silent, blank or frozen screen.
ui.error(text)exists for that. - Text is ASCII. The fonts have no accents, Greek or en/em dashes; write "deg" or use the degree helper, write "-" not a dash.
- Feet, degrees, m/s. The device formats distances as "12 ft" and
"2.5 ft" under 30; use
util.ft_textso yours match. - Feedback instant / real time is the user's choice. A drill that rolls
a ball must honour
device.instant_feedback()(green.rolldoes); a game always rolls; a drill that is instant by nature (angles, rotation) ignores it. - The About page has five sections in a fixed order: Name, Skill Focus, Description, Instructions, Scoring. Write "TODO" rather than leave one out.
- Redraw only what moved. The screen is cast; a busy full-screen redraw costs everyone watching.
- Do not fight the widget. The combined readout is shared UI; place it
with
hud.widgetand nowhere else. - Only claim a target you really have.
analytics.targetfeeds the owner's own analytics endpoint and their own record of their putting. Declare it only for a straight putt at a known distance, with the real distance; declare nothing otherwise. The same goes for anything that touches a player's record:trends.recordwrites to that player's history, so record the round's one honest number, once, at drill over. - Random order alternates devices. When a game shuffles players, the
runtime's deal keeps consecutive players on different devices; use it
(
round.newdoes) rather thanutil.shuffleon the seats.
6. Testing your script
(planned) The intended loop, in order of readiness:
- Desktop simulator. The same Lua runtime with the same physics, compiled for the desktop, fed recorded putts or typed numbers, rendering the 320 x 170 canvas in a window or as PNG contact sheets (the firmware already iterates its scenery and mini golf pictures this way). Run your script, see every page, step the ticks.
- Browser simulator on the dashboard, so an author needs nothing installed (open).
- On the device. Scripts are installed over Wi-Fi from the cloud (or
from a LAN page during development); never by cable. Device status shows
the script's heap and the last error; Settings > Advanced > Logging holds
device.loglines.
An error inside a hook ends on a red page naming the hook and the line, so a bug is never a silent freeze.
7. Publishing and sharing (live since fw 0.8.516, 2026-09-15)
A script is one Lua file plus its manifest (inside the file).
- Publish at https://extensions.foogolf.com: sign up (any e-mail), paste
or upload the file, write the description (required: what the script
does and who it is for), Publish. The store compiles it with real Lua 5.4
and reads the manifest; a syntax error or a missing
foogolf.drill{}/foogolf.game{}is refused with the message. You get a five-character share code - give it to anyone; a script is installable by its code whatever its listing. Tick "Request a listing on the public page" if you want strangers to find it: the FooGolf team reviews the description, the About page and the code, and approved scripts appear on the Browse page (linked from foogolf.com). You can withdraw a request or make a public script private again from My scripts. - Install on a device: Scripts > Get scripts > Install by code, type the code. The device fetches the source over HTTPS, checks the sha256, stores it on LittleFS and loads it; the script appears as a cell in the Scripts row with Play / Settings / Trends / About. Up to six installed scripts, 64 KB each.
- Update: open it under My extensions, edit the source and Save (a new version; the code stays) - every device that has it installed fetches the new version at its next start-up (or Settings > Extensions settings > Check for updates); settings and trends are kept because they are keyed by the code.
- Remove: Scripts > Get scripts > Remove a script. Deleting a script from the store does not touch devices that installed it.
There is no id in the file: the store allocates the five-character
share code when the extension is created, and that code is its identity
for life - the NVS namespace, the trend files, the file on the device and
the store record (with your account). Pasting someone else's source into
Create makes a NEW extension with its own code; every extension's source
is open on the site for exactly that. spoken_name synthesis is still open, so a scripted experience
opens silently unless its name is in the vocabulary. Design record:
script-store-design.md.
8. What is deliberately out of reach
- The device menu beyond your own cells, Wi-Fi, updates, the Players page, device settings other than your own.
- Raw LVGL, files, networking, timers outside the tick, the sensors' raw waveforms.
- Speech beyond the vocabulary and your synthesised name (open: a short list of extra words per script).
- Handicaps (open).
- Anything that would touch the capture loop.
If a script needs something that is not here, the answer is an extension of the runtime, documented in the reference, never more rope.
9. Reading on
api-reference.md- every module, function, field, unit and limit.porting-catalogue.md- the seven drills and two games in detail: what each consumes, draws, says and scores, and how each maps onto this API. The catalogue is the best set of worked examples until the ports exist.../user-scripting-lua-investigation-2026-09-14.md- why Lua, what it costs on the device, the open engineering list.- Firmware design notes the API mirrors:
foodoublebassesp32/docs/games-and-drills-2026-09-14.md,multiplayer-game.md,mini-golf-game.md,make-distance-drill.md,close-in-putting.md,face-angle-redux-drill.md;golfclaude/docs/online-round-design.md.