Browser sim games - writing a web game
A Browser sim game is a FooGolf app that runs in a web browser - on a phone,
a tablet, a TV or a PC - fed by the FooGolf device. The device serves the
game itself over the LAN (http://<ip>:9203/), so nothing is installed on
the computer and no internet is needed once the game is on the device. Your
game receives every putt, every club selector shot, the guests’ shots and an
attached launch monitor’s swings as ball data, and may play a seated round
of Course play’s model on one page.
This page is the author’s guide. The design and the wire protocol are
golfclaude/docs/browser-sim-design.md; the worked example is Putting
green (share code 2YXNL, docs/putting-green-game.md); the extension
store’s side is The extension store, section 13.
1. The shape of a game
One HTML file is the whole game - its source, as the store publishes it and as the device serves it. It carries a manifest, an import map and your code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My green</title>
<script type="application/json" id="foogolf-manifest">{"kind":"web","api":1,"name":"My green","description":"What it is, for the app page."}</script>
<script type="importmap">{"imports":{"three":"/lib/three.module.js","foogolf":"/lib/foogolf.js"}}</script>
</head>
<body>
<canvas id="view"></canvas>
<script type="module">
import * as THREE from 'three';
import { connect } from 'foogolf';
const fg = connect();
fg.on('shot', (s) => { /* roll or fly the ball */ });
</script>
</body>
</html>
- The manifest:
kindweb,api1, aname(1..31 characters) and adescription(up to 2000; it is the app page’s text).players: truedeclares a seated game (section 5). Everything else in the file is yours. - The import map gives you three.js (
three, r170) and the FooGolf library (foogolf) from the device’s own web runtime - both live on the device, so the game works offline. Fetch nothing else you cannot do without: a store game must work with no internet. - Size: 512 KB of source; the device keeps it gzipped (256 KB).
- Everything is drawn in code (textures too) in the games FooGolf ships; you may embed data URIs within the size cap.
2. Connecting: foogolf.js
import { connect } from 'foogolf';
const fg = connect(); // reconnects by itself every 2 s
fg.on('connection', c => ...); // {connected}
fg.on('hello', h => ...); // {api, device, fw, game, monitor, units, stimp, speed, sens}
fg.on('shot', s => ...); // one shot (below)
fg.on('aim', a => ...); // {steps, from}: an aim key, + right / - left
fg.on('key', k => ...); // {k, from}: a device row - reset_aim | mulligan | repeat | repeat_aim
fg.on('monitor', m => ...); // {status}: the launch monitor's state
fg.on('stimp', m => ...); // {value}: the device's green speed changed
fg.on('prefs', p => ...); // {speed, sens}: Speed display / Path/lie difficulty changed (api 6)
fg.on('apps', a => ...); // {on, apps:[{code, name}]}: the device's web games (api 7; the HUD's drop-down draws them)
fg.on('refused', r => ...); // {why}: the device refused a start from this page (api 7; the HUD shows it)
fg.on('rows', r => ...); // the device sent its menu rows (fg.rows)
fg.on('taken', t => ...); // another page took the device (pause your audio)
fg.status('Hole 3 - 142 yd'); // a line on the device's sim page (60 chars)
fg.key('mulligan'); // press one of the device's rows from your code (fg.rows[i].k)
fg.hello; fg.rows; fg.prefs; fg.apps; fg.taken; fg.connected;
A shot (fg.on('shot')):
| field | meaning |
|---|---|
id | the session’s shot counter (a seated game answers with it) |
src | putt | club (a club selector row) | monitor (the launch monitor) |
from, guest, seat | the striker’s name, whether a guest, the seat index in a seated round (else null) |
ball | speed_mph, vla, hla (+ right), spin, spin_axis; a monitor’s carry_yd / total_yd when it sends them |
club | speed_mph, aoa, face, path, or null |
pred_roll_ft | a putt’s roll, in feet, as the device predicts it at ITS stimp - use it, so your ball rolls as far as the device’s own drills say |
club_name | the club selector row’s label |
path | {l, r}: the putt’s path / lie proportions (api 6), or null |
rot | the putt’s face rotation rate, deg/in (api 6), or null |
A putt is ball data too: ball.speed_mph = club speed x the player’s impact
ratio, hla = the face angle at the ball, vla 2, spin 30. Roll it
pred_roll_ft along hla and you match the device.
The device’s rows. The device’s sim page offers Repeat shot, Repeat aim,
Reset aim, Mulligan and the clubs; it sends them to the page as rows and
the HUD’s Menu (section 3) shows exactly those. Whichever platform a row is
pressed on, your game hears the same thing back: a key (reset_aim,
mulligan, repeat_aim) or a shot (Repeat shot, a club). Act on that
echo, never on a click - one code path serves the device’s buttons, a
guest’s and the page.
The aim. The device’s aim keys arrive as aim steps; the arrow keys on
your page should aim one step the same way (page-local, as Putting green’s
nudgeAim does). A click on your green may aim too.
The stimp is the device’s. The hello carries it and stimp follows a
change; a game has no green speed setting of its own.
3. What the HUD draws for you (api 6 / 7) - and the corners to keep clear
So that a player reads the same thing in every game, foogolf.js draws the
page’s chrome itself, unprompted, in a closed shadow root above everything:
- The Menu, top right (one menu since runtime v10): its first row is
your game’s name with a ”>” - behind it every web game installed on the
device (
fg.apps, sent after the hello; yours ticked) and, on the device’s own page, FooGolf device (the device’s screen with its buttons - the way to everything else the device does). A pick starts that game on the device, which then swaps your page for the other one; the device’s reason for a refusal (a guest, no WiFi) shows under the bar for a moment. Under that row, the device’s rows (below). - The shot widget, bottom right, after every putt: the device’s own combined widget - the face angle (“Straight”, “L0.2”, “R1.1”), the pace in the device’s Speed display unit (“33 ft” or “3.4 mph”), the rotation rate (“+1.23°/in”) and the path / lie arrow at the device’s Path/lie difficulty, the device’s geometry and colours at twice its pixels (1.5x under 640 px). It stays until the next shot, as the device’s does, and redraws when the owner changes a setting. A club or launch monitor shot shows a ball-data card in the same corner (ball speed, VLA, HLA, spin, axis, carry / total).
- The device’s rows, in the same Menu under the game row: the device’s rows
(
fg.rows), the device’s labels, the device’s order, plain lines with a rule before the clubs. A pick is sent to the device; your game acts on the echo. M opens it, Escape closes it. - Full screen, beside the Menu (F): it fills the HOST page’s stage - the device page, or the emulator with the device beside the game. A page opened with no host fills its own window.
- “In use in another tab” with Use here when another page takes the device.
Your game never draws any of these: no shot readout, no menu of its own,
no full-screen button, no taken notice - and it keeps its own controls out
of the two corners the HUD owns. The library writes their sizes onto <html>
as CSS variables so you can lay out against them:
| variable | the region | about |
|---|---|---|
--fg-hud-tr-w, --fg-hud-tr-h | top right: the apps / Menu / full-screen bar with its margins | 130 x 56 px before the device’s list, about 300 x 56 px with a game’s name on the bar (measured, so read the variable) |
--fg-hud-br-w, --fg-hud-br-h | bottom right: the widget’s box with its margins | 0 x 0 until the first shot (api 8), then about 330 x 176 px (330 x 138 under 640 px) |
So a top-right panel of yours sits at right: var(--fg-hud-tr-w, 130px)
and a bottom row of buttons goes bottom LEFT, or ends at
right: var(--fg-hud-br-w, 270px). Putting green keeps its score plate top
left, its buttons bottom left, its result card bottom centre and its tally
left of the bar.
A game may have a menu of its own - Putting green’s Settings menu (a gear: Overview, Heat map, Vegetation, New green) sits top LEFT in the HUD’s button look; keep its name short, the bar’s room is shared.
What stays yours: the scene, the roll and the verdict, the score plate, the result card (“Holed in 2”, carry / total - only you know the verdict), your own buttons and toasts, your sounds. Avoid the keys M, F and Escape.
4. The device’s page and the emulator
A real device serves http://<ip>:9203/ - one page per device with three
views: Game (your game in an iframe), Device (the device’s screen
with its buttons) and Apps (the device’s rows, a tap starts an app).
Your game never sees that page’s control socket; app navigation is the
host’s. One game page per device: a second tab takes over and the first
shows the taken notice.
The emulator (foogolf.com/emulator/, or python wasm/build.py --serve
in the firmware repo) runs the real firmware in Chrome with the game beside
the device; foogolf.js picks a BroadcastChannel transport there, nothing
changes in your code. Putts are typed into the emulator’s device.
5. Seated rounds (players: true)
A game that declares players: true (manifest and connect({ players: true, holes: 9 })) plays Course play’s model: the device seats the host’s
and its tethered guests’ active players, alternates the honour, owns every
stroke, the turn, the greens’ seeds and the mulligan; your page owns the
picture, the roll and the verdict.
fg.on('round', r => ...); // seats, turn, strokes, seed, balls, the last shot's verdict
fg.record({ id: shot.id, holed, lost, x, y, rem_ft }); // the verdict once the ball rests
fg.newRound({ seed }); // New green under a round = a new round for everyone
fg.place({ seat, x, y, rem_ft }); // a ball moved by hand before the green's first putt
The device sends a putt only for the seat on turn (shot.seat); a lost ball
is one penalty stroke and the seat is done on that green; the farthest ball
from the pin putts next. The full model, with every rule, is
browser-sim-design.md, “Seated rounds”; Putting green is the worked example.
6. Testing and publishing
- Write it on foogolf.com: Apps > My apps > Create an app, then the app page’s Source editor (Save as new version) and Play it - the emulator installs and plays the version you just saved.
- Locally: copy your file into the firmware repo’s
wasm/build/and openhttp://localhost:8765/?game=/my_green.html; the Browser sims row then offers Game from the URL (emulator). The dev server’s/lib/foogolf.jsstands in for the device’s when you need a newer api. - Check: the device’s putt rolls your ball
pred_roll_ft; the HUD’s widget shows its numbers; a Menu pick echoes back through the device; your controls sit clear of both HUD corners at phone and desktop widths; the page works with the network off. - Publish: Save as new version on the site publishes to your account; ask for a listing from the app page. The store versions the file.
7. Rules of the house
- The device’s rows are the menu; act on the echo, never the click.
- No shot readout, menu, apps drop-down, full-screen control or taken notice of your own; keep clear of the HUD’s two corners.
- The stimp is the device’s; no green speed setting on the page.
- A store game works offline: nothing fetched that it cannot do without.
- Sentence case in every label (“New green”, never “New Green”).
- Under a seated round the page never counts a stroke or deals a green itself; it records verdicts and asks for a new round.
Rendered from golfclaude/docs/lua/web-games.md at 8ef01dd, 2026-10-26. The Markdown in the repository is the source; this page follows it on every push.