# BYOC Worlds: temporary social software on one page

BYOC connects browsers: one tab hosts, friends join by link. **Worlds** is an
optional layer on top that turns a room into a *place*: something that
begins, has people with world-local names, has doors, shared state and rules,
**ends on purpose**, and leaves **relics**: signed fragments that each person
keeps on their own device after the world is gone.

> The network connects people. The world gives those connections form.
> The ending gives the world shape. Culture determines what survives.

Nothing here needs an account, a database or a server of yours. The operator
sees exactly what it sees for a plain BYOC room (Worlds is ordinary
application code over BYOC: a Room, its Connections, and a `world` channel on
each). No world state, names, messages or relics go to the operator.

If you only need "people in a room", use plain BYOC (`docs/llm.md`). Use
Worlds when the lifespan, the people, or what survives is part of the design.

## Import

```js
import { createWorld, joinWorld, peekWorld, shelf, formatRemaining } from 'https://byoc.infinitefun.com/v0/world.js';
```

`world.js` imports `./byoc.js` next to it (one shared copy of the core). In
npm terms: `@infinitefun/byoc/world`.

## Pattern 1: one HTML file, host or guest

```js
import { createWorld, joinWorld, formatRemaining } from 'https://byoc.infinitefun.com/v0/world.js';

let me;                                           // your presence in the world (a WorldGuest)
if (location.hash.includes('byoc=')) {
  me = await joinWorld(location.href, { name });  // guest
} else {
  const world = await createWorld({ name: 'Tuesday Night', lifespan: '90m' });
  world.onparticipant = ({ participant }) => participant.send(JSON.stringify({ type: 'hello' }));
  world.onmessage = ({ participant, data }) => world.broadcast(data);   // the rules
  world.onend = async ({ participants }) => { /* give relics */ };
  me = await world.enter({ name });               // the host is a participant too
  share(world.link);
}
me.onmessage = (e) => render(JSON.parse(e.data));
me.onending = ({ remaining }) => startClosingRitual(remaining);
me.onended = ({ tombstone }) => showRuins(tombstone, me.relics);
setInterval(() => clock.textContent = formatRemaining(me.remaining ?? 0), 250);
me.send(JSON.stringify({ type: 'say', text: 'hi' }));
```

Keep the host logic (the "server": `world.on…`) apart from the client logic
(`me.…`), even though they share the host's tab: guests run only the client.

## createWorld(options) → Promise<WorldHost>

