@xgamesfun/xcore 0.3.0 → 0.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.
package/README.md CHANGED
@@ -191,14 +191,35 @@ has behind it, so the whole game runs before it is published:
191
191
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
192
192
  | `http://127.0.0.1:8787/game/` | the published files (it runs `page.build` first, unless `--no-build`), with the play host's headers |
193
193
  | `/game/api/*` | the game's `server/` under workerd, the runtime xgames.fun runs it on: Durable Objects with their storage kept, WebSockets, `env.GF`, the game's database (a local D1 in `.gamesfun/dev/d1/`, its migrations applied), and its secrets from `.dev.vars`. A change under `server/` or to `.dev.vars` reloads it. Miniflare (with workerd, about 140 MB) is installed once, the first time a game with a server runs |
194
- | `/platform/v1/*` | sign-in, saves and leaderboards: `gamesfun.js` signs in a local player (each tab its own, so two tabs play a room together), and `env.GF.verifyPlayer` accepts its token. Saves and scores are kept in `.gamesfun/dev-platform.json`, never published |
194
+ | `/platform/v1/*` | sign-in, saves and leaderboards: `gamesfun.js` signs in a local player (each tab its own, so two tabs play a room together), and `env.GF.verifyPlayer` accepts its token. Saves and scores are kept in `.gamesfun/dev-platform.json`, never published, and held to the limits of the plan it imitates (below) |
195
195
  | `/platform/v1/live` | the game's live settings (`gf.live`, below): its announcement, config and maintenance, from `.gamesfun/live.json` (read again on each request, never published). With maintenance on there, the game's pages show the holding page and its `api/` and `/platform/v1/*` answer `503` with code `maintenance`, as hosted |
196
196
  | `http://127.0.0.1:8788/` | the game's public project files (`env.GF.files`, `xcore files`), kept in `.gamesfun/dev/files/`: another origin, as `<slug>.xgamesusercontent.com` is to the published game |
197
197
  | `/loot/v1/rpc` | the economy's Solana devnet RPC: xgames.fun's shared relay (what a published page talks to unless the game brings its own: `xcore add rpc`), or your own with `XSOLANA_RPC_URL` in `.env` (its URL is never shown; a call the shared relay would refuse is said in the terminal). The economy's calls answer once it is live on devnet (`xcore loot approve`) |
198
198
 
199
199
  Games made with any engine get all of it through `gamesfun.js` (`xcore add signin`): on a plain local server it stands
200
- in with this browser's storage; under `xcore dev` it uses the real thing. What is not the same as hosted: no CPU or
201
- request limits, and the server's outbound `fetch` is not limited to `backend.json`'s origins.
200
+ in with this browser's storage; under `xcore dev` it uses the real thing.
201
+
202
+ **The plan's limits, refused as published.** `xcore dev` imitates one plan, Free unless `--plan creator` or `--plan
203
+ scale` (it says which when it starts), and its platform refuses what xgames.fun refuses on that plan, with the same
204
+ codes: the plan's boards (8 on Free) and board rules (16), entries a board (10,000), save bytes a game (10 MB), and
205
+ reads (1,200) and writes (600 saves, 600 scores) a minute, the server's and the pages' counted apart; project files'
206
+ total and the plan's file calls a minute (120). What a page may do is the same on every plan: it makes `high` boards
207
+ only, two a player and half the plan's boards in all (the game's server makes the rest, `low` ones too, and
208
+ `GF.scores.list` shows who made each); one player's saves from the page keep at most 1/128 of the game's save bytes
209
+ (80 KB on Free), and with the game's saves full a player still keeps 4 KB; one player writes 60 saves and 30 scores a
210
+ minute; a body is JSON (`content-type: application/json`) of at most 96 KB. A board read from the page shows each player
211
+ as an id of the game's own (16 letters and digits), never their account, with `me: true` on the caller's entry; without
212
+ a player token it reads the top 100 only (`offset` plus `limit` past 100 answers 401), may get the same answer again
213
+ for 5 seconds, and has its own minute (a tenth of the plan's reads). Ranks and offsets are counted to 10,000 (past it a
214
+ rank is `10001` with `rankAtLeast: true`). A public project file is an image, audio, video, a font, JSON or plain text
215
+ whose first bytes are that type (else private), and `env.GF.files.put` holds at most 1 MB of a string or ArrayBuffer
216
+ body (a bigger file goes as a stream with its `length`).
217
+
218
+ What is not imitated, and said instead: one player's requests to the game's server a minute and its CPU a request (the
219
+ dev log says when a request would pass them), and one address's reads of `/platform/v1` at the play edge (120 a minute;
220
+ here every tab is one address). Not imitated at all: the network's delays, the owner's moderation (bans, blocked
221
+ words), the board ids of the published game (these are this machine's own), and the server's outbound `fetch`, which is
222
+ not limited to `backend.json`'s origins. The counts a minute start again when `xcore dev` does.
202
223
 
203
224
  ## 3. Players, a database, a backend
204
225
 
@@ -214,8 +235,9 @@ engine takes:
214
235
  | A Godot 4 project | `addons/gamesfun/gamesfun.gd`, autoloaded as `GamesFun` | `await GamesFun.sign_in()`, `await GamesFun.saves_put("main", {})` |
215
236
  | A Unity project | `Assets/Plugins/GamesFun/` (`GamesFun.jslib`, `GamesFun.cs`) | `await GamesFun.SignIn()`, `await GamesFun.SavesPut("main", json)` |
216
237
 
217
- The same calls everywhere: sign in and out, the player (`id`, `name`, `level`, `wallet`, `x`), saves (any JSON, 64 KB
218
- each, 16 a player), leaderboards (submit, top, the player's own), the game's database (below) and its own server
238
+ The same calls everywhere: sign in and out, the player (`id`, `name`, `level`, `wallet`, `x`; `id` is the player's
239
+ account, `x:<X id>` or for a Google account `g:<id>`, an id different in every game, and never the wallet, which is
240
+ `wallet`, empty when the account has none), saves (any JSON, 64 KB each, 16 a player), leaderboards (submit, top, the player's own), the game's database (below) and its own server
219
241
  (`api`). Every call answers `{data, error, status}` and none throws, so a game plays on as a guest wherever xgames.fun is
220
242
  not there.
221
243
 
@@ -232,7 +254,9 @@ from the page, so anyone can submit any score for themselves from the browser's
232
254
  makes a board nobody can write that way: `env.GF.scores.setup('ranked', {writer: 'server'})` (or a pattern for boards
233
255
  to come, `'daily-*'`), then the page's submits
234
256
  to it are refused (their answer's `code` is `'server-only'`, HTTP 403) and the server submits each run it checked; the page reads the board as before
235
- ([docs/SERVER.md](docs/SERVER.md)).
257
+ ([docs/SERVER.md](docs/SERVER.md)). A board a page makes ranks higher first; a lower-first board (a time) is made by
258
+ the game's server, and a page's `order: 'low'` for a new board is refused (`code: 'order'`). `scores.top` gives each
259
+ entry's `player` as an id of this game's own and marks the signed-in player's own entry `me: true`.
236
260
 
237
261
  Where the sign-in comes from:
238
262
 
@@ -246,9 +270,15 @@ Where the sign-in comes from:
246
270
  can be made and tested before it is published. Under `xcore dev`, a local sign-in (one player a tab) and the
247
271
  platform answer as xgames.fun does, and the game's `server/` accepts its token.
248
272
 
249
- The token lasts an hour and stays in the page's memory. The game's `server/` checks it with `env.GF.verifyPlayer`; a
250
- backend of your own checks it with any JWT library against `https://<slug>-play.xgames.fun/player/v1/key` (issuer
251
- `xgames.fun`, audience `game:<slug>`).
273
+ The token lasts an hour and stays in the page's memory. The game's `server/` checks it with `env.GF.verifyPlayer`,
274
+ first thing, before any work: a request without one answers 401 at once, since strangers' requests that do work count
275
+ toward what caps a game ([docs/SERVER.md](docs/SERVER.md), "Check the token first"). A backend of your own checks it
276
+ with any JWT library against `https://<slug>-play.xgames.fun/player/v1/key` (issuer `xgames.fun`, audience
277
+ `game:<slug>`). Its `sub` is the player's account (`x:<X id>`, or `g:<id>` for Google, different in every game);
278
+ `wallet` is a claim of its own, and may be absent: a game that keeps a player's data by wallet reads `wallet`.
279
+ xgames.fun replaces its signing key from time to time: a backend that keeps the key set must fetch it again when a
280
+ token names a key id (`kid`) it does not hold, once, not in a loop (most JWT libraries' remote key sets do).
281
+ `gamesfun.js` and the Godot and Unity clients check no token themselves.
252
282
 
253
283
  The page calls the game's own server with `GamesFun.api(path, init)` (Godot `GamesFun.api(path, init)`, Unity
254
284
  `GamesFun.Api(path, initJson)`): `path` is relative to the game's `api/` (`'finish'` is `api/finish`, at the
