triggair

Blog / realtime

Real-time multiplayer with zero servers

3 min read

Real-time multiplayer with zero servers cover art

You built a little .io game. Ships drifting, cursors chasing, avatars bouncing around a shared space. Single-player works great. Now you want other people in the room, and suddenly you're staring at WebSocket servers, sticky sessions, presence tracking, and an autoscaling bill for a game that isn't even out yet.

You don't need any of that. Triggair gives you a broadcast room: join by name, see who's there, send your state, receive everyone else's. It's fan-out plus presence, fully managed. There's no server-side simulation: clients broadcast their own state, which is exactly right for casual, client-authoritative games.

Join a room

tg.realtime.join(roomName) returns a live connection. Everyone who joins the same name lands in the same room. Right away you get you (your own identity) and members (who's already here).

ts
const room = await tg.realtime.join('arena-lobby-1');

console.log('I am', room.you);
for (const m of room.members) {
  spawnRemotePlayer(m); // draw everyone already in the room
}

That members snapshot means late joiners see the current population immediately, instead of waiting for the next frame from each player.

Listen for messages and presence

The connection has two events. 'message' delivers another player's broadcast; 'presence' fires when someone joins or leaves. Subscribe to both.

ts
room.on('message', ({ from, data, ts }) => {
  updateRemotePlayer(from, data); // data is whatever they sent, e.g. {x, y}
});

room.on('presence', ({ event, player, members }) => {
  if (event === 'join') spawnRemotePlayer(player);
  if (event === 'leave') removeRemotePlayer(player);
  updatePlayerCount(members.length);
});

Presence is the difference between a multiplayer game and a ghost town. When a player rage-quits, everyone else sees them vanish instantly instead of watching a frozen avatar.

Broadcast your state ~12x/second

Send your normalized position on a fixed interval. You don't need 60Hz. Ten to twelve updates a second is plenty for casual movement, and you interpolate remote players between frames for smoothness.

ts
const TICK_MS = 80; // ~12 updates/sec

const loop = setInterval(() => {
  room.send({
    x: player.x / WORLD_WIDTH,   // 0..1, resolution-independent
    y: player.y / WORLD_HEIGHT,
  });
}, TICK_MS);

// when the player leaves the scene:
clearInterval(loop);
room.close();

Normalizing to 0..1 means every client renders correctly regardless of screen size. On the receiving side, store each remote player's last frame and lerp toward it in your render loop so motion stays fluid between the ~80ms updates.

Gotchas

  • It's client-authoritative. Each client is the source of truth for its own position. That's perfect for casual .io games, cursors, and party rooms, but it means a determined cheater can lie about their state. Don't use a broadcast room for anything with prize money or a competitive ladder.
  • There's no server simulation. The room fans out messages; it doesn't run physics or resolve collisions. Any shared logic (who picked up the coin) has to be handled by convention among clients or kept cosmetic.
  • Always close() when leaving a scene or unmounting, and clear your send interval. Otherwise you leak a live connection and keep broadcasting a stale position.

Going further

Send more than position: emotes, colors, a display name, a tiny score. The data payload is yours. Keep it small and send it often, and a handful of lines turns a single-player toy into a room full of people.

Build this into your game.

Everything above is one import and a publishable key away. Start with the quickstart, or read the guide for the exact feature.

Keep reading