✨✨ Build instant multiplayer webapps, no server required — Magic WebRTC matchmaking over BitTorrent, Nostr, MQTT, IPFS, Supabase, and Firebase
✨✨ Build instant multiplayer webapps, no server required — Magic WebRTC matchmaking over BitTorrent, Nostr, MQTT, IPFS, Supabase, and Firebase
Build instant multiplayer web apps, no server required
Trystero makes browsers discover each other and communicate directly. No accounts. No deploying infrastructure. Just import and connect.
Peers can connect via BitTorrent, Nostr, MQTT, ⚡️ Supabase, Firebase, IPFS, or a self-hosted WebSocket relay – all using the same API.
Besides making peer matching automatic, Trystero offers some nice abstractions on top of WebRTC:
You can see what people are building with Trystero here.
If you just want to try out Trystero, you can skip this explainer and jump into using it.
To establish a direct peer-to-peer connection with WebRTC, a signalling channel is needed to exchange peer information (SDP). Typically this involves running your own matchmaking server but Trystero abstracts this away for you and offers multiple strategies for connecting peers (currently BitTorrent, Nostr, MQTT, Supabase, Firebase, IPFS, and self-hosted WebSocket relay).
The important point to remember is this:
Beyond peer discovery, your app's data never touches the strategy medium and is sent directly peer-to-peer and end-to-end encrypted between users.
You can compare strategies here.
Install Trystero with your preferred package manager, then import it in your code:
npm i trystero
import {joinRoom} from 'trystero'
No package manager? You can also use a CDN:
<script type="module">
import {joinRoom} from 'https://esm.run/trystero'
</script>
The default Trystero package runs on the Nostr network, but you can swap in any other stategy by changing which package you import:
import {joinRoom} from '@trystero-p2p/mqtt'
// or
import {joinRoom} from '@trystero-p2p/torrent'
// or
import {joinRoom} from '@trystero-p2p/supabase'
// or
import {joinRoom} from '@trystero-p2p/firebase'
// or
import {joinRoom} from '@trystero-p2p/ipfs'
// or
import {joinRoom} from '@trystero-p2p/ws-relay'
Next, join the user to a room with an ID:
const config = {appId: 'san_narciso_3d'}
const room = joinRoom(config, 'yoyodyne')
The first argument is a configuration object that requires an appId. This
should be a completely unique identifier for your app¹. The second argument is
the room ID.
Why rooms? Browsers can only handle a limited amount of WebRTC connections at a time so it's recommended to design your app such that users are divided into groups (or rooms, or namespaces, or channels... whatever you'd like to call them).
¹ When using Firebase, appId should be your databaseURL and when using
Supabase, it should be your project URL.
Listen for peers joining the room:
room.onPeerJoin = peerId => console.log(`${peerId} joined`)
Listen for peers leaving the room:
room.onPeerLeave = peerId => console.log(`${peerId} left`)
Listen for peers sending their audio/video streams:
room.onPeerStream = (stream, peerId) =>
(peerElements[peerId].video.srcObject = stream)
To unsubscribe from events, leave the room:
room.leave()
You can access the local user's peer ID by importing selfId like so:
import {selfId} from 'trystero'
console.log(`my peer ID is ${selfId}`)
Send peers your video stream:
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
room.addStream(stream)
Send and subscribe to custom peer-to-peer actions:
const drink = room.makeAction('drink')
// buy drink for a friend
drink.send({drink: 'negroni', withIce: true}, {target: friendId})
// buy round for the house
drink.send({drink: 'mezcal', withIce: false})
// listen for drinks sent to you
drink.onMessage = (data, {peerId}) =>
console.log(
`got a ${data.drink} with${data.withIce ? '' : 'out'} ice from ${peerId}`
)
Actions can also use request/response semantics:
const isEven = room.makeAction('is-even', {
kind: 'request',
onRequest: n => n % 2 === 0
})
const result = await isEven.request(42, {
target: friendId,
timeoutMs: 1000
})
To ask multiple peers at once, use requestMany(). It resolves with a
peer-labeled result for every target, while onResult lets you react as each
peer answers:
const availability = room.makeAction('availability', {
kind: 'request',
onRequest: ({date}) => calendar.isFree(date)
})
const results = await availability.requestMany(
{date: '2026-05-04'},
{
targets: teammateIds,
timeoutMs: 1000,
onResult: result => {
if (result.status === 'fulfilled') {
updateAvailabilityBadge(result.peerId, result.value)
}
}
}
)
const freePeers = results
.filter(result => result.status === 'fulfilled' && result.value)
.map(result => result.peerId)
If you're using TypeScript, you can add a type hint to the action:
type CursorMove = {x: number; y: number}
const cursor = room.makeAction<CursorMove>('cursor-move')
You can also use actions to send binary data, like images:
const pic = room.makeAction('pic')
// blobs are automatically handled, as are any form of TypedArray
canvas.toBlob(blob => pic.send(blob))
// binary data is received as raw ArrayBuffers so your handling code should
// interpret it in a way that makes sense
pic.onMessage = (data, {peerId}) =>
(imgs[peerId].src = URL.createObjectURL(new Blob([data])))
Let's say we want users to be able to name themselves:
const idsToNames = {}
const name = room.makeAction('name')
// tell new peers your name when they connect
room.onPeerJoin = peerId => name.send('Oedipa', {target: peerId})
// listen for peers naming themselves
name.onMessage = (value, {peerId}) => (idsToNames[peerId] = value)
// tell all peers at once when your name changes
nameInput.addEventListener('change', e => name.send(e.target.value))
room.onPeerLeave = peerId =>
console.log(`${idsToNames[peerId] || 'a weird stranger'} left`)
Actions are smart and handle serialization and chunking for you behind the scenes. This means you can send very large files and whatever data you send will be received on the other side as the same type (a number as a number, a string as a string, an object as an object, binary as binary, etc.).
Here's a simple example of how you could create an audio chatroom:
…
Doing the same with video is similar, just be sure to add incoming streams to video elements in the DOM:
const peerVideos = {}
const videoContainer = document.getElementById('videos')
room.onPeerStream = (stream, peerId) => {
let video = peerVideos[peerId]
// if this peer hasn't sent a stream before, create a video element
if (!video) {
video = document.createElement('video')
video.autoplay = true
// add video element to the DOM
videoContainer.appendChild(video)
}
video.srcObject = stream
peerVideos[peerId] = video
}
Let's say your app supports sending various types of files and you want to annotate the raw bytes being sent with metadata about how they should be interpreted. Instead of manually adding metadata bytes to the buffer you can simply pass a metadata argument in the sender action for your binary payload:
const file = room.makeAction('file')
file.onMessage = (data, {peerId, metadata}) =>
console.log(
`got a file (${metadata.name}) from ${peerId} with type ${metadata.type}`,
data
)
file.send(buffer, {
metadata: {name: 'The Courierʼs Tragedy', type: 'application/pdf'}
})
Action sender functions return a promise that resolves when they're done sending. You can optionally use this to indicate to the user when a large transfer is done.
await file.send(amplePayload)
console.log('done sending to all peers')
Action sender functions also take an optional callback function that will be continuously called as the transmission progresses. This can be used for showing a progress bar to the sender for large transfers. The callback is called with a percentage value between 0 and 1 and the receiving peer's ID:
file.send(payload, {
target: [peerIdA, peerIdB, peerIdC],
metadata: {filename: 'paranoids.flac'},
onProgress: (percent, {peerId}) => (loadingBars[peerId].value = percent)
})
Similarly you can listen for progress events as a receiver like this:
const file = room.makeAction('file')
file.onReceiveProgress = (percent, {peerId, metadata}) =>
console.log(
`${percent * 100}% done receiving ${metadata.filename} from ${peerId}`
)
Notice that any metadata is sent with progress events so you can show the receiving user that there is a transfer in progress with perhaps the name of the incoming file.
Since a peer can send multiple transmissions in parallel, you can also use metadata to differentiate between them, e.g. by sending a unique ID.
Once peers are connected to each other all of their communications are
end-to-end encrypted. During the initial connection / discovery process, peers'
SDPs are sent via
the chosen peering strategy medium. By default the SDP is encrypted using a key
derived from your app ID and room ID to prevent plaintext session data from
appearing in logs. This is fine for most use cases, however a relay strategy
operator can reverse engineer the key using the room and app IDs. A more secure
option is to pass a password parameter in the app configuration object which
will be used to derive the encryption key:
joinRoom({appId: 'kinneret', password: 'MuchoMaa$'}, 'w_a_s_t_e__v_i_p')
This is a shared secret that must be known ahead of time and the password must match for all peers in the room for them to be able to connect. An example use case might be a private chat room where users learn the password via external means.
Trystero functions are idempotent so they already work out of the box as React hooks.
Here's a simple example component where each peer syncs their favorite color to everyone else:
…
Astute readers may notice the above example is simple and doesn't
No open issues yet, or sync has not completed.