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/README.md
# Powder Toy napplet

[The Powder Toy](https://github.com/The-Powder-Toy/The-Powder-Toy) as a Nappelin napplet:
upstream's source built for the web without threads, so it runs in any plain
`sandbox="allow-scripts"` frame (the Hangar, napplet.soy), with what the sandbox takes
away put back by the page around it.

- **Saves stay.** `/powder` (local saves, stamps, settings, Lua scripts) lives in the
  host's napplet storage instead of IndexedDB, which a sandboxed frame does not have.
- **Online works, over Nostr.** The game's own server features (the online browser,
  saving with Publish, votes, comments, tags, favourites, profiles) are answered in the
  page from Nostr events the host signs with the player's key. Event formats:
  [specs/saves.md](specs/saves.md).
- **Signed in = signed in.** A player signed in to Nappelin is signed in to the game,
  with no password; the key never leaves the host.
- **A page of its own around the game.** The page draws its own palette (every element
  and tool the game has, by section, searchable), tool bar and settings around the
  simulation and drives the game through its Lua API; the game's own dialogs (the save
  browser, saving, signing in, its settings, the console) appear when they are opened.
- **Scenes to start from.** A nuclear reactor to take critical, a nuclear test, a
  volcano, a black hole, thermite against steel, fireworks over a city, a thunderstorm,
  a dam break and Game of Life glider guns, in a gallery ([Scenes](#scenes)).

It plays in the Hangar of [nappelin.com](https://github.com/BIMbeamFLX/nappelin.com)
(catalog id `powder-toy`), which pins the built file by its sha256, and is published on
napplet.soy as
[Powder Tool V600Billion](https://napplet.soy/n/naddr1qvzqqqyf8ypzqnl7u0vu7h099fgumqlec33xrfdny4xjvmr4j8cpz2mf5uuyw8t2qyv8wumn8ghj7un9d3shjtnwv9c8qmr9wsh8xmme9uqsuamnwvaz7tmwdaejumr0dshsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszyrhwden5te0dehhxarj9ekk7mf0qyd8wumn8ghj7un9d3shjtnsda3kket5wd68ytnrdakj7qq2wphhwer9wgkhgmmeveyk6g)
([docs/NAPPLET-SOY.md](docs/NAPPLET-SOY.md)). Assessment, measurements and decisions:
[docs/FORK.md](docs/FORK.md). The Nappelin release (key, preflight, publish) is done from
nappelin.com: `docs/POWDER-TOY-PUBLISH.md` there.

**Source:** public in napplet.soy's Git, https://git.napplet.soy/npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/powder-toy.git: every release there
carries this repository as the `source/` that builds exactly its page. This repository
itself is private; the Hangar only docks pages napplet.soy has published, so their source
is always there.

**License: GPL-3.0** (`LICENSE`), the same as upstream; the libraries the wasm links and
their licenses are in `licenses/`, see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). The
page says so itself (More → About, license and source, with the public Git's address and
links to it and to the license through the host), and `npm run dock` puts the license next
to the docked file as `LICENSE.txt` and the public source into `provenance.json`.
The napplet runs in its own frame and is never linked into the MIT shell, which is why it
lives in its own repository.

## Build

Node 24 or later.

```sh
npm ci
npm run wasm       # scripts/build-wasm.sh: the game's wasm into vendor/, checked against upstream.json
npm run build      # vite build with @napplet/vite-plugin: dist/index.html + dist/.nip5a-manifest.json
npm test           # 59 offline tests; the artifact checks need the build
npm run conformance  # @napplet/conformance-cli on dist/: sandbox, boot, no browser authority
```

`npm run wasm` builds `vendor/powder.js` and `vendor/powder.wasm` from upstream's source
at the commit in `upstream.json`, the way upstream builds its web release (Emscripten
3.1.72, Meson, LTO) but without threads, with the three patches in `patches/`. Every input
is fetched by Git and checked against a pinned commit; the result must hash to
`upstream.json`'s `build.files`, or the script stops (`npm run wasm -- --pin` takes a new
result on purpose). The result does not depend on the machine: CI checks the pins on every
push, and a build in WSL on another machine gives the same bytes. It needs
Linux (or WSL) with git, python3, ninja, cmake, make, node and Meson at exactly
`upstream.json`'s `build.meson` (1.12.1: `pipx install meson==1.12.1`; from 1.4 on Meson
generates other code for this build, so the version is part of the pin). Emscripten 3.1.72
is always installed into `.wasm-build/`, never taken from an active emsdk: Emscripten names
its own library sources relative to its cache, and an emsdk elsewhere puts that machine's
layout into the wasm. Emscripten also reads the list of its ports in directory order and
links them in that order; the script sorts that list, so the file system does not change
the bytes either. 15 to 25 minutes the first time.

`npm run build` writes `dist/index.html` (one file, 2.79 MiB: the wasm gzip-compressed,
then base64) and `dist/.nip5a-manifest.json` (the kind 35129 template). The packaging is
reproducible too: the same `vendor/` gives the same page on Linux and on Windows
(`.gitattributes` keeps LF line endings, the gzip is fflate's, not the platform's zlib),
so a pin can be checked by rebuilding.

Browser checks (Playwright; `PLAYWRIGHT_CHROMIUM_EXECUTABLE` for a Chromium of your own,
otherwise `npx playwright install chromium`), each in the Hangar's player and in
napplet.soy's (or one of them: `npm run smoke -- soy`):

```sh
npm run smoke      # start without isolation, scaling, input, gravity, files kept, a file too large,
                   # links, the license and source; the page's palette, keys, every scene, the game's dialogs
npm run e2e        # publish, browse, comment, vote, favourite, save in place, unpublish, delete;
                   # on napplet.soy: publishing refused with an explanation, reading works
```

`scripts/harness.mjs` is the small host they run in: either the real Kehto prelude
(`@kehto/shell`, the version the Hangar uses) and a host that answers the way the Hangar
does, or napplet.soy's player document (its policy, `@napplet/shim` 0.30.0, its shell
bootstrap) with its storage limits and its publishing rule. Screenshots land in `qa/`.
The same checks in the real Hangar are `apps/hangar/scripts/test-powder-toy.mjs` in
nappelin.com.