Runs in the host's tab. **A reload resumes the living world in the same
`slot`** (same id, link, participants, shared state, end time). A world that
died while the page was away (its time passed, or `whenHostLeaves`) is buried
(tombstone on the host's shelf, room erased), `world.previous` holds its
tombstone, and a new world is made.

| Option | Default | Meaning |
|---|---|---|
| `name` | `'a world'` | What people call it. Public: it's in the link |
| `about` | none | One public line (≤ 280 chars), for the door screen |
| `slot` | `'world'` | Which world this page hosts (one page can host several) |
| `lifespan` | none | `'90m'`, `'1h30m'`, `'45s'`, or ms. The world ends then |
| `endsAt` | none | Or an absolute end (ms since epoch) |
| `ending` | ⅕ of the lifespan, ≤ 5 min | How long the ending phase lasts |
| `inactivity` | none | End after this long without messages or arrivals |
| `whenEmpty` | off | `true` (30 s grace) or a duration: end when everyone but the host has left |
| `whenHostLeaves` | off | `true` (60 s grace) or a duration: the world dies with the host's tab (a quick reload is fine) |
| `capacity` | unlimited | At most this many present at once (the host's presence counts) |
| `doors` | `'open'` | `'open'` (link), `'invite'` (valid invitation), `'closed'` (only people already in) |
| `closeDoorsWhenEnding` | `false` | No newcomers during the ending phase |
| `final` | `false` | The end is fixed: `extend`/`endAt`/`endIn` throw |
| `shared` | `{}` | Initial shared state |
| `admit({ id, name, info, role, invitedBy })` | admit | Return `false` or a reason string to refuse a newcomer |
| `relicTimeout` | `5000` | How long the end waits for the `onend` hook and relic delivery |
| `fresh` | `false` | Bury whatever is in the slot and start a new world |
| `operators`, `debug`, `baseUrl`, `storage`, … | | Passed to BYOC's `host()` |

Several end conditions can be set; the earliest wins. With none, the world
lives until `world.end()` (or forever, as long as the host keeps it open).

### WorldHost

| Member | Meaning |
|---|---|
| `world.link` | Share this: the room link plus the world's signed charter (`#byoc=…&world=…`) |
| `world.id`, `name`, `about`, `bornAt` | Facts. `id` is the hash of the world's key |
| `world.phase` | `'open'` → `'ending'` → `'ended'` (inactivity/empty endings can go back to `'open'`) |
| `world.endsAt`, `world.remaining` | When it ends (ms since epoch; host clock) and ms left; `null` if nothing ends it |
| `world.participants` | Everyone ever in it (`Participant`s), `world.present` who's here now, `world.get(id)` |
| `world.onparticipant = ({ participant, returning }) => …` | Someone arrived (first time, or back: after a reload, the next day, or after a host reload) |
| `world.onleave = ({ participant }) => …` | Someone left |
| `world.onmessage = ({ participant, data }) => …` | An app message (string or ArrayBuffer) |
| `world.broadcast(data, { except })` | To everyone present |
| `world.share(key, value)` / `world.shared` | Public shared state, replicated to everyone, survives host reloads. `undefined` deletes |
| `world.private` + `await world.save()` | Host-only state that survives host reloads and dies with the world. `save()` batches for 50 ms; await it when a change must survive an immediate reload (pending saves are also flushed on `pagehide`) |
| `world.setRole(p, role)` | Visible roles (`'host'`, `'guest'`, or anything: `'mayor'`, `'ghost'`) |
| `world.remove(p, reason)` | Out for good |
| `world.setDoors('open' \| 'invite' \| 'closed')` | Who may come in. People already in can always come back |
| `await world.enter({ name, info })` | The host's own presence (a `WorldGuest` over a loopback, role `'host'`) |
| `world.onphase`, `world.onending = ({ endsAt, remaining, reason }) => …` | Lifecycle, host side |
| `world.onend = async ({ reason, participants, present, shared }) => …` | Runs as the world ends, before anyone is disconnected: make relics here |
| `world.onended = ({ tombstone }) => …` | It's over: everyone's out, the room's key and records are erased |
| `await world.end(reason?, { successor })` | End now (idempotent; resolves to the tombstone) |
| `world.endIn('5m')`, `world.endAt(ts)`, `world.extend('10m')` | Move the end (not if `final`) |
| `await world.invites.create({ uses, expiresIn, role, by })` | A signed invitation → `{ id, link, uses, used, expiresAt, role, by }` |
| `world.invites.revoke(id)`, `world.invites.list()` | |
| `await world.relics.give(p, { kind, name, data, note })` | Give someone a relic now (delivered when present) |
| `world.relics.distribute(({ participant, present, participants, shared }) => spec \| spec[] \| null)` | Relics made for each person present at the end. To plan a split across people, compute it once in `onend` (which has `present`) and look it up here |
| `await world.fork({ name, lifespan, keep: ['rules'], participants: [ids] \| 'present', role, slot })` | A successor world in this tab. Copies only the `keep` shared keys; chosen people get `onsuccessor` with a one-use link (also kept on their tombstone as `successorLink`). Without `slot`, it's this world's **heir** (`world.successor`): a reload brings both back, and when this world ends the heir takes over its slot, so `createWorld({ slot })` resumes the current generation. It also becomes the tombstone's `successor`, even when forked inside `onend` |
| `world.oncarry = ({ participant, relic, kind, worldId, token }) => …` | Someone brought a relic in from another world (`relic` is null if it didn't verify) |
| `world.previous` | Tombstone of the slot's previous world, if it died while this page was away |
| `world.room` | The BYOC `Room` underneath |

`Participant`: `id` (stable for this person in this world, from their
world-local key), `name`, `role`, `isHost` (the host's own presence, whatever
its role), `joinedAt`, `invitedBy`, `inviteId` (which invitation let them in), `info`,
`present`, `connection` (the BYOC Connection while present: channels,
introductions, `peer.path`), `send(data)`.

## joinWorld(link?, options) → Promise<WorldGuest>

Comes in through a link (default: this page's URL). Options: `name`, `info`,
`invite` (if not in the link), `keepRelics` (default `true`), and anything
BYOC's `connect()` takes (`operators`, `debug`, `wait`, …).

Rejects with a `WorldError` `{ code, message, hint, reason, tombstone }`:

- `'world-ended'`: the world is over. `tombstone` says when and why (signed if
  this device saw the end; otherwise inferred from the link's charter).
- `'world-refused'`, `reason`: `'doors-closed'`, `'invite-required'`,
  `'invite-invalid'`, `'invite-expired'`, `'invite-used'`, `'full'`,
  `'removed'`, `'declined'` (or the `admit` hook's own string).
- `'bad-world'`: the link's charter is forged or for another world.

…or a BYOC `ByocError` (`host-offline`, `bad-proof`, …). Show `e.message`.

`await peekWorld(link?)` reads the link without connecting: `{ worldId, name,
about, bornAt, endsAt, final, endsWhenHostLeaves, authority, ended, invite }`
(`ended` is a tombstone if this device saw it end). Use it for the door
screen: "Tuesday Night ends at 10:30 PM. It has an invitation for one."

### WorldGuest (you, inside)

| Member | Meaning |
|---|---|
| `me.send(data)`, `me.onmessage = (e) => e.data` | App messages to/from the host (kept until you set `onmessage`) |
| `me.myId`, `me.me` | Your id and your `{ id, name, role, present, joinedAt, invitedBy, host }` (`participants` entries have the same shape) |
| `me.participants`, `me.present`, `me.onparticipants` | Who's in the world |
| `me.shared`, `me.onshared = ({ key, value }) => …` | Shared state |
| `me.phase`, `me.endsAt`, `me.endingAt`, `me.remaining`, `me.now()` | The world's clock is the host's; these are corrected for clock skew |
| `me.onphase`, `me.onending = ({ endsAt, remaining }) => …` | `onending` fires on the change; someone who arrives during the ending phase should check `me.phase` after joining |
| `me.onended = ({ tombstone }) => …` | The end. Relics are already on your shelf |
| `me.onrelic = ({ relic }) => …`, `me.relics` | Relics given to you in this visit |
| `me.onsuccessor = ({ link, name }) => …` | You're invited into the next world |
| `me.onresync` | The host reloaded; everything was re-sent. Ask for app state again if you need it |
| `me.onclose = ({ code, reason }) => …` | You're out but the world didn't end (left, removed, or the host vanished) |
| `me.identify({ name })` | Rename yourself |
| `me.carry(relic)` | Bring a relic from another world in (the host gets `oncarry`) |
| `me.leave()` | Leave on purpose |
| `me.channel(name)`, `me.connection` | BYOC channels / the Connection underneath |
| `me.name`, `me.about`, `me.id`, `me.bornAt` | The world's facts |

## Relics and the shelf

A **relic** is `{ id, kind, name, data, note, world: { id, name }, to: { id,
name }, givenAt, token, receivedAt }`. `kind`: `'text'` (string), `'json'`,
`'image'`/`'audio'`/`'file'` (a `data:` URL), `'html'` (a string; show it in a
sandboxed iframe). Up to ~700 KB each. `token` is signed by the world: anyone
can check it offline, forever, with `verifyRelic(relic)`.

The **shelf** is what this device keeps from worlds (IndexedDB, per site):

```js
import { shelf, verifyRelic, verifyTombstone } from '…/world.js';
await shelf.relics();          // every relic on this device, newest first
await shelf.tombstones();      // worlds this device saw end
shelf.exportRelic(relic);      // → JSON text for a download
await shelf.importRelic(text); // verifies, then keeps it (throws if forged)
await shelf.discard(relic);    // gone; there is no other copy
```

There is no central archive. The host doesn't keep relics after the end; the
operator never sees them. **Undelivered relics are lost** when the world ends
(someone who isn't present at the end gets nothing) — digital ruins by design.

A **tombstone** `{ worldId, name, bornAt, endedAt, reason, participants,
successor, token }` is signed proof that a world existed and ended. `reason`:
`'time'`, `'inactivity'`, `'empty'`, `'host'` (ended by `end()`),
`'host-left'`, or the app's own string. An *observed* tombstone (no `token`)
is what a guest saw when the host vanished.

## What happens when…

- **The host reloads:** the world resumes (same link, same end, same people;
  guests get `onresync`). The host's app state is gone unless it's in
  `world.shared` / `world.private`.
- **The host closes the tab:** by default the world waits (guests see
  `onclose`; the world resumes when the host opens the page again, unless its
  time ran out meanwhile). With `whenHostLeaves`, it's dead after the grace.
- **The end comes:** `onend` hook → `distribute` → relics delivered (waits up
  to `relicTimeout` for each to be kept) → signed tombstone to everyone →
  connections closed (code 4100) → the room's identity key, the world's key
  and all its records are erased from the host's device. The link can never
  work again, from any tab.
- **Someone opens a dead link:** `joinWorld` rejects with `'world-ended'`
  immediately if their device saw the end; otherwise after `host-offline`, if
  the link's charter says its time has passed.
- **A guest reloads or closes the tab and comes back:** same participant (a
  per-world key on their device). Two tabs on one device are two
  participants. A different device, or the same device in a different world,
  is someone else: worlds can't link people.
- **Clocks disagree:** the host's clock is the world's. Guests measure the
  offset; `remaining` and `now()` use it.

## Trust (say this plainly in your world)

- BYOC protects transport: the operator can't read or forge anything, and
  guests verify the host's key.
- **The host is the authority.** Its tab runs the rules and sees everything
  guests send. The charter says so (`authority: 'host'`). Don't present a
  world as fair to the host.
- A world's signed statements (charter, invitations, relics, tombstones)
  prove *which world* said something, not that the world's rules were fair.
- Guests learn nothing about each other beyond what the host sends.

## Patterns for temporary worlds

1. **A world that ends in 5 minutes:** `createWorld({ lifespan: '5m' })`, show
   `formatRemaining(me.remaining)`, and design the `ending` phase: dim the
   lights, lock actions, start a ritual in `onending`.
2. **Local identity without accounts:** use `participant.id` as the seat key.
   Names are world-local; let people rename (`identify`) and invent roles.
3. **One-use invitations:** `doors: 'invite'`, then
   `world.invites.create({ uses: 1, by: participant })` when someone asks.
   "Everyone gets exactly two invitations" is one line of host logic; the
   `invitedBy` tree it grows is part of the culture.
4. **Visible authority:** show who the host is (`role === 'host'`) and what
   they can do. Let the host hand out roles.
5. **Closing ceremonies:** `onending` on everyone's side; the host can
   `closeDoorsWhenEnding`, and `onend` is the last word.
6. **Uneven memory:** `world.relics.distribute(({ participant }) => …)` so
   different people keep different things, and some keep nothing.
7. **No persistence at all:** don't give relics. The tombstone is all that's
   left.
8. **Successor worlds:** `world.fork({ keep: ['rules'], participants: 'present' })`
   and `world.end('season over', { successor })`: keep the rules and these
   people, drop the history, change one assumption.
9. **Carrying things between worlds:** a guest `carry(relic)`s; the host's
   `oncarry` verifies it came from a real world, and decides what it means
   here. Worlds talk without becoming the same world.
10. **Revival by activity:** `inactivity: '10m'`: the world lives while people
    keep it alive, and goes into its ending phase when they don't.

## Pitfalls

- Call `world.enter()` for the host to take part; it's not automatic.
- The host tab must stay open and visible (BYOC's rule): hidden tabs are
  throttled, so the world's clock ticks late there. Guests see `host-hidden`.
- `world.shared` is for small state (it's sent whole to each newcomer).
  Stream big or fast state with app messages.
- Messages on the main channel and the world's own messages can arrive in
  either order; `joinWorld` resolves after you're inside, so app messages
  never beat your welcome to your code.
- Timed ends are checked once a second on the host.
