Trystero
β¨π€β¨ Build instant multiplayer webapps, no server required β Magic WebRTC matchmaking over BitTorrent, Nostr, MQTT, IPFS, Supabase, and Firebase
Install / Use
/learn @dmotz/TrysteroREADME
β¨π€β¨ Trystero
Build instant multiplayer web apps, no server required
π Try it live on trystero.dev π
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, or πͺ IPFS β all using the same API.
Besides making peer matching automatic, Trystero offers some nice abstractions on top of WebRTC:
- ππ£ Rooms / broadcasting
- π’π© Automatic serialization / deserialization of data
- π₯π· Attach metadata to binary data and media streams
- βοΈβ³ Automatic chunking and throttling of large data
- β±π€ Progress events and promises for data transfers
- ππ Session data encryption
- πβ‘ Can run peers server-side on Node and Bun
- βοΈπͺ React hooks
You can see what people are building with Trystero here.
Contents
- How it works
- Get started
- Listen for events
- Broadcast events
- Audio and video
- Advanced
- API
- Which strategy should I choose?
How it works
π 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 "serverless" strategies for connecting peers (currently BitTorrent, Nostr, MQTT, Supabase, Firebase, and IPFS).
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.
Get started
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'
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 events
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}`)
Broadcast events
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 [sendDrink, getDrink] = room.makeAction('drink')
// buy drink for a friend
sendDrink({drink: 'negroni', withIce: true}, friendId)
// buy round for the house (second argument omitted)
sendDrink({drink: 'mezcal', withIce: false})
// listen for drinks sent to you
getDrink((data, peerId) =>
console.log(
`got a ${data.drink} with${data.withIce ? '' : 'out'} ice from ${peerId}`
)
)
If you're using TypeScript, you can add a type hint to the action:
type CursorMove = {x: number; y: number}
const [sendCursor, getCursor] = room.makeAction<CursorMove>('cursor-move')
You can also use actions to send binary data, like images:
const [sendPic, getPic] = room.makeAction('pic')
// blobs are automatically handled, as are any form of TypedArray
canvas.toBlob(blob => sendPic(blob))
// binary data is received as raw ArrayBuffers so your handling code should
// interpret it in a way that makes sense
getPic(
(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 [sendName, getName] = room.makeAction('name')
// tell new peers your name when they connect
room.onPeerJoin(peerId => sendName('Oedipa', peerId))
// listen for peers naming themselves
getName((name, peerId) => (idsToNames[peerId] = name))
// tell all peers at once when your name changes
nameInput.addEventListener('change', e => sendName(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.).
Audio and video
Here's a simple example of how you could create an audio chatroom:
// this object can store audio instances for later
const peerAudios = {}
// get a local audio stream from the microphone
const selfStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
})
// send stream to peers currently in the room
room.addStream(selfStream)
// send stream to peers who join later
room.onPeerJoin(peerId => room.addStream(selfStream, peerId))
// handle streams from other peers
room.onPeerStream((stream, peerId) => {
// create an audio instance and set the incoming stream
const audio = new Audio()
audio.srcObject = stream
audio.autoplay = true
// add the audio to peerAudios object if you want to address it for something
// later (volume, etc.)
peerAudios[peerId] = audio
})
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
})
Advanced
Binary metadata
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 [sendFile, getFile] = room.makeAction('file')
getFile((data, peerId, metadata) =>
console.log(
`got a file (${metadata.name}) from ${peerId} with type ${metadata.type}`,
data
)
)
// to send metadata, pass a third argument
// to broadcast to the whole room, set the second peer ID argument to null
sendFile(buffer, null, {name: 'The CourierΚΌs Tragedy', type: 'application/pdf'})
Action promises
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 sendFile(amplePayload)
console.log('done sending to all peers')
Progress updates
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:
sendFile(
payload,
// notice the peer target argument for any action sender can be a single peer
// ID, an array of IDs, or null (meaning send to all peers in the room)
[peerIdA, peerIdB, peerIdC],
// metadata, which can also be null if you're only interested in the
// progress handler
