@hopple/sdk 0.1.0-rc.3 → 1.0.0-next.12

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
@@ -73,16 +74,16 @@ The SDK is not part of your game. It is **the runtime**, which whatever runs
73
74
  the game serves beside it, and your page loads as its first script:
74
75
 
75
76
  ```html
76
- <script src="_rt/1/hopple.js"></script>
77
+ <script src="_rt/hopple.js"></script>
77
78
  ```
78
79
 
79
80
  The runtime defines the global `Hopple`, holding everything this guide
80
81
  describes. Because it is served rather than built into the game, a fix to the
81
82
  SDK (input, on-screen controls, audio, layout, the loop, save) reaches every
82
- game without a rebuild. `_rt/1/` is the newest release of major 1; a change
83
- that would break games is a new major, which a game takes by changing its
84
- tag. Keep the address relative, as above: Hopple serves a game at the root of
85
- its own origin, and a host like itch.io under a path, with `_rt/` beside
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
86
87
  `index.html` either way.
87
88
 
88
89
  - **With a bundler** (Vite, esbuild, webpack, Rollup, Parcel, Bun):
@@ -95,13 +96,13 @@ its own origin, and a host like itch.io under a path, with `_rt/` beside
95
96
  (`const sdk = await Hopple.init()` in a module script or an async function).
96
97
 
97
98
  `hopple check`, `hopple publish` and the server refuse a page that doesn't
98
- load the runtime first, or that carries a copy of the SDK. Games published
99
- before the runtime keep the SDK they bundled, and get no runtime fixes until
100
- they are rebuilt this way.
99
+ load the runtime first, or that carries a copy of the SDK.
101
100
 
102
101
  A `hopple.config.json` can point its `"$schema"` at
103
- `./node_modules/@hopple/sdk/config.schema.json` for an editor to check it; a
104
- `hopple.config.ts` is checked by its type, `HoppleConfig`.
102
+ `./node_modules/hopple/config.schema.json` for an editor to check it; a
103
+ `hopple.config.ts` is checked by its type,
104
+ `import type { HoppleConfig } from 'hopple'`. Both are the CLI's, which reads
105
+ the config.
105
106
 
