FooGolf developers
Documentation › docs/lua/developer-guide.md

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.

  1. 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.
  2. 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.
  3. A putt is an event. A good swing arrives as on_putt(shot). Nothing else changes the numbers; there is no polling.
  4. 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.
  5. 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:


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:

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:

  1. 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.hole and reload your geometry. Never re-place the balls: the round has already restored them, and those positions are the point.
  2. Drop your transient state, the same things you drop in on_stop - a roll in flight, a pending pause, the shot readout.
  3. 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.


6. Testing your script

(planned) The intended loop, in order of readiness:

  1. 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.
  2. Browser simulator on the dashboard, so an author needs nothing installed (open).
  3. 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.log lines.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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

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