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.
# 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
