livqenoDocs

GuidesBuild a group call

Build a group call

A video grid for many people, and the limits that matter as the room fills up.

In this guide you'll turn a two-person call into a group call, like a team meeting, an online class, or a gaming party.

Where this fits: you already have a working two-person call. A group call uses the same API. What changes is your UI (a grid instead of two boxes) and how much video each device has to receive.

What you'll build

A call for more than two people:

  • A grid that adds and removes tiles as people join and leave.
  • Each person sends one camera and one microphone.
  • It keeps working with fifteen people.

Why the extra care? In a two-person call, each device receives one video. In a 15-person call, each device receives up to 14. Phones and slow networks feel that first, so this guide shows you how to receive only what you show.

What you need

Steps

1. Make a token for each person

On your backend, make a token each time someone joins. It's the same call as before. Use the same room for everyone, and a different identity per person:

const credentials = await livqeno.tokens.create({
  room: room.id,
  identity: user.id,
  permissions: { join: true, subscribe: true, publish: true },
});

There's no special "group room". A room becomes a group call simply because more people join it. A room holds up to 50 people by default.

2. Draw the grid from the list of people

In your app: with two people, you can add a video element on each trackSubscribed event. With ten, it's easier and safer to redraw the whole grid from the current list of people each time something changes:

import { ParticipantView, useRemoteParticipants, useLocalParticipant } from '@livqeno/react';
 
function Grid() {
  const local = useLocalParticipant();
  const remote = useRemoteParticipants();
 
  return (
    <div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns(remote.length + 1)}, 1fr)` }}>
      {local && <ParticipantView participant={local} />}
      {remote.map((p) => (
        <ParticipantView key={p.identity} participant={p} />
      ))}
    </div>
  );
}
 
function columns(count: number) {
  return count <= 1 ? 1 : count <= 4 ? 2 : count <= 9 ? 3 : 4;
}

3. Limit how many tiles you show

This is the big one. If your screen shows nine tiles but the room has thirty people, don't play the other twenty-one videos. Every video you receive costs network data, and every video you play costs the device work.

First, decide what you show in your app:

const MAX_TILES = 9;
const shown = remote.slice(0, MAX_TILES);
const overflow = remote.length - shown.length;

When a tile goes off screen, detach its track so it stops playing:

// When a tile leaves the visible set.
track.detach();

Know what detach() does and doesn't do:

  • It stops playback in your page: the <video> element is emptied.
  • It does not stop the video from arriving. You're still subscribed, so the video keeps coming over the network.

To also cut the data a hidden tile uses, ask for its smallest quality layer. A camera published from @livqeno/rtc is sent in three sizes (low, medium, high); this is called simulcast. On the web, call setLayer() on the remote track. It's a request, not a command: you only get layers the sender is actually sending.

track.setLayer('low');    // hidden or tiny: smallest stream
track.setLayer('auto');   // visible again: let the SDK choose

You often don't need to do this by hand. @livqeno/rtc's adaptive stream is on by default: it watches how big each attached <video> is on screen and asks for the matching layer. A small grid tile gets low, a big spotlight gets high. Calling setLayer() with a fixed layer overrides it for that track, until you pass 'auto'.

Audio is cheap. Keep all of it.

4. Watch for bad connections

The more people in a call, the more likely one of them has a bad network. Livqeno has no "active speaker" event, but you can read per-track stats in your app and warn users when their network struggles. Packet loss is the share of media that never arrived; above about 5%, video starts to freeze:

setInterval(async () => {
  const stats = await room.getConnectionStats();
  const struggling = stats.remote.filter((t) => (t.packetLossPercent ?? 0) > 5);
  if (struggling.length) showNetworkWarning(struggling.length);
}, 5000);

Check every few seconds, not in a tight loop. Each call costs a round trip per track.

How it works

  • Each person has one connection, to the media server (SFU). Not one to every other person. Without this, fifteen people would each need fourteen connections and send their video fourteen times.
  • When someone starts sending video, everyone gets an update. Your code first sees trackPublished, then trackSubscribed when the video really arrives. Draw on trackSubscribed. Drawing on trackPublished gives you an empty tile.
  • Rooms have a size limit. 50 people by default (SIGNALING_MAX_PARTICIPANTS_PER_ROOM). Anyone past the limit is refused with ROOM_FULL.

Production tips

  • Test with your own setup. Livqeno's scale tests reach 100 people, but on one machine with fake video. That's not a promise for real networks and real cameras.
  • Show fewer tiles. Every video you receive costs data even when it's hidden. Detach hidden tiles to stop playing them, and use setLayer('low') (or let adaptive stream do it) to shrink what they send you.
  • No automatic quality drop on bad networks yet. Adaptive stream picks a layer by tile size, not by network health. If someone's connection gets worse, they see packet loss instead of a lower-quality video. This is the weakest point at large sizes today. See Known limitations.
  • Pick a maximum grid size. Show pages or the most important people beyond that. A 30-tile grid is too small to use anyway.

Next steps