Add custom peer-to-peer logic to a React Native app
Replace hello-pear-worker's plain-string updater protocol with your own JSON protocol and a second Hyperswarm topic, using the real snake-mobile game as the worked example.
This guide extends Start from the hello-pear-react-native template—read that first if you haven't; this page assumes the template is already running and picks up exactly where it leaves off, at workers/main.js.
The worked example throughout is holepunchto/snake-mobile, a real P2P multiplayer snake game built on the template. Its worker does not use hello-pear-worker at all—it ships a custom workers/main.js with a second Hyperswarm topic and a small JSON protocol in place of the template's plain updater strings. That is exactly the shape most real apps end up in: keep the OTA half the template already gives you, and add your own swarm and protocol next to it.
What changes
| Layer | Change |
|---|---|
Worker (workers/main.js) | A second Hyperswarm (the game topic) joins hello-pear-worker's single updater swarm; a JSON message protocol (join/leave/send/applyUpdate in, ready/connected/disconnected/data/update/... out) replaces the plain updater strings. |
View (src/App.tsx) | Routes between a setup screen, a loading screen, and a game screen based on worker messages, instead of rendering one screen with just an update banner. |
package.json | No hello-pear-worker dependency—hyperswarm, corestore, and hypercore-crypto are used directly. |
Everything else is untouched: bundling with bare-pack, the updater swarm and drive, the minver gate, and where storage lives all work exactly as the template documents.
Require pear-mobile directly
hello-pear-worker does require('pear-runtime') and relies on its own package.json's conditional imports to redirect that specifier to pear-mobile on iOS, Android, and simulator hosts—see Where the app logic goes. A custom worker that only ever runs on mobile doesn't need that indirection:
const PearRuntime = require('pear-mobile') // pear-runtime on desktop; pear-mobile on mobile (see package.json "imports")snake-mobile's package.json carries a copy of the same imports block hello-pear-worker uses, but nothing in this app requires the bare specifier pear-runtime that it remaps, so the field has no effect here. Don't copy it into a mobile-only worker; requiring pear-mobile directly is simpler and is what this worker actually does.
Add a second Hyperswarm for the game topic
The updater swarm from hello-pear-worker stays exactly as-is. Alongside it, open a second Hyperswarm for the actual multiplayer topic, plus a joined variable holding the current topic:
const gameSwarm = new Hyperswarm()
// Only one game topic is ever joined at a time. `joined` holds it while a game
// is live and doubles as the gate below: leaving stops us announcing the topic,
// but a peer that is still in that game may hold our key and dial back in.
let joined = nulljoined doubles as a gate on the connection handler: a peer that connects before anyone has created or joined a game is dropped immediately rather than left dangling. Everything after that gate is the per-peer wiring that forwards traffic to the view:
gameSwarm.on('connection', (peer) => {
const id = b4a.toString(peer.remotePublicKey, 'hex').slice(0, 6)
if (joined === null) {
peer.on('error', () => {})
peer.destroy()
return
}
send({ type: 'connected', id })
peer.on('data', (message) => {
send({ type: 'data', id, payload: message.toString() })
})
peer.on('error', () => {
send({ type: 'disconnected', id })
})
peer.on('close', () => {
send({ type: 'disconnected', id })
})
})joined is null until joinGame sets it, and back to null once leaveGame runs—so this same check also rejects any peer that reconnects mid-leave.
Serialize join and leave
Only one game topic is ever joined at a time, and a fast leave-then-join from the UI must not race two discovery sessions for the same topic. enqueue chains every joinGame/leaveGame call onto one promise so they always run in order:
// Join and leave are serialised so that a quick Leave -> Join cannot leave two
// discovery sessions for the same topic racing each other.
let commands = Promise.resolve()
function enqueue(fn) {
commands = commands.then(fn).catch(console.error)
}The two functions it serializes:
async function joinGame(topicHex) {
await leaveGame()
const topicBuffer = topicHex ? b4a.from(topicHex, 'hex') : crypto.randomBytes(32)
const topic = b4a.toString(topicBuffer, 'hex')
const id = b4a.toString(gameSwarm.keyPair.publicKey, 'hex').slice(0, 6)
joined = topicBuffer
const discovery = gameSwarm.join(topicBuffer, { client: true, server: true })
await discovery.flushed()
send({ type: 'ready', id, topic })
}
// Stop announcing the topic and drop the peers it found. hyperswarm keeps
// connections open across leave(), so without the explicit destroy the peers of
// a game the player has left keep streaming their state, and rejoining that
// same topic never re-emits 'connection' for them — they stay invisible.
async function leaveGame() {
if (joined === null) return
const topic = joined
joined = null
await gameSwarm.leave(topic)
// snapshot: destroying removes the connection from the live set
for (const peer of [...gameSwarm.connections]) peer.destroy()
}The comment on leaveGame matters: Hyperswarm keeps connections open across leave(), so without the explicit peer.destroy() loop, peers from a game you've left keep streaming state at you—and rejoining that same topic later never re-emits 'connection' for them, since the connection never actually closed.
Extend the protocol
hello-pear-worker speaks plain strings ('updating', 'pear:applyUpdate', ...). A custom worker is free to speak whatever it wants over the same FramedStream-framed pipe—here, small JSON envelopes with a type field:
Hand-rolled envelopes like these are worth reaching for only while the protocol is small. Once it grows past a handful of messages, hrpc generates typed methods for both ends from one schema instead—see Structured RPC over IPC. It rides any duplex stream, including this worklet pipe.
pipe.on('data', async (data) => {
let msg = null
try {
msg = JSON.parse(data.toString())
} catch {
return
}
if (msg.type === 'join') {
enqueue(() => joinGame(msg.topic))
} else if (msg.type === 'leave') {
enqueue(() => leaveGame())
} else if (msg.type === 'send') {
for (const peer of gameSwarm.connections) {
peer.write(msg.data)
}
} else if (msg.type === 'applyUpdate') {
// Report failures back: without this a throw here is swallowed by the async
// handler and the banner sits on "Applying..." forever with no reason given.
try {
await pear.ready()
await pear.updater.applyUpdate()
send({ type: 'updateApplied' })
} catch (err) {
send({ type: 'updateFailed', error: err.message })
}
}
})The full message set, extending the plain template's table:
| Direction | Message | Meaning |
|---|---|---|
| view → worker | { type: 'join', topic } | Join a game (topic a hex string), or create one (topic: null). |
| view → worker | { type: 'leave' } | Leave the current game. |
| view → worker | { type: 'send', data } | Broadcast local game state to every connected peer. |
| view → worker | { type: 'applyUpdate' } | Apply a downloaded OTA update—same as the plain template. |
| worker → view | { type: 'ready', id, topic } | The swarm has flushed; the game can start. |
| worker → view | { type: 'connected', id } | A peer joined. |
| worker → view | { type: 'disconnected', id } | A peer dropped. |
| worker → view | { type: 'data', id, payload } | Game state from a peer. |
| worker → view | { type: 'update', connections } | The peer count changed. |
| worker → view | { type: 'updating' } / { type: 'updated' } / { type: 'minverRequired', minver } / { type: 'updateFailed', error } / { type: 'updateApplied' } | The same updater events the plain template sends as bare strings, now wrapped as typed messages so they can coexist with the game protocol on one pipe. |
Route the view
On the view side, the structural change from the plain template is routing between screens instead of rendering one screen with just an update banner. screen flips to 'game' once a ready message arrives, and back to 'setup' on leave:
{screen === 'setup' && <SetupScreen onCreate={createGame} onJoin={joinGame} />}
{screen === 'loading' && (
<View style={styles.centered}>
<Text style={styles.loading}>Loading ...</Text>
</View>
)}
{screen === 'game' && (
<GameScreen
game={game}
size={BOARD_SIZE}
topic={topic}
peers={peers}
over={over}
version={renderCount}
onDirection={handleDirection}
onLeave={leaveGame}
onPlayAgain={() => {
setOver(false)
game.reset()
}}
/>
)}The handleMessage switch that sets screen from each incoming worker message—and the createGame/joinGame/leaveGame functions that write the outgoing join/leave commands—are ordinary React state handling once the protocol above is in place; nothing about them is specific to Bare or Hyperswarm.
Where to go next
- Start from the hello-pear-react-native template—the template this guide extends.
- Pear Mobile OTA—the
pear-mobileAPI the updater half still runs on. - One core, many platforms—why the worker's peer-to-peer logic is portable regardless of what protocol it speaks.
- Structured RPC over IPC—replacing these hand-rolled JSON envelopes with schema-generated typed methods once the protocol grows past a few messages.
- Type a native RPC bridge—the
bare-rpcequivalent, for when one end is a native shell rather than JavaScript. - Handle app suspension—keep both Hyperswarm instances in step with the OS lifecycle.
holepunchto/snake-mobile—the full app, including the game engine, screens, and UI this guide doesn't cover.