@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 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` 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.
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 becomes part of your page; nothing loads it at runtime.
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. 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`.
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 shim that threw, a save value that JSON would silently change. Defaults to `console.error`. |
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 the right button names
625
+ ### Showing which button to press
583
626
 
584
627
  ```ts
585
- prompt.textContent = `Press ${sdk.input.mapping.shoot?.label ?? 'shoot'} to shoot`;
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
- sdk.input.on((now, previous) => redrawPrompts()); // now and previous: { mapping, scheme }
588
- sdk.input.scheme; // 'keyboard' | 'mouse' | 'touch' | 'gamepad': what the player is using
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
- 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).
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 labels from `sdk.input.mapping`, and read input once per frame.
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
- - [ ] Publish a single self-contained HTML file.
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`. 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 */`.
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 license covers the code, not the Hopple name or logo.
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.