Writing apps for the FooGolf Putting Machine

1. What you can build

You can use the popular Lua programming language to create open source apps for the FooGolf Putting Machine.

Once your app is working, you can run it on your own FooGolf device, share it privately with others, or request it to be made public in the FooGolf app gallery so anyone can install it.

The FooGolf device provides optional services to your app so you don’t need to rebuild them, including a perspective view putting green with flag, scorecards, trend storage and APIs for sounds and rendering - or if you want you can build entirely unique putting experiences where you render everything yourself in your lua code.

The different types of app are:

App typeDescriptionUI and audio accessBluetooth accessRuns on
DrillDrills are single player experiences for working on specific aspects of your putting stroke such as distance control.YesNoFooGolf device
GameGames are multi-player, multi-device experiences where you typically compete over 9 or 18 holes under a scoring type (e.g. match play)YesNoFooGolf device
Launch monitorLaunch monitor apps are integrations with third party launch monitors over Bluetooth or USB (future). Players can hit full shots on their launch monitor, putt on the FooGolf unit and feed the combined shots into simulators like GSPro, PurePlay or browser sim apps.NoYesFooGolf device
Browser simBrowser sim apps are more demanding multi-player experiences that run inside your browser as standard web pages, giving them access to the power of your computer or mobile device. Players can hit putts on the FooGolf device and from any attached launch monitor. The FooGolf device hosts the web page and you can start it from a QR code or entering the URL directly. Their guide is Browser sim games.NoNoWeb browser

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", "feedback")

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, Drill feedback), 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 drill’s Drill feedback setting 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", "feedback")   -- the device-wide rows
-- a game that scores by the device: settings.shared("scoring") (fw 0.8.673) puts the Scoring row on its Settings
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 row
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. A game whose putts are all straight at the hole may declare foogolf.game{ handicap = true }, and the engine then rates the players from every r:record of a local seat, as Course play does; Mini golf must not.

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.6a “How do I make my own sound?” (fw 0.8.647)

audio.sound{ wave = "square", hz = "E5", duration = 0.08 }                       -- a key click
audio.sound{ wave = "triangle", notes = { { "C5", 0.12 }, { "E5", 0.12 }, { "G5", 0.3 } } }  -- a jingle
audio.sound{ wave = "noise", lowpass = 600, duration = 0.5, release = 0.3 }      -- a whoosh
audio.sound{ wave = "saw", hz = 2000, hz_end = 200, slide = "exp", duration = 0.25 }  -- a laser

The device cannot carry clips, so it synthesises: a waveform, a pitch with a slide and a vibrato, an envelope, a lowpass, a note sequence - the set sfxr, ZzFX and PICO-8 share - rendered the moment you call and played on the mixer, up to three at once alongside speech. Every field has a default and every value is validated (an out-of-range value is an error, never a quieter or shorter sound). A sound holds the shot scanner like speech does, so sounds are for events - a verdict, a bounce, a key - never something you start every tick. audio.sound returns false when the mixer is full; a replacing utterance (say_face_angle, say_cue("missed")) or a new putt cuts a sound short. The full field table: api-reference.md 12.1.

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_text so yours match.
  • Feedback instant / real time is the user’s choice. A drill that rolls a ball must honour device.instant_feedback() (green.roll does); 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.widget and nowhere else.
  • Only claim a target you really have. analytics.target feeds 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.record writes 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.new does) rather than util.shuffle on the seats.

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. The emulator (foogolf.com/emulator/, or python wasm/build.py --serve locally): the real firmware in Chrome. The log pane under the device shows your extension’s lines in black - device.log(), print() and the runtime’s report of your script’s errors - and nothing else; the firmware’s own lines go to the browser’s DevTools console.
  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 > Dev > 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: Settings > Manage extensions > Install by code (or the Manage extensions cell at the end of either Lua row), type the code. The device fetches the source over HTTPS, checks the sha256, stores it on LittleFS and loads it; the extension appears as a cell on the Lua games or Lua drills row with Play / Settings / Trends / About. Up to sixteen installed extensions, 64 KB each (fw 0.8.624).
  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.


7.1 Your app’s page and its screenshot (2026-10-08)

Every extension is also an app on foogolf.com/apps: a public one is in the gallery, and any one - private too - has a page at foogolf.com/apps/app/?code=<CODE> with its screenshot, Play it (the emulator booted straight into it), your username (linking to your forum profile), the description, the About page and the source. The screenshot is yours to take: sign in on foogolf.com, play the app in the emulator (it is part of the site, foogolf.com/emulator/, so your sign-in carries over), and when you see a screen you like press Capture screenshot under the device and Save as the app’s screenshot. The button is there only for the app’s author (or a FooGolf moderator) while signed in. A Lua app’s picture is the device screen; an in-browser simulator’s is its own canvas.

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). Sounds of your own are audio.sound (4.6a); streaming or per-sample audio from Lua is not - the device cannot render 22050 samples a second inside a 5 ms hook, and a sound must end so the scanner can arm.
  • Handicaps, unless a game declares handicap = true and means it.
  • 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.
  • monitor-drivers.md - the third kind of extension: a Bluetooth launch monitor DRIVER (foogolf.monitor{}), which feeds full swings into a sim session. Its own API, lifecycle and limits; garmin-r10-protocol.md is the Garmin R10 worked through byte by byte.
  • 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.

Rendered from golfclaude/docs/lua/developer-guide.md at 8ef01dd, 2026-10-26. The Markdown in the repository is the source; this page follows it on every push.