@indiegems/gem-web-sdk 13.3.2 → 13.4.0

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.
@@ -6,7 +6,8 @@ for 3 dice rolls". Pressing it is the player's choice to watch, so the Arcade st
6
6
  straight away, labelled as an ad, with no prompt of its own first. The platform then decides
7
7
  whether the player is rewarded, and writes the reward into **your game's own inventory** for that
8
8
  player. Your game never grants the reward, and never learns from the answer whether one was
9
- granted. It reads its inventory.
9
+ granted. It reads its inventory. The Arcade shows no result once the ad closes, either: it hands
10
+ straight back, and **your game shows the reward** (§3).
10
11
 
11
12
  ```js
12
13
  watchAdButton.addEventListener('click', async () => {
@@ -31,21 +32,20 @@ change.
31
32
 
32
33
  ## 1. The reward is your own, and the player keeps it
33
34
 
34
- A reward is one to four grants of **your game's own** items or currencies, up to 100 of each (a
35
- platform currency only as below). It must be something the player keeps and cannot pass on, and
36
- the upload refuses a config where it is not:
35
+ A reward is one to four grants of **your game's own** items or currencies, never a platform
36
+ currency. It must be something the player keeps and cannot pass on, and the upload refuses a
37
+ config where it is not:
37
38
 
38
39
  - **Soulbound.** Every item or currency it grants has `transfer_policy: none`. A reward cannot be
39
40
  traded, gifted or moved.
40
41
  - **An ordinary item or a currency.** Not a bundle, not a loot box, and not a progression.
41
- - **A platform currency only where the platform lists it.** The upload refuses a reward that
42
- names a platform currency unless `gemctl platform config` lists that currency, for your game, as
43
- grantable by `rewarded`.
42
+ - **Never a platform currency.** The upload refuses a reward that names Gems, Tickets or any
43
+ other platform currency.
44
44
  - **Never convertible into one of those.** The upload follows every recipe and catalog entry that
45
45
  spends the reward, then everything that spends what those grant, and so on. If that chain reaches
46
- anything transferable, a platform currency `gemctl platform config` does not list as grantable by
47
- `rewarded`, a bundle, a loot box, or a progression that fires a platform grant, the upload is
48
- refused, and the finding names the path from the reward to the thing it reached.
46
+ anything transferable, a platform currency, a bundle, a loot box, or a progression that fires a
47
+ platform grant, the upload is refused, and the finding names the path from the reward to the
48
+ thing it reached.
49
49
  - **Never spent on a loot draw.** Neither the reward nor anything it converts into may be a cost
50
50
  of a recipe that rolls a loot table, so "spend a token to spin the wheel" is refused too.
51
51
  - **A plain `display_name`.** It is the reward's name wherever the Arcade shows it in its own
@@ -211,19 +211,35 @@ after a press:
211
211
 
212
212
  | what happened | was the reward granted? |
213
213
  | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
214
- | the ad played for its full length | yes, before the answer |
214
+ | the ad played for its full length | yes, and usually before the answer; a provider's confirmation can land after it (below) |
215
215
  | the player closed the ad early | no: closing early is a skip, and a skip pays nothing |
216
216
  | an ad source serves, but no ad was available for this press, or it failed to play | your `without_ads` decides: `grant` grants within your caps, with no ad; `nothing` grants nothing, and the Arcade shows "No ad available right now" |
217
217
  | no ad source serves the channel (§8) | your `without_ads` decides, with no ad |
218
218
  | a cap, a cooldown, `max_held`, the platform's spacing (§6), or something about the player | no |
219
219
 
220
- In some cases the platform also grants with no ad for reasons your game is not told. So **branch on
221
- your inventory, never on the outcome.** Re-read it once the ask settles, whatever it settled as.
220
+ The answer is `shown` whenever the Arcade put its ad stage up, however the ad then ended: watched
221
+ to the end, closed early, or no ad could play and `without_ads` decided. It is `no_ad` when no
222
+ stage went up. In some cases the platform also grants with no ad for reasons your game is not
223
+ told. So **branch on your inventory, never on the outcome.** Re-read it once the ask settles,
224
+ whatever it settled as.
225
+
226
+ **The Arcade shows no result: your game shows the reward.** When the ad closes, the Arcade hands
227
+ straight back to your game, for a test ad and a real one alike. It draws no "you received" line and
228
+ no Continue button of its own. While the ad plays it is labelled "Ad", or **TEST AD** for a test
229
+ ad, and after it the only thing the Arcade ever draws is "No ad available right now", when no ad
230
+ could play and nothing was granted. Telling the player what they got is yours: re-read the
231
+ inventory, then show the new token your own way, such as "+1 revive", or use it at once as §5's
232
+ revive does. When the ad played to the end and the grant landed at once, it is already in the
233
+ inventory when the answer arrives.
222
234
 
223
235
  **A reward can also arrive after that re-read.** Some ad providers confirm a finished ad from their
224
- own server, a little after the player closes it, so the reward can land after the ask has
225
- answered. So also re-read when the screen that spends the reward opens, and spend a token the
226
- player already holds before asking for another (§5's code does both).
236
+ own server, a little after the player closes it. The Arcade waits a few seconds for that
237
+ confirmation before it hands back, so the reward has usually landed by the time the ask answers,
238
+ but it can land after. So pick the reward up whenever a later read finds it, not only on the
239
+ re-read right after the ask: re-read when the screen that spends the reward opens, show the reward
240
+ then if it is new, and spend a token the player already holds before asking for another (§5's code
241
+ does both). Your game hears of no grant on its own: the SDK's change event fires only for your own
242
+ writes, so a later read is how you find it.
227
243
  [`inventory-and-catalogs.md` §6](inventory-and-catalogs.md#6-when-to-re-read) is the general rule.
228
244
 
229
245
  A granted reward shows in your order history like any other change, as an order the platform made
@@ -414,32 +430,35 @@ The caps are **yours to declare and the platform's to enforce.** The reward is y
414
430
  economy, so you choose its limits, and the platform holds them on its side, where a modified client
415
431
  cannot reach them. They apply the same with an ad and without one.
416
432
 
417
- | limit | declared by | enforced by |
418
- | ---------------------------------------------------------------------------------------- | --------------------- | ------------- |
419
- | `max_per_day` — grants per player per UTC day | you, optionally | the platform |
420
- | `cooldown_seconds` — the time between two grants | you | the platform |
421
- | `max_held` — no grant while the player holds this many | you, for item rewards | the platform |
422
- | `min_watch_secs` — how long a test ad plays before it pays | you, optionally | the platform |
423
- | at least 15 seconds between two rewarded offers to one player in one game | the platform | the platform |
424
- | at least 15 seconds between two rewards granted with no ad, in any of the player's games | the platform | the platform |
425
- | once per run, only on the death screen, never in ranked | you | **your game** |
426
-
427
- - **The second spacing reaches across games.** If another game granted this player a reward with
428
- no ad a few seconds ago, an ask in yours that would also grant with no ad (§8) answers `no_ad`
429
- with nothing granted, even though your game has offered nothing.
433
+ | limit | declared by | enforced by |
434
+ | ---------------------------------------------------------------------------------------------- | --------------------- | ------------- |
435
+ | `max_per_day` — grants per player per UTC day | you, optionally | the platform |
436
+ | `cooldown_seconds` — the time between two grants | you | the platform |
437
+ | `max_held` — no grant while the player holds this many | you, for item rewards | the platform |
438
+ | `min_watch_secs` — how long a test ad plays before it pays | you, optionally | the platform |
439
+ | a gap between two rewards granted with no ad, only where the platform turns one on (off today) | the platform | the platform |
440
+ | once per run, only on the death screen, never in ranked | you | **your game** |
441
+
442
+ - **The platform makes no player wait between rewarded ads.** A player who has just watched one
443
+ to the end and presses again is offered the next at once. Your placement's own limits are the
444
+ only spacing there is.
445
+ - **A no-ad gap applies only where the platform turns one on, and it is off today.** Set, it
446
+ spaces two rewards granted with no ad (§8) to one player, across all of their games: an ask in
447
+ yours that would grant with no ad inside it answers `no_ad` with nothing granted, even though
448
+ your game has offered nothing. It never touches an ask that plays an ad.
430
449
  - **What counts toward a cap** is a grant, after an ad or with none, and an ad that is still
431
450
  playing. A grant `without_ads: grant` makes because no ad could play counts the same as any
432
451
  other. An ad the player closed early, and a press that paid nothing, do not count, so a skip
433
- costs the player nothing against your caps. Every press does start the first spacing, though:
434
- a player who closes the ad early and presses again within 15 seconds gets `no_ad` and nothing.
452
+ costs the player nothing against your caps, and a player who closes the ad early can press
453
+ again at once.
435
454
  - **The platform may hold a daily cap lower than the one you declared, never higher.** It sets no
436
455
  daily limit of its own unless one is configured, and a configured one applies to every placement
437
456
  from the next ask, with no upload from you.
438
457
  - ⚠️ **Nothing protects your economy by default but your own caps.** A placement with no
439
- `max_per_day` and no `cooldown_seconds` can pay once every 15 seconds. That holds with an ad,
440
- and under `without_ads: grant` without one, wherever no ad plays. `max_held: 1` stops a player
441
- banking a second token; it does not stop them spending one and earning the next. Declare the
442
- daily cap and cooldown your economy needs.
458
+ `max_per_day` and no `cooldown_seconds` pays after every ad watched to the end, as fast as the
459
+ player can watch them, and under `without_ads: grant` on every press, wherever no ad plays.
460
+ `max_held: 1` stops a player banking a second token; it does not stop them spending one and
461
+ earning the next. Declare the daily cap and cooldown your economy needs.
443
462
  - **`without_ads: grant` means the reward is fine to give free, within its caps.** Every press
444
463
  that finds no ad pays under it, so the caps are all that limit it. Leave the field out, which is
445
464
  `nothing`, for a reward that should only ever follow an ad.
@@ -456,11 +475,11 @@ platform sets them all. A rewarded reward is your economy, so the caps are yours
456
475
  - **A rewarded ad does not replace the break's platform moment.** Signal the moment from the press
457
476
  that leaves the break as usual ([`platform-moments.md`](platform-moments.md)); whether the Arcade
458
477
  shows anything then is its decision.
459
- - **Rewarded has its own spacing:** your placement's `cooldown_seconds`, and the platform's 15
460
- seconds (§6). An ask inside any of them answers `no_ad` with nothing granted, so do not re-ask on
461
- a timer. Never ask on a timer at all: the ad would start with no press. The SDK refuses a timer's
462
- ask only when no input came in the last few seconds (§3), so do not count on it. A player who
463
- presses again later gets a fresh ask.
478
+ - **Rewarded has its own spacing:** your placement's `cooldown_seconds` and `max_per_day` (§6),
479
+ and nothing of the platform's. An ask inside a cooldown, or past the daily cap, answers `no_ad`
480
+ with nothing granted, so do not re-ask on a timer. Never ask on a timer at all: the ad would
481
+ start with no press. The SDK refuses a timer's ask only when no input came in the last few
482
+ seconds (§3), so do not count on it. A player who presses again later gets a fresh ask.
464
483
  - A grant made with no ad holds nothing off, because nothing was shown.
465
484
 
466
485
  ## 8. Where an ad plays, and where it does not
@@ -554,7 +573,8 @@ Play your game on its `dev` or `test` channel. `dev` is open to your team and `t
554
573
  testers. Pressing the button starts a test ad at once, marked **TEST AD**, which counts down the