## Into the Hangar

With a nappelin.com checkout next to this one (or its path as the argument), from a
committed tree:

```sh
npm run dock -- ../nappelin.com
cd ../nappelin.com/apps/hangar
node scripts/pin-napplet.mjs powder-toy     # the reviewed step: the new bytes go on the shelf
node scripts/gen-registry.mjs               # then set pin.sourceCommit to the commit dock printed
npm test && npm run build && node scripts/test-powder-toy.mjs
```

`dock` copies `dist/` into `apps/hangar/public/napplets/powder-toy/` and writes
`provenance.json` next to it: this repository, the commit, the upstream source, the
patches and the pinned build. It refuses a working tree with uncommitted changes, so the
commit is always the source of the bytes.

## On napplet.soy

`npm run soy` writes `soy/` (gitignored): the project folder napplet.soy's `soyli` CLI
publishes, with the built page byte for byte and this repository as its `source/`. The
steps, the creator key and what players get there: [docs/NAPPLET-SOY.md](docs/NAPPLET-SOY.md).

## Scenes

A scene is a Lua script in `src/scenes/` (`NN-name.lua`, in gallery order) that builds its
experiment with the game's own Lua API. A header of `-- key: value` lines describes it:
`title`, `icon`, `blurb`, `hint` (shown above the game while it runs), `view` (the game's
display preset, 0 to 12), `thumb` (seconds until its gallery picture is taken) and, if
needed, `snap` (one line of Lua run just before the picture). Before a scene the page stops
the previous scene's scripts, keeps the simulation for Undo, empties it and resets the
settings; the helpers the scripts use (`box`, `disc`, `line`, `walls`, `each`, `set`,
`celsius`, `every` after each simulation step, `watch` every frame and allowed to change
settings) are `KIT` in `src/ui/scene-meta.js`. A scene's scripts end with the scene: the
wall cells of its ground are its anchors, and once they are gone (the simulation emptied,
a save opened, the scene undone) the scripts take themselves off.

