DEV Community

Cover image for Effortless Serverless Multiplayer in Three.js with Trystero
bandinopla
bandinopla

Posted on

Effortless Serverless Multiplayer in Three.js with Trystero

Let’s say you want to do a quick prototype in ThreeJs and play with your friends, but you quickly realize how hard mutliplayer programming is :( Did you know that the multiplayer doesn’t have to be a headache? There’s a magical tool available to add multiplayer easy and free: Trystero to the rescue!

Trystero is a minimalist, open-source JavaScript library (MIT-licensed) that empowers you to build multiplayer web apps without needing your own server infrastructure. It handles peer matching using WebRTC and various decentralized signaling backends — like BitTorrent, Nostr, MQTT, IPFS, Supabase, and Firebase — so all your app data flows directly between clients, fully encrypted end-to-end. Trystero also offers developer-friendly abstractions like rooms, event broadcasting, data chunking, progress tracking, and even React hooks — making it a smooth fit for creating serverless multiplayer experiences with Three.js

And that’s exactly what I used for my multiplayer tech demo! which I was able to code in just a day thanks to how Trystero makes it!

A real world example

Instead of explaining what trystero is, I’ll show you how I used it to make a multiplayer demo, a real use-case. You can refer to their documentation for the details.

The demo I used is here. For this demo I automatically put a new player in a queue looking for a free room, let him/her interact and log them off after a few seconds and repeat the process. This is how I did it:

Work in layers!

So, before doing multiplayer stuff, you may first want to start with single player mindset, but in a way that the player’s input is not tied to the entity being moved. This means, always code first separating the logic for input from the logic for manipulating the avatar/entity. What does it means? It means, your entity should have an interface with the methods you want to be “controllable” clearly exposed and public. Think of your players or entities as a control board with buttons. You don’t care who press the buttons, a finger, a cat or a stick, you just care about what you do once a button is pressed.

interface IEntity {
   walkTo(targetPosition: Vector3, from?: Vector3):void
   sayHi():void
   wakeup(playerId: string):void;
   // etc...
}
Enter fullscreen mode Exit fullscreen mode

That way, your game entities have no idea of what input mechanic is used, they will just respond to their interface. And internally, you may use a simple state machine to handle the states, transitions, etc. The entity is input agnostic. And that’s what will make hooking trystero super easy.

The setup

At this point, we assume you have a “single player application”, and controllable entities implement some interface of your choosing. You did all the testing to make sure they work ( like for example, you can do a setTimeout and call entity.walkTo and see if it moves well and stops at destination, etc ) . Now you want to “hook” multiplayer capabilities!

So you install trystero:

npm install trystero
Enter fullscreen mode Exit fullscreen mode

Players connect directly through P2P with no server in between. But before that connection can happen, they need a way to discover each other. That’s where Trystero comes in, offering multiple signaling options. I chose the Firebase strategy since it felt simpler and more reliable for a quick demo.

The room

In Trystero, everything happens inside a “room.” You can think of it like a shared stage where all the players gather , every move, action, or event takes place within that space, and everyone present instantly sees what the others are doing.

import { joinRoom } from 'trystero/firebase' // because I chose Firebase...
//...
const serverConfig = { appId: "https://XXXXXXX.firebaseio.com" }; // because I chose Firebase strategy
const room = joinRoom( serverConfig, "mySuperFunRoom");
Enter fullscreen mode Exit fullscreen mode

How easy was that? “room” now is our interface to “the outside world”, it is what we will be using to “talk to the others” in the room.

Actions and Listeners

So we have a reference to a room. Now what? Well, logically, one would ask “what should I do now?” and that’s exactly what the room is expecting for you to do! Tell the room what can be done, and, if something is done, what to do in response? We do that like this:

These are the real hooks I used in the demo, a copy paste of them:

// these are extracted from my demo app to give a real life example:
const [askStatus, onSomeoneAskStatus] = this.room.makeAction<PeerId>("askstatus");
const [sendStatusOf, onStatusOf] = this.room.makeAction<PlayerStatus>("statusof"); 
const [sendPlayerMoved, onSomeoneMoved] = this.room.makeAction<MoveCommand>("move");
const [sendChat, onChat] = this.room.makeAction<string>("chat");
const [sendHi, onPlayerHi] = this.room.makeAction<null>("hi");
Enter fullscreen mode Exit fullscreen mode

Actions and response to actions are handled similar to react hooks. You call “room.makeAction” and it will return a tuple where the first element is the function that will trigger the action, and the second one is a function you will use to subscribe a callback to in case of that action being triggered by someone ( trystero will execute that callback when someone else, not you, triggers that action, because you already know when you do it, obviously)

A player has entered the room!

So we have an instance of our room, we are connected, and defined what can be done and how to respond to actions… how do we know when someone enters the room?

Meet: room.onPeerJoin(…)

//
// This will be called when someone joins the room
//
this.room.onPeerJoin(peerId => {   

    // instantiate a player and put it in "limbo"
    this.game.spawnPlayer( peerId, false );

    // get data of that player.... so we call the action "askStatus"
    askStatus( peerId, peerId ); 
});  
Enter fullscreen mode Exit fullscreen mode

Player joined, let’s setup!

In my demo, I did 2 things in that callback. I spawn the peer’s avatar (his player mesh) and then immediately after, I call a room function “askStatus” because someone else might know the player ( think of a player disconnecting due to his cat jumping on the keyboard and turning the PC off, he will lose connection, but other players will hold information of where he was, his items, etc… so just in case, we ask everyone in the room “hey, someone knows me?”)

 onSomeoneAskStatus( async (target, asker) => {

      if( target!=selfId ) return; // only the target can give the status, since each player is it's own source of truth

      // do I know the status of this target??
      const player = this.game.getPlayer( target );

      // I used "inLimbo" flag as a way to know if  player is still not active.
      if( player && !player.inLimbo )
      { 
          sendStatusOf( this.packPlayerStatus( player ) , asker);
      } 

      // else, it means we are not ready... ignore. 
  });
Enter fullscreen mode Exit fullscreen mode

So a player enters the room, I call askStatus and then on the listener for that action I check if we have info of that player, and if we do, we call the function “sendStatusOf” that will send the status of it…

//
// we got the status of a player
//
onStatusOf( (status, by)=> 
{
    let player = this.game.getPlayer( status[0] );

    if( !player ) return;

    if( player.inLimbo )
    {
        this.game.writeChat(`${status[4]} joined the room.`);
    }
    // here we bridge the trystero world with our world...
    this.game.updatePlayerStatus( status[0], {
        position: new Vector3( status[1], status[2], status[3] ),
        username: status[4]
    })
} ); 
Enter fullscreen mode Exit fullscreen mode

The “schizophrenic” mindset

Client and server development at the same time
Working with Trystero often requires a kind of “schizophrenic” mindset as a developer. In the same block of code, you’ll both emit actions and immediately need to handle those same actions as if they came from another client. It’s a strange dual perspective — you’re simultaneously the sender and the receiver, pretending you’re two different people using the app. This way of thinking is essential, because in a peer-to-peer system there’s no central authority to process events for you. Every action you fire off has to be treated as though it were broadcast by someone else, ensuring your local state stays in sync with the networked state.

The bridge

When building with Trystero, you quickly realize it doesn’t magically understand your app’s world — it only moves raw data between peers. That means you, as the developer, have to write the bridge: the layer that listens to Trystero events and translates them into meaningful changes in your scene. If a packet says a player moved or fired, your bridge code is what takes that data and updates your Three.js objects accordingly. In other words, Trystero gives you the network plumbing, but you’re responsible for mapping those signals into actions that make sense inside your 3D world.

play animation of

// bridge trystero's room action call to our ThreeJs scene
onPlayerHi( (ev, who)=>{

    // Trystero told us that a player is saying hi, so we look that player
    // in our scene, and trigger it's .hi() method!
    let sender = this.game.getPlayer(who);
    if( sender )
    { 
        sender.hi();
    } 
});
Enter fullscreen mode Exit fullscreen mode

It’s a dance between Trystero’s calls and calling our interface. Since we initially coded our game in a way that is input agnostic, it becomes almost trivial to hook trystero into it.

Player left the room

When a player leaves a room, trystero will call room.onPeerLeave

this.room.onPeerLeave( peerId => {

      // look for the player with that ID on our ThreeJs scene...
      let player = this.game.getPlayer(peerId);
      if(!player) return;

      // show the classic text in the chat's ui
      this.game.writeChat(`${player.username} left the room.`);

      // remove the player from our scene...
      this.game.removePlayer( peerId ); 

  });
Enter fullscreen mode Exit fullscreen mode

That’s where you will want to cleanup, return entities to pools, or dispose of them. Any entity representing that player should be removed.

Conclusion

Trystero makes multiplayer in the browser feel almost effortless. By handling the heavy lifting of peer-to-peer networking, it removes the need for servers, accounts, or complex backend setups — freeing you to focus entirely on your 3D experience. Combined with Three.js, it becomes an ideal toolkit for rapid prototyping: you can spin up real-time, collaborative scenes in just a few lines of code, without worrying about technical hurdles or ongoing server costs. Whether you’re experimenting with new gameplay ideas, building interactive art, or testing multiplayer mechanics, Trystero gives you the simplest path to bring your concepts to life.

Top comments (0)