@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/LICENSE +21 -0
- package/README.md +729 -2
- package/config.schema.json +776 -0
- package/hopple-sdk.min.js +139 -0
- package/index.d.ts +3650 -0
- package/index.js +3548 -0
- package/manifest.md +281 -0
- package/package.json +30 -4
package/README.md
CHANGED
|
@@ -1,3 +1,730 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hopple/sdk: writing a game
|
|
2
2
|
|
|
3
|
-
|
|
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.
|