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
- A working two-person call. See Build a video call.
- A project and an API key.
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;
}function render() {
const everyone = [room.localParticipant, ...room.remoteParticipants];
grid.replaceChildren();
for (const p of everyone) {
const tile = document.createElement('div');
tile.dataset.identity = p.identity;
const camera = p.tracks.find((t) => t.kind === 'camera');
if (camera) tile.append(camera.attach());
grid.append(tile);
}
}
for (const event of ['participantJoined', 'participantLeft', 'trackSubscribed', 'trackUnsubscribed'] as const) {
room.on(event, render);
}Redrawing from the list on each event is less code than patching the page by hand, and it can't get out of sync.
You should now see one tile per person. Open a third tab with a new name and a new tile appears in every tab. Close it and the tile goes away.
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 chooseYou 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, thentrackSubscribedwhen the video really arrives. Draw ontrackSubscribed. Drawing ontrackPublishedgives you an empty tile. - Rooms have a size limit. 50 people by default
(
SIGNALING_MAX_PARTICIPANTS_PER_ROOM). Anyone past the limit is refused withROOM_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
- Handle reconnection: more people means more network drops. Do this next.
- Add screen sharing: the next feature most meetings need.
- Tracks & publishing: layers and subscriptions in detail.
- Limits & quotas: room size and other ceilings.