```sh
node scripts/scene-lab.mjs src/scenes/01-reactor.lua --at 3,10,30 --probe "return sim.partCount()"
node scripts/scene-thumbs.mjs reactor   # after npm run build: src/scenes/thumbs/reactor.webp
```

`scene-lab` runs a scene file in the built page, without rebuilding, and takes pictures
(`qa/lab-*.png`); `npm test` checks every scene's header, its picture and that every
element it names exists in the pinned game; `npm run smoke` loads every scene and checks
that a scene stops when something else replaces it.

## How it fits together

| Path | What it does |
|---|---|
| `scripts/build-wasm.sh`, `patches/` | The game's wasm without threads from upstream's pinned source: `the-powder-toy-single-thread.patch` (a Meson option `emscripten_threads`; tasks on the main thread, network tasks as request steps, no render or gravity thread), `the-powder-toy-napplet-bridge.patch` (two exported functions for the page: `Napplet_Lua` runs Lua as the console does, `Napplet_GameViewActive` says whether one of the game's dialogs is open) and `tpt-libs-single-thread.patch` (the libraries without `USE_PTHREADS`). |
| `vite.config.js` | Vite + `@napplet/vite-plugin` (single-file, manifest). Its own plugin inlines the glue and the wasm (gzip, then base64) and adds the page's CSP (`connect-src 'none'`, `worker-src 'none'`) and `napplet-requires`. |
| `scripts/upstream.mjs` | Checks `vendor/` against `upstream.json` and applies the glue patches: `/powder` is mounted on the filesystem `Module.nappletFS` returns; the game's requests, WebSockets and links go to `Module.nappletFetch`, `nappletWebSocket` and `nappletOpen`; Emscripten's loaders by URL and IDBFS lose their browser authority. The build stops if a patch no longer matches exactly as often as it should. |
| `src/main.js` | Boot: waits for the host, signs in, sets up the server, unpacks the wasm (`DecompressionStream`), starts `create_powder` with it, the filesystem hook and the Module hooks (the server as `nappletFetch`, links through `link.open`, WebSockets that fail like an unreachable server). Reads the host's `appData` hint: a host that signs only its own records gets no publishing attempts, and the game says why. |
| `src/game.js`, `src/ui/` | The page's own controls. `game.js` wraps the bridge; `catalog.js` reads the game's tools from Lua (`tools.index`); `palette.js`, `app.js` (top and bottom bar, the game's dialogs by clicking the game's hidden buttons, the game's state a few times a second), `frame.js` (only the simulation while the game view is on top, the whole window while a dialog is open), `scenes.js` and `scene-meta.js` (the gallery, the Lua a scene loads with). |
| `src/scenes/` | The scenes (Lua) and their gallery pictures (`thumbs/`, from `scripts/scene-thumbs.mjs`). |
| `src/napplet.js` | The only code that touches `window.napplet` (the Kehto prelude, or `@napplet/shim` on napplet.soy). Nostr goes through NAP-OUTBOX: `outbox.query` to read, `outbox.subscribe` for saves that arrive while the game runs, `outbox.publish` to hand the host unsigned events. |
| `src/storage-fs.js` | MEMFS plus a `syncfs` against napplet storage, the shape IDBFS has, so upstream's own sync calls drive it. Large files in pieces a strict host takes; a refused file does not stop the others. Never deletes anything unless the load before it read every entry. |
| `src/session.js` | The host's identity as the game's session (`#PowderSessionInfo`, read by upstream at start). |
| `src/server/` | The game's server API answered in the page: `api.js` (endpoints in the formats upstream parses), `store.js` (saves, votes, comments, profiles, private saves, favourites), `events.js` (Nostr formats and validation), `names.js` (save IDs and usernames). |
| `src/save/` | Reading saves for the online browser's pictures: bounded bzip2, strict BSON, the OPS1 particle layout, a PNG writer. `elements.js` is generated from upstream source by `scripts/gen-elements.mjs`. |
| `scripts/dock.mjs` | Copies the build into a nappelin.com checkout with `provenance.json`. |
| `scripts/soy-project.mjs` | The project folder napplet.soy's `soyli` publishes: the page, `napplet.json`, LICENSE, README, `source/`, checked against their limits. |
| `tools/coi-probe/` | The 2026-09-20 measurement of isolation in sandboxed frames that the threaded build depended on (docs/FORK.md §4.1); history now. |

## Requirements on the host

- **A plain napplet frame.** `sandbox="allow-scripts"`, no isolation, no workers, no
  network. The page's own policy allows no more than napplet.soy's player policy.
- **Escape.** The game closes its own windows with Escape; the Hangar leaves Escape to it
  (`KEEPS_ESCAPE` in nappelin.com `apps/hangar/src/community-controls.ts`).
- **Domains.** `storage`, `identity`, `outbox`, `link`. Without storage nothing is kept;
  without identity and outbox the game plays offline and the online browser is empty. A
  host whose `outbox.publish` signs only its own record format (napplet.soy) leaves the
  online browser readable and publishing off.

## Napplet conventions

- **Conformance.** `npm run conformance` (`@napplet/conformance-cli`, in CI too) passes:
  the page runs under `sandbox="allow-scripts"` alone, boots after the runtime injects
  `window.napplet`, survives without any domain, and holds no browser authority. The
  Emscripten glue's own loaders by URL and IDBFS are patched out, and the game's HTTP
  requests, WebSockets and links go to Module hooks the page supplies (`GLUE_PATCHES` in
  `scripts/upstream.mjs`); the page takes over no browser global. `npm test` repeats the
  static part on every build.
- **Archetypes and intents: none, reviewed.** The game fulfils no role another napplet
  would hand work to, and it hands none on; `archetypes: []` in `vite.config.js`, and the
  Hangar's catalog records both as reviewed and empty.
- **Nostr through NAP-OUTBOX**, not the low-level `relay` domain: the host signs, picks
  the relays and fans out. Event formats: [specs/saves.md](specs/saves.md).
- **One host-specific read:** napplet.soy's shell handshake carries an `appData` hint and
  asks napplets to check it before offering to publish (its `docs/SHARED-DATA.md`);
  `src/napplet.js` reads it where a host offers `window.napplet.shell`, and nothing else
  depends on it.

## Updating upstream

1. In `upstream.json`: the new `version`, `tag` and `commit`; if upstream moved to a new
   Emscripten or tpt-libs release, `build.emsdk`, `build.libraries` and the port pins
   (their `.github/build.sh` and `meson.build` name them).
2. Check the patches still apply: `git apply --check` in a checkout of the new tag
   (`patches/the-powder-toy-single-thread.patch`, then
   `patches/the-powder-toy-napplet-bridge.patch`) and of tpt-libs
   (`patches/tpt-libs-single-thread.patch`). Refresh them if not; upstream's code that
   uses threads is in the patch header. When the glue patch (`scripts/upstream.mjs`) no
   longer applies, read upstream's Emscripten changes before adapting it.
3. `npm run wasm -- --pin` (it writes the new hashes into `upstream.json`), then
   `npm run build`, `npm test`, `npm run smoke`, `npm run e2e`.
4. Regenerate the colour table from a checkout of the new tag:
   `node scripts/gen-elements.mjs <checkout>`. `scripts/make-fixtures.mjs` rewrites the
   test saves with the game itself.
5. Commit, then "Into the Hangar" above, and `npm run soy` for napplet.soy.

When the Hangar moves to a new `@kehto/shell`, move `package.json` here with it, so the
harness keeps injecting the prelude the Hangar injects.