Skip to content

Guide · Developer

Build PK Battles and Multi-Guest Live Rooms

Run two hosts side by side, bring viewers on stage, keep a PK score over chat and end the match cleanly with VoltKit RTC roles, promote, seats and webhooks.

Author
VoltKit
Published
Reading time
8 min read

A PK battle puts two hosts side by side for a timed contest: viewers back a host with gifts, the gifts count as points, and the score decides the winner when the timer runs out. Multi-guest live rooms are the everyday form of the same thing: a host with co-hosts or guests on seats, and the audience in the same room, watching and chatting. This guide builds both on VoltKit RTC rooms, roles, chat and webhooks, with code from the SDK guides.

What VoltKit gives you and what you build

VoltKit RTC doesYour app builds
Rooms with hosts and co-hosts on air and the audience in the same roomMatching hosts for a battle, the invitation and the countdown
Roles signed into each token and enforced by the serviceThe scoring rules: which gifts count, and for how many points
Raise hand, promote and send back, with the reconnect handled by the SDKsThe side-by-side or grid layout
Seats: named slots that one person holds at a timeCoins, the gift wallet and creator payouts
Chat with typed messages and metadata, from apps and from your backendLeaderboards, families and teams
Signed webhooks for joins, leaves, role changes and room endsPlaying the gift animations, with a player you choose

1v1 PK, 1-vs-N PK, 2v2 team PK and family PK are app logic built on rooms, chat and the promote-and-reconnect recipe; VoltKit has no PK module. It also does not mix streams on a server: every viewer receives the hosts' own tracks, and your app lays them out.

Step 1: one room, two publishers

The battle happens in the host's room. Your backend creates the room, or gets it back if it is already live, and mints a token for each person with the role it decides. With the Node.js server SDK:

// Create the room, or get it back if it already exists.
const room = await rtc.ensureRoom({ roomId: "show-42", hostIdentity: "user-7" });
// -> { roomId, originNode, hostToken, wsUrl, expiresAt, existed, ... }

// Let someone in, with the role YOU decide.
const { token, wsUrl, expiresAt } = await rtc.createToken({ roomId: "show-42", identity: "user-99", role: "viewer" });

Viewers get viewer, as above. Mint the challenger's token the same way with role: "cohost", so both hosts can publish camera and microphone. The role is signed into the token, so an app cannot promote itself. existed tells you whether the room was already live, which separates a host going live from a host reconnecting. The endpoint around these two calls, with your own login in front, is on issue tokens from your backend.

The service sets no limit on how many participants may publish in a room. A 1v1 PK, a three-guest panel, or a 5-seater, 9-seater or 20-seater grid is the same room; your app decides how many seats it shows and enforces them.

Step 2: lay out the hosts' video

Each viewer receives both hosts' tracks and draws them side by side. In React, the hooks of the React SDK give you the remote tracks as state:

const videos = useRemoteTracks({ kind: "video" });
const sounds = useRemoteTracks({ kind: "audio" });

{videos.map((t) => (
  <figure key={t.producerId} className="tile">
    <VideoTrack track={t.track} producerId={t.producerId} />
    <figcaption>{t.identity}</figcaption>
  </figure>
))}

producerId on a remote VideoTrack turns on adaptive stream: the video is received at the size it is shown and paused while it is off screen. Key each tile by producerId, and use identity to put each host on their side of the battle.

In Flutter, every remote video arrives on onTrack, as the Flutter SDK guide shows:

client.onTrack.listen((t) async {
  if (t.kind != 'video') return;                 // remote sound plays without a renderer
  final renderer = RTCVideoRenderer();
  await renderer.initialize();
  renderer.srcObject = t.stream;
  setState(() => videos[t.producerId] = renderer);   // show each with RTCVideoView(renderer)
});

From version 0.8.0, setVideoView tells the SDK how big each tile is, so it receives the smallest copy that fills it and pauses a hidden tile at the server; publishers encode only the copies someone receives. Video quality explains the ladder and what it means for data use and rates.

Step 3: keep the score over chat

The score must come from your server, because a gift spends money. When a viewer sends a gift, your backend checks the sender, debits your own coins, works out the points, and only then posts the result to the room's chat. A message sent from your backend can be trusted in a way a client-sent one cannot, which makes it the right place for gifts and scores. With the Node.js SDK:

await rtc.sendMessage({ channel: "show-42", type: "gift", from: "user-7", metadata: { gift: "rose", count: 5 } });

Post the new totals the same way, as a message of your own type such as pk-score with both scores in its metadata. Any short lowercase name is yours to define as a type, and the service never reads the metadata, up to 4 KB of JSON. Every app listens for messages and redraws the score bar when one arrives:

client.on("message", (m) => appendToList(m));   // your own messages come back too

An app that joins late reads the latest score from the chat history with getMessages. To keep an audit trail of every gift and score, tick the opt-in chat.message webhook: each stored message then reaches your server, signed. The limits and the daily allowance that apply to chat are in the chat docs.

Step 4: bring a viewer or a guest on stage

Multi-guest rooms and 1-vs-N battles need people to move between the audience and the stage. From the browser SDK, as Rooms shows:

await viewer.raiseHand({ requestRole: "cohost" });                    // the viewer asks
await host.promote("user-99", "cohost");     // the host says yes
await host.promote("user-99", "viewer");     // and later, back to the audience

