Learn page: Building with AI tools — the official guide this template implements, and where these four starter templates are listed.
A complete, working Minit game in 3D on Three.js — a lit sphere on a ground plane, with a fogged horizon.
The game. A ball sits on the grass. Tap it and it bounces, and every tap scores. Hit it again in mid-air to build a rally; let it land and the rally resets. A 30 second clock ends the run and posts the result.
Built with Vite and the official
@minit-games/sdk. There are
matching templates for plain Canvas/DOM, PixiJS, Phaser and Three.js, plus
Defold, Godot, Unity and PlayCanvas — same game, so the parts around it are
easy to compare.
npm install
npm run dev # local dev server
npm run package # build, verify, and write dist/minit-template-three.zipUpload dist/minit-template-three.zip at console.minit.games.
Size. ~890 KB built (371 KB gzipped), most of it the engine and the music.
index.html page shell + the Minit audio repair (read the comment)
src/main.js SDK lifecycle, scoring, HUD — the part worth copying
src/scene.js the 3D scene: camera, lights, meshes, camera-derived bounds
src/audio.js one AudioContext: the music loop and synthesised effects
src/assets/music.js generated — the loop, as inlined mu-law bytes
public/meta.json title, controls, logic, description, config knobs
tools/ build, validation, and the offline + audio gates
public/ is copied verbatim to the root of dist/, which is what puts
meta.json and the notices at the root of the ZIP where the console reads them.
Light intensities are physical since r155. Values copied from older examples come out far too dim: the ball rendered as a near-black blob against a bright field until the hemisphere and directional lights were roughly doubled. If the scene looks unlit, check the units before moving the lights.
Fog eats anything you want to stay bright. The fog that hides where the
ground plane ends also drained the clouds to the same flat blue until their
material was given fog: false.
The ball's bounds come from the camera, never from a number. How much world
fits across the screen depends on the field of view, on how far the ball is from
the camera, and on the aspect ratio of a slot whose shape the host decides. A
hardcoded limit is wrong on some viewport — sideLimit() and ceilingY()
project it each frame, with a margin slightly over the ball's radius because a
sphere's silhouette under perspective is wider than its centre-plane radius.
A Minit drop is one session: load → play → result. There is no title screen, no "tap to begin", and no replay menu — the host owns both ends, and the run ends itself on a 30 second clock rather than asking the player to press a button.
| Call | When | Where |
|---|---|---|
initializeSDK() |
once, at startup | top of src/main.js |
loadingDone() |
the first interactive frame is on screen | in the update handler |
reportResult(score, { flavorText, userData }) |
once, when the clock runs out | endGame() |
Until loadingDone() fires the app holds its own loading screen over the
WebView, so it goes as soon as there is a real frame. After reportResult the
app overlays its result screen and takes focus, so the loop stops rather than
animating for nobody.
flavorText is a session moment rendered by the host, never in-game, and never
the score again — this template sends the best rally.
Declared once in public/meta.json, read at runtime with getConfigValue.
They are what let other creators post mods of the game without touching code.
| Key | Type | Default | Effect |
|---|---|---|---|
pointsPerTap |
number, 1–100 | 10 |
Points per tap on the ball |
sound |
boolean | true |
Sound effects |
music |
boolean | true |
Background music loop |
Values always arrive as strings, including "false", so coerce every one:
Number(...) or === 'true'. Test locally by appending them yourself:
http://localhost:5173/?pointsPerTap=25&music=false
npm run build cross-checks the declared keys against the ones the bundle
actually reads, in both directions.
A game can be perfectly healthy — context running, engine mixing, buffers
queued — and completely inaudible inside the Minit app. The app replaces every
game's AudioContext with a subclass whose destination is a mute gain it
owns, seeded at 0, then states the real volume. Two defects can stop that
landing (both tracked in DROP-8164), and the repair for both is inline at the
top of index.html:
- On iOS the volume message is thrown away. The game is served from
minitlocal://, a custom scheme with an opaque origin, and the app posts the volume withpostMessage(payload, window.location.origin)— which for an opaque origin is the string"null", andpostMessagethrows rather than ignoring it. Retrying the same-window post with'*'delivers the app's own message, so its real intent still decides the volume. - The volume is stated once, at document load. A game whose
AudioContextappears later is constructed into a0nobody chose, and nothing restates it until the next foreground transition — the "no sound until I background the app and come back" symptom.
The second is why the repair tracks whether the app ever spoke at all. From
inside the page a deliberate mute and a silence nobody asked for are identical:
both are a mute gain at 0. An explicit 0 is honoured untouched; a 0 we
were never addressed about is repaired, and only after a real gesture.
The game's own half is in src/audio.js: never start audio before a gesture has
resumed the context — audio played into a suspended context is discarded, not
queued — and hand the context to registerAudioContext so the SDK suspends and
resumes it with the page.
npm run package will not produce a ZIP for a silent build.
tools/verify-audio.mjs boots the built bundle behind a test double for the
app's audio injection, with autoplay disabled so the context starts suspended
exactly as it does in a WKWebView, taps the ball, and measures peak RMS off an
AnalyserNode spliced in after the mute gain. Two cases, both of which must
hold:
host never states a volume : audible (the bug fixed)
host explicitly mutes : 0.00000 (a real mute still mutes)
An engine flag like isPlaying is not evidence here — a game mixing happily
into a gain node held at zero reports exactly the same thing as one you can
hear. Measure the graph.
Most are enforced by tools/check-meta.mjs, which runs on every build.
- Relative asset paths (
base: './'). Vite's default absolute/assets/...resolves to nothing under the app's custom scheme and the page loads blank. - No
crossoriginon the entry script — its CORS check cannot pass on an opaque origin, and the script silently never runs. - No network. The forbidden-API sweep runs against
src/, not the bundle: a bundled engine ships its own asset loader containingfetchandXMLHttpRequestwhether or not a single asset is loaded, so grepping the bundle would fail every engine-based game while proving nothing.tools/verify-offline.mjsmeasures what the running game actually requests instead, and fails on anything off-origin. - Touch only. Pointer events throughout, tap targets past 44 px, no hover and no keyboard.
- Portrait, any aspect ratio. The app's slot is nearer 2:3 than the 9:18 a phone screen suggests, and differs again on the web player — so nothing is hardcoded and the layout is measured from the live viewport.
npm run gen:musicDownloads the CC0 source pack (cached in .cache/), resamples to mono 16 kHz,
encodes as G.711 mu-law, and writes src/assets/music.js. The bytes travel
inside the JS bundle rather than being fetched, because the platform forbids
fetch and XMLHttpRequest outright.