# Quickstart

> Build a two-player tic-tac-toe game with live voice: one player opens a call, shares a link, and the two browsers talk directly to each other.

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 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 /docs/configuration.md.

## 2. Create the app

```sh
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:

```sh
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.

```ts
// 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

```ts
// 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. The React component

The board and whose turn it is are React state; the connection and its data channel are refs. 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.

Two things to know. React StrictMode mounts effects twice in development, so without the `started` guard the invited player burns their single-use token on the first run and gets a 404 on the second. And gate play on the data channel being open rather than on the connection state — the channel is what your moves actually travel through.

```tsx
// 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);

	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}`);

			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();
	}, []);

	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;
		}
	}

	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>
	);
}
```

## 6. Play it

```sh
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`.