106
107
  ```ts
107
108
  import { init } from '@hopple/sdk';
@@ -140,7 +141,9 @@ Options for `init()`, all optional and rarely needed:
140
141
 
141
142
  | Option | Meaning |
142
143
  |---|---|
143
- | `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`. |
144
+ | `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`. |
145
+ | `timeoutMs` | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. |
146
+ | `save` | How to merge two saves made on two devices: `{ merge }`. See [Save](#save). |
144
147
 
145
148
  The host is told about your errors too: from the moment `init()` is called,
146
149
  the SDK reports every exception nothing caught, every promise rejection nothing
@@ -149,9 +152,6 @@ handled, and everything it passes to `onError`, with its stack. That is how
149
152
  console. Nothing is suppressed: the console prints everything as before. An
150
153
  error thrown before `init()` is called, or a script that doesn't parse, can't
151
154
  be reported, so look in the browser console when a game never starts.
152
- | `timeoutMs` | How long each request waits for the host. Default 10 s. The handshake always waits at least 10 s, whatever this says. |
153
- | `transport` | A channel of your own. By default the SDK finds the host itself. |
154
- | `scope` | The window to use. For tests. |
155
155
 
156
156
  Everything else hangs off the returned object: `sdk.lifecycle`, `sdk.layout`,
157
157
  `sdk.save`, and so on. It also has `sdk.ping()` (resolves with the round-trip
@@ -361,6 +361,7 @@ sdk.save.available; // false where this host keeps no
361
361
  sdk.save.status; // what is stored, before you fetch it (see below)
362
362
  sdk.save.on((now, previous) => redraw(now)); // status changed: a read, a write or a clear
363
363
  const saved = await sdk.save.read(); // { version, state } | null
364
+ sdk.save.onChange((now, why) => restore(now)); // the save changed under the game: play on from this
364
365
  await sdk.save.write(state, { version: 2 }); // resolves once the host says it is safely stored
365
366
  await sdk.save.clear();
366
367
  sdk.save.maxBytes; // the largest save this host accepts
@@ -377,6 +378,47 @@ sdk.save.maxBytes; // the largest save this host acce
377
378
 
378
379
  It follows the game's own reads, writes and clears.
379
380
 
381
+ **Two ways to load.** A signed-in player's save comes from a server, which
382
+ may take a moment:
383
+
384
+ - **Wait for it:** `await sdk.save.read()` before the first frame. The host
385
+ waits for the server (a few seconds at most, and not at all offline) and
386
+ answers with the player's save.
387
+ - **Start at once:** listen with `onChange`, call `read()` without awaiting
388
+ it, and apply whatever either hands over. The game plays from nothing, or
389
+ from this device's save, and takes the player's save when it comes.
390
+
391
+ ```ts
392
+ const restore = (s: SaveState | null) => { progress = s ? (s.state as Progress) : fresh(); };
393
+ sdk.save.onChange(restore); // before read()
394
+ sdk.save.read().then(restore);
395
+ ```
396
+
397
+ **Listen with `onChange` either way.** The save can also change while the
398
+ game plays: the server answered later than the host would wait, or a page left
399
+ open hears that another device played on. `onChange(save, why)` hands the game
400
+ the save to play on from then, and why:
401
+
402
+ | `why` | What happened |
403
+ |---|---|
404
+ | `'arrived'` | Another device's save, built on what the game had. With a `merge`, merged with what the game last saved. |
405
+ | `'merged'` | Two saves that went their own ways, made one by the game's `merge`. |
406
+ | `'gone'` | Thrown away on another device, with nothing made here since. `save` is `null`. |
407
+
408
+ To keep the game's own instead (progress it hasn't saved yet, or a New game
409
+ the player chose a moment before a save arrived), write it from the listener:
410
+ it takes the handed save's place.
411
+
412
+ ```ts
413
+ sdk.save.onChange((now, why) => {
414
+ if (why === 'arrived' && justStartedNewGame) void sdk.save.write(progress); // the New game wins
415
+ else restore(now);
416
+ });
417
+ ```
418
+
419
+ A game that does not listen is never written over: its next write is kept
420
+ beside the save that arrived, for its `merge` or the player to settle.
421
+
380
422
  How to use it:
381
423
 
382
424
  - **Save as you go, not only at the end.** Nothing saves on your behalf, and a
@@ -386,17 +428,47 @@ How to use it:
386
428
  - **`version` is your own save format number.** It is stored next to the data,
387
429
  so you can check it before deciding whether to read an old save. There is no
388
430
  migration hook: write the `if` yourself.
389
- - **Strings are stored as they are; anything else goes through
390
- `JSON.stringify`.** So a `Map`, a `Date` or a typed array won't come back
391
- the way it went in. Inside `hopple dev`, the SDK warns (via `onError`) when
392
- you save something JSON would change. For binary data, encode it as a
393
- base64 string yourself.
394
- - **Every host accepts at least 64 KiB** (`SAVE_BYTES`). A larger save is
395
- rejected with `too_large` before it is sent.
431
+ - **Strings are stored as they are, and a `Uint8Array` as its bytes; anything
432
+ else goes through `JSON.stringify`.** Each comes back as it went in: a
433
+ string, a `Uint8Array`, or the parsed value. So a `Map`, a `Date` or a typed
434
+ array inside an object won't come back the way it went in. Inside
435
+ `hopple dev`, the SDK warns (via `onError`) when you save something JSON
436
+ would change.
437
+ - **Bytes pack a big save small.** Write a `Uint8Array` with
438
+ [`@hopple/bytes`](https://www.npmjs.com/package/@hopple/bytes) (varints, floats, strings, and flags a
439
+ bit each), and it is stored and counted as those bytes.
440
+ - **Every host accepts at least 64 KiB** (`SAVE_BYTES`), counted in UTF-8 for
441
+ a string or JSON and in bytes for a `Uint8Array`. A larger save is rejected
442
+ with `too_large` before it is sent.
443
+
444
+ **Two devices.** A signed-in player's save follows them to every device. When
445
+ two devices each saved without seeing the other's progress (one was offline),
446
+ the host keeps both and asks the game to merge them, with the `merge` passed to
447
+ `init()`:
448
+
449
+ ```ts
450
+ const sdk = await init({
451
+ save: {
452
+ merge: (mine, theirs) => ({ version: 2, state: combine(mine.state, theirs.state) }),
453
+ },
454
+ });
455
+ sdk.save.onChange((now) => restore(now)); // merged while playing: play on from this
456
+ ```
457
+
458
+ - `merge(mine, theirs)` is pure and answers with the save to keep in place of
459
+ both. Throw where the two can't be merged, and the host asks the player
460
+ which to keep, as it does for a game with no `merge`.
461
+ - Asked while the game loads, the merge is what `read()` resolves with. Asked
462
+ while it plays, it goes to `onChange` listeners, and waits for the next load
463
+ where there are none.
464
+ - A save that arrives while the game plays is merged with what the game last
465
+ saved too, where it has a `merge`, before `onChange` hears it.
466
+ - Inside `hopple dev`, the SDK warns (via `onError`) when the save changed
467
+ while nothing listened with `onChange`.
396
468
 
397
469
  | Error | Meaning |
398
470
  |---|---|
399
- | `unsupported` | This host has nowhere to store saves (`available` is `false`). |
471
+ | `unsupported` | This host has nowhere to store saves (`available` is `false`), or is too old to keep a `Uint8Array`. |
400
472
  | `too_large` | Over `sdk.save.maxBytes`. |
401
473
  | `rate_limited` | Saving too often, usually from inside the game loop. |
402
474
  | `bad_params` | The state can't be turned into JSON at all (a cycle, a `BigInt`). |
@@ -477,7 +549,8 @@ sdk.share.offer({
477
549
  meta: { level: 3, from: 'results-screen' }, // kept by the platform, never handed back
478
550
  });
479
551
 
480
- sdk.share.data; // the data from the link that opened this run, or null
552
+ sdk.share.data; // the string from the link that opened this run, or null
553
+ sdk.share.bytes; // the Uint8Array from the link that opened this run, or null
481
554
  sdk.share.available; // whether this host can make links at all
482
555
  ```
