View .md

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.

Text
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 browser

React 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.

Shell
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 tsx

Then put your credentials in .env, at the project root:

.env
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.

server.ts
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.

vite.config.ts
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.

src/App.tsx
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.

src/App.tsx
  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():

src/App.tsx — continued
      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.

src/App.tsx — continued
  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.

src/App.tsx — continued
  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.

Shell
npx tsx --env-file=.env server.ts   # API on :8787, needs Node 20.6+
npm run dev                         # app on :5173

Open 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