@xgamesfun/xcore 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
package/docs/SERVER.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Most games need no server: the page is the game. When a game needs one party everybody trusts (a leaderboard nobody
4
4
  can write to from the console, a result that pays a prize, a room the host cannot cheat in), it can carry **its own
5
- server code** in `server/`. `xcore publish` bundles it and puts it on games.fun beside the game's files, at the game's
5
+ server code** in `server/`. `xcore publish` bundles it and puts it on xgames.fun beside the game's files, at the game's
6
6
  own `/api/*`. You set up no account elsewhere, no keys and no deploy.
7
7
 
8
8
  The contract is `gf-server-v1`. Every name and number in it lives in one module,
@@ -61,10 +61,10 @@ export default {
61
61
  removed on the way in.
62
62
  - **`env`**: `env.GF` (the platform, below), one Durable Object namespace per `objects` entry, the plain strings
63
63
  `env.GF_GAME_ID`, `env.GF_SLUG` and `env.GF_CONTRACT` (`"gf-server-v1"`), and your own secrets as `env.<NAME>`
64
- (below, "Secrets"). No key of games.fun's. Keep keys out of server code: publish refuses one it finds.
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 games.fun's own. To read Solana devnet
67
- from the server, call games.fun's relay, `https://play.xgames.fun/loot/v1/rpc` (the methods loot's client uses, one
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
68
  request a call): the public devnet RPC (`api.devnet.solana.com`) answers the Workers runtime with 403 ("Your IP or
69
69
  provider is blocked"), under `xcore dev` as well.
70
70
  - **Limits**: set by the game's plan (README §5, `xcore plan`). Free: 50 ms of CPU per request to `fetch`, 20
@@ -158,8 +158,9 @@ Every call answers `{ok: true, …}` or `{ok: false, code, why}` (`why` is words
158
158
  | `GF.verifyPlayer(token)` | `{ok, sub, wallet, x, lvl, exp}`: 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) |
159
159
  | `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
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 |
161
- | `GF.loot.report({round, endedAt, winners})`, `GF.loot.status()` | a result that pays prizes by place: games.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") |
161
+ | `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
162
  | `GF.info()` | `{ok, contract, gameId, slug}` |
163
+ | `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 |
163
164
 
164
165
  `player` is the game's own word for a player; normally `verifyPlayer`'s `sub`, so nobody can write another player's.
165
166
  The page writes the same saves and boards for its own signed-in player (`/platform/v1/*`), so a board is the player's
@@ -174,6 +175,50 @@ boards at all (a board's own rule wins over a pattern, a longer pattern over a s
174
175
  **`GF.scores.drop(board)`** removes one (a past day's, once its prizes are paid) and frees its place. Only the server
175
176
  can set or lift a rule (`writer: 'any'`) or drop a board.
176
177
 
178
+ ### Project files: `env.GF.files`
179
+
180
+ Files that you or the game's server write, never players: replays, generated levels, a large saved world, content
181
+ packs. xgames.fun keeps them for the game (Free 100 MB, Creator 10 GB, Scale 100 GB; 25 MB a file), and serves the
182
+ public ones from a site of their own, `https://<slug>.xgamesusercontent.com/<path>`.
183
+
184
+ ```js
185
+ // the server keeps a replay, public: the page loads it from its address
186
+ const saved = await env.GF.files.put(`replays/${runId}.json`, JSON.stringify(replay), {
187
+ type: 'application/json',
188
+ public: true,
189
+ });
190
+ if (!saved.ok) return Response.json({ why: saved.why }, { status: saved.code === 'full' ? 507 : 400 });
191
+ return Response.json({ replay: saved.url });
192
+
193
+ // a file only the server reads
194
+ const world = await env.GF.files.get('worlds/main.bin');
195
+ if (world.ok) state = new Uint8Array(await new Response(world.body).arrayBuffer());
196
+ ```
197
+
198
+ | Call | Answer |
199
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
200
+ | `GF.files.put(path, body, {type, public = false, length})` | `{ok, path, bytes, url?}`: `body` a string, an ArrayBuffer or a stream of bytes; a stream is held to exactly its `length` (more or fewer bytes: `code: "body"`, nothing stored), and one with no `length` is read up to 1 MB (longer: `code: "body"`); `url` when public; over the game's cap, `code: "full"` |
201
+ | `GF.files.get(path)` | `{ok, body, type, bytes, public}`: `body` is a stream of the bytes (`await new Response(body).text()`) |
202
+ | `GF.files.head(path)` | the same, without `body` |
203
+ | `GF.files.list({prefix, after, limit = 100})` | `{ok, items: [{path, bytes, type, public, updatedAt}], after}`: a prefix is empty or a folder ending in `/`; pass `after` on |
204
+ | `GF.files.delete(path)`, `GF.files.usage()` | `{ok, deleted}`, `{ok, bytes, files, cap}` |
205
+
206
+ - **Paths** are letters, digits, `.`, `_` and `-`, with `/` between folders, at most 512 characters, and no `.` or
207
+ `..` part: `levels/forest-3.json`.
208
+ - **The files site is a stranger to the game.** A file there cannot use the player's sign-in or call the game's
209
+ `api/`. Every answer carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox; default-src
210
+ 'none'`. Pictures (PNG, JPEG, WebP, GIF, AVIF), sounds, videos, fonts, JSON and plain text are served as
211
+ themselves; anything else (HTML, SVG, JavaScript, CSS, XML, PDF) is a download, `application/octet-stream`. The
212
+ game's page may load pictures, sounds, fonts and `fetch` from it, never scripts or styles.
213
+ - **From your machine:** `xcore files put <file> [path] [--public]`, `ls`, `get`, `rm`, `usage`; with `--live`, the
214
+ game's files on xgames.fun.
215
+ - **Locally**, `xcore dev` keeps them in `.gamesfun/dev/files/` (never published) and serves the public ones on a port
216
+ of their own (`project files: … http://127.0.0.1:8788`), another origin to the page, as live. (Here it is the same
217
+ site, both being `127.0.0.1`; live, `xgamesusercontent.com` is another site too, so cookies and the like keep apart
218
+ there in a way they may not on this machine.) `xcore files` without `--live` reads and writes the same ones.
219
+ - Publish refuses a server that uses `env.GF.files` while xgames.fun does not keep project files yet (its
220
+ `GET /api/kit/me` lists `"files"` once it does).
221
+
177
222
  ### Secrets
178
223
 
179
224
  A key the server needs and no player may see (a voucher-signing key, an outside API's key, an RPC URL with its key)
@@ -185,7 +230,7 @@ xcore secret list # the names; val
185
230
  xcore secret delete VOUCHER_SEED
186
231
  ```
187
232
 
188
- - games.fun keeps the value only on the server's host (Cloudflare), never stores or shows it, and never gives it to
233
+ - xgames.fun keeps the value only on the server's host (Cloudflare), never stores or shows it, and never gives it to
189
234
  the page. A new publish keeps the secrets.
190
235
  - Set it once the game is published with its `server/`; it reaches the server's next request, with no publish.
191
236
  - Names: A–Z, 0–9 and `_`, starting with a letter, not `GF` or `GF_*`, not a room's binding. Values up to 5,120
@@ -195,7 +240,7 @@ xcore secret delete VOUCHER_SEED
195
240
 
196
241
  ### The economy's own RPC
197
242
 
198
- A game with an economy (`xcore loot add`) sends its Solana calls through games.fun's shared devnet relay by default
243
+ A game with an economy (`xcore loot add`) sends its Solana calls through xgames.fun's shared devnet relay by default
199
244
  (the play host's `/loot/v1/rpc`), and every call counts toward the game's plan: $10 a million, the biggest cost a
200
245
  player of an economy game has (a page reading balances every 5 s spends Free's $1 in ~140 player-hours). Bring the
201
246
  game's own devnet RPC (Helius, Triton, QuickNode; their free plans fit a small game) instead:
@@ -266,21 +311,21 @@ about 3 ms of CPU for each second played, the largest line of its bill. Replay e
266
311
  voucher, a ranked board) and a random sample of the rest (one in ten, say), and bound the others as above. A cheater
267
312
  cannot tell which runs are replayed, and a run that pays is always one of them.
268
313
 
269
- ### Paying a result: games.fun's keeper, or a voucher key of your own
314
+ ### Paying a result: xgames.fun's keeper, or a voucher key of your own
270
315
 
271
316
  A reward for a result (a score, a win, a podium) is a grant in the game's economy, claimed with a voucher: a
272
317
  signature that says this wallet earned it. The page can never sign one (anyone can change a page in the console), so
273
318
  a voucher comes from one of two signers. Both start from a result your server stands behind (above); they differ in
274
319
  who holds the key.
275
320
 
276
- | | **games.fun's keeper** (`env.GF.loot.report`) | **your server's own key** (`xcore add vouchers`) |
321
+ | | **xgames.fun's keeper** (`env.GF.loot.report`) | **your server's own key** (`xcore add vouchers`) |
277
322
  | ------------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
278
- | Who signs | games.fun, with the game's host key (made for the game, never shown to anyone) | your server, with `VOUCHER_SEED` (a secret only it reads) |
323
+ | Who signs | xgames.fun, with the game's host key (made for the game, never shown to anyone) | your server, with `VOUCHER_SEED` (a secret only it reads) |
279
324
  | What a result pays | a place: up to 16 winners a result, each paid its grant's own amount | any amount up to the grant's (by score, by rank), worked out by your server |
280
325
  | How often | 120 reports an hour a game | your plan's request rates |
281
326
  | To set up | the owner certifies the host key in Studio once; the grant names the keeper's key | `xcore add vouchers`, `xcore secret set VOUCHER_SEED --new-key`, its address in the grant |
282
327
  | Under `xcore dev` | printed, not signed | signed with the key in `.dev.vars`, against devnet |
283
- | Follows loot | when games.fun updates the keeper | when you update xcore (publish keeps `server/gf-loot/` at its loot) |
328
+ | Follows loot | when xgames.fun updates the keeper | when you update xcore (publish keeps `server/gf-loot/` at its loot) |
284
329
 
285
330
  **Use the keeper** when the server already decides who won and the prize is fixed by place: a room's winner, the top
286
331
  three of a match, a hidden thing found first. No key to make or keep.
@@ -321,7 +366,7 @@ xcore add vouchers <folder> # needs the economy (xcore loot add) with a gra
321
366
  It writes `server/vouchers.js` (routed at `api/vouchers/`, with its Durable Object `GameVouchers`), copies loot's
322
367
  voucher code into `server/gf-loot/` (bundled with the server, never a public file), makes a **test key** for
323
368
  `xcore dev` with its seed as `VOUCHER_SEED` in `.dev.vars` (and, in a folder kept in git, `.dev.vars` into
324
- `.gitignore`), and says its address. The **live key** is made on games.fun, so it is never on your machine:
369
+ `.gitignore`), and says its address. The **live key** is made here and sent to xgames.fun only, never kept on your machine:
325
370
 
326
371
  1. `xcore publish`, then `xcore secret set VOUCHER_SEED --new-key`: a new key made and sent to the server only; it
327
372
  prints the key's address.
@@ -332,7 +377,7 @@ voucher code into `server/gf-loot/` (bundled with the server, never a public fil
332
377
  page's JSON, the signed-in player and `env`, and answers `{grant, round, amount}` (grant: its key or id; round:
333
378
  the result's own id, any text, used once; amount: base units, or `'grant'` for the grant's own amount, always
334
379
  named so a forgotten amount never pays the most) or `{why}` to refuse. Until you write it, nothing is paid. Count a
335
- player's daily total in `PAY.paid` (it runs once a voucher is signed), not in `check`.
380
+ player's daily total in `PAY.paid` (it runs once a round, after its voucher is signed; again only if it threw), not in `check`.
336
381
  4. Who is paid: set `VOUCHERS.payOnly` to `'x'` for rewards worth farming (above, "Who gets paid"); the grant's
337
382
  `perDay` total caps the rest.
338
383
  5. `xcore loot approve` (the grant's change goes live), then `xcore publish`. `api/vouchers/key` answers the live
@@ -360,14 +405,14 @@ const { voucher } = await voucherFor(env, { grant: 'clear', wallet, round: match
360
405
  gets the same voucher (with no chain read, and a fresh one when it is about to run out), never one for another
361
406
  wallet or amount, and never one once the grant has been closed and made again (a new campaign pays new rounds only).
362
407
  - **Rate:** a player may make `VOUCHERS.perMinute` pay and link requests a minute (20).
363
- - **Keys:** the live key lives only on games.fun's server and is never shown again; a lost or leaked one: run
408
+ - **Keys:** the live key lives only on xgames.fun's server and is never shown again; a lost or leaked one: run
364
409
  `xcore secret set VOUCHER_SEED --new-key` again, put the new address in the grant in place of the old, approve. A
365
410
  leaked key can pay no more than the grant's limits, so keep them tight. The test key in `.dev.vars` signs only
366
411
  what the grant lets it sign.
367
412
  - `person`: a player signed in with X is counted as one person, hashed with your key (never the account itself), for
368
413
  a grant with `perPerson`. A player signed in with a wallet only has no person, so a grant with `perPerson` refuses
369
414
  to pay them (`person`): sign in with X.
370
- - Each pay is one request to your server and one read of the grant, through games.fun's relay or your own RPC
415
+ - Each pay is one request to your server and one read of the grant, through xgames.fun's relay or your own RPC
371
416
  (`xcore add rpc`).
372
417
  - A grant's limits count claims, not amounts: a daily cap of tokens is the grant's amount with one claim a day, or
373
418
  your server's own count in `PAY.check`.
@@ -375,15 +420,170 @@ const { voucher } = await voucherFor(env, { grant: 'clear', wallet, round: match
375
420
  export `GameVouchers`, as `xcore add vouchers` says. `signVoucher` from `@xgamesfun/solana/results` is the same
376
421
  signature, for a server that signs by itself.
377
422
 
423
+ ## A database of its own
424
+
425
+ `xcore add database` gives the game **one D1 database** (SQLite, run by Cloudflare), made on xgames.fun by the
426
+ first publish that has one and kept between publishes. Its server reads it as `env.DB`. It holds all of the game's
427
+ own data: its records (guilds, match history, shop stock), a player's state with rules (runs left today, gold), and,
428
+ for a game with no server, the documents its page saves.
429
+
430
+ ```json
431
+ {
432
+ "database": {
433
+ "engine": "d1",
434
+ "binding": "DB",
435
+ "migrations": "server/migrations",
436
+ "collections": { "*": { "read": "owner", "write": "owner" }, "profiles": { "read": "anyone", "write": "owner" } }
437
+ }
438
+ }
439
+ ```
440
+
441
+ | Field | Rule |
442
+ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
443
+ | `engine` | `"d1"`, the only one |
444
+ | `binding` | default `"DB"`: its name on `env`. The rules of a Durable Object's binding (A–Z, 0–9 and `_`, not `GF` or `GF_…`), and not one of theirs |
445
+ | `migrations` | default `"server/migrations"`, a folder inside `server/` (never a public file). Files named `0001_init.sql`, `0002_add_guilds.sql` …: four digits, `_`, lowercase words; at most 200, 1 MB each, no statement over D1's 100,000 bytes |
446
+ | `collections` | who may read and write each collection of the page's documents (`gf.db`, at `api/db/`): read by the stock server, or by `server/gf-docs.js` in a server of its own (below). `read`: `"owner"` or `"anyone"`; `write`: `"owner"` or `"server"`. Without it, each player's own, as `"*"` |
447
+
448
+ **Migrations are append only.** Publish applies the new ones, in name order, before the new version goes live; a
449
+ migration that fails stops the publish (the version before keeps playing; the ones applied before it stay applied).
450
+ Once a migration is published it never changes: put the next change in a new file
451
+ (`0002_add_gold.sql`: `ALTER TABLE players ADD COLUMN gold INTEGER NOT NULL DEFAULT 0;`). A changed or missing one is
452
+ refused, by `xcore publish` before anything goes up and by xgames.fun, and so is an empty one (0 bytes). `xcore add
453
+ database` writes `server/migrations/.gitattributes` (`*.sql -text`), so git on Windows never changes a migration's
454
+ line endings (and so its sha256). Each database records what it has applied in its own table, `_gf_migrations`, in
455
+ the same batch as the migration: a restore to before a migration un-applies it, and the next publish applies it
456
+ again.
457
+
458
+ ### From a server of its own
459
+
460
+ `xcore add database` in a game with a `server/` writes the block, an example first migration
461
+ (`server/migrations/0001_init.sql`, change it before you publish) and `server/gf-db.js`:
462
+
463
+ ```js
464
+ import { db, withPlayer } from './gf-db.js';
465
+
466
+ export default {
467
+ async fetch(request, env) {
468
+ const sub = await withPlayer(request, env); // env.GF.verifyPlayer's sub, or null for a guest
469
+ if (!sub) return Response.json({ why: 'Sign in first.' }, { status: 401 });
470
+ await db(env).run('INSERT OR IGNORE INTO players (id) VALUES (?)', sub);
471
+ // a rule is one statement, so a burst of requests cannot spend the same run twice
472
+ const left = await db(env).first(
473
+ 'UPDATE players SET runs = runs - 1 WHERE id = ? AND runs > 0 RETURNING runs',
474
+ sub,
475
+ );
476
+ if (!left) return Response.json({ why: 'No runs left today.' }, { status: 409 });
477
+ return Response.json({ runs: left.runs });
478
+ },
479
+ };
480
+ ```
481
+
482
+ `db(env).all`, `.first`, `.run` and `.batch([[sql, ...params], …])` (all of them or none) are prepared statements:
483
+ pass every value as a `?` and an argument, never inside the SQL's text. `env.DB` is D1 itself, for anything else.
484
+
485
+ **The page's documents in a server of its own.** When the block has `collections`, `xcore add database` (and
486
+ `add multiplayer`, `rpc` and `vouchers`) also writes `server/gf-docs.js`, which answers `api/db/*` as the stock server
487
+ does, from the same table (`gf_docs`), under `collections` (it reads them from `gamesfun.json` when the server is
488
+ bundled). xcore's own `server/index.js` routes it first; in an index of your own, first thing in `fetch`:
489
+
490
+ ```js
491
+ import { handleDocs } from './gf-docs.js';
492
+ const db = await handleDocs(request, env);
493
+ if (db) return db;
494
+ ```
495
+
496
+ `docsOf(env)` is the server's own access to them, past the rules (`docsOf(env).put('news', 'today', {…})`). A game the
497
+ stock server served keeps its documents and rules when it gets a server of its own: `xcore add multiplayer` writes
498
+ the rules it had into `collections`, and `server/gf-docs.js` serves them. Publish refuses a game whose block has
499
+ `collections` while its server does not route `api/db/` (every `gf.db` call would answer 404), and says how.
500
+
501
+ That check reads the text of the files in `server/` only (not `server/gf-docs.js` or `server/database.js`): a call to
502
+ `handleDocs(` (or `handleDatabase(`) there, or `api/db` in the code. A router kept outside `server/` (a `src/router.js`
503
+ the server imports) is not read, so a game whose `api/db/` is routed only there is refused all the same. Keep xcore's
504
+ route in `server/`: the three lines above, first thing in `fetch` of `server/index.js` (or of another file in
505
+ `server/`), before the request reaches the router.
506
+
507
+ ### With no server: the stock server
508
+
509
+ A game with the block and no `server/` gets **xcore's stock server** as its server: publish bundles it
510
+ ([`lib/client/server/stock.js`](../lib/client/server/stock.js), with `collections` built in) and it serves the page's
511
+ `api/db/*` from the database, with the same routes and answers as the older `server/database.js`, so
512
+ `GamesFun.db` in the page is the same. Its documents are in a table it makes itself, `gf_docs`.
513
+
514
+ - **Each player has a space of their own** in every collection: a document is `{id, owner, value, updatedAt}`,
515
+ found by collection, owner and id, so two players can both have a `main` and nobody takes another's id. By default
516
+ a player reads and writes only their own.
517
+ - `read: "anyone"`: everyone reads the collection; another player's document by its owner,
518
+ `GamesFun.db.get('profiles', 'main', { owner: playerId })`, a player's list with `{ owner: playerId }`. An id alone
519
+ is the player's own: a `get` by id alone that finds only another player's document answers 400 `owner` ("name
520
+ whose document it is with `{owner}`"), and `GamesFun.db.get` gives `data: null` with `code: 'owner'` and says it
521
+ once in the console. Before each player had a space of their own (gamesfun.js 1.3, xcore 0.1.0) an id named any
522
+ player's document, so a page that reads another's by id alone needs its `owner` now (each document has it).
523
+ - `write: "server"`: only a server writes it (owner `"server"`); the page reads it without naming an owner, from the
524
+ server's space and then from the player's own (what the server wrote for that player, with `{owner: sub}`), so a
525
+ `read: "owner"` + `write: "server"` collection (an inbox, a verified record) is read by id alone. The stock server
526
+ has no code of yours, so such a collection needs a server of your own to fill it.
527
+ - A list comes newest first, a page at a time: `const page = await GamesFun.db.list('levels')`, then
528
+ `GamesFun.db.list('levels', { after: page.after })`; `after` is `null` on the last page. `offset` is gone: the
529
+ client and the server refuse it in words, so a loop that paged with it stops instead of reading page 1 again.
530
+ - The order is by when each document was **last written** (`updatedAt`), so a document written between two pages
531
+ moves to the front: a listing taken while players write can show it twice, or not at all. List what must be exact
532
+ in one page (`limit` up to 100), or keep it in a table of your own (a server with its own migrations) with an order
533
+ that does not change.
534
+ - Any `read` but `"anyone"` lists only the player's own (it fails closed), and `collections` (or `RULES`) is checked
535
+ when the server starts: a mistyped rule stops it, in words.
536
+
537
+ ### On this machine, and on xgames.fun: `xcore db`
538
+
539
+ `xcore dev` runs a local D1 for the server, kept in `.gamesfun/dev/d1/` (hidden: never published), and applies the
540
+ same migrations when it starts and reloads. A migration changed after it was applied here keeps the server down with
541
+ the reason, as xgames.fun would refuse it; put it back, or delete `.gamesfun/dev/d1/` to start the local database
542
+ afresh.
543
+
544
+ ```sh
545
+ xcore db info # here: its size, the migrations applied, its tables
546
+ xcore db query "SELECT * FROM players LIMIT 10" # one statement; reads only without --write
547
+ xcore db query "UPDATE players SET runs = 3 WHERE id = ?" --params '["dev:ab12"]' --write
548
+ xcore db export # SQL into .gamesfun/exports/ (or --out <file>)
549
+ xcore db import seed.sql # statements of your own, or an export; all or none
550
+ xcore db info --live # the same on xgames.fun, with --live
551
+ xcore db restore 2026-11-08T12:00:00Z --live # back in time, up to 30 days (D1 Time Travel)
552
+ ```
553
+
554
+ `--live` goes through xgames.fun's owner routes with your sign-in (`xcore login`), and each call is kept in the
555
+ game's audit log. Without `--write`, a query is only `SELECT`, `EXPLAIN`, `PRAGMA table_info` or `table_list`, here
556
+ and live. `restore` says what it would go back to first, and goes on only with `--yes` or when you type `restore`.
557
+ It prints the command that undoes it, `xcore db restore <the moment before it> --live`. That moment is this
558
+ machine's clock, read just before the restore is asked for, because xgames.fun's restore takes a time, not a
559
+ bookmark: `restored.previous` (the bookmark from before it) is printed too, but cannot be passed back. A clock that
560
+ is off by more than the restore took to start puts the undo a little before or after the true moment; check it with
561
+ the preview (`xcore db restore <time> --live`, without `--yes`) before you go on.
562
+ **A game with an economy:** a restore puts back the database only. Loot balances, items and payouts on chain stay
563
+ as they are, so a player can keep what they won while the database forgets it; export first. An export holds your
564
+ players' data: keep it out of git and off shared drives.
565
+
566
+ Publish refuses a game with a database while xgames.fun does not run databases yet (its `GET /api/kit/me` lists
567
+ `"d1"` once it does), and says so.
568
+
569
+ **The older database**, `server/database.js` (one Durable Object with SQLite storage, from xcore 0.1.0), keeps
570
+ working for a game that has it, and `xcore add database` keeps it up to date; the check says it is the older way. It
571
+ has the same owner spaces, rules and paging, with `RULES` at its top and `dataOf(env)` for the server's own code:
572
+ without an owner it is the server's own space (`dataOf(env).put('news', 'today', …)`), with one a player's
573
+ (`dataOf(env).get('profiles', 'main', { owner: sub })`). In 0.1.0 a document was found by its id alone, whoever wrote
574
+ it, so 0.1.0 code that reads or writes a player's document by id alone needs `{ owner }`: `xcore add database` and
575
+ publish list each such call (the README's "Upgrading from 0.1.0").
576
+
378
577
  ## Running it locally
379
578
 
380
- `xcore dev <folder>` runs `server/` under workerd, the runtime games.fun runs it on, at the page's `/game/api/*`
579
+ `xcore dev <folder>` runs `server/` under workerd, the runtime xgames.fun runs it on, at the page's `/game/api/*`
381
580
  (WebSockets too), with the game's page beside it and a local `env.GF`: `verifyPlayer` accepts the local sign-in that
382
581
  `gamesfun.js` gets from it, and saves and scores are the ones the page reads through `/platform/v1/*` (kept in
383
582
  `.gamesfun/dev-platform.json`). `env.GF.loot.report` is printed, not signed or paid; a voucher key of your own
384
583
  (`xcore add vouchers`) signs for real, with the test key in `.dev.vars`. The dev host answers only `localhost`,
385
584
  `127.0.0.1` and `[::1]`, so a page on another site can't reach it. Durable Object storage is kept in
386
- `.gamesfun/dev-server/`, its secrets come from `.dev.vars`, and a change under `server/`, to `gamesfun.json` or to
585
+ `.gamesfun/dev-server/`, the game's database (a local D1, its migrations applied) in `.gamesfun/dev/d1/`, its project files in
586
+ `.gamesfun/dev/files/` (the public ones on a port of their own), its secrets come from `.dev.vars`, and a change under `server/`, to `gamesfun.json` or to
387
587
  `.dev.vars` rebuilds and reloads it. Miniflare (with
388
588
  workerd, about 140 MB) is installed once on first use, into xcore's state folder (`GAMESFUN_SERVER_DEPS=<folder>` uses
389
589
  one installed elsewhere). What is not the same as hosted: no CPU or request limits (its log says when the page's requests would pass Free's
@@ -397,7 +597,7 @@ only the default export and the Durable Object classes exported (the check says
397
597
 
398
598
  `xcore publish` bundles `server/` into **one file** with esbuild (minified, with the contract's own options), runs
399
599
  the checks above and the secret scan over each server file and the bundle, and sends it with the game's files when
400
- games.fun says it takes server code. Otherwise it publishes the files alone and says so. `xcore publish --check` shows
600
+ xgames.fun says it takes server code. Otherwise it publishes the files alone and says so. `xcore publish --check` shows
401
601
  the bundle without publishing:
402
602
 
403
603
  ```