483
556
 
@@ -492,16 +565,21 @@ sdk.share.available; // whether this host can make links at all
492
565
  - **Show your Share button from `sdk.share.available`**, never from
493
566
  `sdk.share.data`: `null` there only means this player didn't arrive by a
494
567
  link.
495
- - **Treat `sdk.share.data` as untrusted input.** It comes from outside your
496
- game. Check it as strictly as you would any user input.
497
- - `data` must be a string, at most 512 bytes (`SHARE_BYTES`) on every host.
498
- Put anything bigger in your save.
568
+ - **Treat `sdk.share.data` and `sdk.share.bytes` as untrusted input.** They
569
+ come from outside your game. Check them as strictly as you would any user
570
+ input.
571
+ - `data` is a string, which comes back as `sdk.share.data`, or a `Uint8Array`,
572
+ which comes back as `sdk.share.bytes`. Either way it is at most 512 bytes
573
+ (`SHARE_BYTES`) on every host. Bytes pack a board or a replay far tighter
574
+ than text: see [`@hopple/bytes`](https://www.npmjs.com/package/@hopple/bytes). Put anything bigger in
575
+ your save.
499
576
  - `meta` is any JSON object, at most 1024 bytes of JSON (`SHARE_META_BYTES`) on
500
577
  every host. No game ever reads it back.
501
578
 
502
579
  Errors go to `onError`: `too_large`, `rate_limited` (the host won't offer a
503
- link right now), `bad_params` (`data` isn't a string, or `meta` isn't a JSON
504
- object). On a host that can't make links, `offer` does nothing.
580
+ link right now), `bad_params` (`data` isn't a string or a `Uint8Array`, or
581
+ `meta` isn't a JSON object), `unsupported` (bytes, on a host too old to carry
582
+ them). On a host that can't make links, `offer` does nothing.
505
583
 
506
584
  The website and the dev shell support sharing.
507
585
 
@@ -518,7 +596,9 @@ sdk.panel.available; // false where there are none
518
596
  sdk.panel.open('scores'); // returns nothing: what happens arrives through sdk.panel.on
519
597
  ```
520
598
 
521
- The possible pages are `about`, `scores`, `achievements` and `input`.
599
+ The possible pages are `about`, `scores`, `achievements` and `input`. A game
600
+ that plays over the network also has `room`, the player's party, from
601
+ [`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network).
522
602
 
523
603
  - **Check `sdk.panel.pages` before drawing a button for a page.** It is known
524
604
  from the start, so you can decide while laying out your menu. If the host has
