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/specs/saves.md
Powder Toy saves on Nostr
=========================

`draft` `nappelin`

How the Powder Toy napplet (this repository) keeps shared saves, votes and
comments on relays. No new kind: a save is NIP-78 application data, social
is NIP-25 and NIP-22 at the save's address, taking something back is NIP-09.
Any client can read, show and answer these events; the napplet is one reader
and writer of them. Implemented in `src/server/events.js`,
checked by `test/server.test.mjs`.

The game's own server API (upstream The Powder Toy, `src/client/http/`) is
answered in the page from these events. That mapping is an implementation
detail of the napplet and is not part of this document.

## Save: `kind:30078` (NIP-78, addressable)

```json
{
  "kind": 30078,
  "content": "<the .cps save file, base64>",
  "tags": [
    ["d", "powder-toy/save/<first 16 hex of sha256(lowercase(trim(name)))>"],
    ["t", "powder-toy"],
    ["title", "<name, 1-100 characters>"],
    ["summary", "<description, up to 2000 characters>"],
    ["published_at", "<unix seconds of the first publication>"],
    ["x", "<sha256 hex of the save file>"],
    ["size", "<bytes of the save file>"],
    ["alt", "Powder Toy save: <name>"],
    ["t", "<tag>"]
  ]
}
```

- **Addressing.** The `d` tag is derived from the name, so publishing again
  under the same name (case and surrounding spaces ignored) replaces the save,
  as saving over your own save does in the game. A different name is a
  different save. `published_at` carries over from the first version.
- **Content.** The file exactly as the game writes it: `OPS1` magic, then
  bzip2-compressed BSON (upstream `GameSave::serialiseOPS`). Readers must
  treat it as untrusted input.
- **Size.** At most 45 000 bytes of save file, so the whole event stays under
  the 64 KiB many relays accept. Larger saves are kept on the player's device.
- **Discovery.** `["t", "powder-toy"]` on every save; filter
  `{"kinds": [30078], "#t": ["powder-toy"]}`.
- **Tags.** Further `t` tags are the save's tags: 1-16 lowercase letters or
  digits, at most 20. Only the author changes them, by publishing the save
  again.
- **Validity.** A reader ignores an event without the `powder-toy` topic, a
  `d` outside `powder-toy/save/`, an empty title, or content that is not
  base64 or is longer than the size limit allows.

Unpublished ("private") saves are **not** published at all. The napplet keeps
them in the host's napplet storage on the player's device. There is no
encrypted variant: a napplet cannot decrypt, so it could never read one back.

## Vote: `kind:7` (NIP-25)

```json
{ "kind": 7, "content": "+", "tags": [
  ["a", "30078:<author>:<d>"], ["e", "<save event id>"], ["p", "<author>"], ["k", "30078"]
] }
```

`+` is up, `-` is down. A player's vote is their newest reaction to the
address that has not been deleted. Authors do not vote on their own saves.
Taking a vote back is a deletion of it (below).

## Comment: `kind:1111` (NIP-22)

```json
{ "kind": 1111, "content": "<text, up to 2000 characters>", "tags": [
  ["A", "30078:<author>:<d>"], ["K", "30078"], ["P", "<author>"],
  ["a", "30078:<author>:<d>"], ["e", "<save event id>"], ["k", "30078"], ["p", "<author>"]
] }
```

A comment on the save as a whole: root and parent are the save's address.
Filter `{"kinds": [1111], "#A": ["30078:<author>:<d>"]}`.

## Deletion: `kind:5` (NIP-09)

- **A save**, by its author: `["e", "<save event id>"]`, `["a", "30078:<author>:<d>"]`,
  `["k", "30078"]` and `["t", "powder-toy"]`. The topic tag lets readers fetch
  deletions with the saves (`{"kinds": [5], "#t": ["powder-toy"]}`). A reader
  hides every version of that address up to the deletion's `created_at`. A
  deletion signed by anyone but the author is ignored.
- **A vote**, by the voter: `["e", "<reaction id>"]`, `["k", "7"]`. It carries
  no `a` tag, because an `a` tag in a deletion means "delete this address" and
  the voter does not own it.

## Names

The game needs a username per author. The napplet derives one from the
author's kind-0 `name`, or `display_name` when there is none (letters, digits,
`_` and `-`, at most 16), followed by
`_` and the first four hex digits of the key, for example `alice_84bf`; without
a usable name it is `nym_` and the first eight hex digits. The suffix keeps
two authors with the same profile name apart. Names are display only; the
key is the identity.

## Save IDs

The game shows integer save IDs. The napplet derives them from the address
(32-bit FNV-1a of `30078:<author>:<d>`, lowest 31 bits, never 0), so a save
has the same ID for every player, and typing the ID into the game's search
finds it.