@@ -498,7 +528,7 @@ holds the addresses the economy went live at stays in the project (keep it in gi
498
528
  runs the tools from a terminal. Going live is the person's own step, approved in their own wallet: `xcore loot
499
529
  approve` in the folder with `xsolana.json`.
500
530
 
501
- **When loot is upgraded.** This package pins the tools for one build of loot (0.25.0). loot is upgraded in place on
531
+ **When loot is upgraded.** This package pins the tools for one build of loot (0.26.0, on devnet since 2026-11-10). loot is upgraded in place on
502
532
  devnet, and no version so far has carried accounts over: an economy that went live on an earlier build goes live
503
533
  again (`xcore loot approve`), and the next publish brings `gf/loot/` up to the installed package. **Nothing runs by
504
534
  itself** on chain: the page client sends what a player needs (settles, refunds, claims, a draw that is due); a
@@ -549,7 +579,10 @@ vouchers changed since its last review (it never refuses for it).
549
579
  ## 5. Publish
550
580
 
551
581
  `xcore login` signs this computer in (your browser signs in to xgames.fun and hands it a publish token for 30
552
- days; xcore never sees your password). `xcore publish <folder>` then builds the game, checks it (the page
582
+ days; xcore never sees your password). On the approval page you tick the games this computer may work on, or your
583
+ whole account, and `xcore login` says which it got (`login --status` says it again). A game you publish as new from
584
+ that sign-in is added to it; a command for any other game is refused with code `not-granted` ("your sign-in was not
585
+ given this game"): run `xcore login` again and tick it. `xcore publish <folder>` then builds the game, checks it (the page
553
586
  check on exactly the files built), and uploads its published folder with its `server/` bundle:
554
587
 
555
588
  1. **The files**: the published folder, without hidden files, `evidence/`, `node_modules/`, `server/`, `supabase/` and the makers' notes at its top (`README.md`, `NOTES.md`, `LAUNCH.md` and the like). A `.env` file or a link
@@ -623,8 +656,11 @@ free their slot unless their link is still live. What the game's server, saves a
623
656
  | Sites that embed it | 3 (`xcore embed`) | 10 | 25 |
624
657
  | Domains of your own | none | 1 (`xcore domain`) | 5 |
625
658
 
626
- Past what is included, a game is **capped** until the month ends: its server answers at most 60 requests a minute (20
627
- from one player) and saves and scores slow down, but nothing is lost and the game keeps working. Paid slots are $15 for
659
+ Past what is included, a game is **capped** until the month ends: **its server is switched off** (every `api/*` request
660
+ answers 451 with `reason: "capped"`, and a room that is running stops within about five minutes), and saves and scores
661
+ slow down (60 save writes, 30 score submits and 60 reads a minute). Its pages keep loading and nothing is lost: the server comes
662
+ back by itself when the cap lifts ([docs/SERVER.md](docs/SERVER.md), "Publishing it"). An unpublished or taken-down
663
+ game's server is switched off the same way. Paid slots are $15 for
628
664
  30 days, prepaid in SOL or USDC on xgames.fun (your console → Plans; no automatic renewal, 7 days' grace after the period
629
665
  ends; when they end, their games go back to free slots and nothing is taken down).
630
666
  Usage credit, bought the same way, lets Creator games go past what their slots include, at twice what xgames.fun pays,
@@ -706,9 +742,9 @@ without one needs none: the D1 database, `xcore db` and `xcore files` are new.
706
742
  WebAssembly). Such a server runs on a host of your own, declared with `backend use custom`; or its logic moves into
707
743
  `server/`, or into the page itself (WebAssembly is allowed: Pyodide runs Python in the browser). Each is a change to
708
744
  how the game is built: the person who made it decides.
709
- - **A Node server as the backend on xgames.fun**: `server/` is a Worker, not Node (Node built-ins only with
710
- `nodejs_compat`, and no `fs`, `child_process` or listening sockets). A Node backend runs on a host of your own,
711
- declared with `backend use custom`.
745
+ - **A Node server as the backend on xgames.fun**: `server/` is a Worker, not Node (no Node built-ins: `nodejs_compat`
746
+ is not offered; no `fs`, `child_process`, raw sockets or listening sockets). A Node backend runs on a host of your
747
+ own, declared with `backend use custom`.
712
748
  - **Scripts, style sheets and fonts from a CDN**: the page loads code and styles from its own files only, so a copy goes
713
749
  in the game (or the package is bundled).
714
750
  - **A fallback to `index.html` for every path**: deep links of a client-side router reload to "not found"; use a hash
package/commands/dev.mjs CHANGED
@@ -4,21 +4,25 @@ import { guard, parse, printJson, UsageError } from '../lib/cli.mjs';
4
4
  import { resolveGame, shown } from '../lib/games.mjs';
5
5
  import { madeByKit } from '../lib/publish/index.mjs';
6
6
  import { CMD } from '../lib/command.mjs';
7
+ import { PLAN_NAMES } from '../lib/dev/plans.mjs';
7
8
 
8
- const usage = `Usage: xcore dev [dir] [--port <n>] [--no-build] [--open] [--json]
9
+ const usage = `Usage: xcore dev [dir] [--port <n>] [--plan <name>] [--no-build] [--open] [--json]
9
10
 
10
11
  Play the game on this machine as xgames.fun will host it, with everything a published game has behind it:
11
12
  http://127.0.0.1:<port>/game/ the published files, with the play host's headers
12
13
  /game/api/* the game's own server/ (Durable Objects, WebSockets, env.GF), reloaded on change
13
- /platform/v1/* sign-in (a local player), saves and leaderboards, the same store env.GF uses
14
+ /platform/v1/* sign-in (a local player), saves and leaderboards, the same store env.GF uses,
15
+ with xgames.fun's limits on the plan it imitates (Free unless --plan)
14
16
  /loot/v1/rpc the economy's Solana devnet RPC (XSOLANA_RPC_URL in .env, else xgames.fun's relay)
15
17
  gamesfun.js (${CMD} add signin|database|multiplayer) uses all of it here, from any engine: open the page in two
16
18
  windows to play a room together. Running server/ needs Miniflare (workerd), installed once on first use (~140 MB).
17
19
  Saves, scores and Durable Object storage are kept in <dir>/.gamesfun/ (never published).
18
- --port <n> the port (default 8787)
19
- --no-build serve what is built already; do not run gamesfun.json page.build first
20
- --open open the page in your browser
21
- --json print where it runs as JSON, then keep running`;
20
+ --port <n> the port (default 8787)
21
+ --plan <name> the plan to imitate: free (the default), creator or scale: its boards, saves, reads and writes a
22
+ minute and file sizes, as xgames.fun holds a game on that plan (${CMD} plan shows a game's own)
23
+ --no-build serve what is built already; do not run gamesfun.json page.build first
24
+ --open open the page in your browser
25
+ --json print where it runs as JSON, then keep running`;
22
26
 
23
27
  export default {
24
28
  name: 'dev',
@@ -28,12 +32,20 @@ export default {
28
32
  guard(async () => {
29
33
  const { values, positionals } = parse(
30
34
  args,
31
- { port: { type: 'string' }, 'no-build': { type: 'boolean' }, open: { type: 'boolean' } },
35
+ {
36
+ port: { type: 'string' },
37
+ plan: { type: 'string' },
38
+ 'no-build': { type: 'boolean' },
39
+ open: { type: 'boolean' },
40
+ },
32
41
  usage,
33
42
  );
34
43
  const port = values.port === undefined ? 8787 : Number(values.port);
35
44
  if (!Number.isInteger(port) || port < 0 || port > 65535)
36
45
  throw new UsageError(`--port is a port number\n\n${usage}`);
46
+ const plan = String(values.plan ?? 'free').toLowerCase();
47
+ if (!PLAN_NAMES.includes(plan))
48
+ throw new UsageError(`--plan is ${PLAN_NAMES.slice(0, -1).join(', ')} or ${PLAN_NAMES.at(-1)}\n\n${usage}`);
37
49
  const dir = await resolveGame(positionals[0]);
38
50
  if (madeByKit(dir)) {
39
51
  console.error(
@@ -45,18 +57,27 @@ export default {
45
57
  const { startDev } = await import('../lib/dev/index.mjs');
46
58
  let dev;
47
59
  try {
48
- dev = await startDev(dir, { port, noBuild: values['no-build'] === true, log: say });
60
+ dev = await startDev(dir, { port, plan, noBuild: values['no-build'] === true, log: say });
49
61
  } catch (e) {
50
62
  if (e.code === 'EADDRINUSE')
51
63
  throw new Error(`port ${port} is in use: pick another with --port <n>`, { cause: e });
52
64
  throw e;
53
65
  }
54
66
  if (values.json)
55
- printJson({ url: dev.url, origin: dev.origin, files: dev.files, server: dev.server, rpc: dev.rpc });
67
+ printJson({
68
+ url: dev.url,
69
+ origin: dev.origin,
70
+ files: dev.files,
71
+ server: dev.server,
72
+ rpc: dev.rpc,
73
+ plan: dev.plan,
74
+ });
56
75
  else {
57
76
  say(`\n${shown(dir)} on this machine: ${dev.url}`);
58
77
  say(` server: ${dev.server ? (dev.server.running ? 'running' : 'not running (above)') : 'none (no server/)'}`);
59
- say(` sign-in, saves, scores: a local player; kept in ${shown(dev.store.file)}`);
78
+ say(
79
+ ` sign-in, saves, scores: a local player, with the ${dev.store.plan.name} plan's limits; kept in ${shown(dev.store.file)}`,
80
+ );
60
81
  say(` project files (env.GF.files, xcore files): public ones at ${dev.files}/, an origin of their own`);
61
82
  say(` economy RPC: ${dev.rpc}`);
62
83
  say('Ctrl+C stops it.');
@@ -1,17 +1,20 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { guard, parse, printJson } from '../lib/cli.mjs';
3
3
  import { openUrl } from '../lib/open.mjs';
4
- import { login, signInState, storeWords, studioOrigin } from '../lib/publish/client.mjs';
4
+ import { signInState, studioOrigin } from '../lib/publish/client.mjs';
5
+ import { currentReach, loginWithReach, loginWords, reachWords } from '../lib/login-reach.mjs';
5
6
  import { storeName } from '../lib/credentials.mjs';
6
7
  import { CMD } from '../lib/command.mjs';
7
8
 
8
9
  const usage = `Usage: xcore login [--code] [--status] [--json]
9
10
 
10
11
  Sign in to xgames.fun so you can publish your games (${CMD} publish). Your browser opens xgames.fun: sign in
11
- there and approve this computer. xcore never sees your password; it keeps a publish token for 30 days in
12
- ${storeName()} (never in a game or in this folder). ${CMD} logout ends it.
12
+ there, tick the games this computer may work on (or your whole account) and approve. xcore says which it got; a game
13
+ you publish as new from here is added to them, and for another, run ${CMD} login again and tick it. xcore never sees
14
+ your password; it keeps a publish token for 30 days in ${storeName()} (never in a game or in this folder).
15
+ ${CMD} logout ends it.
13
16
  --code no browser on this machine (SSH, a container): open the printed link anywhere and paste the code it shows
14
- --status who this computer is signed in as, and where the token is kept
17
+ --status who this computer is signed in as, which games it reaches, and where the token is kept
15
18
  GAMESFUN_STUDIO=<origin> signs in to another xgames.fun (a local Studio while developing).`;
16
19
 
17
20
  export default {
@@ -24,13 +27,24 @@ export default {
24
27
  const origin = studioOrigin();
25
28
  if (values.status) {
26
29
  const { token: _token, words, ...s } = await signInState(origin);
27
- if (values.json) printJson({ ...s, signedIn: s.state === 'signed-in' });
28
- else console.log(s.state === 'signed-in' ? words : `${words} Run: ${s.next}`);
30
+ const reach = s.state === 'signed-in' ? await currentReach(origin) : null;
31
+ if (values.json)
32
+ printJson({
33
+ ...s,
34
+ signedIn: s.state === 'signed-in',
35
+ ...(reach ? { scope: reach.scope, games: reach.games, reachChecked: reach.checked } : {}),
36
+ });
37
+ else
38
+ console.log(
39
+ s.state === 'signed-in'
40
+ ? `${words}${reachWords(reach) ? ` ${reachWords(reach)}` : ''}`
41
+ : `${words} Run: ${s.next}`,
42
+ );
29
43
  return 0;
30
44
  }
31
45
  const rl = values.code ? createInterface({ input: process.stdin, output: process.stderr }) : null;
32
46
  try {
33
- const r = await login({
47
+ const r = await loginWithReach({
34
48
  origin,
35
49
  code: values.code,
36
50
  open: url => openUrl(url, { web: true }),
@@ -38,10 +52,7 @@ export default {
38
52
  log: m => console.error(m),
39
53
  });
40
54
  if (values.json) printJson({ studio: origin, ...r });
41
- else
42
- console.log(
43
- `Signed in to ${origin} as ${r.email}. The token is kept in ${storeWords(r.store)} until ${new Date(r.expiresAt).toISOString().slice(0, 10)}. Next: ${CMD} publish`,
44
- );
55
+ else console.log(loginWords(r));
45
56
  return 0;
46
57
  } finally {
47
58
  rl?.close();
@@ -1,5 +1,6 @@
1
1
  import { guard, parse, printJson } from '../lib/cli.mjs';
2
2
  import { logout, studioOrigin } from '../lib/publish/client.mjs';
3
+ import { forgetReach } from '../lib/login-reach.mjs';
3
4
 
4
5
  const usage = `Usage: xcore logout [--json]
5
6
 
@@ -26,6 +27,7 @@ export default {
26
27
  const { values } = parse(args, {}, usage);
27
28
  const origin = studioOrigin();
28
29
  const r = await logout(origin);
30
+ await forgetReach(origin);
29
31
  if (values.json) printJson({ studio: origin, ...r });
30
32
  else
31
33
  console.log(
package/docs/SERVER.md CHANGED
@@ -35,7 +35,7 @@ my-game/
35
35
  | `main` | default `server/index.js`; a `.js` or `.mjs` file under `server/` |
36
36
  | `objects` | 0–8 Durable Objects (a room, a run, a vault): `class` is the class the entry exports, `binding` the name it has on `env` (A–Z, 0–9 and `_`, starting with a letter, at most 32 characters, never `GF` or `GF_…`). Each gets its own SQLite storage. **Append only**: once published, keep every class (removing one deletes its data, so the upload refuses it) |
37
37
  | `routes` | `"/api/*"`, the only value for now |
38
- | `compatibilityFlags` | empty, or `["nodejs_compat"]` to use `node:crypto`, `node:buffer` and the other Node built-ins a Worker has |
38
+ | `compatibilityFlags` | empty. `["nodejs_compat"]` is not offered: with Node's built-in modules a server can open raw network connections, so xgames.fun refuses a publish that asks for it, and the kit's check says so first. Use the Web APIs a Worker has (`fetch`, `crypto.subtle`, `URL`, `TextEncoder`) |
39
39
 
40
40
  Without a `gamesfun.json` server entry, a `server/index.js` alone is a server with no objects. `server/` and
41
41
  `gamesfun.json` are **never served** as files, and a game with a server keeps no files under `api/` (its `/api/*` is
@@ -50,7 +50,7 @@ export class Vault extends DurableObject {
50
50
  }
51
51
  export default {
52
52
  async fetch(request, env, ctx) {
53
- /* every /api/* request of the game, URL unchanged */
53
+ /* every /api/* request of the game, URL unchanged: check the player's token first (below) */
54
54
  },