555
574
  placement's `min_watch_secs` (5 seconds unless you set it). Watch it to the end and the reward
556
575
  lands in that channel's inventory. Close it before then and that is a skip, which pays nothing.
557
- Your game is paused while it plays, and the ask answers when it closes.
576
+ Your game is paused while it plays, and the ask answers when it closes, with no result shown by
577
+ the Arcade: your game's re-read finds the reward and shows it.
558
578
 
559
579
  To pick a behaviour before you press, add `gem_test_ads=<mode>` to the play page's address:
560
580
 
@@ -983,6 +983,53 @@ template either way.
983
983
  This is still quick-join: the player is in a room at once, possibly alone. For "any of these modes,
984
984
  but never start short", name the same slugs on `queueForMatch()` instead.
985
985
 
986
+ ## 5e. Landing in a friend's room: play-with
987
+
988
+ The Arcade can open your game **to play with someone**: when the player presses Join on a friend
989
+ who is playing it, or answers the prompt the Arcade shows a party member when their leader starts
990
+ it. Your game makes no call for this. The Arcade adds a
991
+ launch key of its own, `gemPlayWith`: an opaque ticket the platform issued for this player, which
992
+ names nobody your game can recognise. The SDK reads it once as `createGem` runs, and every
993
+ `quickJoin()` of the session passes it on: the platform tries the room of the person the ticket
994
+ stands for first, and otherwise quick-joins exactly as it always does.
995
+
996
+ ```js
997
+ const room = await gem.rooms.quickJoin({ templateSlug: 'hub' });
998
+ if (gem.rooms.playWith === 'joined') showToast('You joined your friend');
999
+ ```
1000
+
1001
+ - **Their room is tried first, when the player may play with them** — an accepted friend whose
1002
+ presence shows the player that they are in your game, or a member of the same party. The room
1003
+ must be a public one of a template the quick-join named, on the channel being played, that
1004
+ admits a join now (open, or a running match whose template admits late joiners), with a free
1005
+ seat. When it is not, the quick-join goes on as usual: a fuller room, or a new one.
1006
+ - **`gem.rooms.playWith` says which.** `'joined'` — seated in that player's room; `'unavailable'`
1007
+ — their room could not be joined, or the ticket was no longer good, so the quick-join went on as
1008
+ usual; `null` — this quick-join did not ask, because the Arcade did not open the game to play
1009
+ with anyone. It never says why, and it is read-only: there is no option for naming a player
1010
+ yourself. A game that could name one could ask the platform, one friend at a time, where each of
1011
+ them is.
1012
+ - **Who it was stays with the platform.** Neither the launch key, the answer nor anything else of
1013
+ this mechanism names the person: your game learns that it was opened to play with somebody, and
1014
+ whether the quick-join seated the player with them. When it was `'joined'`, the people in the room are its members, as they
1015
+ always are.
1016
+ - **The two share a `groupId`.** A member who came to play with someone carries the same
1017
+ `RoomMember.groupId` as them, as a party's members do, so a roster grouped by it already shows
1018
+ them together. It is not a party: either of them leaves alone.
1019
+ - **No `seated` fires for it.** `seated` reports a seat your game did not make; this one your own
1020
+ `quickJoin()` made, and its answer is the room.
1021
+ - **Only quick-join takes part.** `create()`, `join()`, `joinByCode()` and `queueForMatch()`
1022
+ never try a friend's room, and a party leader's quick-join brings the party into a room of its
1023
+ own rather than joining anyone else's.
1024
+ - **Never read `gemPlayWith` from the URL yourself, and never define a key of that name.** It is
1025
+ the platform's, like `gemChallenge`
1026
+ ([`getting-started.md`](getting-started.md#linking-to-your-game)).
1027
+
1028
+ A game that quick-joins as it starts — a hub, a drop-in lobby — gets all of this with no change.
1029
+ One that shows a mode picker first keeps it: the friend's room is tried for the modes the player
1030
+ picks, and a friend playing another mode reads `'unavailable'`. Naming several modes in one
1031
+ quick-join (§5d) tries the friend's room for any of them.
1032
+
986
1033
  ## 6. Scope
987
1034
 
988
1035
  **In:** rooms, membership, host, ready state, the match lifecycle, and the dedicated-server connect
@@ -997,7 +1044,8 @@ skill rating, and inventing one is not a small addition to this.
997
1044
  **Elsewhere:** P2P _connection_ and parties. Both are
998
1045
  [`p2p-and-parties.md`](p2p-and-parties.md). Three things surface here:
999
1046
 
1000
- - **A member's group, not their party.** `RoomMember.groupId` says who arrived together, as an opaque
1047
+ - **A member's group, not their party.** `RoomMember.groupId` says who arrived together — as a
1048
+ party, or one coming to play with the other (§5e) — as an opaque
1001
1049
  label that is stable for the life of this room and unrelated to the label the same people carry in
1002
1050
  any other room. It is gem-api's, read off the wire; it replaced a pass-through of the real
1003
1051
  `party_id`, which is account-level and cross-game and therefore a social-graph key rather than a
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
80
80
  also publishes on its download host, with the hash beside it:
81
81
 
82
82
  ```bash
83
- curl -fsSL -O https://downloads.gemarcade.com/sdk/v13.3.2/gem-web-sdk.tgz
84
- curl -fsSL https://downloads.gemarcade.com/sdk/v13.3.2/gem-web-sdk.tgz.sri
83
+ curl -fsSL -O https://downloads.gemarcade.com/sdk/v13.4.0/gem-web-sdk.tgz
84
+ curl -fsSL https://downloads.gemarcade.com/sdk/v13.4.0/gem-web-sdk.tgz.sri
85
85
  openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
86
86
  ```
87
87
 
@@ -729,7 +729,7 @@ the `var()` above, or read `gem.viewport`. The release checklist looks for the b
729
729
  The Arcade keeps **one control of its own over every game**, on every device — a phone's full-bleed
730
730
  game, a framed or full-window game on a desktop, a tablet or a TV in native fullscreen:
731
731
 
732
- - **The Gem button**, the Gem Arcade's gem, always there (a touch target, about 50px across), in
732
+ - **The Gem button**, the Gem Arcade's gem, always there (a 44px square touch target), in
733
733
  the **top-left corner** unless your game [asks for another](#moving-the-gem-gemsetgemcorner). It
734
734
  opens [the Arcade's menu](#the-arcades-menu): Return to the Arcade, the player's Gems, Tickets and
735
735
  achievement score, mute, the player's Arcade-wide settings and the rest. Not your settings: those
@@ -756,7 +756,8 @@ where "back" and "settings" are once, across every game:
756
756
  Arcade's menu, so a player always knows which control does which.
757
757
  - **Whichever of your controls shares the Gem's corner sits level with the Gem, on the side toward
758
758
  the centre.** With the Gem top-left, nothing of yours does. With the Gem top-right, your Back and
759
- settings sit just left of it.
759
+ settings sit just left of it. "Level" means centred on the Gem button's own middle, which the
760
+ SDK gives you as `--gem-reserved-tl-center-y` (below), never on the middle of its reserved area.
760
761
  - **Your settings button is there on every screen.** It goes top-right even where no Arcade control
761
762
  is: another embedder may keep one in that corner, so place it from the reserved areas below and it
762
763
  moves aside for whatever is there.
@@ -769,32 +770,72 @@ into the camera band, but that edge is somewhere else on every device — read t
769
770
  An inset cannot say that — as a left or top inset, a button a few dozen pixels wide would take a whole
770
771
  strip of your screen. So `gem.viewport` also carries the rectangles, in the same CSS pixels of your
771
772
  frame, measured from its top-left corner — one for the Gem button, and one for the frame-rate
772
- readout while the player shows it. Each is grown out to the frame edges it sits nearest, because the
773
- sliver between the Gem and the edge is no place for a button either. On a notched phone in portrait,
774
- with the Gem top-left:
773
+ readout while the player shows it.
774
+
775
+ **Each rectangle is a keep-out area, not the control.** It is grown out to the frame edges it sits
776
+ nearest, because the sliver between the Gem and the edge is no place for a button either. So its
777
+ middle is not the button's middle, and nothing should be lined up with it. Beside it, `control`
778
+ is the control's own box, as drawn and not grown: that is what you line up with. On a notched phone
779
+ in portrait, with the Gem top-left, the Gem sits 8px in from the left and at the foot of the
780
+ 47px inset:
775
781
 
776
782
  ```json
777
783
  {
778
784
  "insets": { "top": 47, "right": 0, "bottom": 34, "left": 0 },
779
- "reserved": [{ "x": 0, "y": 0, "width": 60, "height": 99 }]
785
+ "reserved": [
786
+ {
787
+ "x": 0,
788
+ "y": 0,
789
+ "width": 52,
790
+ "height": 91,
791
+ "control": { "x": 8, "y": 47, "width": 44, "height": 44 }
792
+ }
793
+ ]
780
794
  }
781
795
  ```
782
796
 
783
- **The SDK turns them into one pair of custom properties per corner**, written beside
784
- `--gem-safe-*` before anything else hears the push: `--gem-reserved-tl-width` and
785
- `--gem-reserved-tl-height`, and the same pair for `tr`, `bl` and `br` — how far that corner's
786
- reserved rectangles reach in from its two edges. A rectangle belongs to the corner its centre lies
787
- nearest to. Lay the top row out between the two top corners, and it is right wherever the Gem is:
797
+ Here the area's middle is about 46px down and the button's is 69px down, some 23px apart: a top bar
798
+ centred on the area would have its middle level with the top of the Gem. The numbers change with the phone, the
799
+ orientation, a letterboxed game and a television, so read them rather than copying these.
800
+ `control` is absent under an arcade or embedder that does not send it yet; then you have the
801
+ keep-out area alone.
802
+
803
+ **The SDK turns them into custom properties per corner**, written beside `--gem-safe-*` before
804
+ anything else hears the push. A rectangle belongs to the corner its centre lies nearest to.
805
+
806
+ | Property (and the same for `tr`, `bl`, `br`) | What it is |
807
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
808
+ | `--gem-reserved-tl-width` | How far that corner's keep-out areas reach in from the side edge. Keep your controls outside it. |
809
+ | `--gem-reserved-tl-height` | How far they reach down from the top edge (up from the bottom edge, for `bl` and `br`). |
810
+ | `--gem-reserved-tl-center-y` | The vertical middle of that corner's controls themselves, from your frame's top edge. Line things up on it. |
811
+ | `--gem-reserved-tl-control-height` | Those controls' height: the Gem button's, or the Gem and the frame-rate readout together when both are there. |
812
+
813
+ To line a top bar up with the Gem, give it the Gem's height and centre it on the Gem's middle. Put
814
+ the bar between the two top corners' keep-out areas, and it is right wherever the Gem is:
788
815
 
789
816
  ```css
790
- /* The top row: clear of whatever the Arcade reserves in either top corner */
817
+ /* The Gem's middle and height, from whichever top corner holds it. The fallback is the Arcade's
818
+ own placement on a full-bleed game, for an embedder that does not send the control's box.
819
+ On :root, where the SDK writes its properties, so every element below can use them. */
820
+ :root {
821
+ --gem-mid: var(
822
+ --gem-reserved-tl-center-y,
823
+ var(
824
+ --gem-reserved-tr-center-y,
825
+ calc(max(8px, var(--gem-safe-top, env(safe-area-inset-top, 0px))) + 22px)
826
+ )
827
+ );
828
+ --gem-size: var(--gem-reserved-tl-control-height, var(--gem-reserved-tr-control-height, 44px));
829
+ }
830
+ /* The top row: level with the Gem, clear of whatever the Arcade reserves in either top corner */
791
831
  #top-row {
792
832
  position: absolute;
793
- top: var(--gem-safe-top, env(safe-area-inset-top, 0px));
833
+ top: calc(var(--gem-mid) - var(--gem-size) / 2);
834
+ height: var(--gem-size);
794
835
  left: max(var(--gem-safe-left, 0px), var(--gem-reserved-tl-width, 0px));
795
836
  right: max(var(--gem-safe-right, 0px), var(--gem-reserved-tr-width, 0px));
796
837
  display: flex;
797
- align-items: center;
838
+ align-items: center; /* anything taller than the Gem still centres on its middle */
798
839
  gap: 16px;
799
840
  }
800
841
  /* your information takes the middle; Back, then settings, at the right end */
@@ -804,6 +845,18 @@ nearest to. Lay the top row out between the two top corners, and it is right whe
804
845
  }
805
846
  ```
806
847
 
848
+ With the Gem in a bottom corner, neither top corner has a control, and the fallback puts the row
849
+ where the Gem would sit at the top. From script, read the box itself:
850
+
851
+ ```js
852
+ // The Gem's own box: the tallest control on the top edge (the frame-rate readout is smaller)
853
+ const boxes = (gem.viewport?.reserved ?? []).filter((r) => r.y === 0 && r.control);
854
+ const gemBox = boxes.map((r) => r.control).sort((a, b) => b.height - a.height)[0];
855
+ const middle = gemBox
856
+ ? gemBox.y + gemBox.height / 2
857
+ : Math.max(8, gem.viewport?.insets.top ?? 0) + 22;
858
+ ```
859
+
807
860
  The per-corner properties say how far a corner's rectangles reach, not how much room is left beside
808
861
  them. To know whether your top row fits between the Arcade's controls, read the rectangles from
809
862
  script: the room is what lies between the top rectangles on the left and those on the right.
@@ -817,9 +870,12 @@ const to = Math.min(w, ...top.filter((r) => !isLeft(r)).map((r) => r.x));
817
870
  row.classList.toggle('below-arcade-row', to - from < row.scrollWidth + 8);
818
871
  ```
819
872
 
820
- All eight properties are written on every push, `0px` for a clear corner, so a rotation never leaves
821
- a stale value; they are never set under an embedder that sends no viewport, which is what the `0px`
822
- fallback is for. `gem.viewport.reserved` is `[]` when nothing is covered.
873
+ The eight width and height properties are written on every push, `0px` for a clear corner, so a
874
+ rotation never leaves a stale value; they are never set under an embedder that sends no viewport,
875
+ which is what the `0px` fallback is for. The centre and control height are different: a corner with
876
+ no control in it, or one whose embedder sends no control box, has them **removed** rather than set to
877
+ `0px` (a middle of 0 would put your bar half off the screen), so your `var()` fallback stands.
878
+ `gem.viewport.reserved` is `[]` when nothing is covered.
823
879
 
824
880
  **Lay out against it whenever it changes.** The Gem button's rectangle is there for the whole
825
881
  session, and it moves with a rotation, a resize, the frame-rate readout and a change of corner. Read
@@ -978,12 +1034,14 @@ This is the whole model, and every other page that mentions settings links here:
978
1034
  **The button goes in the top-right corner**, the same corner in every game, so a player never has
979
1035
  to look for it, with your Back button just to its left when a screen has one. Place it with the
980
1036
  reserved-area properties so it never sits under an Arcade control: if the Gem is in that corner, the
981
- button sits level with it, just inside it.
1037
+ button sits level with it, just inside it. Inside [the top row](#the-top-of-the-screen) it is
1038
+ level already; placed on its own, centre it on the same middle:
982
1039
 
983
1040
  ```css
984
1041
  #settings-button {
985
1042
  position: absolute;
986
- top: var(--gem-safe-top, env(safe-area-inset-top, 0px));
1043
+ top: var(--gem-mid); /* defined on :root, as for the top row */
1044
+ transform: translateY(-50%);
987
1045
  right: max(var(--gem-safe-right, 0px), var(--gem-reserved-tr-width, 0px));
988
1046
  }
989
1047
  ```
@@ -994,19 +1052,19 @@ The player sets their defaults once, in the Arcade's Settings, separately for ea
994
1052
  and a television can say different things. They reach your game as `gem.prefs.recommended`, in the
995
1053
  same push as `muted` and `reducedMotion`, and again whenever the player changes one:
996
1054
 
997
- | Field | Values | What it suggests |
998
- | ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
999
- | `volume` | `0` to `100` | Your master volume's starting level, as a percentage — divide by 100 for a gain. A level, never a mute. |
1000
- | `batterySaver` | `true` / `false` | Spend less power: a lower frame rate, fewer effects, a lower resolution. |
1001
- | `keyboardLayout` | `'wasd'` / `'arrows'` | Which keys move by default. |
1002
- | `gamepadConfirm` | `'south'` / `'east'` | Which face button confirms, by position: the bottom one (A on an Xbox controller) or the right one (B). The other one goes back. |
1003
- | `vibration` | `true` / `false` | Your vibration toggle's starting state ([if your game vibrates](#if-your-game-vibrates)). |
1004
- | `lookSensitivity` | `20` to `400` | Look speed for touch, mouse and pen, as a percentage of the base speeds: `100` is 1×. |
1005
- | `gamepadLookSensitivity` | `20` to `400` | Look speed for a gamepad stick, as a percentage: `100` is 1×. |
1006
- | `invertLookY` | `true` / `false` | Up/down look inverted: pushing up or dragging up looks down. Pitch only, never yaw. |
1007
- | `touchMovement` | `'floating'` / `'fixed'` | On a touch screen, a movement stick that appears where the thumb lands, or one fixed in place. |
1008
- | `graphicsQuality` | `'low'` / `'medium'` / `'high'` | The graphics quality to start at on this device. |
1009
- | `graphicsQualityAuto` | `true` / `false` | `true`: the Arcade picked that tier for this device, so you may adapt from it. `false`: the player picked it, so hold it. |
1055
+ | Field | Values | What it suggests |
1056
+ | ------------------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1057
+ | `volume` | `0` to `100` | Your master volume's starting level, as a percentage — divide by 100 for a gain. A level, never a mute. |
1058
+ | `batterySaver` | `true` / `false` | Spend less power: a lower frame rate, fewer effects, a lower resolution. |
1059
+ | `keyboardLayout` | `'wasd'` / `'arrows'` | Which keys move by default. |
1060
+ | `gamepadConfirm` | `'south'` / `'east'` | Which face button confirms, by position: the bottom one (A on an Xbox controller) or the right one (B). The other one goes back. |
1061
+ | `vibration` | `true` / `false` | Your vibration toggle's starting state ([if your game vibrates](#if-your-game-vibrates)). |
1062
+ | `lookSensitivity` | `20` to `400` | Look speed for touch, mouse and pen, as a percentage of the base speeds: `100` is 1×. |
1063
+ | `gamepadLookSensitivity` | `20` to `400` | Look speed for a gamepad stick, as a percentage: `100` is 1×. |
1064
+ | `invertLookY` | `true` / `false` | Up/down look inverted: pushing up or dragging up looks down. Pitch only, never yaw. |
1065
+ | `touchMovement` | `'floating'` / `'fixed'` | On a touch screen, a movement stick that appears where the thumb lands, or one fixed in place. |
1066
+ | `graphicsQuality` | `'low'` / `'medium'` / `'high'` | The graphics quality to start at on this device. |
1067
+ | `graphicsQualityAuto` | `true` / `false` | `true`: the Arcade picked that tier for this device, so you may adapt from it. `false`: the player picked it, so hold it. |
1010
1068
 
1011
1069
  `lookSensitivity` to `touchMovement` are the camera and controls settings: see
1012
1070
  [camera and look controls](#camera-and-look-controls) for `gem.controls.look()`, which applies the
@@ -1034,8 +1092,15 @@ sends neither; the SDK then fills `graphicsQuality` from `batterySaver` (`'low'`
1034
1092
 
1035
1093
  ```js
1036
1094
  const DEFAULTS = {
1037
- volume: 100, batterySaver: false, keyboardLayout: 'wasd', gamepadConfirm: 'south', vibration: true,
1038
- lookSensitivity: 100, gamepadLookSensitivity: 100, invertLookY: false, touchMovement: 'floating',
1095
+ volume: 100,
1096
+ batterySaver: false,
1097
+ keyboardLayout: 'wasd',
1098
+ gamepadConfirm: 'south',
1099
+ vibration: true,
1100
+ lookSensitivity: 100,
1101
+ gamepadLookSensitivity: 100,
1102
+ invertLookY: false,
1103
+ touchMovement: 'floating',
1039
1104
  graphicsQuality: 'medium',
1040
1105
  };
1041
1106
  let chosen = {}; // ONLY what the player picked in your settings screen
@@ -1106,11 +1171,16 @@ function turn({ yaw: dYaw, pitch: dPitch }) {
1106
1171
 
1107
1172
  // Touch and pen: the change since the last event. A mouse: movementX/Y under pointer lock.
1108
1173
  let last = null;
1109
- canvas.addEventListener('pointerdown', (e) => { last = { x: e.clientX, y: e.clientY }; });
1110
- canvas.addEventListener('pointerup', () => { last = null; });
1174
+ canvas.addEventListener('pointerdown', (e) => {
1175
+ last = { x: e.clientX, y: e.clientY };
1176
+ });
1177
+ canvas.addEventListener('pointerup', () => {
1178
+ last = null;
1179
+ });
1111
1180
  canvas.addEventListener('pointermove', (e) => {
1112
1181
  if (e.pointerType === 'mouse') {
1113
- if (document.pointerLockElement === canvas) turn(look.fromPointer(e.movementX, e.movementY, 'mouse'));
1182
+ if (document.pointerLockElement === canvas)
1183
+ turn(look.fromPointer(e.movementX, e.movementY, 'mouse'));
1114
1184
  return;
1115
1185
  }
1116
1186
  if (!last) return;
@@ -1400,6 +1470,7 @@ Arcade, which draws a small readout in its own chrome. **Your game draws nothing
1400
1470
  a second call for one frame counts it twice. It is free while the overlay is off — a flag check,
1401
1471
  nothing sent — so leave it in your loop. A worker renderer posts to the main thread when it draws
1402
1472
  and calls it there.
1473
+
1403
1474
  - **Do not draw an FPS counter of your own over the Arcade's.** Two readouts that disagree — and
1404
1475
  they will, since they count different things — teach a player that one of them is lying. A debug
1405
1476
  overlay in your own builds is fine; keep it off in an Arcade build, or at least off by default.
@@ -1557,7 +1628,9 @@ first:
1557
1628
  const services = context.sourceCode.parserServices;
1558
1629
  const checker = services.program.getTypeChecker();
1559
1630
  const isPlayerText = (type) =>
1560
- (type.isUnion() ? type.types : [type]).some((t) => t.getSymbol()?.getName() === 'PlayerText');
1631
+ (type.isUnion() ? type.types : [type]).some(
1632
+ (t) => t.getSymbol()?.getName() === 'PlayerText',
1633
+ );
1561
1634
  const typeOf = (node) => checker.getTypeAtLocation(services.esTreeNodeToTSNodeMap.get(node));
1562
1635
  const check = (node) => {
1563
1636
  if (node && isPlayerText(typeOf(node))) context.report({ node, messageId: 'stringified' });
@@ -1607,6 +1680,7 @@ first:
1607
1680
 
1608
1681
  It needs type information, so it lints TypeScript only. In plain JavaScript, rely on the console
1609
1682
  warning.
1683
+
1610
1684
  - **The field list.** The package carries `docs/player-text-fields.json`: every field that holds
1611
1685
  `PlayerText`, by entry (`sdk` is `@indiegems/gem-web-sdk/sdk`), each with the first release in
1612
1686
  which that entry shipped it as `PlayerText`. Search your code for every field it lists, not only
@@ -1651,9 +1725,9 @@ accepts, uploading and selling your own, the purchase flow and loading the model
1651
1725
  has **two tabs**, and they are **two separate worn choices** — wearing one never changes the
1652
1726
  other:
1653
1727
 
1654
- | Tab | What the player wears | On `gem.player` and every room member |
1655
- |---|---|---|
1656
- | **2D** | a portrait — a picture | `avatarIconId`, `avatarIconUrl` (`avatar_icon_id`, `avatar_icon_url` on the wire) |
1728
+ | Tab | What the player wears | On `gem.player` and every room member |
1729
+ | ------ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
1730
+ | **2D** | a portrait — a picture | `avatarIconId`, `avatarIconUrl` (`avatar_icon_id`, `avatar_icon_url` on the wire) |
1657
1731
  | **3D** | a 3D character — a rigged model | `avatar3dId`, `avatar3dModelUrl`, `avatar3dIconUrl` (`avatar_3d_id`, `avatar_3d_model_url`, `avatar_3d_icon_url`) |
1658
1732
 
1659
1733
  - **`avatar3dId`** is the worn 3D character's item id. Absent means none is worn — draw your
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indiegems/gem-web-sdk",
3
- "version": "13.3.2",
3
+ "version": "13.4.0",
4
4
  "description": "postMessage bridge contract between gem-web and browser-playable games",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1250,7 +1250,28 @@
1250
1250
  "x": { "kind": "int", "min": 0, "max": 16384 },
1251
1251
  "y": { "kind": "int", "min": 0, "max": 16384 },
1252
1252
  "width": { "kind": "int", "min": 0, "max": 16384 },
1253
- "height": { "kind": "int", "min": 0, "max": 16384 }
1253
+ "height": { "kind": "int", "min": 0, "max": 16384 },
1254
+ "control": {
1255
+ "kind": "object",
1256
+ "optional": true,
1257
+ "$comment": [
1258
+ "The box of the control this area protects, as drawn: NOT grown to",
1259
+ "the frame's edges, clipped to the frame, in the same space and",
1260
+ "rounded outward like the area. The area is a keep-out zone, grown",
1261
+ "to the nearest edges so nothing lands in the sliver between the",
1262
+ "control and the edge; its middle is not the control's middle. This",
1263
+ "is what a game lines its own controls up with, such as a top bar",
1264
+ "centred on the Gem button. Additive: absent from an older parent,",
1265
+ "which leaves the game knowing only the keep-out area, and ignored",
1266
+ "by an older SDK, which never reads it."
1267
+ ],
1268
+ "fields": {
1269
+ "x": { "kind": "int", "min": 0, "max": 16384 },
1270
+ "y": { "kind": "int", "min": 0, "max": 16384 },
1271
+ "width": { "kind": "int", "min": 0, "max": 16384 },
1272
+ "height": { "kind": "int", "min": 0, "max": 16384 }
1273
+ }
1274
+ }
1254
1275
  }
1255
1276
  }
1256
1277
  }