@@ -601,21 +681,42 @@ Call `read()` **once per frame**. It reports presses that started and ended
601
681
  since the previous call (so a tap shorter than a frame isn't lost), which means
602
682
  a second call in the same frame sees none of them.
603
683
 
604
- ### Showing the right button names
684
+ ### Showing which button to press
605
685
 
606
686
  ```ts
607
- prompt.textContent = `Press ${sdk.input.mapping.shoot?.label ?? 'shoot'} to shoot`;
687
+ prompt.textContent = `Press ${sdk.input.glyph('shoot')} to shoot`; // written once
688
+ prompt.setAttribute('aria-label', `Press ${sdk.input.describe('shoot')} to shoot`);
608
689
 
609
- sdk.input.on((now, previous) => redrawPrompts()); // now and previous: { mapping, scheme }
610
- sdk.input.scheme; // 'keyboard' | 'mouse' | 'touch' | 'gamepad': what the player is using
690
+ ctx.font = `20px 'My Font', sans-serif, ${sdk.input.font}`; // on a canvas
691
+ ctx.fillText(`Press ${sdk.input.glyph('shoot')} to shoot`, x, y);
692
+ ```
693
+
694
+ ```css
695
+ body { font-family: 'My Font', sans-serif, hopple-keys; }
611
696
  ```
612
697
 
613
- Always print labels from `sdk.input.mapping[action].label`, never from your
614
- manifest. The label is what is written on whatever the player would press:
615
- `Z` on a keyboard, `A` on a gamepad, and the manifest's own `label` on an
616
- on-screen button. It follows the player's remapping. `sdk.input.on` fires when
617
- the player rebinds a key (`mapping` changed) or switches device (`scheme`
618
- changed).
698
+ `sdk.input.glyph(action)` is one character, drawn by a font the SDK builds:
699
+ every key or button bound to the action for the device in the player's hands —
700
+ `[Z] / [Space]` as keycaps, a pad's face buttons as a diamond with the one to
701
+ press filled, the same on every controller, the on-screen button on a touch
702
+ screen. When the player rebinds the action or picks up another device the SDK
703
+ swaps the font, so text in the DOM is right without being touched, and a canvas
704
+ is right the next time it is painted (a canvas drawn once redraws on
705
+ `sdk.input.on`). `glyph('pad.start')` draws a button that is none of your
706
+ actions.
707
+
708
+ **Name `hopple-keys` (`sdk.input.font`) last in every font stack that prints
709
+ a glyph**, the canvas's included. A stack without it draws a box; one that
710
+ names it first changes the line height of its text in some browsers. Children
711
+ inherit a `body` rule, form controls don't unless they set `font: inherit`.
712
+ Under `hopple dev` a glyph printed anywhere else is reported to `onError`.
713
+
714
+ `sdk.input.describe(action)` is the same in words (`Z or Space`, `bottom face
715
+ button`), for an `aria-label` and for text drawn without fonts.
716
+ `sdk.input.mapping[action].label` still names one binding in text, and
717
+ `sdk.input.scheme` is what the player is using: `'keyboard' | 'mouse' | 'touch'
718
+ | 'gamepad'`. `sdk.input.on` fires when the player rebinds a key (`mapping`
719
+ changed) or switches device (`scheme` changed).
619
720
 
620
721
  ### On-screen controls
621
722
 
@@ -631,8 +732,74 @@ sdk.input.controls.showing; // whether controls are actually visible
631
732
  - Hiding them only stops drawing them; keys and gamepads keep working.
632
733
  - While shown, they appear in `sdk.layout.obstructions` as reserved
633
734
  rectangles, so code that avoids the host's buttons avoids them too.
634
- - They are drawn in a shadow root inside your frame, so your CSS can't affect
635
- them and they don't appear in your DOM.
735
+ - They are drawn in a shadow root inside your frame, so they don't appear in
736
+ your DOM and your CSS only reaches them where it is meant to: through
737
+ `hopple-controls::part(…)` and the `--hopple-pad-*` properties (see
738
+ [Styling them](#styling-the-on-screen-controls)).
739
+
740
+ ### Styling the on-screen controls
741
+
742
+ ```css
743
+ hopple-controls {
744
+ --hopple-pad-tint: #2b1d0ecc; /* a button's fill */
745
+ --hopple-pad-edge: #f5c542; /* rims, the knob */
746
+ --hopple-pad-ink: #fff8e1; /* labels and icons */
747
+ --hopple-pad-radius: 14px;
748
+ --hopple-pad-font: 700 16px/1 'My Font', sans-serif, hopple-keys;
749
+ }
750
+ hopple-controls::part(face pressed) { translate: 0 2px; }
751
+ hopple-controls::part(face action-jump) { background: url(ui/jump.png) center / 70% no-repeat; }
752
+ ```
753
+
754
+ ```ts
755
+ sdk.input.icon('jump', 'M12 3 4 13h5v8h6v-8h5z'); // a filled path in a 24×24 box, or null
756
+ ```
757
+
758
+ - Parts: `face`, `label` and `icon` on a button; `group` behind a group of
759
+ buttons; `well`, `knob`, `groove` and `arrow` on a stick. Each also carries
760
+ `action-<id>` and `control-<id>`, and the states `pressed`, `held`,
761
+ `floating` and `resting`.
762
+ - `<hopple-controls>` has `layout`, `sticks` and `look` attributes, and a
763
+ stick has `--hopple-stick-x` and `--hopple-stick-y` (−1 to 1) for a game to
764
+ read.
765
+ - Sizes (`--hopple-pad-size`, `-stick`, `-track`, `-gap`, `-inset`) are held
766
+ between a size a thumb can press and one that leaves the game room. How big
767
+ one control is next to the others is its `scale` in the manifest. What a
768
+ part draws never changes where a touch lands.
769
+ - A player can turn a game's look off, for every game or for one, and gets the
770
+ plain controls back: test that they still read well over the game.
771
+ - Under `hopple dev`, a rule that names a part or a property the controls don't
772
+ have is reported to `onError`.
773
+
774
+ ### Several players on one device
775
+
776
+ A manifest's `input.player` declares what each local player does, and
777
+ `players.local` how many may play on one device: one set of actions every
778
+ player has a copy of, bound once for every pad, and the parts of the keyboard
779
+ players share (`keyboard`, each with a title and its keys).
780
+
781
+ ```ts
782
+ sdk.input.joining = true; // the screen where players join
783
+ sdk.input.onPlayers((players) => drawRoster(players));
784
+ for (const player of sdk.input.players) { // the first player is always there
785
+ const now = player.read(); // once a frame, as sdk.input.read()
786
+ if (now.started('bomb')) dropBomb(player.slot);
787
+ label[player.slot].textContent = player.glyph('bomb'); // their own key or button
788
+ }
789
+ sdk.input.joining = false; // play starts
790
+ ```
791
+
792
+ - **The first player hears every controller nobody claimed**, so a player
793
+ alone loses nothing between devices. Setting `joining` gives them the one
794
+ they last used; a join action pressed on a free pad or part of the keyboard
795
+ seats a new player with it, up to `mostPlayers`, and is nobody else's press.
796
+ - **`slot`** is a player's place from 0, kept until `leave()`.
797
+ **`controller`** is what they hold, or `null` once their pad went away:
798
+ they keep their place, and the next free pad pressed is theirs again.
799
+ - **`sdk.input.read()` and `onAction` answer for player actions too**, as
800
+ anybody pressing them, for menus any player may work.
801
+ - **In a match**, the device tells the match how many play on it, and
802
+ `mostPlayers` takes no more than the match has room for.
636
803
 
637
804
  ### Text fields
638
805
 
@@ -725,6 +892,14 @@ sdk.pause.on(async (paused) => { // the host asked; return a promise to make i
725
892
  });
726
893
  ```
727
894
 
895
+ ## Match
896
+
897
+ Play over the network, in matches: `sdk.match`. It is a preview, not stable
898
+ yet, so it is a package of its own,
899
+ [`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network), which
900
+ the game installs and declares in its manifest as `previews.network`, at its
901
+ revision. A game that does not declare it has no `sdk.match`.
902
+
728
903
  ## Checklist
729
904
 
730
905
  - [ ] Let a rejected `init()` through rather than catching it.
@@ -738,10 +913,11 @@ sdk.pause.on(async (paused) => { // the host asked; return a promise to make i
738
913
  - [ ] Submit every run's score. Only show a rank when you get one.
739
914
  - [ ] Share and open the panel only from a player's press.
740
915
  - [ ] Validate `sdk.share.data` like any untrusted input.
741
- - [ ] Print button labels from `sdk.input.mapping`, and read input once per frame.
916
+ - [ ] Print button prompts with `sdk.input.glyph` (and `sdk.input.describe` for words), and read input once per frame.
742
917
  - [ ] Route every sound through `sdk.audio.output` or `media`, or multiply it by `sdk.audio.volume(channel)`, and obey mute.
743
918
  - [ ] Keep `sdk.pause` up to date, separately from the loop.
744
- - [ ] Load the runtime as the page's first script, `<script src="_rt/1/hopple.js"></script>`, and import `@hopple/sdk` rather than bundling a copy.
919
+ - [ ] 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.
920
+ - [ ] Load the runtime as the page's first script, `<script src="_rt/hopple.js"></script>`, and import `@hopple/sdk` rather than bundling a copy.
745
921
  - [ ] Refer to every file relatively, and publish the output directory.
746
922
 
747
923
  ## License