55
55
  };
56
56
  ```
@@ -63,24 +63,38 @@ export default {
63
63
  `env.GF_GAME_ID`, `env.GF_SLUG` and `env.GF_CONTRACT` (`"gf-server-v1"`), and your own secrets as `env.<NAME>`
64
64
  (below, "Secrets"). No key of xgames.fun's. Keep keys out of server code: publish refuses one it finds.
65
65
  - **Runtime**: Cloudflare Workers, compatibility date `2025-06-01`.
66
- - **Outbound `fetch`** reaches only the origins in your `backend.json` and xgames.fun's own. To read Solana devnet
67
- from the server, call xgames.fun's relay, `https://play.xgames.fun/loot/v1/rpc` (the methods loot's client uses, one
68
- request a call): the public devnet RPC (`api.devnet.solana.com`) answers the Workers runtime with 403 ("Your IP or
69
- provider is blocked"), under `xcore dev` as well.
70
- - **Limits**: set by the game's plan (README §5, `xcore plan`). Free: 50 ms of CPU per request to `fetch`, 20
71
- subrequests, **600 requests a minute per game and 120 from one player**; Creator: 200 ms, 50, 6,000 and 600; a 5 MB
72
- bundle on every plan. Past a rate, `/api/*` answers 429 with `retry-after: 60`, `scope` (`"player"` or `"game"`)
73
- and `plan`. Keep frequent traffic,
66
+ - **Outbound `fetch`**: the list in `backend.json` (and xgames.fun's own origins) applies to the calls your server's
67
+ entry makes while it answers a request; any other answers 403 with the header `x-gf-outbound: refused`. Do not rely
68
+ on it for anything else: keep what your Durable Objects call to what they need, in their own code. No number of calls
69
+ out a request is promised. To read Solana devnet from the server, call xgames.fun's relay,
70
+ `https://play.xgames.fun/loot/v1/rpc` (the methods loot's client uses, one request a call): the public devnet RPC
71
+ (`api.devnet.solana.com`) answers the Workers runtime with 403 ("Your IP or provider is blocked"), under `xcore dev`
72
+ as well.
73
+ - **Limits**: set by the game's plan (README §5, `xcore plan`). Free: 50 ms of CPU per request to `fetch`, **600
74
+ requests a minute per game and 120 from one player**; Creator: 200 ms, 6,000 and 600; a 5 MB bundle on every plan.
75
+ Requests with no good game token share half of the game's minute (Free: 300), so signed-in players always have the
76
+ other half. Past a rate, `/api/*` answers 429 with `retry-after: 60`, `scope` (`"player"` or `"game"`) and `plan`.
77
+ Keep frequent traffic,
74
78
  such as positions in a real-time game, on a WebSocket rather than in many HTTP requests. A Durable
75
79
  Object's WebSocket counts as one request for its whole life, but a room's messages are held to a few milliseconds
76
80
  of CPU each **on every plan** (a bigger plan raises a request's CPU, not a room's): a room doing ~3 ms a message ran
77
81
  for minutes, one doing ~15 ms was reset within seconds (every socket closes with code 1006). Do a room's work as
78
82
  messages arrive rather than in timers, keep it to a millisecond or two per message, and reconnect on 1006. Heavy
79
83
  work (a replay, a long simulation) goes in an HTTP request instead, which gets the plan's CPU.
80
- - **Not allowed**: code made from text at run time (`eval`, `new Function`, `import()` of a computed path,
81
- `importScripts`), files from outside the game folder (packages from `node_modules` are fine), Node built-ins without
82
- `nodejs_compat`, and those a Worker does not have even with it (`node:fs`, `child_process`, `vm`, …). The check names
83
- the file and line.
84
+ - **Not allowed** (the kit's check names the file and line before anything is sent; xgames.fun refuses the same at
85
+ publish):
86
+ - **raw network connections**: an import, a re-export or an `import()` of `cloudflare:sockets`, `node:net`,
87
+ `node:tls`, `node:dgram`, `node:http2`, `node:module` or `node:child_process` (with or without `node:`), in the
88
+ server's own code or in a package it bundles. A game's server reaches the network with `fetch()` only: a
89
+ database driver or a mail or chat client that opens sockets runs on a host of your own;
90
+ - **an `import()` of a name made at run time** (`import(name)`): it could load any module. Import each module by
91
+ its own name; a path esbuild can list when it bundles (``import(`./levels/${name}.js`)``) is bundled, every
92
+ file there, and is fine. xgames.fun parses the bundle it is sent; the kit reads the same bundle with esbuild's
93
+ own parser, never by searching its text, so a word in a comment or a string is not an import;
94
+ - **Node's built-in modules** (`node:crypto`, `node:buffer`, `fs`, …): `nodejs_compat` is not offered, so use the
95
+ Web APIs a Worker has (`crypto.subtle`, `crypto.getRandomValues`, `TextEncoder`, `URL`);
96
+ - code made from text at run time (`eval`, `new Function`, `importScripts`; the runtime refuses them too), and files
97
+ from outside the game folder (packages from `node_modules` are fine).
84
98
  - **One request at a time, between awaits.** A Durable Object holds other requests back while it waits on its own
85
99
  storage, but an `await` of anything else (`fetch`, `env.GF.verifyPlayer`, another object) lets the next request
86
100
  in. Read and update a player's state (a daily cap, a balance) with nothing but storage calls in between: verify
@@ -106,6 +120,36 @@ export default {
106
120
  (Rapier's `rapier_wasm2d.js` is such a loader; a game running the same deterministic build in the page and in the
107
121
  server got identical results.)
108
122
 
123
+ ### Check the token first
124
+
125
+ A request with no good game token is a stranger's. Check the token before any work, and answer 401 at once without
126
+ one: the work your server does for callers with no token (reading the database, waking a room, calling out) counts
127
+ toward what caps your game, and a capped game's server is switched off until the month ends ("Publishing it", below).
128
+
129
+ ```js
130
+ export default {
131
+ async fetch(request, env) {
132
+ // what is truly public (a leaderboard everyone sees) may come before this; nothing else does
133
+ const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
134
+ if (!token) return Response.json({ why: 'Sign in first.' }, { status: 401 }); // no call, no work: cheap
135
+ const player = await env.GF.verifyPlayer(token); // its signature, its audience (this game) and its expiry
136
+ if (!player.ok) return Response.json({ why: 'Sign in again.' }, { status: 401 });
137
+ // from here on, player.sub is who is asking
138
+ return Response.json(await env.GF.saves.get(player.sub));
139
+ },
140
+ };
141
+ ```
142
+
143
+ - `env.GF.verifyPlayer` checks the token's signature, that it was made for this game (its audience, `game:<slug>`)
144
+ and that it has not expired. A backend of your own that checks tokens with a JWT library checks all three:
145
+ `jwtVerify(token, keys, { issuer: 'xgames.fun', audience: 'game:<slug>' })`, never the signature alone (every
146
+ game's tokens are signed with one key, so a token without its audience checked is any game's).
147
+ - The page sends it for you: `GamesFun.api(path)` adds `Authorization: Bearer …` for a signed-in player. A WebSocket
148
+ cannot carry that header: send `GamesFun.token` in the address (below).
149
+ - `server/gf-db.js`'s `withPlayer(request, env)` is the same check (null without a token, with no call made).
150
+ - `xcore review` names a server of the game's own whose files do work and never check a token, and an entry whose
151
+ `fetch` does work before its first check (fine for what is truly public, and yours to decide).
152
+
109
153
  ### A room over a WebSocket
110
154
 
111
155
  A Durable Object holds the room: every player's socket is accepted there, and a message from one goes to the others.
@@ -129,8 +173,14 @@ export class Room extends DurableObject {
129
173
  export default {
130
174
  async fetch(request, env) {
131
175
  const url = new URL(request.url);
132
- if (url.pathname === '/api/room') return env.ROOM.get(env.ROOM.idFromName('main')).fetch(request);
133
- return new Response('not found', { status: 404 });
176
+ if (url.pathname !== '/api/room') return new Response('not found', { status: 404 });
177
+ // the player first, before the room is woken: a WebSocket cannot carry an Authorization header, so the page sends
178
+ // its token in the address
179
+ const token = url.searchParams.get('token');
180
+ if (!token) return new Response('sign in first', { status: 401 });
181
+ const player = await env.GF.verifyPlayer(token);
182
+ if (!player.ok) return new Response('sign in again', { status: 401 });
183
+ return env.ROOM.get(env.ROOM.idFromName('main')).fetch(request);
134
184
  },
135
185
  };
136
186
  ```
@@ -139,6 +189,7 @@ export default {
139
189
  // the page
140
190
  const url = new URL('api/room', document.baseURI);
141
191
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
192
+ url.searchParams.set('token', GamesFun.token);
142
193
  const socket = new WebSocket(url);
143
194
  ```
144
195
 
@@ -146,8 +197,8 @@ const socket = new WebSocket(url);
146
197
  server, so it notes the socket instead of failing on it.
147
198
 
148
199
  **When you publish an update**, the page's files and the server change within about 30 seconds, not all at once, and
149
- a room that has players connected can keep running the previous code until it empties. Make your messages tolerate one
150
- version talking to the other (a version number in the first message is enough to tell a player to reload).
200
+ a room that is running keeps the previous code for up to about five minutes. Make your messages tolerate one version
201
+ talking to the other (a version number in the first message is enough to tell a player to reload).
151
202
 
152
203
  ### `env.GF`, the platform
153
204
 
@@ -157,11 +208,18 @@ Every call answers `{ok: true, …}` or `{ok: false, code, why}` (`why` is words
157
208
  | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
209
  | `GF.verifyPlayer(token)` | `{ok, sub, wallet, x, lvl, exp, banned}`: who the player is, from the sign-in token the page sends (no display name: show `x.handle` when they signed in with X, else a name the page sends, cleaned). `banned`: the creator's ban from the console (`{kind: 'ban' \| 'hide', until}`, or null; always null in `xcore dev`). A token made before a ban still verifies for its hour, so refuse `kind: 'ban'` yourself |
159
210
  | `GF.saves.get(player, key = 'main')`, `.put(player, key, value)`, `.delete(player, key)`, `.list(player)` | a player's saves: any JSON value, 64 KB each, 16 a player |
160
- | `GF.scores.submit(board, player, score, {name, order, writer})`, `.setup(board, {order, writer})`, `.top(board, {limit, offset})`, `.best(board, player)`, `.remove(board, player)`, `.drop(board)` | leaderboards: a player's best is kept; `order` `"high"` (default) or `"low"`; `writer: "server"`: only the server writes the board (or every board a pattern names: `daily-*`, `*`); `drop`: the board gone, its place among the 16 free |
211
+ | `GF.scores.submit(board, player, score, {name, order, writer})`, `.setup(board, {order, writer})`, `.top(board, {limit, offset})`, `.best(board, player)`, `.remove(board, player)`, `.drop(board)` | leaderboards: a player's best is kept; `order` `"high"` (default) or `"low"`; `writer: "server"`: only the server writes the board (or every board a pattern names: `daily-*`, `*`); `drop`: the board gone, its place among the plan's boards free |
161
212
  | `GF.loot.report({round, endedAt, winners})`, `GF.loot.status()` | a result that pays prizes by place: xgames.fun's keeper signs it with the game's host key (set up by the owner in Studio); for amounts your server works out, your own key (below, "Paying a result") |
162
213
  | `GF.info()` | `{ok, contract, gameId, slug}` |
163
214
  | `GF.files.put(path, body, {type, public, length})`, `.get(path)`, `.head(path)`, `.list({prefix, after})`, `.delete(path)`, `.usage()` | the game's project files (below, "Project files"): replays, levels, content packs; public ones on the files site |
164
215
 
216
+ **Who the player is.** `sub` is the player's account, never their wallet: `x:<X id>` for a player signed in with X,
217
+ or `g:<id>` for a Google account with no X, an id that is **different in every game** (so it never says who the
218
+ person is elsewhere). `wallet` is a claim of its own: the wallet linked to the account, or null when there is none, and
219
+ it can change (a player links another). Key a player's data (saves, rows, rooms) on `sub`; read `wallet` only for what
220
+ is about the wallet itself (paying it, what it holds). A game that kept a player's data by wallet reads `wallet` now,
221
+ and one that keys on `sub` must not expect a Google player's id to match the same person's in another game.
222
+
165
223
  `player` is the game's own word for a player; normally `verifyPlayer`'s `sub`, so nobody can write another player's.
166
224
  The page writes the same saves and boards for its own signed-in player (`/platform/v1/*`), so a board is the player's
167
225
  own word until the server takes it: **`GF.scores.setup(board, {writer: 'server'})`** (or `writer: 'server'` in the
@@ -171,7 +229,8 @@ server's own `submit`) makes a board only the server writes. From then on the pa
171
229
  the page wrote while anyone could write it is removed (`setup` answers how many, `removed`). **A pattern** names every
172
230
  board starting that way, now and to come: `GF.scores.setup('daily-*', {writer: 'server'})` before the first day, and
173
231
  nobody fills a day's board ahead of time; `GF.scores.setup('*', {writer: 'server'})` keeps the page from making
174
- boards at all (a board's own rule wins over a pattern, a longer pattern over a shorter one). A game has 16 boards:
232
+ boards at all (a board's own rule wins over a pattern, a longer pattern over a shorter one). A game has 8 boards on
233
+ Free (16 rules) and 16 on Creator and Scale (32 rules):
175
234
  **`GF.scores.drop(board)`** removes one (a past day's, once its prizes are paid) and frees its place. Only the server
176
235
  can set or lift a rule (`writer: 'any'`) or drop a board.
177
236
 
@@ -258,8 +317,9 @@ xcore secret set XSOLANA_RPC_URL # the RPC's URL with its key: the serve
258
317
  `getProgramAccounts` only with filters and only of loot's own programs (loot, Metaplex Core, lookup tables); and
259
318
  `sendTransaction` only for the game's own transactions (one with a loot instruction, or a plain SOL send). So a
260
319
  stranger calling `api/rpc` cannot spend your RPC's credits on anything else. Mainnet URLs are not used (loot runs on devnet until it is audited).
261
- - The server may call only `backend.json`'s origins: `xcore add rpc` adds the RPC's (`https://host` alone, never the
262
- key) when `XSOLANA_RPC_URL` is in `.dev.vars` or `.env`, and publish says when it is missing.
320
+ - The server's entry, where `api/rpc` runs, may call only `backend.json`'s origins: `xcore add rpc` adds the RPC's
321
+ (`https://host` alone, never the key) when `XSOLANA_RPC_URL` is in `.dev.vars` or `.env`, and publish says when it
322
+ is missing.
263
323
  - Solana's public `https://api.devnet.solana.com` refuses calls from Cloudflare ("Your IP or provider is blocked",
264
324
  measured 2026-11-03): use a provider's URL.
265
325
  - Under `xcore dev`, put `XSOLANA_RPC_URL` in `.dev.vars` to run this path; in `.env` it is the dev server's own
@@ -622,6 +682,14 @@ my-vault: 278 files, 10.0 MB, nothing refused. Left out: .gamesfun/, .gitignore,
622
682
  Hosted, the game's server answers at `https://<slug>-play.xgames.fun/api/…`. A blocked or over-limit server answers
623
683
  `{error, message}` with 451, 429, 404, 503 or 502 instead of the server's own answer.
624
684
 
685
+ **A game that is unpublished, taken down or capped** (past its plan's money for the month: README "Its plan") has its
686
+ server switched off: every `api/*` request answers `451 {"error": "stopped", "reason": "unpublished" | "taken-down" |
687
+ "capped", "message"}`, its Durable Objects' alarms lapse and their sockets close. A room that is running stops within
688
+ about five minutes. Its rooms' data and its secrets are kept, and the server comes back by itself when the game is
689
+ published again (within 30 days, to be sure of its data) or its cap lifts (the month's turn, a paid slot, usage
690
+ credit); a version published while it is capped goes live with its pages, and its server waits. The page keeps
691
+ loading meanwhile, so a game whose server can be off says so when `api/` answers 451.
692
+
625
693
  While the game's owner has it **paused for maintenance** (xgames.fun/console, the Live tab; README "Live"), xgames.fun
626
694
  answers its `api/*` itself with `503 {ok: false, code: 'maintenance', why, since}` and `retry-after: 60`, WebSocket
627
695
  upgrades included: the server's code does not run for a new request. What already runs keeps running (an open
package/lib/check.mjs CHANGED
@@ -556,6 +556,7 @@ export async function pageVerify(
556
556
  const { preflight } = await import('./publish/index.mjs');
557
557
  const { scanPage } = await import('./publish/adopt.mjs');
558
558
  const pre = await preflight(game);
559
+ if (pre.linkName) notes.push(pre.linkName);
559
560
  const scan = existsSync(roots.out)
560
561
  ? await scanPage(roots.out, {
561
562
  backend: pre.backend,
package/lib/cli.mjs CHANGED
@@ -28,6 +28,8 @@ export async function guard(fn, args = []) {
28
28
  try {
29
29
  return (await fn()) ?? 0;
30
30
  } catch (e) {
31
+ // xgames.fun refused a game this computer's sign-in was not given: said as that, with what to do
32
+ if (e?.code === 'not-granted') await (await import('./login-reach.mjs')).explainNotGranted(e).catch(() => {});
31
33
  const usage = e instanceof UsageError;
32
34
  console.error(usage ? e.message : `xcore: ${e.message}`);
33
35
  if (!usage && process.env.GAMESFUN_DEBUG) console.error(e.stack);
@@ -929,7 +929,10 @@
929
929
  if (opts.order) body.order = opts.order;
930
930
  return json('POST', PLATFORM + 'scores/' + seg(board), body, 'submitting the score');
931
931
  },
932
- /** → {data: [{rank, player, name, score, at}]}; anyone may read, signed in or not */
932
+ /**
933
+ * → {data: [{rank, player, name, score, at, me?}]}: `player` is an id of this game's own (never the account), `me`
934
+ * true on the signed-in player's entry. Anyone may read; signed out, the top 100 only (offset + limit past 100: 401)
935
+ */
933
936
  top: function (board, opts) {
934
937
  opts = opts || {};
935
938
  if (!seg(board)) return bad('board name');
@@ -3,7 +3,7 @@
3
3
  -- does the same on this machine. Once this one is published, never change it: put the next change in
4
4
  -- 0002_<what>.sql (ALTER TABLE players ADD COLUMN …).
5
5
  CREATE TABLE players (
6
- id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js)
6
+ id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js), never the wallet
7
7
  gold INTEGER NOT NULL DEFAULT 0,
8
8
  runs INTEGER NOT NULL DEFAULT 3, -- runs left today
9
9
  updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
@@ -9,7 +9,8 @@
9
9
  // POST api/db/<collection> {value} a new id, the player's own → {ok, item}
10
10
  // DELETE api/db/<collection>/<id> the player's own → {ok, deleted}
11
11
  //
12
- // A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub`), or "server"
12
+ // A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub`: the account,
13
+ // x:<X id> or for Google g:<id>, different in every game; never the wallet), or "server"
13
14
  // for what this server's own code writes. Every player has a space of their own in each collection: a document is
14
15
  // found by its collection, its owner and its id, so two players can both have a "main", and no player can take an id
15
16
  // in anyone else's space. A page names whose document it wants with `owner`; without it, the player's own (in a
@@ -5,7 +5,7 @@
5
5
  // database on this machine. Add a migration for each change; never change one that was published.
6
6
  //
7
7
  // import { db, withPlayer } from './gf-db.js';
8
- // const sub = await withPlayer(request, env); // the signed-in player's id, or null
8
+ // const sub = await withPlayer(request, env); // the signed-in player's id (never the wallet), or null
9
9
  // if (!sub) return Response.json({ why: 'Sign in first.' }, { status: 401 });
10
10
  // const me = await db(env).first('SELECT gold, runs FROM players WHERE id = ?', sub);
11
11
  // // a rule in one statement, so two requests at once cannot both pass it
@@ -46,7 +46,11 @@ export function db(env) {
46
46
  };
47
47
  }
48
48
 
49
- /** The signed-in player's id (env.GF.verifyPlayer's sub), from the token gamesfun.js sends; null for a guest. */
49
+ /**
50
+ * The signed-in player's id (env.GF.verifyPlayer's sub: the account, x:<X id> or for Google g:<id>, different in every
51
+ * game; never the wallet, which is v.wallet), from the token gamesfun.js sends; null for a guest, at once and with no
52
+ * call when the request has no token, so a stranger costs the server nothing: answer 401 then, before any work.
53
+ */
50
54
  export async function withPlayer(request, env) {
51
55
  const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
52
56
  if (!token) return null;
@@ -177,6 +177,7 @@ export class GameRoom extends DurableObject {
177
177
  let player = null;
178
178
  if (m.token) {
179
179
  const v = await this.env.GF.verifyPlayer(String(m.token));
180
+ // id: the account (x:<X id>, or g:<id> for Google, different in every game); the wallet is its own claim
180
181
  if (v?.ok) player = { id: v.sub, x: v.x?.handle ?? null, wallet: v.wallet ?? null };
181
182
  }
182
183
  // its own storage: the room takes no other message while it waits on that