@hopple/sdk 0.0.0-stage → 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,730 @@
1
- # Temporary Holding Version
1
+ # @hopple/sdk: writing a game
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@hopple/sdk` is the library a game imports. It connects the game to whatever
4
+ host is running it (the website, the app, or the dev shell) and gives the game
5
+ everything it would otherwise have to build itself: saves, scores,
6
+ achievements, input, sound settings, and so on.
7
+
8
+ This guide covers what a game can do and what it must do. For the manifest the
9
+ game ships with, see [the manifest's reference](https://docs.hopple.app/manifest.md). To run
10
+ and publish a game, see [the `hopple` CLI](https://docs.hopple.app/cli.md). Every exported
11
+ name and type is documented where it is declared, in the package's
12
+ `index.d.ts`.
13
+
14
+ ## Contents
15
+
16
+ - [What a game is](#what-a-game-is)
17
+ - [Getting started](#getting-started)
18
+ - [Lifecycle: when to run](#lifecycle-when-to-run)
19
+ - [Layout: where to draw](#layout-where-to-draw)
20
+ - [Info: facts about the player and device](#info-facts-about-the-player-and-device)
21
+ - [Analytics](#analytics)
22
+ - [Save](#save)
23
+ - [Score](#score)
24
+ - [Achievements](#achievements)
25
+ - [Share](#share)
26
+ - [Panel](#panel)
27
+ - [Input](#input)
28
+ - [Audio](#audio)
29
+ - [Pause](#pause)
30
+ - [Checklist](#checklist)
31
+
32
+ ## What a game is
33
+
34
+ A game is:
35
+
36
+ - an HTML page, and
37
+ - a manifest that describes it.
38
+
39
+ Both are named in the project's hopple config (`hopple.config.ts`, `.js` or
40
+ `.json`): the built page as `page`, and the manifest itself as `manifest`. See
41
+ [the CLI](https://docs.hopple.app/cli.md#the-projects-config). Build the page however you
42
+ like. `npx hopple create my-game` sets up the recommended way (Vite, with the
43
+ config in TypeScript); see [`hopple create`](https://docs.hopple.app/cli.md#hopple-create).
44
+
45
+ The host loads the page into a frame sandboxed with only `allow-scripts` and
46
+ `allow-pointer-lock`. That has some consequences you need to plan for:
47
+
48
+ - **One file to publish.** `hopple publish` uploads only the page, so
49
+ everything the game needs (scripts, styles, images, sounds) must be inside
50
+ it, and it refuses a page that loads anything from beside itself. During
51
+ development `hopple dev` also serves the other files in the page's
52
+ directory, but a published game can't rely on them.
53
+ - **No network.** A game can't fetch, open a socket or load anything from a
54
+ URL, on any host. It may load only from its own page and from `data:` and
55
+ `blob:` URLs. Anything the game could send out could carry what it knows
56
+ about the player. During development `hopple dev` lets the game load the
57
+ files beside its page, and nothing else.
58
+ - **Inline your scripts.** Inline scripts run, classic or module, and a
59
+ published game's scripts are inline anyway.
60
+ - **No `localStorage`, cookies or IndexedDB.** They are unavailable on an
61
+ opaque origin. Use [`sdk.save`](#save) instead.
62
+ - **No `alert`, `confirm` or `prompt`.** They are blocked in the sandbox.
63
+ - **Sound may need a tap inside the game first.** Browsers only allow audio
64
+ after a user gesture in the frame. Sound routed through
65
+ [`sdk.audio.output`](#audio) starts on the first press by itself; show a
66
+ "tap to start sound" hint until then.
67
+
68
+ ## Getting started
69
+
70
+ The SDK becomes part of your page; nothing loads it at runtime.
71
+
72
+ - **With a bundler** (Vite, esbuild, webpack, Rollup, Parcel, Bun):
73
+ `npm install @hopple/sdk` and import it. Types come with it, and so does
74
+ every doc comment, in `node_modules/@hopple/sdk/index.d.ts`.
75
+ - **Without one:** paste `node_modules/@hopple/sdk/hopple-sdk.min.js` into a
76
+ classic `<script>` before your own, and use the global `Hopple`
77
+ (`await Hopple.init(…)` in a module script or an async function). Its first
78
+ line says which SDK version it is, for your manifest's `sdk`.
79
+
80
+ A `hopple.config.json` can point its `"$schema"` at
81
+ `./node_modules/@hopple/sdk/config.schema.json` for an editor to check it; a
82
+ `hopple.config.ts` is checked by its type, `HoppleConfig`.
83
+
84
+ ```ts
85
+ import { init } from '@hopple/sdk';
86
+ import type config from '../hopple.config.ts';
87
+
88
+ const sdk = await init<typeof config.manifest>();
89
+
90
+ // load assets, build the scene …
91
+
92
+ sdk.lifecycle.ready(); // "I can be shown now"
93
+ ```
94
+
95
+ `init()` sends the hello and resolves with the SDK object once the host has
96
+ answered and the page's window has taken the frame's size, so `innerWidth`
97
+ and `innerHeight` are right from the game's next line. It **rejects** if there
98
+ is no host, the host doesn't answer, or the host speaks an incompatible
99
+ protocol.
100
+
101
+ The type argument is the game's manifest. With it, every name the game passes
102
+ in — actions, on-screen layouts, score boards, achievements, analytics events
103
+ and their properties, audio channels — is checked against what the manifest
104
+ declares, so a typo is a compile error. Without it every name is a `string`.
105
+
106
+ **Don't catch that rejection.** A host never shows a game that didn't get
107
+ through the handshake, so no player would see a message you drew. Let it
108
+ through: it ends up in the game's console, with its stack, where you can read
109
+ it while developing. The host records the failure on its own side.
110
+
111
+ Don't write the game to also run without a host either. That means every
112
+ feature written twice (the SDK's input and your own key listeners, the
113
+ lifecycle and `visibilitychange`, the host's volume and your own), and the half
114
+ no host ever runs is the half that breaks. While developing, run the game with
115
+ `npx hopple dev`.
116
+
117
+ Options for `init()`, all optional and rarely needed:
118
+
119
+ | Option | Meaning |
120
+ |---|---|
121
+ | `onError` | Called with problems the game should know about but that don't reject a call: a refused analytics event, a shim that threw, a save value that JSON would silently change. Defaults to `console.error`. |
122
+
123
+ The host is told about your errors too: from the moment `init()` is called,
124
+ the SDK reports every exception nothing caught, every promise rejection nothing
125
+ handled, and everything it passes to `onError`, with its stack. That is how
126
+ `hopple dev` can show what a game threw without anyone opening the frame's
127
+ console. Nothing is suppressed: the console prints everything as before. An
128
+ error thrown before `init()` is called, or a script that doesn't parse, can't
129
+ be reported, so look in the browser console when a game never starts.
130
+ | `timeoutMs` | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. |
131
+ | `transport` | A channel of your own. By default the SDK finds the host itself. |
132
+ | `scope` | The window to use. For tests. |
133
+
134
+ Everything else hangs off the returned object: `sdk.lifecycle`, `sdk.layout`,
135
+ `sdk.save`, and so on. It also has `sdk.ping()` (resolves with the round-trip
136
+ time in milliseconds) and `sdk.dispose()` (disconnects; calls still waiting
137
+ reject with `disposed`).
138
+
139
+ ### When a feature isn't available
140
+
141
+ A host doesn't have to offer every feature. Each one a host may lack says so
142
+ when `init()` resolves: `sdk.save.available`, `sdk.score.available`,
143
+ `sdk.achievements.available`, `sdk.share.available` and
144
+ `sdk.panel.available`. Leave out the buttons and screens of a feature that
145
+ isn't there.
146
+
147
+ Calls come in two kinds:
148
+
149
+ - **Calls with an answer** (`save.read`, `save.write`, `save.clear`,
150
+ `score.submit`) return a promise. On a host without the
151
+ feature — one that doesn't offer it, or one older than it — they reject with
152
+ a `BridgeError` whose code is `unsupported`. Carry on; never crash on it.
153
+ - **Calls with nothing to answer** (`achievements.unlock`,
154
+ `achievements.progress`, `share.offer`, `panel.open`, `lifecycle.ready`)
155
+ return nothing.
156
+ On a host without the feature they do nothing, and anything else that goes
157
+ wrong goes to `onError`. There is nothing to await, catch or check first.
158
+
159
+ ### State the host may not know yet
160
+
161
+ `sdk.save.status`, `sdk.score.standing` and `sdk.achievements.standing` are
162
+ known at startup when the host has the answer to hand, and say
163
+ `{ known: false }` when it doesn't yet — a store still answering. Never draw
164
+ "nothing yet" for `known: false`; each one's `on()` says when it becomes known.
165
+
166
+ ### Listeners
167
+
168
+ Every `on(listener)` hears a change of state and returns a function that
169
+ removes the listener. Most are called with `(now, previous)`; the host's orders
170
+ with the new value (`loop.on` and `resources.on` with `allowed`, `pause.on`
171
+ with `paused`), and `audio.on` with nothing: read `volume()` and `allowed`.
172
+ Streams of events rather than state have their own names: `input.onAction`,
173
+ `input.onStick` and `input.onPointer`.
174
+
175
+ ## Lifecycle: when to run
176
+
177
+ **Phase** is how far the game has got in starting up. It only moves forward:
178
+
179
+ | Phase | Meaning |
180
+ |---|---|
181
+ | `loading` | Connected, preparing. |
182
+ | `ready` | The host heard `sdk.lifecycle.ready()`. Only the game can make this happen. |
183
+ | `disposed` | About to be removed. Last chance to save. |
184
+
185
+ Everything else the host decides, and tells the game as one order per system.
186
+ The game never learns why (swiped past, off screen, under an ad): it obeys each
187
+ order on its own.
188
+
189
+ | Order | While `false`, the game… | Done for you by |
190
+ |---|---|---|
191
+ | `sdk.audio.allowed` | makes no sound. | `audio.output`, `audio.media`; `audio.volume()` is 0 |
192
+ | `sdk.loop.allowed` | asks for no frames. | `loop.tick` |
193
+ | `sdk.resources.allowed` | frees what it can load again. | nothing: what is expensive is the game's to know |
194
+
195
+ ```ts
196
+ sdk.lifecycle.phase; // 'loading' | 'ready' | 'disposed'
197
+
198
+ // The game loop, run only while the loop is allowed. dt is in seconds, capped
199
+ // at MAX_STEP, and 0 on the first frame after the loop starts or comes back.
200
+ sdk.loop.tick((dt) => {
201
+ update(dt);
202
+ draw();
203
+ });
204
+
205
+ // An engine with its own loop sleeps and wakes it itself.
206
+ sdk.loop.on((allowed) => (allowed ? engine.loop.wake() : engine.loop.sleep()));
207
+
208
+ sdk.resources.on(async (allowed) => {
209
+ if (allowed) await reloadTextures(); // the host waits for this
210
+ else unloadTextures();
211
+ });
212
+ ```
213
+
214
+ All three are known as soon as `init()` resolves. A host may show the game
215
+ while it is still `loading`, so a loading screen is worth drawing.
216
+
217
+ When an order changes, the host waits for the game's reply. If your listener
218
+ returns a promise, the reply is sent when it settles, so the host knows you
219
+ have actually let go. The host only waits about half a second, then carries on
220
+ regardless.
221
+
222
+ Rules:
223
+
224
+ - **Stop your game loop when it isn't allowed.** A game that keeps running
225
+ while hidden drains the player's battery.
226
+ - **Pause when the host asks** (`sdk.pause.on`): it asks when it takes a game in
227
+ play away from the player, and leaves resuming to the player.
228
+ - If the host doesn't hear `ready()` within 5 seconds of connecting, it
229
+ assumes you're ready and shows you anyway.
230
+ - A game that reloads itself starts again at `loading`; the orders stay what
231
+ they were.
232
+
233
+ ## Layout: where to draw
234
+
235
+ The host draws its own buttons over or next to the game, and the device may
236
+ have a notch or rounded corners. `sdk.layout` tells you where everything
237
+ covering the frame is.
238
+
239
+ The frame is the page's own window, sized by the time `init()` resolves: size
240
+ the game from `innerWidth` and `innerHeight` or with CSS, as any page does.
241
+
242
+ ```ts
243
+ sdk.layout.frame; // { width, height } of the frame, in CSS pixels: the window's size
244
+ sdk.layout.obstructions; // what covers the frame: [{ rect, kind, reserved }]
245
+ sdk.layout.insets; // { top, right, bottom, left }: the simple version
246
+ sdk.layout.on((now, previous) => relayout(now.insets)); // now and previous: frame, obstructions and insets
247
+ ```
248
+
249
+ Each obstruction has:
250
+
251
+ | Field | Meaning |
252
+ |---|---|
253
+ | `rect` | `{ x, y, width, height }`, in the frame's own coordinates. |
254
+ | `kind` | `chrome` (the host's buttons), `cutout` (the device: a notch, a camera hole), `overlay` (the host's panel or a dialog over the game), or `controls` (the on-screen controls, while shown). New kinds may appear in future. |
255
+ | `reserved` | `true`: that area belongs to the host, and taps there never reach you. `false`: something is on top of you but you are still running, and it's your choice whether to dim, move things or ignore it. |
256
+
257
+ **Two ways to use it:**
258
+
259
+ - **Simple:** keep everything the player must touch inside `sdk.layout.insets`.
260
+ This is always correct, but can waste space: a button in a corner becomes a
261
+ whole strip along one edge.
262
+ - **Precise:** read the reserved rectangles and avoid just those.
263
+
264
+ ```ts
265
+ // Stay clear of the host's buttons and overlays, but draw behind the notch:
266
+ import { insetsFrom } from '@hopple/sdk';
267
+
268
+ const insets = insetsFrom(
269
+ sdk.layout.obstructions.filter((o) => o.kind !== 'cutout'),
270
+ sdk.layout.frame,
271
+ );
272
+ ```
273
+
274
+ The insets are also CSS custom properties on the page's root, kept up to
275
+ date before any `on` listener runs, for a game made of DOM elements:
276
+
277
+ ```css
278
+ #menu { padding: var(--hopple-inset-top) var(--hopple-inset-right) var(--hopple-inset-bottom) var(--hopple-inset-left); }
279
+ ```
280
+
281
+ The manifest's `layout.insets` setting decides whether the host shrinks the
282
+ frame to avoid its buttons (`reserve`, the default) or gives you the whole
283
+ screen and relies on you to keep clear (`draw-under`). See
284
+ [the manifest's reference](https://docs.hopple.app/manifest.md#layout).
285
+
286
+ ## Info: facts about the player and device
287
+
288
+ A snapshot taken when the game connected. It never changes during a run.
289
+
290
+ ```ts
291
+ sdk.info.app; // { id, version, surface: 'app' | 'web' | 'dev' }
292
+ sdk.info.game; // { id, version }: what the host believes it loaded
293
+ sdk.info.player; // { id: string | null, returning: boolean, locale: string }
294
+ sdk.info.device; // { tier: 'low' | 'mid' | 'high' }
295
+ ```
296
+
297
+ - **`device.tier`** is a rough measure of the device's power. Use it to pick
298
+ asset quality or effect counts:
299
+ `const particles = { low: 50, mid: 200, high: 800 }[sdk.info.device.tier];`
300
+ - **`player.id`** is different for every game, so two games can't recognise the
301
+ same player. `null` means the host has no notion of a player.
302
+ - **`player.locale`** is the language the player chose in the host, which may
303
+ differ from `navigator.language`.
304
+ - **`app.surface`** is `'dev'` only inside `hopple dev`. Use it for debug
305
+ overlays, never for game logic.
306
+ - **`game.version`** comes from the manifest the host read, not from your code.
307
+ If it differs from the version your code expects, the wrong build was
308
+ uploaded.
309
+
310
+ On a host too old to send info, you get safe defaults: surface `web`, tier
311
+ `mid`, no player, locale `en`, empty ids.
312
+
313
+ ## Analytics
314
+
315
+ ```ts
316
+ sdk.analytics.track('level.complete', { level: 3, won: true }); // name and property types checked against the manifest
317
+ ```
318
+
319
+ `track` sends the event immediately and returns nothing. It never throws and
320
+ never waits.
321
+
322
+ - **Declare every event in the manifest** (see
323
+ [the manifest's reference](https://docs.hopple.app/manifest.md#analytics)). An undeclared name is
324
+ refused. Properties are flat: strings, numbers and booleans only.
325
+ - **Refusals are reported to `onError`**, so you find out during development.
326
+ Your code doesn't need to handle them.
327
+ - **Hosts rate-limit events.** Track things that happen (a level finished),
328
+ not things that are happening (every frame of movement).
329
+ - You don't need to track sessions, loading or placement changes. The host
330
+ records those itself, under names starting with `hopple.`.
331
+
332
+ ## Save
333
+
334
+ One save document per player per game. The host decides where it is stored and
335
+ never looks inside it.
336
+
337
+ ```ts
338
+ sdk.save.available; // false where this host keeps no saves
339
+ sdk.save.status; // what is stored, before you fetch it (see below)
340
+ sdk.save.on((now, previous) => redraw(now)); // status changed: a read, a write or a clear
341
+ const saved = await sdk.save.read(); // { version, state } | null
342
+ await sdk.save.write(state, { version: 2 }); // resolves once the host says it is safely stored
343
+ await sdk.save.clear();
344
+ sdk.save.maxBytes; // the largest save this host accepts
345
+ ```
346
+
347
+ **`sdk.save.status`** is available immediately, so you can show "Continue" or
348
+ "New game" without waiting for a round trip. It has three possible values:
349
+
350
+ | Value | Meaning |
351
+ |---|---|
352
+ | `{ known: true, stored: { at, version, bytes } }` | A save exists. `at` is when it was written. |
353
+ | `{ known: true, stored: null }` | The host checked: there is no save. |
354
+ | `{ known: false }` | The host hasn't said (it is still loading). Call `read()` to find out. |
355
+
356
+ It follows the game's own reads, writes and clears.
357
+
358
+ How to use it:
359
+
360
+ - **Save as you go, not only at the end.** Nothing saves on your behalf, and a
361
+ game that waits until it is being removed may not finish in time.
362
+ - **There is no `flush()`.** Each write is sent immediately; awaiting it is
363
+ how you know it is stored.
364
+ - **`version` is your own save format number.** It is stored next to the data,
365
+ so you can check it before deciding whether to read an old save. There is no
366
+ migration hook: write the `if` yourself.
367
+ - **Strings are stored as they are; anything else goes through
368
+ `JSON.stringify`.** So a `Map`, a `Date` or a typed array won't come back
369
+ the way it went in. Inside `hopple dev`, the SDK warns (via `onError`) when
370
+ you save something JSON would change. For binary data, encode it as a
371
+ base64 string yourself.
372
+ - **Every host accepts at least 64 KiB** (`SAVE_BYTES`). A larger save is
373
+ rejected with `too_large` before it is sent.
374
+
375
+ | Error | Meaning |
376
+ |---|---|
377
+ | `unsupported` | This host has nowhere to store saves (`available` is `false`). |
378
+ | `too_large` | Over `sdk.save.maxBytes`. |
379
+ | `rate_limited` | Saving too often, usually from inside the game loop. |
380
+ | `bad_params` | The state can't be turned into JSON at all (a cycle, a `BigInt`). |
381
+ | `internal` | Storage failed (a full disk, a network problem). |
382
+
383
+ ## Score
384
+
385
+ One number per run, submitted to a board declared in the manifest. The host
386
+ draws the leaderboard; the game only ever sees the player's own numbers.
387
+
388
+ ```ts
389
+ sdk.score.available; // false where this host keeps no scores
390
+ sdk.score.boards; // ['points', 'speed']
391
+ sdk.score.standing; // { known: true, boards: { points: { best, at, runs, rank?, of? }, speed: null } }
392
+ // { known: false } until the host says
393
+ sdk.score.on((now, previous) => redraw(now)); // the host's answer arrived, or a submission moved it
394
+ const result = await sdk.score.submit(1200, { board: 'points' });
395
+ // { improved: true, best: 1200, rank?: 23, of?: 400 }
396
+ ```
397
+
398
+ - **Submit every run**, not only improvements. The host keeps every run and
399
+ works out the best itself (which is what makes "best this week" possible).
400
+ A worse run returns `improved: false`.
401
+ - **`board` can be left out** if the game has exactly one board.
402
+ - **`submit` resolves once the score is stored on the host's side**, which may
403
+ be before it reaches a server. That is why `rank` and `of` are optional: only
404
+ show a rank when you were given one.
405
+ - **`standing` has the same three answers as the save status:** a board's
406
+ standing, `null` (never scored on this board), or `known: false` (unknown
407
+ right now). Don't show "no scores yet" for `known: false`.
408
+ - **There is no anti-cheat.** The SDK only rejects values that aren't finite
409
+ numbers in a safe integer range.
410
+
411
+ Errors: `unsupported` (this host keeps no scores), `bad_params` (a board the
412
+ manifest doesn't declare), `rate_limited` (far more submissions than runs
413
+ could explain).
414
+
415
+ ## Achievements
416
+
417
+ Declared in the manifest. The game says when one is earned or how far along the
418
+ player is; the host draws the list.
419
+
420
+ ```ts
421
+ sdk.achievements.available; // false where this host keeps none
422
+ sdk.achievements.standing; // { known: true, items: [{ id: 'first-ping', unlocked, at?, progress? }, …] }
423
+ // { known: false, items: [{ id }, …] } until the host says
424
+ sdk.achievements.on((now, previous) => redraw(now)); // the host's answer arrived, or a report moved something
425
+ sdk.achievements.unlock('first-ping'); // returns nothing: the host announces it
426
+ sdk.achievements.progress('centurion', 40);
427
+ ```
428
+
429
+ - **Progress is an absolute value, not an increment.** `progress(id, 40)` means
430
+ "the player is at 40". The host keeps the better of the old and new value,
431
+ so reporting the same thing twice, or out of order, is harmless. If you need
432
+ a running total, keep it in your save.
433
+ - **The host shows the banner**, once, the first time something is earned.
434
+ Reporting something already held is harmless, and reporting to a host
435
+ without achievements does nothing: there is nothing to await, catch or check.
436
+ - **Calling `unlock` on a measured achievement completes it** (for a shortcut
437
+ or a cheat code); its progress is set to the target.
438
+ - **`standing` has three answers**, like the score's: don't show
439
+ `known: false` as "locked".
440
+
441
+ What the host refuses goes to `onError`: `bad_params` (an undeclared id, or
442
+ `progress` on an achievement with no target), `rate_limited`.
443
+
444
+ ## Share
445
+
446
+ Asks the host to offer the player a link to this game, carrying some data of
447
+ your own (such as a level seed). Opening that link starts the game with the
448
+ same data. You can also say why the link was made, in `meta`: the platform
449
+ keeps it with its record of the link, so you can later learn which of your
450
+ shares bring players in.
451
+
452
+ ```ts
453
+ sdk.share.offer({
454
+ data: 'seed=42', // handed back to whoever opens the link
455
+ meta: { level: 3, from: 'results-screen' }, // kept by the platform, never handed back
456
+ });
457
+
458
+ sdk.share.data; // the data from the link that opened this run, or null
459
+ sdk.share.available; // whether this host can make links at all
460
+ ```
461
+
462
+ - **You never see the URL.** The host builds it and decides how to offer it.
463
+ - **There is nothing to wait for.** `offer` returns nothing. The host shows
464
+ the share sheet or copies the link, and tells the player what happened
465
+ ("Link copied" included). Don't draw your own message about it, and keep the
466
+ game running: the player can leave the sheet open as long as they like.
467
+ Pressing Share again while a sheet is open doesn't open a second one.
468
+ - **Only share when the player presses something.** Hosts may refuse offers
469
+ nobody asked for.
470
+ - **Show your Share button from `sdk.share.available`**, never from
471
+ `sdk.share.data`: `null` there only means this player didn't arrive by a
472
+ link.
473
+ - **Treat `sdk.share.data` as untrusted input.** It comes from outside your
474
+ game. Check it as strictly as you would any user input.
475
+ - `data` must be a string, at most 512 bytes (`SHARE_BYTES`) on every host.
476
+ Put anything bigger in your save.
477
+ - `meta` is any JSON object, at most 1024 bytes of JSON (`SHARE_META_BYTES`) on
478
+ every host. No game ever reads it back.
479
+
480
+ Errors go to `onError`: `too_large`, `rate_limited` (the host won't offer a
481
+ link right now), `bad_params` (`data` isn't a string, or `meta` isn't a JSON
482
+ object). On a host that can't make links, `offer` does nothing.
483
+
484
+ The website and the dev shell support sharing.
485
+
486
+ ## Panel
487
+
488
+ Hosts have a panel of their own that opens over the game. Some of its pages
489
+ are about the game: its details, the scoreboard, the achievements list, and the
490
+ key-binding page. A game can ask the host to open the panel on one of them, for
491
+ example from its own pause menu.
492
+
493
+ ```ts
494
+ sdk.panel.pages; // e.g. ['about', 'scores', 'input']: the pages this host offers for this game
495
+ sdk.panel.available; // false where there are none
496
+ sdk.panel.open('scores'); // returns nothing: what happens arrives through sdk.panel.on
497
+ ```
498
+
499
+ The possible pages are `about`, `scores`, `achievements` and `input`.
500
+
501
+ - **Check `sdk.panel.pages` before drawing a button for a page.** It is known
502
+ from the start, so you can decide while laying out your menu. If the host has
503
+ no `input` page, draw your own key-binding screen.
504
+ - The list depends on the game as well as the host: a game with no score
505
+ boards won't have `scores`.
506
+ - `open()` on a page the host doesn't offer does nothing. Any other refusal
507
+ (`rate_limited`: asked too often, or mid-run) goes to `onError`.
508
+ - **There is no way to close the panel.** The player closes it.
509
+
510
+ You can also find out when the panel is over the game, whether you opened it
511
+ or the player did, and on which of the game's pages:
512
+
513
+ ```ts
514
+ sdk.panel.isOpen; // correct from the first frame
515
+ sdk.panel.page; // 'input' | null: null when closed, or open on a page of the host's own
516
+ sdk.panel.on((now, previous) => {
517
+ if (!now.isOpen) undim(); // closed
518
+ else if (!previous.isOpen) dim(); // opened
519
+ else redrawFor(now.page); // moved to another page: don't undim here
520
+ });
521
+ ```
522
+
523
+ The open panel also appears in `sdk.layout.obstructions` as an `overlay`. Use
524
+ layout for "where can I draw", and `panel` for "what is the player looking at".
525
+ The panel opening is not an order: the game's loop and sound stay allowed under
526
+ it. If the game is `running`, the host asks it to pause through `pause` (below);
527
+ otherwise what you do about it is your choice.
528
+
529
+ ## Input
530
+
531
+ Actions (named buttons), thumbsticks, a pointer, and on-screen touch controls.
532
+ Using this is optional; a game can still read the DOM's keyboard, pointer or
533
+ gamepad events directly. What `sdk.input` adds is key remapping, controller
534
+ support, on-screen controls on phones, and correct button labels.
535
+
536
+ Declare your actions and control layouts in the manifest (see
537
+ [the manifest's reference](https://docs.hopple.app/manifest.md#input)). The host reads them from the
538
+ published manifest and sends them at the handshake, with the player's remapped
539
+ bindings and what device they are using. Key presses are read inside the
540
+ frame.
541
+
542
+ ### Events
543
+
544
+ ```ts
545
+ sdk.input.onAction('shoot', (down) => { … }); // pressed or released, from any bound key, button or tap
546
+ sdk.input.onStick('pad.left.stick', ({ x, y }) => { … }); // -1…1 on each axis
547
+ sdk.input.onPointer((pointer) => { … }); // { x, y, down } | null (null: not over the game)
548
+ ```
549
+
550
+ Each returns a function that removes the listener. An action or on-screen
551
+ layout the manifest doesn't declare is a compile error with
552
+ `init<typeof config.manifest>()`, and is reported to `onError` without it.
553
+
554
+ An action bound to several things (say a key and a gamepad button) is pressed
555
+ while *any* of them is held, and released when the *last* one is let go.
556
+ Everything held is released automatically when the game loses focus, its loop
557
+ is stopped, the bindings change, or you call `suspend()`, so a key can't get
558
+ stuck.
559
+
560
+ The host has a controller's home button, and Start held, to open its panel; a
561
+ tap of Start is the game's. While the panel is up it has the focus, and a game
562
+ hears no pad until the focus comes back. A game that reads
563
+ `navigator.getGamepads()` itself sees every press, the host's included.
564
+
565
+ ### Polling once per frame
566
+
567
+ ```ts
568
+ sdk.input.poll = true; // turn on pointer tracking; keys, taps and gamepads are always tracked
569
+
570
+ sdk.loop.tick(() => {
571
+ const now = sdk.input.read();
572
+ if (now.started('jump')) jump(); // went down since the last read
573
+ if (now.pressed('left')) moveLeft(); // is held now
574
+ const { x, y } = now.stick('pad.left.stick');
575
+ });
576
+ ```
577
+
578
+ Call `read()` **once per frame**. It reports presses that started and ended
579
+ since the previous call (so a tap shorter than a frame isn't lost), which means
580
+ a second call in the same frame sees none of them.
581
+
582
+ ### Showing the right button names
583
+
584
+ ```ts
585
+ prompt.textContent = `Press ${sdk.input.mapping.shoot?.label ?? 'shoot'} to shoot`;
586
+
587
+ sdk.input.on((now, previous) => redrawPrompts()); // now and previous: { mapping, scheme }
588
+ sdk.input.scheme; // 'keyboard' | 'mouse' | 'touch' | 'gamepad': what the player is using
589
+ ```
590
+
591
+ Always print labels from `sdk.input.mapping[action].label`, never from your
592
+ manifest. The label is what is written on whatever the player would press:
593
+ `Z` on a keyboard, `A` on a gamepad, and the manifest's own `label` on an
594
+ on-screen button. It follows the player's remapping. `sdk.input.on` fires when
595
+ the player rebinds a key (`mapping` changed) or switches device (`scheme`
596
+ changed).
597
+
598
+ ### On-screen controls
599
+
600
+ ```ts
601
+ sdk.input.controls.show('play'); // show the 'play' layout from your manifest
602
+ sdk.input.controls.hide();
603
+ sdk.input.controls.showing; // whether controls are actually visible
604
+ ```
605
+
606
+ - They are hidden by default. Show them when play starts; hide them on menus.
607
+ - **Check `controls.showing`, not whether you called `show()`.** On a desktop,
608
+ or when the layout doesn't fit, the host says no and they stay hidden.
609
+ - Hiding them only stops drawing them; keys and gamepads keep working.
610
+ - While shown, they appear in `sdk.layout.obstructions` as reserved
611
+ rectangles, so code that avoids the host's buttons avoids them too.
612
+ - They are drawn in a shadow root inside your frame, so your CSS can't affect
613
+ them and they don't appear in your DOM.
614
+
615
+ ### Text fields
616
+
617
+ If your game draws its own text input (on a canvas, for example), call
618
+ `sdk.input.suspend()` while it has focus and `sdk.input.resume()` afterwards,
619
+ so typing doesn't trigger actions. Ordinary `<input>`, `<textarea>` and
620
+ `contenteditable` elements are handled automatically.
621
+
622
+ ## Audio
623
+
624
+ The host decides whether the game may make sound (`sdk.audio.allowed`) and how
625
+ loud. **A game is required to obey both.** A game that plays sound when it
626
+ isn't allowed, or while muted, is not accepted on the platform.
627
+
628
+ The SDK can obey it for you. Route every sound through it:
629
+
630
+ ```ts
631
+ const sound = new AudioContext();
632
+ source.connect(sdk.audio.output(sound, 'music')); // a GainNode kept at the channel's volume
633
+ sdk.audio.media(tuneElement, 'music'); // an <audio> or <video> element, likewise
634
+ ```
635
+
636
+ A sound routed this way follows the volume and mute at once, is silenced the
637
+ moment sound isn't allowed (before the host is told the game has gone quiet),
638
+ resumes when it is, and starts on the player's first press in the frame.
639
+
640
+ A game whose engine mixes for itself reads the volume instead:
641
+
642
+ ```ts
643
+ engine.volume = sdk.audio.volume('music'); // 0 to 1: multiply this channel's sound by it
644
+
645
+ sdk.audio.on(() => {
646
+ engine.volume = sdk.audio.volume('music'); // apply it at once: mute means silent now
647
+ if (sdk.audio.allowed) engine.resume();
648
+ else engine.pause(); // optional: volume() is already 0
649
+ });
650
+ ```
651
+
652
+ - **Use `volume(channel)`.** It already combines `allowed`, mute, the master
653
+ volume and that channel's volume. The separate parts (`sdk.audio.muted`,
654
+ `sdk.audio.master`, `sdk.audio.channel(id)`) are only for drawing your own
655
+ volume screen; mixing from them is an easy way to break mute later.
656
+ - **Mute is not the same as volume 0.** They are separate, so unmuting brings
657
+ back the volume the player had.
658
+ - **Declare your channels in the manifest** (see
659
+ [the manifest's reference](https://docs.hopple.app/manifest.md#audio)) to get a labelled slider for
660
+ each one in the host's sound controls. A channel name is checked against
661
+ the manifest with `init<typeof config.manifest>()`; without it, `volume()`
662
+ for an undeclared channel returns the master volume.
663
+ - **The opening volume is in the welcome**, so you know whether you are muted
664
+ before playing your first note.
665
+ - When the volume changes, the SDK answers the host with the volume it now
666
+ holds, as soon as every listener has been called. Nothing a listener returns
667
+ is waited for: a mute is silent now.
668
+ - On a host with no volume controls, everything is at full volume.
669
+
670
+ ## Pause
671
+
672
+ The host can ask the game to pause or resume, and the game says whether it can.
673
+
674
+ ```ts
675
+ sdk.pause.state; // 'running' | 'paused' | 'unpausable'
676
+ sdk.pause.set('paused'); // tell the host where you now are, whoever caused it
677
+
678
+ sdk.pause.on(async (paused) => { // the host asked; return a promise to make it wait
679
+ if (nothingToPause()) return; // stay 'unpausable'
680
+ holdEverything(paused);
681
+ sdk.pause.set(paused ? 'paused' : 'running');
682
+ });
683
+ ```
684
+
685
+ - **`unpausable` is a normal state**, not an error: a title screen, a cut
686
+ scene, the gap between rounds. While you are `unpausable`, the host hides its
687
+ pause button and doesn't ask.
688
+ - **Every game starts `unpausable`.** Call `set('running')` once there is
689
+ something to pause, and keep it up to date.
690
+ - **Also call `set()` when the game pauses by itself** (the player pressed your
691
+ own pause key, or a round ended), so the host's button shows the right thing.
692
+ - **The host asks a running game to pause when its panel opens over it, and
693
+ when it takes the game away from the player**, and doesn't ask it to resume
694
+ for either: resuming is the player's.
695
+ - **Keep pause separate from the loop.** `sdk.loop.allowed` is what the host
696
+ lets the game run; pause is what the player asked for. If you merge them, the
697
+ game will resume a player's pause as soon as its loop is allowed again:
698
+
699
+ ```ts
700
+ sdk.loop.tick((dt) => { // stopped by the host's order
701
+ if (sdk.pause.state !== 'paused') update(dt); // held still by the player's pause
702
+ draw();
703
+ });
704
+ ```
705
+
706
+ ## Checklist
707
+
708
+ - [ ] Let a rejected `init()` through rather than catching it.
709
+ - [ ] Call `sdk.lifecycle.ready()` when you can draw.
710
+ - [ ] Run the loop only while `sdk.loop.allowed` and make sound only while `sdk.audio.allowed` (`sdk.loop.tick` and `sdk.audio.output` do); free memory when `sdk.resources.allowed` is `false`.
711
+ - [ ] Keep touch targets out of reserved areas (`sdk.layout`).
712
+ - [ ] Leave out what isn't `available`, and treat `unsupported` as "not available", never as a crash.
713
+ - [ ] Save as you play, and await the write.
714
+ - [ ] Never treat `known: false` (unknown) as nothing there.
715
+ - [ ] Declare every analytics event, score board, achievement, action and audio channel in the manifest, and pass its type to `init`.
716
+ - [ ] Submit every run's score. Only show a rank when you get one.
717
+ - [ ] Share and open the panel only from a player's press.
718
+ - [ ] Validate `sdk.share.data` like any untrusted input.
719
+ - [ ] Print button labels from `sdk.input.mapping`, and read input once per frame.
720
+ - [ ] Route every sound through `sdk.audio.output` or `media`, or multiply it by `sdk.audio.volume(channel)`, and obey mute.
721
+ - [ ] Keep `sdk.pause` up to date, separately from the loop.
722
+ - [ ] Publish a single self-contained HTML file.
723
+
724
+ ## License
725
+
726
+ MIT; see `LICENSE`. The SDK is built into every game, so a game carries its
727
+ one-line notice, `/*! @hopple/sdk | Copyright (c) 2026 The Hopple authors | MIT License */`.
728
+ Bundlers keep comments that start with `/*!` unless they minify them away:
729
+ with Vite, set `build.rolldownOptions.output.comments.legal` to `true`, as a
730
+ `hopple create` project does. The license covers the code, not the Hopple name or logo.