# Host multiple rooms in one chat app





This guide shows you how to **extend [`pear-chat`](https://github.com/holepunchto/pear-docs/tree/preview/examples/getting-started/pear-chat) from a single room to an account model that owns and joins many rooms**. The reference implementation is [`pear-chat-multi-rooms`](https://github.com/holepunchto/pear-docs/tree/preview/examples/how-to/connect-to-peers/multi-rooms).

<Callout type="info">
  **This guide is about the Pear-end, not the shell.** The code below lives in the Bare [worker](/pear/explanation/workers)—the peer-to-peer logic, not the user interface. Because the Pear-end never imports DOM APIs and never assumes a UI framework, the same worker is portable across **desktop (Electron)**, **mobile (React Native via Bare iOS / Bare Android)**, and **terminal**. The example apps ship an Electron shell, but only the UI half changes per platform—the logic here stays the same. See [Runtime and languages](/pear/explanation/runtime-and-languages) for the cross-platform model and current support.
</Callout>

This is a **delta-only** how-to. The shared scaffold is explained in the [Reshape into a production app](/p2p/getting-started/build-a-peer-to-peer-chat/reshape-into-a-production-app) tutorial—read it first.

## Before you begin [#before-you-begin]

* A working clone of `pear-chat` (or your own app built from the getting-started path).
* Familiarity with [Autobase](/p2p/reference/building-blocks/autobase)—each room is one Autobase.

## What changes [#what-changes]

| Layer     | Change                                                                                                                                        |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Worker    | Introduce a `ChatAccount` that holds a map of `ChatRoom`s keyed by a generated room id.                                                       |
| Schema    | Add a `room` collection persisting the rooms a user has joined (`{ id, name, invite, info }`).                                                |
| Transport | Add `add-room` and `join-room` message types, push a `rooms` event, and make the `messages` event and `add-message` command carry a `roomId`. |
| Renderer  | Add a left-rail room list with a "new room" button and an invite-paste input.                                                                 |

The Electron shell, the build/forge configuration, and the per-room ChatRoom code stay identical to the getting-started chat app.

## Steps [#steps]

<Steps>
  <Step>
    ### Replace `room` with a `ChatAccount` on `WorkerTask` [#replace-room-with-a-chataccount-on-workertask]

    In `workers/worker-task.js`, swap the single `ChatRoom` for a `ChatAccount` (L6). The worker keeps the same [Corestore](/p2p/reference/helpers/corestore) + [Hyperswarm](/p2p/reference/building-blocks/hyperswarm) setup (L17–L19), but delegates all room management to the account (L21) and forwards its `messages` event over the worker pipe as JSON tagged with the `roomId` (L24–L26).

    `_open` opens the account (L30–L31), then handles `add-room`, `join-room`, and `add-message` on the pipe (L44–L50). `_close` tears down account → swarm → store (L55–L59):

    ```js file=<rootDir>/examples/how-to/connect-to-peers/multi-rooms/workers/worker-task.js title="workers/worker-task.js" lineNumbers {6,17-19,21-26,30-31,44-50,55-59} skip="example-import"
    const Corestore = require('corestore')
    const debounce = require('debounceify')
    const Hyperswarm = require('hyperswarm')
    const ReadyResource = require('ready-resource')

    const ChatAccount = require('./chat-account')

    class WorkerTask extends ReadyResource {
      constructor (pipe, storage, opts = {}) {
        super()

        this.pipe = pipe
        this.storage = storage
        this.invite = opts.invite
        this.name = opts.name || `User ${Date.now()}`

        this.store = new Corestore(storage)
        this.swarm = new Hyperswarm()
        this.swarm.on('connection', (conn) => this.store.replicate(conn))

        this.account = new ChatAccount(this.store, this.swarm)
        this.debounceRooms = debounce(() => this._rooms())
        this.account.on('update', () => this.debounceRooms())
        this.account.on('messages', (roomId, messages) => {
          this.pipe.write(JSON.stringify({ type: 'messages', roomId, messages }))
        })
      }

      async _open () {
        await this.store.ready()
        await this.account.ready()

        if (this.invite) {
          await this.account.joinRoom(this.invite)
        }

        this.pipe.on('data', async (data) => {
          let message
          try {
            message = JSON.parse(data)
          } catch {
            return
          }
          if (message.type === 'add-room') {
            await this.account.addRoom(message.name, { at: Date.now() })
          } else if (message.type === 'join-room') {
            await this.account.joinRoom(message.invite)
          } else if (message.type === 'add-message') {
            await this.account.addMessage(message.roomId, message.text, { name: this.name, at: Date.now() })
          }
        })
        await this.debounceRooms()
      }

      async _close () {
        await this.account.close()
        await this.swarm.destroy()
        await this.store.close()
      }

      async _rooms () {
        const rooms = Object.entries(this.account.rooms).map(([id, room]) => ({
          id,
          name: room.name,
          invite: room.invite,
          info: room.info
        }))
        // A just-joined room has no `info` yet — it only arrives once its
        // ChatAccount 'update' syncs the room metadata from the peer who created
        // it (see joinRoom in chat-account.js). If _rooms() runs in that window,
        // `.info` is undefined and a bare `.at` access throws, taking the whole
        // worker down.
        rooms.sort((a, b) => (a.info?.at ?? 0) - (b.info?.at ?? 0))
        this.pipe.write(JSON.stringify({ type: 'rooms', rooms }))
      }
    }

    module.exports = WorkerTask
    ```
  </Step>

  <Step>
    ### Build `ChatAccount` [#build-chataccount]

    Create `workers/chat-account.js` modelled on `chat-room.js`. The account is itself an [Autobase](/p2p/reference/building-blocks/autobase)-backed HyperDB that stores the user's room list:

    * each entry is `{ id, name, invite, info }`, where `id` is a generated handle and `invite` is the room's pairing code.
    * On `_open`, the account opens its base, then `openRooms()` materialises one `ChatRoom` per stored entry, each in its own Corestore namespace (`this.store.namespace(id)`) (L156–L168).
    * `addRoom` (L170–L184) and `joinRoom` (L186–L206) spin up a new `ChatRoom`, then append the room metadata to the account base so it survives restarts:

    ```js file=<rootDir>/examples/how-to/connect-to-peers/multi-rooms/workers/chat-account.js#L156-L206 title="workers/chat-account.js" lineNumbers {1-13,15-29,31-51} skip="example-import"
      async openRooms () {
        const rooms = await this.view.find('@pear-chat-multi-rooms/rooms', { reverse: true, limit: 100 }).toArray()
        await Promise.all(rooms.map(async (item) => {
          const roomStore = this.store.namespace(item.id)
          const room = new ChatRoom(roomStore, this.swarm, { name: item.name, info: item.info, invite: item.invite })
          this.rooms[item.id] = room

          this._watchMessages(item.id)
          await room.ready()

          await this._messages(item.id)
        }))
      }

      async addRoom (name, info) {
        const id = Math.random().toString(16).slice(2)

        const roomStore = this.store.namespace(id)
        const room = new ChatRoom(roomStore, this.swarm, { name, info })
        this.rooms[id] = room

        this._watchMessages(id)
        await room.ready()

        await room.addRoomInfo()
        await this.base.append(
          ChatDispatch.encode('@pear-chat-multi-rooms/add-room', { id, name: room.name, invite: room.invite, info: room.info })
        )
      }

      async joinRoom (invite) {
        const id = Math.random().toString(16).slice(2)

        const roomStore = this.store.namespace(id)
        const room = new ChatRoom(roomStore, this.swarm, { invite })
        this.rooms[id] = room

        room.on('update', async () => {
          const remoteRoom = await room.getRoomInfo()

          if (remoteRoom && remoteRoom.name !== room.name) {
            room.name = remoteRoom.name
            room.info = remoteRoom.info
            await this.base.append(
              ChatDispatch.encode('@pear-chat-multi-rooms/add-room', { id, name: room.name, invite, info: room.info })
            )
          }
        })
        this._watchMessages(id)
        await room.ready()
      }
    ```

    The key change: each room is an independent Autobase in its own namespace, so the account never co-mingles room data. The account base only stores room metadata (id, name, invite, info), encrypted by the account's own Autobase. See [Storage and distribution](/pear/explanation/storage-and-distribution) for the underlying mental model.
  </Step>

  <Step>
    ### Extend the schema [#extend-the-schema]

    This step touches all three builders in `schema.js`, so it is easiest to follow the full [`pear-chat-multi-rooms` `schema.js`](https://github.com/holepunchto/pear-docs/blob/preview/examples/how-to/connect-to-peers/multi-rooms/schema.js). The changes are:

    1. **Rename the namespace** from `pear-chat` to `pear-chat-multi-rooms` in all three `.namespace(...)` calls (schema, db, dispatch). The type references below (`@pear-chat-multi-rooms/...`) resolve against this name—if you leave the namespace as `pear-chat`, `node schema.js` throws `TypeError: Cannot read properties of undefined (reading 'frameable')` because the referenced type does not exist.
    2. **Register** a `room` schema (`{ id, name, invite, info }`), plus a `rooms` HyperDB collection and an `add-room` HyperDispatch entry.

    The roomId that the renderer uses to address a specific room is carried on the plain-JSON messages exchanged over the worker pipe, so the schema only needs to persist the room metadata. The new `room` registration looks like this (L26–L34):

    ```js file=<rootDir>/examples/how-to/connect-to-peers/multi-rooms/schema.js#L26-L34 title="schema.js" lineNumbers {1-9} skip="example-import"
    schema.register({
      name: 'room',
      fields: [
        { name: 'id', type: 'string', required: true },
        { name: 'name', type: 'string', required: true },
        { name: 'invite', type: 'string', required: true },
        { name: 'info', type: 'json' }
      ]
    })
    ```

    Regenerate `spec/` from a **clean** directory:

    ```bash skip="desktop-gui"
    rm -rf spec && npm run build:db
    ```

    <Callout type="warning">
      Delete `spec/` before regenerating. The schema generators (`hyperschema`, `hyperdispatch`, `hyperdb`) **merge** into the existing manifests rather than overwriting them, so regenerating on top of the old `pear-chat` spec leaves stale registrations next to the new `pear-chat-multi-rooms` ones—the generated `spec/` then carries duplicate definitions.
    </Callout>
  </Step>

  <Step>
    ### Update the renderer [#update-the-renderer]

    In [`renderer/index.html`](https://github.com/holepunchto/pear-docs/blob/preview/examples/how-to/connect-to-peers/multi-rooms/renderer/index.html) and [`renderer/app.js`](https://github.com/holepunchto/pear-docs/blob/preview/examples/how-to/connect-to-peers/multi-rooms/renderer/app.js), split the layout into a left rail (the room list) and a right pane (the active room).
    The renderer:

    1. sends `{ type: 'add-room', name }` and `{ type: 'join-room', invite }` over the worker pipe,
    2. renders the left rail from the `{ type: 'rooms', rooms }` events the worker pushes,
    3. the existing message send becomes `{ type: 'add-message', text, roomId }`, and
    4. incoming `{ type: 'messages', roomId, messages }` events are filed under their `roomId`, so the renderer just shows the selected room's messages.
  </Step>
</Steps>

## Run it [#run-it]

```bash skip="desktop-gui"
npm run build
npm start -- --storage /tmp/multi-user1 --name user1
```

Type a room name and click **Create**. Select the room in the left rail, then click **Copy invite** in the room header to copy the **room invite** (not the `Account Invite` line in the terminal—that pairs a whole account, not a single room). In a second terminal:

```bash skip="desktop-gui"
npm start -- --storage /tmp/multi-user2 --name user2 --invite <room-invite>
```

Or start without `--invite` and paste the room invite into the join field in the UI.

user2's app shows the joined room in its left rail. Both peers can now create or join additional rooms—each one is an independent [Autobase](/p2p/reference/building-blocks/autobase) with its own pairing flow.

## Where to go next [#where-to-go-next]

* [Add blind peering to a chat app](/p2p/how-to/blind-peering/add-blind-peering-to-a-chat-app)—keep individual rooms reachable when their writers are offline.
* [Work with many Hypercores using Corestore](/p2p/how-to/store-and-replicate/work-with-many-hypercores-using-corestore)—the pattern that makes hosting many rooms in one app cheap.
* [Storage and distribution](/pear/explanation/storage-and-distribution)—why each room can be its own [Autobase](/p2p/reference/building-blocks/autobase) without paying per-room infrastructure costs.
