@hopple/sdk 0.1.0-rc.2 → 1.0.0-next.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 +150 -44
- package/config.schema.json +211 -41
- package/index.d.ts +515 -880
- package/index.js +14 -3585
- package/manifest.d.ts +1011 -0
- package/manifest.js +851 -0
- package/manifest.md +73 -10
- package/package.json +7 -6
- package/hopple-sdk.min.js +0 -155
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ name and type is documented where it is declared, in the package's
|
|
|
27
27
|
- [Input](#input)
|
|
28
28
|
- [Audio](#audio)
|
|
29
29
|
- [Pause](#pause)
|
|
30
|
+
- [Match](#match)
|
|
30
31
|
- [Checklist](#checklist)
|
|
31
32
|
|
|
32
33
|
## What a game is
|
|
@@ -42,23 +43,25 @@ Both are named in the project's hopple config (`hopple.config.ts`, `.js` or
|
|
|
42
43
|
like. `npx hopple create my-game` sets up the recommended way (Vite, with the
|
|
43
44
|
config in TypeScript); see [`hopple create`](https://docs.hopple.app/cli.md#hopple-create).
|
|
44
45
|
|
|
45
|
-
The host loads the page into a frame sandboxed with only `allow-scripts
|
|
46
|
-
`allow-pointer-lock
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- **No network.** A game can't fetch, open a socket or load anything from
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
The host loads the page into a frame sandboxed with only `allow-scripts`,
|
|
47
|
+
`allow-same-origin` and `allow-pointer-lock`, every version on an origin of
|
|
48
|
+
its own. That has some consequences you need to plan for:
|
|
49
|
+
|
|
50
|
+
- **Its output directory is published.** `hopple publish` uploads the
|
|
51
|
+
directory the page is in, with the page as its `index.html`, and it refuses
|
|
52
|
+
a page that loads a file the directory doesn't hold. Refer to files
|
|
53
|
+
relatively. A page beside its config is published alone.
|
|
54
|
+
- **No network.** A game can't fetch, open a socket or load anything from
|
|
55
|
+
another host. It may load its own files, and `data:` and `blob:` URLs.
|
|
56
|
+
Anything the game could send out could carry what it knows about the
|
|
57
|
+
player.
|
|
58
|
+
- **No `localStorage`, `sessionStorage`, IndexedDB, Cache Storage or
|
|
59
|
+
cookies.** Whatever a game stored would be cleared every time its page
|
|
60
|
+
loads, so the runtime makes each of them throw an error naming
|
|
61
|
+
`Hopple.save`, and `hopple check` refuses a game that uses them. Use
|
|
62
|
+
[`sdk.save`](#save) instead.
|
|
63
|
+
- **Every address relative, no `<base>`, and UTF-8 pages.** `hopple check`
|
|
64
|
+
refuses the rest.
|
|
62
65
|
- **No `alert`, `confirm` or `prompt`.** They are blocked in the sandbox.
|
|
63
66
|
- **Sound may need a tap inside the game first.** Browsers only allow audio
|
|
64
67
|
after a user gesture in the frame. Sound routed through
|
|
@@ -67,15 +70,33 @@ The host loads the page into a frame sandboxed with only `allow-scripts` and
|
|
|
67
70
|
|
|
68
71
|
## Getting started
|
|
69
72
|
|
|
70
|
-
The SDK
|
|
73
|
+
The SDK is not part of your game. It is **the runtime**, which whatever runs
|
|
74
|
+
the game serves beside it, and your page loads as its first script:
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<script src="_rt/hopple.js"></script>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The runtime defines the global `Hopple`, holding everything this guide
|
|
81
|
+
describes. Because it is served rather than built into the game, a fix to the
|
|
82
|
+
SDK (input, on-screen controls, audio, layout, the loop, save) reaches every
|
|
83
|
+
game without a rebuild. Which release a game gets is its track's: the major
|
|
84
|
+
its manifest's `api` names, or `next`, built with the previews it declares. A change that would break games is a
|
|
85
|
+
new major, which a game takes by changing its `api`. Keep the address
|
|
86
|
+
relative, as above: Hopple serves a game at the root of its own origin, and a host like itch.io under a path, with `_rt/` beside
|
|
87
|
+
`index.html` either way.
|
|
71
88
|
|
|
72
89
|
- **With a bundler** (Vite, esbuild, webpack, Rollup, Parcel, Bun):
|
|
73
|
-
`npm install @hopple/sdk` and import it.
|
|
74
|
-
every doc comment, in `node_modules/@hopple/sdk/index.d.ts
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
(`
|
|
78
|
-
|
|
90
|
+
`npm install @hopple/sdk` and import it. The package is the SDK's types,
|
|
91
|
+
with every doc comment, in `node_modules/@hopple/sdk/index.d.ts`, and a
|
|
92
|
+
stub of a few lines that forwards each import to `Hopple`: your bundle
|
|
93
|
+
carries the stub, never the SDK. Set your bundler to write relative
|
|
94
|
+
addresses (Vite: `base: './'`).
|
|
95
|
+
- **Without one:** use the global `Hopple` directly
|
|
96
|
+
(`const sdk = await Hopple.init()` in a module script or an async function).
|
|
97
|
+
|
|
98
|
+
`hopple check`, `hopple publish` and the server refuse a page that doesn't
|
|
99
|
+
load the runtime first, or that carries a copy of the SDK.
|
|
79
100
|
|
|
80
101
|
A `hopple.config.json` can point its `"$schema"` at
|
|
81
102
|
`./node_modules/@hopple/sdk/config.schema.json` for an editor to check it; a
|
|
@@ -118,7 +139,9 @@ Options for `init()`, all optional and rarely needed:
|
|
|
118
139
|
|
|
119
140
|
| Option | Meaning |
|
|
120
141
|
|---|---|
|
|
121
|
-
| `onError` | Called with problems the game should know about but that don't reject a call: a refused analytics event, a
|
|
142
|
+
| `onError` | Called with problems the game should know about but that don't reject a call: a refused analytics event, a save value that JSON would silently change. Defaults to `console.error`. |
|
|
143
|
+
| `timeoutMs` | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. |
|
|
144
|
+
| `save` | How to merge two saves made on two devices: `{ merge }`. See [Save](#save). |
|
|
122
145
|
|
|
123
146
|
The host is told about your errors too: from the moment `init()` is called,
|
|
124
147
|
the SDK reports every exception nothing caught, every promise rejection nothing
|
|
@@ -127,9 +150,6 @@ handled, and everything it passes to `onError`, with its stack. That is how
|
|
|
127
150
|
console. Nothing is suppressed: the console prints everything as before. An
|
|
128
151
|
error thrown before `init()` is called, or a script that doesn't parse, can't
|
|
129
152
|
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
153
|
|
|
134
154
|
Everything else hangs off the returned object: `sdk.lifecycle`, `sdk.layout`,
|
|
135
155
|
`sdk.save`, and so on. It also has `sdk.ping()` (resolves with the round-trip
|
|
@@ -372,6 +392,27 @@ How to use it:
|
|
|
372
392
|
- **Every host accepts at least 64 KiB** (`SAVE_BYTES`). A larger save is
|
|
373
393
|
rejected with `too_large` before it is sent.
|
|
374
394
|
|
|
395
|
+
**Two devices.** A signed-in player's save follows them to every device. When
|
|
396
|
+
two devices each saved without seeing the other's progress (one was offline),
|
|
397
|
+
the host keeps both and asks the game to merge them, with the `merge` passed to
|
|
398
|
+
`init()`:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const sdk = await init({
|
|
402
|
+
save: {
|
|
403
|
+
merge: (mine, theirs) => ({ version: 2, state: combine(mine.state, theirs.state) }),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
sdk.save.onMerge((merged) => restore(merged.state)); // merged while playing: play on from this
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
- `merge(mine, theirs)` is pure and answers with the save to keep in place of
|
|
410
|
+
both. Throw where the two can't be merged, and the host asks the player
|
|
411
|
+
which to keep, as it does for a game with no `merge`.
|
|
412
|
+
- Asked while the game loads, the merge is what `read()` resolves with. Asked
|
|
413
|
+
while it plays, it goes to `onMerge` listeners, and waits for the next load
|
|
414
|
+
where there are none.
|
|
415
|
+
|
|
375
416
|
| Error | Meaning |
|
|
376
417
|
|---|---|
|
|
377
418
|
| `unsupported` | This host has nowhere to store saves (`available` is `false`). |
|
|
@@ -496,7 +537,9 @@ sdk.panel.available; // false where there are none
|
|
|
496
537
|
sdk.panel.open('scores'); // returns nothing: what happens arrives through sdk.panel.on
|
|
497
538
|
```
|
|
498
539
|
|
|
499
|
-
The possible pages are `about`, `scores`, `achievements` and `input`.
|
|
540
|
+
The possible pages are `about`, `scores`, `achievements` and `input`. A game
|
|
541
|
+
that plays over the network also has `room`, the player's party, from
|
|
542
|
+
[`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network).
|
|
500
543
|
|
|
501
544
|
- **Check `sdk.panel.pages` before drawing a button for a page.** It is known
|
|
502
545
|
from the start, so you can decide while laying out your menu. If the host has
|
|
@@ -579,21 +622,42 @@ Call `read()` **once per frame**. It reports presses that started and ended
|
|
|
579
622
|
since the previous call (so a tap shorter than a frame isn't lost), which means
|
|
580
623
|
a second call in the same frame sees none of them.
|
|
581
624
|
|
|
582
|
-
### Showing
|
|
625
|
+
### Showing which button to press
|
|
583
626
|
|
|
584
627
|
```ts
|
|
585
|
-
prompt.textContent = `Press ${sdk.input.
|
|
628
|
+
prompt.textContent = `Press ${sdk.input.glyph('shoot')} to shoot`; // written once
|
|
629
|
+
prompt.setAttribute('aria-label', `Press ${sdk.input.describe('shoot')} to shoot`);
|
|
586
630
|
|
|
587
|
-
|
|
588
|
-
sdk.input.
|
|
631
|
+
ctx.font = `20px 'My Font', sans-serif, ${sdk.input.font}`; // on a canvas
|
|
632
|
+
ctx.fillText(`Press ${sdk.input.glyph('shoot')} to shoot`, x, y);
|
|
589
633
|
```
|
|
590
634
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
635
|
+
```css
|
|
636
|
+
body { font-family: 'My Font', sans-serif, hopple-keys; }
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
`sdk.input.glyph(action)` is one character, drawn by a font the SDK builds:
|
|
640
|
+
every key or button bound to the action for the device in the player's hands —
|
|
641
|
+
`[Z] / [Space]` as keycaps, a pad's face buttons as a diamond with the one to
|
|
642
|
+
press filled, the same on every controller, the on-screen button on a touch
|
|
643
|
+
screen. When the player rebinds the action or picks up another device the SDK
|
|
644
|
+
swaps the font, so text in the DOM is right without being touched, and a canvas
|
|
645
|
+
is right the next time it is painted (a canvas drawn once redraws on
|
|
646
|
+
`sdk.input.on`). `glyph('pad.start')` draws a button that is none of your
|
|
647
|
+
actions.
|
|
648
|
+
|
|
649
|
+
**Name `hopple-keys` (`sdk.input.font`) last in every font stack that prints
|
|
650
|
+
a glyph**, the canvas's included. A stack without it draws a box; one that
|
|
651
|
+
names it first changes the line height of its text in some browsers. Children
|
|
652
|
+
inherit a `body` rule, form controls don't unless they set `font: inherit`.
|
|
653
|
+
Under `hopple dev` a glyph printed anywhere else is reported to `onError`.
|
|
654
|
+
|
|
655
|
+
`sdk.input.describe(action)` is the same in words (`Z or Space`, `bottom face
|
|
656
|
+
button`), for an `aria-label` and for text drawn without fonts.
|
|
657
|
+
`sdk.input.mapping[action].label` still names one binding in text, and
|
|
658
|
+
`sdk.input.scheme` is what the player is using: `'keyboard' | 'mouse' | 'touch'
|
|
659
|
+
| 'gamepad'`. `sdk.input.on` fires when the player rebinds a key (`mapping`
|
|
660
|
+
changed) or switches device (`scheme` changed).
|
|
597
661
|
|
|
598
662
|
### On-screen controls
|
|
599
663
|
|
|
@@ -612,6 +676,36 @@ sdk.input.controls.showing; // whether controls are actually visible
|
|
|
612
676
|
- They are drawn in a shadow root inside your frame, so your CSS can't affect
|
|
613
677
|
them and they don't appear in your DOM.
|
|
614
678
|
|
|
679
|
+
### Several players on one device
|
|
680
|
+
|
|
681
|
+
A manifest's `input.player` declares what each local player does, and
|
|
682
|
+
`players.local` how many may play on one device: one set of actions every
|
|
683
|
+
player has a copy of, bound once for every pad, and the parts of the keyboard
|
|
684
|
+
players share (`keyboard`, each with a title and its keys).
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
sdk.input.joining = true; // the screen where players join
|
|
688
|
+
sdk.input.onPlayers((players) => drawRoster(players));
|
|
689
|
+
for (const player of sdk.input.players) { // the first player is always there
|
|
690
|
+
const now = player.read(); // once a frame, as sdk.input.read()
|
|
691
|
+
if (now.started('bomb')) dropBomb(player.slot);
|
|
692
|
+
label[player.slot].textContent = player.glyph('bomb'); // their own key or button
|
|
693
|
+
}
|
|
694
|
+
sdk.input.joining = false; // play starts
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
- **The first player hears every controller nobody claimed**, so a player
|
|
698
|
+
alone loses nothing between devices. Setting `joining` gives them the one
|
|
699
|
+
they last used; a join action pressed on a free pad or part of the keyboard
|
|
700
|
+
seats a new player with it, up to `mostPlayers`, and is nobody else's press.
|
|
701
|
+
- **`slot`** is a player's place from 0, kept until `leave()`.
|
|
702
|
+
**`controller`** is what they hold, or `null` once their pad went away:
|
|
703
|
+
they keep their place, and the next free pad pressed is theirs again.
|
|
704
|
+
- **`sdk.input.read()` and `onAction` answer for player actions too**, as
|
|
705
|
+
anybody pressing them, for menus any player may work.
|
|
706
|
+
- **In a match**, the device tells the match how many play on it, and
|
|
707
|
+
`mostPlayers` takes no more than the match has room for.
|
|
708
|
+
|
|
615
709
|
### Text fields
|
|
616
710
|
|
|
617
711
|
If your game draws its own text input (on a canvas, for example), call
|
|
@@ -703,6 +797,14 @@ sdk.pause.on(async (paused) => { // the host asked; return a promise to make i
|
|
|
703
797
|
});
|
|
704
798
|
```
|
|
705
799
|
|
|
800
|
+
## Match
|
|
801
|
+
|
|
802
|
+
Play over the network, in matches: `sdk.match`. It is a preview, not stable
|
|
803
|
+
yet, so it is a package of its own,
|
|
804
|
+
[`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network), which
|
|
805
|
+
the game installs and declares in its manifest as `previews.network`, at its
|
|
806
|
+
revision. A game that does not declare it has no `sdk.match`.
|
|
807
|
+
|
|
706
808
|
## Checklist
|
|
707
809
|
|
|
708
810
|
- [ ] Let a rejected `init()` through rather than catching it.
|
|
@@ -716,15 +818,19 @@ sdk.pause.on(async (paused) => { // the host asked; return a promise to make i
|
|
|
716
818
|
- [ ] Submit every run's score. Only show a rank when you get one.
|
|
717
819
|
- [ ] Share and open the panel only from a player's press.
|
|
718
820
|
- [ ] Validate `sdk.share.data` like any untrusted input.
|
|
719
|
-
- [ ] Print button
|
|
821
|
+
- [ ] Print button prompts with `sdk.input.glyph` (and `sdk.input.describe` for words), and read input once per frame.
|
|
720
822
|
- [ ] Route every sound through `sdk.audio.output` or `media`, or multiply it by `sdk.audio.volume(channel)`, and obey mute.
|
|
721
823
|
- [ ] Keep `sdk.pause` up to date, separately from the loop.
|
|
722
|
-
- [ ]
|
|
824
|
+
- [ ] For network play, install `@hopple/sdk-preview-network` and declare `previews.network`, leave the lobby and invites to the host, and start matches from a player's press.
|
|
825
|
+
- [ ] Load the runtime as the page's first script, `<script src="_rt/hopple.js"></script>`, and import `@hopple/sdk` rather than bundling a copy.
|
|
826
|
+
- [ ] Refer to every file relatively, and publish the output directory.
|
|
723
827
|
|
|
724
828
|
## License
|
|
725
829
|
|
|
726
|
-
MIT; see `LICENSE`.
|
|
727
|
-
|
|
830
|
+
MIT; see `LICENSE`. A game carries the stub, which carries its one-line
|
|
831
|
+
notice, `/*! @hopple/sdk <version> | Copyright (c) 2026 The Hopple authors | MIT License */`.
|
|
728
832
|
Bundlers keep comments that start with `/*!` unless they minify them away:
|
|
729
833
|
with Vite, set `build.rolldownOptions.output.comments.legal` to `true`, as a
|
|
730
|
-
`hopple create` project does. The
|
|
834
|
+
`hopple create` project does. The runtime, which is the SDK itself, is MIT
|
|
835
|
+
too, and is served beside the game rather than carried in it. The license
|
|
836
|
+
covers the code, not the Hopple name or logo.
|