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: kind web, api 1, a name (1..31 characters) and a description (up to 2000; it is the app page’s text). players: true declares 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')):

fieldmeaning
idthe session’s shot counter (a seated game answers with it)
srcputt | club (a club selector row) | monitor (the launch monitor)
from, guest, seatthe striker’s name, whether a guest, the seat index in a seated round (else null)
ballspeed_mph, vla, hla (+ right), spin, spin_axis; a monitor’s carry_yd / total_yd when it sends them
clubspeed_mph, aoa, face, path, or null
pred_roll_fta 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_namethe club selector row’s label
path{l, r}: the putt’s path / lie proportions (api 6), or null
rotthe 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:

variablethe regionabout
--fg-hud-tr-w, --fg-hud-tr-htop right: the apps / Menu / full-screen bar with its margins130 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-hbottom right: the widget’s box with its margins0 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 open http://localhost:8765/?game=/my_green.html; the Browser sims row then offers Game from the URL (emulator). The dev server’s /lib/foogolf.js stands 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.