Getting Started
Quickstart
Build a two-player tic-tac-toe game with live voice, the same way the Radist demo works: one player opens a call, shares a link, and the two browsers talk directly to each other.
How it fits together
A Radist call is created on your backend with your secret key. Creating it returns a callId and exactly two participant tokens — one per player. Your backend decides who gets which token;
the browser only ever sees a token and your public key. Once both tokens are used, the browsers
negotiate WebRTC and audio and data flow peer to peer.
tictactoe/
├── .env # project id + secret key
├── server.ts # Hono API that mints tokens
├── vite.config.ts # proxies /api to that API
└── src/App.tsx # React app in the browserReact and Hono here, but neither SDK cares: the server half is two routes that drop into Express, Next.js, or FastAPI unchanged, and the browser half is four SDK calls you can make from any framework.
1. Get your keys
In your project's Keys section, copy the public key (rad_pk_…) and create a
secret key (rad_sk_…). The secret key is shown once and must stay on your server.
See Keys & auth for the full
breakdown.
2. Create the app
A stock Vite React app, plus one SDK for the trusted side and one for the browser.
npm create vite@latest tictactoe -- --template react-ts
cd tictactoe && npm install
npm install @radist-tech/client @radist-tech/server hono @hono/node-server
npm install -D tsxThen put your credentials in .env, at the project root:
RADIST_PROJECT_ID=your-project-id
RADIST_KEY=rad_sk_...3. Mint tokens on the server
createP2PConnection() returns callTokens with two tokens in it. The player
who opens the call takes the first; hold the second until someone follows the invite link. Each
token admits one participant, so this route deletes the token as it hands it out.
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { RadistServerClient } from '@radist-tech/server';
const radist = new RadistServerClient(); // reads RADIST_PROJECT_ID and RADIST_KEY
const guestTokens = new Map<string, string>();
const app = new Hono();
// The player who opens the call keeps token 0; we hold token 1 for the invitee.
app.post('/api/host', async (c) => {
const { callId, callTokens } = await radist.createP2PConnection();
guestTokens.set(callId, callTokens[1]);
return c.json({ callId, token: callTokens[0] });
});
// A call token admits one participant, so hand it out once.
app.post('/api/guest/:callId', (c) => {
const callId = c.req.param('callId');
const token = guestTokens.get(callId);
guestTokens.delete(callId);
if (!token) return c.json({ error: 'This link has already been used.' }, 404);
return c.json({ token });
});
serve({ fetch: app.fetch, port: 8787 });4. Point Vite at it
So the browser can call /api/… without thinking about CORS in development.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:8787' } }
});5. Set up the component
The board and whose turn it is are React state. The connection and its data channel are refs — they are long-lived objects, not render inputs.
import { useEffect, useRef, useState } from 'react';
import { RadistClient, type RadistConnection } from '@radist-tech/client';
type Cell = 'X' | 'O' | null;
export default function App() {
const [board, setBoard] = useState<Cell[]>(Array(9).fill(null));
const [mark, setMark] = useState<Cell>(null);
const [invite, setInvite] = useState('');
const [ready, setReady] = useState(false);
const connection = useRef<RadistConnection | null>(null);
const channel = useRef<RTCDataChannel | null>(null);
const audio = useRef<HTMLAudioElement>(null);
const started = useRef(false);6. Get a token and connect
The URL decides which role this browser plays: with a callId in the query string we
are joining an invite, without one we are opening a new call. Paste the public key from step 1 straight
into the component — unlike the secret key, it is meant to be visible.
useEffect(() => {
if (started.current) return; // StrictMode runs effects twice in dev; join once.
started.current = true;
async function join() {
const joinCallId = new URLSearchParams(location.search).get('callId');
const path = joinCallId ? `/api/guest/${joinCallId}` : '/api/host';
const { callId, token } = await fetch(path, { method: 'POST' }).then(
(response) => response.json() as Promise<{ callId?: string; token: string }>
);
const radist = new RadistClient({ publicKey: 'rad_pk_...' });
const conn = await radist.call(token).connect({ channels: ['audio', 'data'] });
connection.current = conn;
// The first peer in a call is its host — here, whoever created it.
const myMark = conn.role === 'host' ? 'X' : 'O';
setMark(myMark);
if (callId) setInvite(`${location.origin}/?callId=${callId}`);Guard the effect
React StrictMode mounts effects twice in development. Without started, the
invited player burns their single-use token on the first run and gets a 404 on the second.
7. Wire up moves and audio
Asking for the data channel gives you an ordinary RTCDataChannel once the peer connection is up, and both sides receive it through the datachannel event. The audio channel already captured the microphone during connect(), so there is nothing to do but play the other side. This is the rest of join():
conn.on('datachannel', ({ channel: dataChannel }) => {
channel.current = dataChannel;
dataChannel.addEventListener('open', () => setReady(true));
dataChannel.addEventListener('message', (event) => {
const index = Number(event.data);
const theirs = myMark === 'X' ? 'O' : 'X';
setBoard((prev) => prev.map((cell, i) => (i === index ? theirs : cell)));
});
});
conn.on('remotestream', ({ stream }) => {
if (audio.current) audio.current.srcObject = stream;
});
}
void join();
}, []);8. Play and mute
A move updates local state and goes out on the channel. Muting is a flag on the tracks the SDK already captured — no renegotiation.
function play(index: number) {
setBoard((prev) => prev.map((cell, i) => (i === index ? mark : cell)));
channel.current?.send(String(index));
}
function toggleMic() {
for (const track of connection.current?.localStream?.getAudioTracks() ?? []) {
track.enabled = !track.enabled;
}
}9. Render the board
Nothing Radist-specific left. Gate play on the data channel being open rather than on the connection state — the channel is what your moves actually travel through.
const myTurn = ready && board.filter(Boolean).length % 2 === (mark === 'X' ? 0 : 1);
return (
<main>
<p>{ready ? (myTurn ? 'Your turn' : 'Their turn') : 'Connecting…'}</p>
{invite && <p>{invite}</p>}
<div className="board">
{board.map((cell, index) => (
<button key={index} disabled={!!cell || !myTurn} onClick={() => play(index)}>
{cell ?? '·'}
</button>
))}
</div>
<button onClick={toggleMic}>Toggle mic</button>
<audio ref={audio} autoPlay playsInline />
</main>
);
}10. Play it
Two terminals — the API and the app.
npx tsx --env-file=.env server.ts # API on :8787, needs Node 20.6+
npm run dev # app on :5173Open http://localhost:5173, allow the microphone, and copy the invite link into a
second window. The board unlocks for X as soon as the second player connects. To play across
two machines you need HTTPS — browsers only grant microphone access on a secure origin or localhost.
Next steps
Client SDK
@radist-tech/client
Video channels, connection events, and reconnecting an interrupted call.
Server SDK
@radist-tech/server
Public calls, multi-party SFU rooms, and persistent spaces with reusable links.
No-code
Easy interface
Skip the WebRTC wiring entirely and drop in a ready-made call UI.
Reference
Keys & auth
How secret keys, public keys, and participant tokens relate to each other.