Everyone hears roleChanged when a role changes: show the new co-host their publish controls then. If the promotion moves them to another media server, their app reconnects by itself with a token for the new role, so ask the user to start camera and microphone after acceptance and publish once ready. In Flutter, call publish() when onRoleChanged names you with a publishing role; if it fails with disconnected, the move was still under way, so try again once connectionState is connected. A demoted publisher stops publishing, and its camera and microphone are released. The same stage recipe for voice-only rooms is in group audio rooms.

Step 5: end the match cleanly

When the timer runs out, your backend posts the final score as one last message, and the host sends the challenger back to the audience with promote(identity, "viewer"), or the challenger leaves with close(). Your backend hears all of it by webhook: role.changed for the demotion, participant.left with durationSeconds and the reason for each session, and room.ended with durationSeconds when the room closes. Deliveries are signed, retried and not ordered, so key presence on identity and the newest joinedAt. What happens when a host leaves early is your rule: watch participant.left and settle the match as your app decides.

Seats for fixed grids

A 9-grid live room or a row of guest seats is a set of named slots:

await client.claim("seat-3");                 // take it; claim("seat-3", identity) grants it (moderators)
await client.release("seat-3");
await client.blockSlot("seat-9");             // moderators: lock it; refused with slot-occupied while held
await client.unblockSlot("seat-9");
await client.setMetadata({ name: "Ana" });   // your display metadata; the room hears participantUpdated
await client.setRoomMetadata({ seats: 10 }, { requireFree: ["seat-11", "seat-12"] }); // moderators

A seat whose holder's connection dropped is away for about a minute: draw it as taken, never as free. Room metadata holds the layout for everyone who joins later, and requireFree refuses a smaller layout while those seats are held.

Family PK, team PK and cross-room battles

  • Family PK and 2v2 or team PK are grouping rules. Your app knows which creators belong to which family or team and adds up their points; the room needs only enough co-host seats for the creators on stage.
  • 1-vs-N PK is one host against several challengers, each a co-host on a seat, with your app splitting the score bar.
  • Cross-room PK: VoltKit cannot mix two rooms into one picture. Run the battle in one room: the challenger joins the host's room as a co-host, and the challenger's followers join it as viewers. Both audiences then see and hear both hosts, and chat in one conversation.

What a battle costs

Viewers are participants, billed per second at the rate of the media they receive, so a battle where everyone watches video costs people × minutes at the video rate:

A PK battle: a host, a challenger and 500 viewers for 30 minutes

502 people × 30 minutes = 15,060 participant-minutes

15,060 participant-minutes × 99 ÷ 1,000 = 1,490.94 credits, ₹1,490.94 (≈ $15.7) at today's rate

Full HD Video + HD Audio: 99 credits per 1,000 participant-minutes. Billed per second, so a 7½-minute call costs 7½ minutes.

Prices are in Indian rupees; dollar figures are approximate, at ₹95 per US dollar on 24 October 2026, for orientation only. You are billed in rupees.

Chat is not charged, and people on a chat-only connection are not metered as participants; see live chat. Work out your own sessions with the participant-minute calculator.

Gift animations over the battle

Gifts are artwork your app plays over the room when a gift event arrives: SVGA or animated WebP from VoltKit's gift store, or your own. Check a file over a screenshot of your battle screen before it ships:

Test gift animations in theSVGA Player

Keep files small with SVGA Maker, which turns short video or GIF clips into SVGA files, and follow how to add gifts to an RTC app for the server-validated event flow. For the audio version of the same room, with seats and raise hand, see voice chat rooms.

Multi-guest live is familiar to viewers by now: Instagram's Live Rooms, launched on 1 March 2021, let up to four people go live together (TechCrunch, read 2026-10-24). App names identify a category of app; their trademarks belong to their owners, and VoltKit is not affiliated with them.

Frequently asked questions

What is a PK battle in a live streaming app?

A timed contest between hosts shown side by side. Viewers back a host by sending gifts, the gifts count as points, and the higher score wins when the timer runs out. Common variants are 1v1 PK, 1-vs-N PK, 2v2 or team PK, and family PK.

Can I build PK battles with VoltKit RTC?

Yes, as app logic on top of rooms. Both hosts publish in one room as host and co-host, your app lays out their video, runs the timer and keeps the score over chat. VoltKit has no PK module and no scoring service.

Does VoltKit merge the two hosts into one video for viewers?

No. VoltKit RTC does not mix streams on a server. Every viewer is a participant in the room and receives both hosts' own tracks, and your app draws them side by side.

How many co-hosts can a live room have?

The service sets no limit on how many participants publish in a room: three co-host seats or twenty are the same room to it, and your app enforces its own seat count. Only VoltKit's public demo room is capped, at six people and three co-hosts.

How does a viewer become a co-host?

The viewer raises a hand, the host promotes them to cohost, and the viewer's app receives roleChanged. If the promotion moves them to another media server, the SDK reconnects by itself; the app then publishes. Sending them back to viewer works the same way in reverse.

Can a family or team of creators battle another?

Yes, at the app level. Your app groups creators into a family or a team and adds up their scores; the room only needs enough co-host seats for the creators on stage.

Can I link two live rooms for a cross-room PK?

Not by mixing them: VoltKit cannot combine streams from two rooms. Run the battle in one room instead: the challenger joins the host's room as a co-host, and the challenger's audience joins it as viewers.

How are the viewers billed during a battle?

Every connected participant is billed per second, viewers included, at the rate of the media they receive. Chat is not charged, and people who only follow the chat on a chat-only connection are not metered as participants.

← Back to Guides

You might also like

Something useful from another corner of VoltKit.