Back to Powder Tool V600Billion
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool V600Billion

Release
142767edcab8…
Author-recorded commit
6d92971effd0…
License
LICENSE
Author’s source reference
nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy

Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

source/docs/FORK.md
# Powder Toy: fork as a napplet?

> Review of [The-Powder-Toy/The-Powder-Toy](https://github.com/The-Powder-Toy/The-Powder-Toy)
> (falling-sand physics sandbox, C++/SDL2, GPL-3.0) as a catalog napplet.
> As of 2026-09-20, upstream v100.1.400. Same method as
> [CHESS-FORK.md](https://github.com/BIMbeamFLX/nappelin.com/blob/main/docs/CHESS-FORK.md): license, what the repo has, what the
> sandbox breaks, recommendation.
>
> **Status 2026-09-27: a Nappelin game, single-threaded, playable on
> napplet.soy too, in its own repository.** See the next section. The review
> below (§1 to §7) is kept as written on 2026-09-20; where it disagrees with
> the status section, the status section applies.
>
> Written in [BIMbeamFLX/nappelin.com](https://github.com/BIMbeamFLX/nappelin.com)
> as `docs/POWDER-TOY-FORK.md`. Paths under `apps/hangar/`, `deploy/`,
> `services/`, `tools/deploydock/` and the other `docs/` refer to that
> repository; `apps/powder-toy/` there is the root of this one.

## Status and decisions, 2026-09-27

**What it is now.** This repository (until 2026-09-27 `apps/powder-toy` in
nappelin.com) builds upstream's v100.1.400 source
for the web **without threads** and packages it into one file with Vite and
`@napplet/vite-plugin` (single-file mode, kind 35129 manifest template), the
toolchain napplet.soy and the napplet packages use. The page runs in any
`sandbox="allow-scripts"` frame: no `SharedArrayBuffer`, no workers, no
cross-origin isolation, no special headers from the host. Around the game,
the page puts back what the sandbox takes away:

| Upstream needs | In the napplet |
|---|---|
| pthreads, so `SharedArrayBuffer`, so a cross-origin isolated page | a single-thread build (below) |
| IndexedDB for `/powder` (saves, stamps, settings, scripts) | napplet `storage`, through a MEMFS with IDBFS's `syncfs` shape (`src/storage-fs.js`); one line of the Emscripten glue mounts it (§4.2) |
| powdertoy.co.uk for the online browser, saving with Publish, votes, comments, tags, favourites, profiles, login | answered in the page from Nostr through `outbox` and `identity` (`src/server/`); formats in [specs/saves.md](../specs/saves.md) |
| server-rendered thumbnails | drawn in the page from the save itself (`src/save/`) |
| a login form | the host's account: signed in to Nappelin is signed in to the game, no password |
| `window.open` for links | `link.open`, which asks the player (through a Module hook, `window.open` itself stays untouched) |

Unpublished saves (the game's default) never leave the device; published
saves are public events under the player's own key. Shared saves are
limited to 45 000 bytes so an event stays under the 64 KiB relays commonly
accept.

**The single-thread build.** `scripts/build-wasm.sh` runs
upstream's own release configuration for `wasm32-emscripten-emscripten-static`
(Emscripten 3.1.72, Meson, `debugoptimized`, LTO, the same compile and link
flags) from the pinned commit, with two patches:

- `patches/the-powder-toy-single-thread.patch` (12 files). A Meson option
  `emscripten_threads` (default `true`, upstream's behaviour); `false` drops
  `USE_PTHREADS`, the `threads` dependency (Meson turns it into `-pthread`)
  and the pthread-only link symbol. In the code, all behind
  `__EMSCRIPTEN__ && !__EMSCRIPTEN_PTHREADS__` or valid either way:
  - `Task::Start` does the work at once; `Poll` reports it as before
    (file browser, thumbnails, local deletes).
  - The five tasks that waited for HTTP on a worker (saving a loaded save
    again, delete, publish/unpublish, favourite/unfavourite) become
    `RequestTask`s: their requests start one after another from `Start` and
    `Poll`, nothing blocks. This form needs no thread in any build.
  - The separate rendering thread stays off (its option is greyed out);
    Newtonian gravity's FFT runs when dispatched, with the same one-frame
    latency the worker had.
  - The request manager leaves the caller's stack with
    `emscripten_async_call` instead of the pthread proxying call.
- `patches/tpt-libs-single-thread.patch`: fftw, jsoncpp and Lua, upstream's
  prebuilt libraries, built by their own recipe (tpt-libs
  v20251019131007) without `USE_PTHREADS`.

Every input comes by Git and is checked against a commit in
`upstream.json`; Emscripten's ports that it would fetch as GitHub archives
(zlib, SDL2, bzip2) are seeded from Git at their tags, libpng comes from
Emscripten's own bucket with its hash. Emscripten gets a cache of its own in
the work directory and every compiler call maps that directory to
`/wasm-build`, so no path of the building machine ends up in the wasm (FFTW
and SDL name their source files in assertion messages); two builds in
different directories gave the same bytes. (On one machine only, as it
turned out; see "Reproducible across machines" below.) Result: `powder.wasm` 6.14 MB
(upstream's threaded release: 6.17 MB) with no atomics and one memory the
module owns; in the page gzip then base64, the whole napplet **2.67 MiB**
(the threaded one was 8.7 MB).

**Host changes in the Hangar** (in nappelin.com). Catalog domains `identity`, `link`, `outbox`,
`storage`; `storage` granted in `grants.ts`; Escape is left to the game
(`KEEPS_ESCAPE` in `community-controls.ts`: in the Powder Toy, Escape closes
the game's own windows, and closing the bay with it lost the running
simulation). The isolation parts of the threaded build (`isolated` in the
catalog, `allow="cross-origin-isolated"` in `host.ts`, COOP/COEP in
`deploy/alpha-box/Caddyfile.alpha-routes` and on the dev server) are gone
again: nothing needs them.

**Storage on different hosts.** The Hangar keeps values up to 8 MB;
napplet.soy keeps 1 MiB and 256 keys per napplet and drops any message over
360 000 characters unanswered. `src/storage-fs.js` stores a file's base64 in
pieces of 256 KiB, writes new pieces before the entry that names them (an
interrupted write leaves the previous version whole), keeps the other files
when a host refuses one, tells the player once, and removes orphaned pieces
at the next complete load.

**Verified.** 59 offline tests here (bzip2 against Python's `bz2` and hostile
input, the storage filesystem with pieces and a full host, the save reader
against saves the real game wrote, two players sharing a relay through the
same fetch handler the game uses, the pinned artifact down to the unpatched
glue and wasm) and 5 in nappelin.com (the docked copy against the catalog
pin, the grants, the registry and `provenance.json`). In a browser, each time in two players, the Hangar's (the
real Kehto prelude) and napplet.soy's (their player document: policy,
`@napplet/shim` 0.30.0, shell bootstrap, storage limits, its publishing
rule): start in a plain
sandboxed frame without isolation, scaling and input, gravity, files kept
across reloads, a file too large for the host reported while the rest is
kept, links, no policy violation, the page's palette setting the game's
tools, keys in the page's search box staying out of the game, every scene
loading and stopping when something else replaces it, the game's dialogs
opening from the page and leaving no tooltip behind (`scripts/smoke.mjs`); the game's own save
dialog publishing to Nostr, browsing, commenting and voting as a second
player, then favourite, save in place, unpublish, publish again and delete,
the request tasks, and in napplet.soy's player the refusal with its reason,
saving without Publish, and reading saves shared elsewhere
(`scripts/e2e-online.mjs`). In the real Hangar over the
built site (nappelin.com `apps/hangar/scripts/test-powder-toy.mjs`): start without
headers with the four domains, a guest signing in inside the game, a
published save reaching the relay signed by the guest's key, the online
browser showing it with its picture, Escape staying in the game, storage
across a page reload. **napplet.soy's own checker:** `soyli check` (soyLI
0.23.4, runtime profile `space-playback-4`) on the project
`scripts/soy-project.mjs` wrote: `status: checked`; `soyli screenshot`
shows the game running.

**Decisions and open items.**

- **napplet.soy:** publishable now, steps in
  [NAPPLET-SOY.md](NAPPLET-SOY.md). It plays there, but the
  game cannot share there: their host signs only records in its own
  `soy.app-data/1` format (JSON up to 16 KiB, binaries through their upload
  domain, reactions through `common.react`) and says so at the handshake
  (`appData`), which the game reads and explains. Carrying shared saves in
  that format is possible and its own piece of work. Their storage is small:
  a few large local saves fill the 1 MiB, then the game says so and keeps
  the rest for the session.
- **Upstream:** the patch keeps threads as the default and is small; offering
  the `emscripten_threads` option and `RequestTask` upstream would leave us
  nothing to maintain.
- **Frame rate:** rendering and gravity share the main thread now. 60 FPS on
  the empty sandbox in headless Chromium; heavy scenes with the fancy display
  modes may run slower than upstream's threaded web build. Phones not
  measured.
- **Totem:** needs no headers any more; not yet tried on a device.
- **Own repository (2026-09-27).** The source moved out of nappelin.com into
  this repository, with its history, as §2 asks: the GPL napplet lives apart
  from the MIT shell. nappelin.com keeps the host changes, the docked
  artifact, its pin and the publish job; its napplet registry names this
  repository, the build command and the commit the pinned bytes came from
  (`provenance.json` next to the docked copy says the same).
- **Napplet conventions (2026-09-28).** Checked against the napplet skills'
  rules and `@napplet/conformance-cli` 0.2.19, which first failed the page on
  `boot/no-forbidden-globals`: Emscripten's glue carried `fetch`,
  `XMLHttpRequest`, `WebSocket` and `indexedDB` (loaders by URL, IDBFS, the
  game's request manager). Glue patches take them out; the game's requests,
  WebSockets and links now reach Module hooks the page supplies instead of
  the globals the page used to replace. Conformance passes and runs in CI.
  Shared saves, votes and comments moved from the low-level `relay` domain to
  NAP-OUTBOX (`outbox.query`, `outbox.subscribe`, `outbox.publish`); both
  hosts treat the two alike (napplet.soy routes both publishes through its
  app-data check, the Hangar signs both), so nothing changes for players.
  Archetypes and intents: none, and reviewed as none. The one reading of
  `window.napplet.shell` (napplet.soy's `appData` hint) stays: napplet.soy's
  own docs ask for it, and nothing else depends on it.
- **Reproducible across machines (2026-09-28).** The first CI build from
  source gave other bytes than the pins the build had been made with. Two
  causes, both found by building on several runners and comparing: an
  emsdk outside the work directory (Emscripten names its own library
  sources relative to its cache, so the wasm carried
  `../../../../../../emsdk/…` from that machine's layout, and
  `emsdk_env.sh` moved the cache when the script installed emsdk itself),
  and the Meson version (1.0.1 and 1.3.2 agree with each other, 1.12.1
  generates other code). `scripts/build-wasm.sh` now always installs
  emsdk into the work directory with the cache inside it, and refuses any
  Meson but `build.meson` (1.12.1). The pins in `upstream.json` are what
  two CI builds on different runners produced byte for byte; against the
  first pin the data section is 176 bytes shorter (those paths) and the
  code differs in addresses and the order of some functions. The page is
  now `d8c7cdd6…53ca`; the packaging from `vendor/` gives the same page on
  Windows as on Linux. A third cause showed when the wasm was first built in
  WSL (same toolchain versions, same sources): the functions of bzip2 and
  libpng came in another order. Emscripten 3.1.72 loads its ports with
  `os.listdir` and links their libraries in that order, which is the order
  of the directory on that file system (CI's runners share one disk image,
  so they agreed with each other). `build-wasm.sh` sorts that list; the pins
  since the bridge patch are built that way.
- **A page of its own around the game (2026-09-28).** The game's own UI is
  small (629 × 424, its pixel font), its menus sit right of and below the
  simulation. The page now shows only the simulation, as large as the frame
  allows, with its own palette, tool bar and settings around it, in a light
  or dark scheme after the player's system. It drives the game through the
  game's own Lua API, the one the console uses:
  `patches/the-powder-toy-napplet-bridge.patch` exports two functions,
  `Napplet_Lua` (runs Lua as the console does; a line someone is typing in
  the console is kept aside) and `Napplet_GameViewActive` (whether the game
  view is on top). For the game's online dialogs the page clicks the game's
  own buttons, hidden below the simulation, and shows the whole game window
  while a dialog is open. Keys typed into the page's inputs never reach the
  game (it listens on the window). Nine scenes come with it (`src/scenes/`),
  among them a nuclear reactor: the game's fission speeds up with air
  pressure, so a reactor vessel that holds its steam ran away within
  seconds, whatever the control rods; the scene switches pressure off while
  its control rods are in and back on when half of them are gone, which
  gives a reactor that runs, boils its water into steam and can be taken
  critical. The page grew to 2.79 MiB (the scenes and their pictures are
  about 70 KB of it).
- **The source is public on napplet.soy (2026-09-29).** GPL-3.0 §6:
  whoever gets the built file from nappelin.com or napplet.soy must be able
  to get its corresponding source. This repository stays private; the
  source is public in napplet.soy's Git (https://git.napplet.soy/npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/powder-toy.git), where every release
  carries this repository as the `source/` that builds exactly its page
  (GPL-3.0 §6(d) allows the source on another server, a third party's
  too, with clear directions next to the file). So the Hangar only docks
  a page that napplet.soy has published, and the page (About), the
  Hangar's card, `LICENSE.txt` and `provenance.json` (`publicSource`) all
  point there.
- **Moderation:** anyone can publish a kind 30078 with the `powder-toy`
  topic to the public relays the host reads. relay.nappelin.com accepts
  members only; the other four do not filter. A reader-side filter (follows,
  members, guilds) is the next step if spam appears.

---

## 1. Short answer

**Yes in principle, and it is the cheapest game we could add: upstream
already ships a WebAssembly build.** The Powder Toy has an official
Emscripten target in its Meson build and a hosted web version at
powdertoy.co.uk/Wasm.html. We would not port anything; we would package
their wasm as a single-file napplet and replace two things the sandbox
takes away.

Two things decide go or no-go, and both are a one-day spike, not a
project:

1. **Threads.** Upstream's web build uses pthreads, which need
   `SharedArrayBuffer`, which needs a cross-origin-isolated document. A
   napplet is a `srcdoc` iframe with `sandbox="allow-scripts"` and an
   opaque origin. **Measured: that frame does get isolated**, if the
   Hangar sends COOP/COEP and the iframe carries
   `allow="cross-origin-isolated"`. Both are small host changes; the COEP
   one has shell-wide side effects to check (§4.1).
2. **Size.** Napplets are one HTML file of at most 10 MB, so the wasm
   goes in as base64. The release wasm size could not be read in this
   session (the GitHub asset list did not load); the 8.9 MB sticker
   napplets show the limit is real but not tight (§4.3).

## 2. License: GPL-3.0, and that is fine here

Upstream is GPL-3.0 (LICENSE file, GNU GPL v3 of 29 June 2007). The
reasoning from [CHESS-FORK.md §3](https://github.com/BIMbeamFLX/nappelin.com/blob/main/docs/CHESS-FORK.md) applies unchanged:

- **The NIP-5D frame is the license boundary.** A napplet is its own
  program in its own frame, talking to the shell by messages. A GPL
  napplet does not touch the MIT shell.
- **What we owe:** publish the source of the napplet itself under GPL:
  the fork with our patches, the packaging script, the host-bridge
  shim. A public fork repository under the organisation does that.
- **What we must not do:** copy Powder Toy code into `apps/hangar` or
  any MIT package. The bridge between wasm and `window.napplet` lives
  in the fork, not here.

[GAMING-LANDSCAPE.md](https://github.com/BIMbeamFLX/nappelin.com/blob/main/docs/GAMING-LANDSCAPE.md) already records the same
boundary for Torii Quest (also GPL-3.0). Powder Toy is the second case,
and the first where we would actually ship the code rather than link
out to it.

## 3. What upstream has

| Building block | Assessment |
|---|---|
| **Emscripten target** in `meson.build` (`host_platform = 'emscripten'`) | The hard part is done and maintained by upstream, not by us |
| CI cross file `.github/emscripten-ghactions.ini`: `em++`, `wasm32` | Reproducible: emsdk 3.1.72 per `.github/build.sh` |
| Link flags: `WASM=1`, `ALLOW_MEMORY_GROWTH=1`, `FORCE_FILESYSTEM=1`, `MODULARIZE`, `-lidbfs.js` | Modular JS glue, memory grows on demand, local saves via IndexedDB (see §4.2) |
| Compile flags: `USE_SDL=2`, `USE_BZIP2`, `USE_LIBPNG`, **`USE_PTHREADS`**, exceptions on | SDL2, bzip2 and libpng come from Emscripten ports, no vendoring. Pthreads is the one that bites (§4.1) |
| HTTP/curl: disabled on emscripten (`enable_http and host_platform != 'emscripten'`) | No online save browser, no login to their server. Good for us: the sandbox has no network anyway, so nothing is lost that upstream did not already drop |
| Lua scripting | Present on desktop; whether the web build keeps it is untested. Not needed for a first napplet |
| Output: `powder.js` + `powder.wasm` (+ `.wasm.map`, `serve-wasm.py`) | Two files to fold into one document |
| Project size | About 300 elements, air/pressure/heat simulation, save format. None of it is a library we could "take the hard part" from as with chess.js. It is all or nothing |

Upstream is alive: v100.1.400 released 2026-08, Steam and web releases,
active issue tracker.

## 4. What the sandbox breaks

The host loads a napplet as `frame.srcdoc = html` with
`sandbox="allow-scripts"`, never `allow-same-origin`
(`apps/hangar/src/host.ts`). The Hangar CSP allows
`script-src 'unsafe-inline' 'wasm-unsafe-eval' blob:` and
`worker-src 'self' blob:` (`apps/hangar/index.html`), so wasm and blob
workers are not the problem. Three things are.

### 4.1 Threads need SharedArrayBuffer: measured, it works

Upstream builds with `USE_PTHREADS`. Pthreads in Emscripten are Web
Workers over a shared `WebAssembly.Memory`, which needs
`SharedArrayBuffer`, which browsers only hand out to documents that are
cross-origin isolated (top level served with
`Cross-Origin-Opener-Policy: same-origin` and
`Cross-Origin-Embedder-Policy: require-corp` or `credentialless`).
Upstream says so themselves: the wasm build cannot be opened from a file,
it ships a `serve-wasm.py` that sets those headers.

The open question was whether a **sandboxed, opaque-origin `srcdoc`
frame** inherits that isolation. Measured on 2026-09-20 with
`tools/coi-probe/probe.mjs` (headless Chromium 1194 via Playwright 1.56;
the probe frame is created exactly as `host.ts` does it,
`sandbox="allow-scripts"`, `srcdoc`, no `allow-same-origin`):

| Top-level headers | `allow="cross-origin-isolated"` on iframe | top `crossOriginIsolated` | frame `crossOriginIsolated` | `SharedArrayBuffer` in frame | blob worker + Atomics on shared buffer |
|---|---|---|---|---|---|
| none | no | false | false | undefined | fails |
| none | yes | false | false | undefined | fails |
| COOP + COEP `require-corp` | no | true | **false** | undefined | fails |
| COOP + COEP `require-corp` | yes | true | **true** | function | **works** |
| COOP + COEP `credentialless` | no | true | **false** | undefined | fails |
| COOP + COEP `credentialless` | yes | true | **true** | function | **works** |

So the frame is isolated exactly when both hold:

1. **The Hangar itself is cross-origin isolated:** `deploy/` sends
   `Cross-Origin-Opener-Policy: same-origin` and a COEP on the Hangar
   document. `credentialless` is enough and is the one to take: it lets
   the shell keep loading cross-origin images and audio (Blossom, Wavlake,
   the meme snapshot) without CORP headers on their side, by stripping
   credentials instead of blocking. `require-corp` would block every
   cross-origin resource that does not opt in.
2. **The iframe delegates the feature:** `frame.allow =
   'cross-origin-isolated'` in `host.ts` next to the sandbox line. Without
   it the opaque origin matches nothing in the default `'self'` allowlist
   and the frame stays unisolated even under an isolated top level. This
   can be per napplet: only entries that declare threads get it.

Not measured: Firefox and Safari (only Chromium is installed here).
Firefox implements the same permissions-policy gate; Safari's
`credentialless` support landed late and should be checked on a real
device before this is relied on. The Hangar's CSP (`worker-src blob:`,
`'wasm-unsafe-eval'`) was not part of the probe; it permits both things
the game needs, and `srcdoc` inherits it.

Side effects of isolating the Hangar, to check before flipping the
headers on: `window.open`/`opener` to other origins break under COOP
`same-origin` (Alby and Keycast flows that open a popup must be tested),
and any cross-origin iframe the shell embeds must send
`Cross-Origin-Resource-Policy` or be dropped. `credentialless` does not
apply to iframes, only to subresources.

The single-threaded fallback (patch `USE_PTHREADS` out of `meson.build`)
is therefore no longer the plan, only the reserve if the COOP side
effects turn out unacceptable.

### 4.2 Local saves need storage the frame does not have

`-lidbfs.js` mounts an IndexedDB-backed filesystem for local saves and
settings. A sandboxed frame without `allow-same-origin` has an opaque
origin, and **IndexedDB and localStorage throw in opaque-origin frames**.
IDBFS will fail to sync. Two ways out, both small:

- Drop IDBFS (`MEMFS` only, everything in memory, gone on close). Simplest;
  the sandbox is then a toy you play, not a workshop you return to.
- Route saves through the host. The save is a byte string; the napplet
  can hand it to `napplet.relay.publish` as an app-data event (kind
  30078, `d` = save name) and read it back with `relay.query`. That is
  exactly what the companion napplet does with its state, and it makes a
  Powder Toy save a thing under the player's nym: shareable, listable,
  ours to render a gallery from later. This is the version worth
  building.

### 4.3 One file, at most 10 MB

The Blossom server refuses larger artifacts and the control board checks
it (`docs/NAPPLETS.md`). The wasm has to go in as a base64 `data:` URI
or an inline `Uint8Array`, plus the JS glue, plus the pthread worker
script if threads survive. Emscripten's `-sSINGLE_FILE=1` does the
inlining for us and works together with `MODULARIZE`. Base64 costs 33 %,
so the raw `.wasm` must stay under about 7 MB. Upstream's debug wasm on
SourceForge is the one that is public; the release wasm size still has
to be read off a release run. The sticker napplets already sit at
8.9 MB, so the pipeline copes with that size.

### 4.4 What is not a problem

- **No network:** upstream already disables HTTP on emscripten. Nothing
  to rewire, unlike chessnut.
- **Identity, theme:** the napplet needs no domain to run. `relay` and
  `identity` only if we do host-backed saves (§4.2). `theme` if we want
  the brass frame around it; the game itself keeps its own look, which
  is fine for a retro pixel sandbox in a way it was not for a Lichess
  board.
- **Input:** SDL2 on Emscripten handles mouse, keyboard and touch inside
  the frame. The canvas needs focus; the shell's key handling must not
  swallow the game's shortcuts while the frame is focused. Same as every
  game napplet.

## 5. Recommendation

**Do the spike, then decide. Do not start a "fork" in the sense of a
maintained divergent codebase.** Our fork is a thin one: upstream, a
handful of patches, a packaging step.

| Take | Replace |
|---|---|
| Upstream's Emscripten build, unchanged as far as possible; build with their emsdk version in our CI | `serve-wasm.py` and the two-file output → `-sSINGLE_FILE` into one `index.html` with the `napplet-requires` meta and the community controls |
| The game as it is: elements, simulation, UI, save format | IDBFS → host-backed saves via `relay.publish`/`relay.query` (kind 30078), or MEMFS only for the first cut |
| Their web build's dropped features (no online saves, no login) as the definition of scope | `USE_PTHREADS` → a single-threaded build **only if** §4.1 says the frame cannot be isolated |

Sequence:

1. **Spike (1 day):** set COOP + COEP `credentialless` on a staging
   Hangar and `allow="cross-origin-isolated"` on the frame, click through
   Alby, Keycast, Blossom images and music; build upstream wasm with
   emsdk 3.1.72 in a CI job; read the size.
2. **Go/no-go on three numbers:** nothing in the shell broke under the
   headers, raw wasm ≤ 7 MB, first paint in the frame.
3. **Fork repo** `BIMbeamFLX/the-powder-toy` (GPL-3.0 kept, upstream as a
   remote), branch `napplet` with the patches and a
   `scripts/build_napplet.py` like the TCG's.
4. **Key, manifest, shelf:** the publishing checklist in §6. THIRD-PARTY-
   NOTICES gets a GPL-3.0 section like Torii Quest's.

What this is for the platform: a single-player sandbox, not a ranked
game. It touches neither NIP-E1 nor NAP-RTC. Its value is a well-known
free-culture title on the shelf on day one, and, with host-backed saves,
the first napplet whose creations live under the player's nym.

## 6. Publishing: the napplet toolchain and a key of its own

Question from 2026-09-23: can this go through the napplet ecosystem's own
toolchain (the one napplet.soy documents) with a key generated only for
it? **Yes, and that is already the house rule, not an exception.**
`PUBLISHING.md` §2: one publishing key per napplet, never the account key,
never a shared key. The sticker napplet shows the toolchain in use:
`@napplet/vite-plugin` writes the kind 35129 manifest, the `napplet` CLI
deploys blob and manifest, `@napplet/conformance-cli` checks the artifact
against the sandbox before anything is signed. Nothing new has to be
invented for Powder Toy; the list below is what has to be filled in.

### The key

- **Minted locally, never in a cloud session or a chat.**
  `node services/agent-api/scripts/mint-identity.mjs powder-toy` writes
  `identities/powder-toy.ncryptsec` (NIP-49, scrypt log n 20, mode 0600)
  and `powder-toy.public.json`, and prints only pubkey and npub. The
  passphrase goes on paper. The key image (PNG) goes to two places that
  are not a browser, per `PUBLISHING.md` §2.
- **Where the public half is entered**, all in one PR:
  1. `apps/hangar/src/published.ts`: `'powder-toy': { dTag: 'powder-toy',
     pubkey: '<hex>' }` (until minted: `null`, and the publish script
     refuses to sign, by design).
  2. `deploy/alpha-box/allowlist.json` → `publishers`: the Blossom
     server takes HTML only from listed keys. Needs a box deploy.
  3. `tools/deploydock/keys.json`: label `powder-toy`, role, npub,
     `secretAt` pointing at the ncryptsec on the minting machine.
  4. `apps/hangar/src/napplet-status.json`: `publishKey` = the npub.
- **Into the CLI:** `node apps/hangar/scripts/import-napplet-keys.mjs
  --from identities powder-toy` (not on Windows; there
  `publish-napplets.mjs --publish --from identities` reads the file
  directly).

### The build, in toolchain terms

A small Vite project `apps/powder-toy/` whose only job is packaging:

| Step | Tool | Note |
|---|---|---|
| Compile | emsdk 3.1.72, upstream Meson, `-sSINGLE_FILE=1` added to the link line | CI job in the fork repo; output `powder.js` with the wasm inlined |
| Wrap | Vite + `nip5aManifest({ nappletType: 'powder-toy', artifactMode: 'single-file', requires: [...] })` | Same config shape as `600-culture-stickers/sticker-napplet/vite.config.ts`; `assetsInlineLimit` high so nothing is emitted beside `index.html` |
| Threads inside `srcdoc` | Emscripten `Module.mainScriptUrlOrBlob` = a Blob of the same script | In `about:srcdoc` there is no script URL for pthread workers to load; hand them a blob (the CSP allows `worker-src blob:`). Untested, one of the spike items |
| Host bridge | `window.napplet` shim, in the fork under GPL | Saves via `fs.pickSaveFile`/`fs.write` (the sticker napplet's route, a plain file download) or via `relay.publish` kind 30078 (§4.2) |
| Check | `napplet-conformance ./dist` and `node scripts/check-napplets.mjs --id=powder-toy` | Both before the first signature |

`requires` is decided by the save route: `[]` for MEMFS only, `['fs']`
for file export, `['relay', 'identity']` for saves under the nym.

### The publish, in order

1. Catalog entry `powder-toy` in `catalog.ts` (`domains` as above,
   `archetypes: []`, `intents: []`; the publish script asserts both are
   reviewed and empty), status line `unknown` in `napplet-status.json`,
   `published.ts` line, job line in `publish-napplets.mjs`, and the
   expectation in `test/published.test.mjs`. This is the PR that makes
   the shelf aware of the napplet; it can land before the wasm does.
2. Artifact into `apps/hangar/public/napplets/powder-toy/index.html`,
   `node scripts/pin-napplet.mjs powder-toy` writes the sha256 pin.
3. `node apps/hangar/scripts/publish-napplets.mjs --only powder-toy`
   (preflight: builds, compares with the reviewed copy, plans the deploy).
4. `... --publish --only powder-toy`: blob to `blossom.bimcvp.com`,
   manifest 35129 to the relays, read-back verified, then
   `published-napplets.json` updated. The naddr goes into
   `napplet-status.json` → `published`.
5. Discover shows it within a minute; the shelf shows it once the pin
   PR is merged.

The Plumbing Station (`PUBLISHING.md` §4) will do steps 3 and 4 in the
browser once its publishing parts exist; until then it is the script.

**What this session did not do:** it did not mint a key (a secret
generated here would have passed through a cloud container and a chat
transcript), and it did not touch `published.ts` or the catalog, because
without an artifact those lines only add failing asserts. Both are one PR
once the spike in §5 has run. napplet.soy itself could not be read from
this session (egress blocked); the toolchain facts above come from the
sticker napplet and the scripts in this repo that already use it.

## 7. Open items

- [x] Test `crossOriginIsolated` and `SharedArrayBuffer` inside a
      sandboxed `srcdoc` napplet frame (2026-09-20, Chromium: works with
      COOP + COEP + `allow="cross-origin-isolated"`, see §4.1)
- [x] Not needed any more (2026-09-27): the probe in Firefox and Safari and
      the COOP + COEP decision for the Hangar; the single-thread build uses
      no isolation
- [x] Our own emsdk build: `scripts/build-wasm.sh`, every
      input pinned in `upstream.json`
- [x] A `USE_PTHREADS`-free build (2026-09-27): `patches/`, see the status
      section
- [x] Saves through the host: `storage` for `/powder`, kind 30078 via
      `outbox` (first `relay`) for shared saves
- [x] Size: 2.67 MiB as one file (the wasm gzip-compressed, then base64)
- [x] Own repository, history kept, the pinned page rebuilt byte for byte (2026-09-27)
- [x] The corresponding source public before the first deploy that carries
      the file (GPL-3.0 §6): in napplet.soy's Git, one release per docked page;
      this repository stays private (2026-09-29)
- [ ] Mint `powder-toy.ncryptsec` locally, enter the pubkey in the four
      places listed in §6, box deploy for the Blossom allowlist
- [ ] napplet.soy: a creator key of its own there (`soyli account create
      --new`), then publish the project from `npm run soy` ([NAPPLET-SOY.md](NAPPLET-SOY.md))
- [ ] Offer the single-thread patch upstream (LBPHacker maintains the web
      build); a patch that lands upstream is a patch we do not maintain