skills and photots

This commit is contained in:
-Puter
2026-07-22 00:21:04 +05:30
parent 677a355397
commit 070c6c0a91
337 changed files with 46087 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
# AI Agent
> Source: `src/content/cookbook/ai-agent.mdx`
> Canonical URL: https://rivet.dev/cookbook/ai-agent
> Description: Build an AI agent backend with persistent memory: one Rivet Actor per conversation, queued message handling, and streaming LLM responses as realtime events.
---
Patterns for building AI agent backends with RivetKit, where each conversation is one Rivet Actor that owns its memory, its message queue, and its streaming output.
## Starter Code
Start with one of the working examples on [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/ai-agent) and adapt it. The sections below describe the flagship `ai-agent` example unless a variant is called out explicitly.
| Variant | Starter Code | Use When |
| --- | --- | --- |
| Queue-driven AI SDK agent | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/ai-agent) | You want a streaming chat agent where each conversation keeps its own persistent memory and processes one message at a time. |
| Sandbox coding agent | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/sandbox-coding-agent) | The agent should run a coding agent (Codex by default) inside an isolated [sandbox](/docs/actors/sandbox) via Docker, Daytona, or E2B. |
| Durable streams agent (experimental) | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/experimental-durable-streams-ai-agent) | You want replayable, restart-safe prompt and response delivery through durable streams instead of actor state and events. |
## Conversation Memory
Use one actor per conversation, keyed by a conversation or agent id (see [Actor Keys](/docs/actors/keys)). The agent actor's persistent [state](/docs/actors/state) is the conversation memory: in the `ai-agent` example, `messages` and `status` live in JSON actor state and survive sleep and restarts with no external database. Every model call rebuilds the prompt from `c.state.messages` plus a system prompt, so memory and inference input are the same data.
| Variant | Where Memory Lives | Persisted State Fields |
| --- | --- | --- |
| `ai-agent` | JSON actor state | `messages`, `status` |
| `sandbox-coding-agent` | JSON actor state plus the sandbox ACP session | `messages`, `status`, `sessionId` |
| `experimental-durable-streams-ai-agent` | Durable streams; the actor stores only its conversation id and a read cursor | `conversationId`, `promptStreamOffset` |
## Message Handling
In the `ai-agent` example, the client pushes user input onto the agent's `message` [queue](/docs/actors/queues) with `agent.connection.send("message", { text, sender })`. This is a queue push, not an action call. The actor's `run` hook (see [Lifecycle](/docs/actors/lifecycle)) consumes the queue serially with `for await (const queued of c.queue.iter())`.
Serial queue consumption is the per-conversation concurrency guarantee: at most one in-flight model call per actor, with no extra locking. The `status` field (`thinking` while a model call is in flight) is UI signal only; the run loop is the actual lock. The loop also checks `c.aborted` inside the token stream so shutdown exits gracefully.
| Variant | Message Ingress | Serialization Guarantee |
| --- | --- | --- |
| `ai-agent` | `message` queue pushed via `connection.send` | `run` hook pops one queued message at a time with `c.queue.iter()`. |
| `sandbox-coding-agent` | `sendMessage` [action](/docs/actors/actions), no queue | Each call awaits the sandbox round trip before broadcasting the result. |
| `experimental-durable-streams-ai-agent` | Durable prompt stream long-polled from `onWake` | `promptStreamOffset` is persisted per chunk, so restarts resume without reprocessing prompts. |
## Streaming Responses
The `ai-agent` actor broadcasts a `response` [event](/docs/actors/events) for every model text delta. The payload carries `messageId`, the per-token `delta`, the cumulative `content`, and a `done` flag (plus `error` on failure), so clients can either append deltas or idempotently replace the message by `messageId` using `content`. The example frontend replaces by `messageId`, which tolerates dropped events. The terminal broadcast has an empty `delta`, the full `content`, and `done: true`.
Because the assistant message object lives in `c.state.messages` and is mutated in place during streaming, partial content persists if the actor restarts mid-stream. The example broadcasts once per AI SDK delta with no throttling; batching or throttling deltas is a recommended extension for high-traffic deployments, not something the example implements.
Variant differences: `sandbox-coding-agent` sends a single `response` broadcast with `done: true` after the sandbox finishes (no incremental streaming), and `experimental-durable-streams-ai-agent` appends per-token chunks to a durable response stream, then broadcasts `responseComplete` or `responseError`.
## Architecture
| Topic | Summary |
| --- | --- |
| Topology | `agentManager["primary"]` singleton directory plus one `agent[agentId]` actor per conversation. |
| Ingress | Client pushes `AgentQueueMessage` payloads onto the agent's `message` queue with `connection.send`. |
| Streaming | One `response` broadcast per model delta, terminal broadcast with `done: true`. |
| Memory | Full transcript and status in JSON actor state; no external database. |
The manager creates `AgentInfo` records and warms each agent through [actor-to-actor communication](/docs/actors/communicating-between-actors): `createAgent` calls `c.client<typeof registry>()`, then `client.agent.getOrCreate([info.id])` and awaits `getStatus()` so the conversation actor exists before the client connects. The sandbox variant extends this topology with a `codingSandbox` actor that shares the agent's key (`codingSandbox.getOrCreate([c.key[0]])`), so the agent-to-sandbox mapping is implicit in the key space.
**Actors**
- **Key**: `agentManager["primary"]`
- **Responsibility**: Directory actor. Creates `AgentInfo` records, lists agents, and warms each agent actor via `c.client()`.
- **Actions**
- `createAgent`
- `listAgents`
- **Queues**
- None
- **State**
- JSON
- `agents`
- **Key**: `agent[agentId]`
- **Responsibility**: One actor per conversation. Holds the full message history and status, consumes queued user messages in its `run` loop, calls the model via the AI SDK, and broadcasts streaming deltas.
- **Actions**
- `getHistory`
- `getStatus`
- **Queues**
- `message`
- **Events**
- `messageAdded`
- `status`
- `response`
- **State**
- JSON
- `messages`
- `status`
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant AM as agentManager
participant A as agent
participant LLM as Model API
C->>AM: createAgent(name)
AM->>A: getOrCreate([info.id]) + getStatus()
AM-->>C: AgentInfo
C->>A: connection.send("message", {text, sender})
Note over A: run loop pops queue via c.queue.iter()
A-->>C: messageAdded (user message)
A-->>C: messageAdded (assistant placeholder)
A-->>C: status (thinking)
A->>LLM: streamText(system prompt + history)
loop each text delta
LLM-->>A: delta
A-->>C: response {messageId, delta, content, done: false}
end
A-->>C: response {delta: "", content, done: true}
A-->>C: status (idle)
```
## Security Checklist
The examples ship without auth so they stay minimal. Apply this baseline before exposing an agent backend.
- **API keys stay server-side**: `OPENAI_API_KEY` (or `ANTHROPIC_API_KEY`) is read by the AI SDK inside the actor process. The key never reaches the browser; clients only talk to the actor over RivetKit. The sandbox variant forwards keys into the sandbox env, never to the client.
- **Add authentication**: The examples have no auth, so anyone who reaches the server can create agents, list them, and message any agent whose key they can guess. Add `onBeforeConnect` or `createConnState` checks with scoped tokens as a recommended extension. See [Authentication](/docs/actors/authentication).
- **Validate and rate-limit queue payloads**: The example only skips bodies without a string `text`. Enforce payload size limits, schema validation, and per-connection rate limits as a recommended extension.
- **Derive sender identity server-side**: The example trusts the client-supplied `sender` field verbatim. Bind sender identity to the authenticated connection instead.
- **Cap or trim message history**: The example sends the full transcript on every model call with no cap. Trim or summarize old messages as a recommended extension so prompts and state stay bounded.
- **Set cost ceilings per conversation**: Add per-agent token budgets and quotas as a recommended extension. The sandbox variant runs real compute, so also enforce per-user sandbox quotas and restrict sandbox network egress.
_Source doc path: /cookbook/ai-agent_

View File

@@ -0,0 +1,114 @@
# Chat Room
> Source: `src/content/cookbook/chat-room.mdx`
> Canonical URL: https://rivet.dev/cookbook/chat-room
> Description: Build a realtime chat room backend with Rivet Actors: one actor per room, SQLite-backed message history, and WebSocket broadcast to every connected client.
---
Patterns for building a chat room backend with RivetKit: room-scoped actors, persistent message history, and realtime delivery over WebSocket connections.
## Starter Code
Start with the working example on [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room) and adapt it. The backend is a single `chatRoom` actor; the frontend is a React app using `@rivetkit/react` (see the [React quickstart](/docs/actors/quickstart/react)).
| Topic | Summary |
| --- | --- |
| Room model | One `chatRoom` actor per room key. The frontend defaults the key to `general`; typing a different room name connects to a different actor. |
| History | SQLite `messages` table created in `db({ onMigrate })`, read back with `ORDER BY id ASC`. |
| Delivery | `sendMessage` inserts the row, then broadcasts a typed `newMessage` event to every connected client. |
| Identity | None in the example. `sender` is a plain action argument; production should bind identity to the connection. |
## Room-Per-Actor Model
Each room is one Rivet Actor instance, addressed by [key](/docs/actors/keys). The client calls `useActor({ name: "chatRoom", key: [roomId] })`, which gets-or-creates the actor for that room. This gives you:
- **Isolation**: each room's history and connections are fully scoped to its key. Switching the room input re-keys the hook and connects to a different actor with separate history.
- **A single serialized writer**: all `sendMessage` calls for one room run through one actor, so message ordering is consistent without locks. The SQLite `AUTOINCREMENT` id is the canonical order, which is why `getHistory` sorts by `id` rather than by timestamp.
- **Natural scaling**: rooms spread across the cluster independently. A hot room does not slow down other rooms.
## Message History Storage
This example stores history in the actor's SQLite database, not in JSON state. Pick based on history size and query needs:
| Approach | Use When | Implementation Guidance |
| --- | --- | --- |
| [SQLite](/docs/actors/sqlite) (what this example uses) | Large or long-lived history that needs ordering, caps, pagination, or search | Create the `messages` table in `db({ onMigrate })`, insert with parameterized queries (`c.db.execute("INSERT ... VALUES (?, ?, ?)", ...)`), and read with `ORDER BY id ASC`. History survives actor sleep and scales past what you want in memory. |
| [JSON state](/docs/actors/state) | Small recent history, for example the last 50 to 100 messages | Push onto a `messages` array in actor state and trim to a cap on every send. Simplest option, but the whole history lives in memory and there is no query layer, so it only fits bounded recent-history use cases. |
## Broadcast Delivery
New messages reach connected clients through a typed [event](/docs/actors/events):
- The actor declares `events: { newMessage: event() }`, where `Message` is `{ sender, text, timestamp }`.
- The `sendMessage` [action](/docs/actors/actions) builds the message with a server-side `Date.now()` timestamp, inserts it into the `messages` table, then calls `c.broadcast("newMessage", message)` and returns the message to the caller.
- Each client subscribes with `useEvent("newMessage", ...)` and appends to its local list. The sender renders its own message through the same broadcast path as everyone else, so all clients stay on one code path.
- History load is connection-gated: once the connection is ready, the client calls `getHistory()` once to render the backlog, then relies on events for everything after.
Use `c.broadcast(...)` for room-wide messages. For private or per-recipient payloads (such as DMs inside a room), send on the individual connection instead, which is a recommended extension beyond this example.
## Typing Indicators And Presence (Extension)
The example does not implement typing indicators, presence, or join/leave handling of any kind. There is no `createConnState`, `onConnect`, or `onDisconnect` in the code. If you need them, add them as ephemeral [connection](/docs/actors/connections) behavior:
- **Keep it ephemeral**: store the username and typing flag in per-connection state, never in SQLite or persisted actor state. Presence is derived from live connections and should disappear with them.
- **Broadcast on change only**: emit a typing event when a user starts or stops typing, and a presence event from `onConnect` / `onDisconnect`, rather than polling or ticking.
- **Expire on the client**: clear a typing indicator after a short client-side timeout so a dropped connection never leaves a stuck "is typing" row.
## Per-User Inbox (Extension)
For offline delivery, DMs, unread counts, or notification fanout, add a `userInbox[userId]` actor per user. This is an extension beyond the example:
- The room actor forwards each message to the inbox actor of every member via [actor-to-actor calls](/docs/actors/communicating-between-actors), so users who are not connected to the room still accumulate messages.
- The inbox actor owns per-user unread state and serves it when the user comes online, independent of which rooms they are in.
- DMs become a degenerate room: either a `chatRoom` keyed by the sorted pair of user ids, or direct inbox-to-inbox delivery if you do not need shared history semantics.
## Actors
- **Key**: `chatRoom[roomId]`
- **Responsibility**: Owns one chat room. Persists the room's message history in its SQLite database and broadcasts each new message to every connected client.
- **Actions**
- `sendMessage`
- `getHistory`
- **Queues**
- None
- **Events**
- `newMessage`
- **State**
- SQLite
- `messages` table: `id` (autoincrement primary key), `sender`, `text`, `timestamp`
## Lifecycle
```mermaid
sequenceDiagram
participant A as Client A
participant B as Client B
participant R as chatRoom
A->>R: connect with key [roomId]
Note over R: every start runs onMigrate (CREATE TABLE IF NOT EXISTS messages)
A->>R: getHistory()
R-->>A: Message[] ordered by id
B->>R: connect with key [roomId]
B->>R: getHistory()
R-->>B: Message[] ordered by id
A->>R: sendMessage(sender, text)
Note over R: INSERT row with server timestamp
R-->>A: newMessage (broadcast)
R-->>B: newMessage (broadcast)
A->>R: disconnect
Note over R: history stays in SQLite for the next connection
```
## Security Checklist
The example is intentionally minimal and skips all of the following. Add them before production:
- **Auth before join**: any client can join any room by knowing its name, and `sender` is arbitrary client input on every call. Validate a token during [connection auth](/docs/actors/authentication), bind identity to [connection state](/docs/actors/connections), and check room membership before serving history. Never trust a sender name passed as an action argument.
- **Message length clamps**: the example accepts empty messages and has no length limit. Trim server-side, reject empty text, and clamp to a maximum length.
- **Per-connection rate limiting**: rate limit `sendMessage` per connection to stop spam and broadcast amplification.
- **Server-side timestamps and ids**: the example already does this correctly. `timestamp` comes from `Date.now()` inside the action and `id` from SQLite `AUTOINCREMENT`. Keep it that way; never accept client-supplied timestamps or ids.
- **History caps**: `getHistory` returns every row with no limit. Add a `LIMIT` plus pagination, and prune or archive old rows so a long-lived room cannot grow unbounded.
- **Parameterized queries**: the example already inserts with `?` placeholders. Keep all user-supplied text out of SQL string interpolation.
_Source doc path: /cookbook/chat-room_

View File

@@ -0,0 +1,157 @@
# Collaborative Text Editor
> Source: `src/content/cookbook/collaborative-text-editor.mdx`
> Canonical URL: https://rivet.dev/cookbook/collaborative-text-editor
> Description: Build a collaborative text editor backend with Yjs CRDTs and Rivet Actors: per-document actors relay sync and awareness updates and persist snapshots.
---
Patterns for building a Yjs server on RivetKit: CRDT document sync, presence and cursors, and snapshot persistence, with one Rivet Actor per document acting as a relay.
## Starter Code
Start with the working example on [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/collaborative-document) and adapt it to your editor. It ships a React frontend with a plain textarea, remote cursor overlays, and a workspace document index.
| Use Case | Starter Code | Common Examples |
| --- | --- | --- |
| Shared document editing | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/collaborative-document) | Notion-style docs, shared notes, pair-writing tools, form co-editing |
## CRDT vs OT
Two families of algorithms solve concurrent text editing. The choice decides what your server has to do.
| Dimension | CRDT (Yjs) | Operational Transformation |
| --- | --- | --- |
| Conflict resolution model | Commutative merges. Updates apply in any order on any peer and converge to the same result. | Server transforms each operation against every concurrent operation. Correctness depends on a central sequencer. |
| Offline support | Strong. Clients keep editing locally and merge buffered updates on reconnect. | Weak. Long-lived divergence makes transformation chains complex and fragile. |
| Server role | Relay plus persistence. The server applies opaque updates and rebroadcasts them. It never needs to understand document semantics. | Authoritative transformer. The server must implement transformation logic for every operation type. |
| Library maturity | Yjs is mature and widely deployed, with bindings for ProseMirror, CodeMirror, Monaco, and others. | Production-grade implementations are mostly proprietary (Google Docs) or aging (ShareDB). |
The example uses Yjs because CRDTs let the server stay a relay-style Rivet Actor. The actor applies each incoming update to a server-side `Y.Doc` so it can persist the merged state and serve late joiners, but it never transforms operations or arbitrates conflicts. Ordering does not matter because Yjs merges are commutative.
## Document Actor Model
| Topic | Summary |
| --- | --- |
| Topology | One `document[workspaceId, documentId]` actor per document plus one `documentList[workspaceId]` coordinator per workspace. |
| Sync model | Each client holds a local `Y.Doc`. The document actor relays incremental Yjs updates as broadcast [events](/docs/actors/events) and keeps a server-side merged copy in vars. |
| Persistence | Full merged Yjs snapshot overwritten in one binary [actor KV](/docs/actors/kv) key (`yjs:doc`) on every sync update. Document metadata lives in JSON [state](/docs/actors/state). |
| Queues | None. The example is purely [actions](/docs/actors/actions) plus broadcast events. |
| Presence | Yjs Awareness relayed through the same `applyUpdate` action. Per-connection `connState` tracks asserted awareness clientIds for disconnect cleanup. |
The two-actor split follows the coordinator pattern from [Design Patterns](/docs/actors/design-patterns): the coordinator owns discovery and creation, and each document actor owns one document's realtime state. Multi-part [keys](/docs/actors/keys) scope both actors to a workspace.
**Actors**
- **Key**: `document[workspaceId, documentId]`
- **Responsibility**: Applies incoming sync and awareness updates to a server-side `Y.Doc` and `Awareness`, persists the merged Yjs snapshot to actor KV, and broadcasts updates to all connected collaborators.
- **Actions**
- `getContent`
- `applyUpdate`
- `getAwareness`
- **Queues**
- None
- **State**
- JSON metadata only: `title`, `createdAt`, `updatedAt`
- Binary KV key `yjs:doc` holding the full merged Yjs snapshot
- Ephemeral vars: the live `Y.Doc` and `Awareness`, created in `createVars` and rehydrated from KV on actor start
- Per-connection `connState`: `clientIds` of awareness clients asserted by that connection
- **Key**: `documentList[workspaceId]`
- **Responsibility**: Coordinator for one workspace. Creates document actors through the actor-to-actor client and maintains the index of document summaries.
- **Actions**
- `createDocument`
- `listDocuments`
- `deleteDocument`
- **Queues**
- None
- **State**
- JSON
- `documents` array of `DocumentSummary` entries (`id`, `title`, `createdAt`, `updatedAt`)
The coordinator's `createDocument` generates a UUID, then explicitly creates the document actor with `c.client<typeof registry>()` and passes `{ title, createdAt }` as creation [input](/docs/actors/input), which the document actor's `createState` consumes. See [Communicating Between Actors](/docs/actors/communicating-between-actors) for the actor-to-actor client.
## Update Relay
A single `applyUpdate(update, kind, clientId?)` action handles both update kinds. Updates cross the action boundary as `number[]` byte arrays and are converted back to `Uint8Array` on each side.
| Kind | Server Applies To | Persists | Broadcasts |
| --- | --- | --- | --- |
| `"sync"` | `c.vars.doc` via `Y.applyUpdate` with origin `"client"` | Full merged snapshot to KV key `yjs:doc`, then bumps `updatedAt` | `sync` event carrying the incremental update |
| `"awareness"` | `c.vars.awareness` via `applyAwarenessUpdate` with origin `"client"` | Nothing. Presence is ephemeral. | `awareness` event carrying the update |
Note the asymmetry on the sync branch: the broadcast carries only the small incremental update, while the KV write stores the full merged document re-encoded with `Y.encodeStateAsUpdate`.
Yjs origin tags are the echo guards that keep the relay loop-free:
| Origin Tag | Set Where | Effect |
| --- | --- | --- |
| `"local"` | Client edits inside `doc.transact(..., "local")` | The client's update listener fires and sends `applyUpdate` to the actor. |
| `"client"` | Server applying an incoming update to its `Y.Doc` or `Awareness` | Marks the change as client-originated on the server copy. |
| `"remote"` | Client applying broadcast events or initial sync data | Update listeners early-return on `"remote"`, so a client never re-sends its own echo. |
On connect or reconnect, the client calls `getContent` and `getAwareness`, then applies both results to its local `Y.Doc` and `Awareness` with origin `"remote"`. After that, every change flows through `applyUpdate` and the broadcast events.
## Awareness And Presence
Presence (user names, colors, cursor positions) rides on the Yjs Awareness protocol instead of actor state:
- Clients set presence with `awareness.setLocalStateField` for the `user` and `cursor` fields. The awareness update listener encodes the change and sends `applyUpdate(update, "awareness", awareness.clientID)`.
- The actor records each asserted `clientId` in that connection's `connState.clientIds`, applies the update to the server-side `Awareness`, and broadcasts the `awareness` event to all peers. See [Connections](/docs/actors/connections) for per-connection state.
- `onDisconnect` reads the connection's `clientIds`, calls `removeAwarenessStates` on the server-side `Awareness`, and broadcasts the encoded removal so every remaining client drops the departed user's cursor. See [Lifecycle](/docs/actors/lifecycle) for the hook.
Because the actor tracks which awareness clientIds belong to which connection, presence cleanup is automatic on disconnect with no client cooperation required.
## Persistence And Compaction
The example persists with a full-snapshot overwrite: on every `"sync"` update, the actor re-encodes the entire merged document with `Y.encodeStateAsUpdate` and overwrites the single binary KV key `yjs:doc`. There is no append-only update log and no separate compaction job. Compaction is implicit because `Y.encodeStateAsUpdate` emits one compact merged representation of the document, so Yjs merge semantics keep the stored blob compact on their own.
| Property | Full-Snapshot Overwrite (the example) |
| --- | --- |
| Write cost | One full-document KV write per sync update, so every keystroke rewrites the whole blob. |
| Read cost | One binary KV read in `createVars` rehydrates the document on actor start. |
| Crash safety | The last completed `applyUpdate` is durable. No log replay needed. |
| Sweet spot | Small to medium documents where simplicity beats write amplification. |
**Recommended extension (not in the example)**: for large documents or very high edit rates, switch to appending incremental updates to a KV update log and writing a merged snapshot only periodically (for example every N updates). Boot becomes snapshot plus log replay, and the snapshot write becomes the explicit compaction step that truncates the log. Adopt this only when full-snapshot writes become the measured bottleneck or the blob approaches KV value size limits.
## Lifecycle
```mermaid
sequenceDiagram
participant A as Client A
participant B as Client B
participant DL as documentList
participant D as document
A->>DL: listDocuments()
A->>DL: createDocument(title)
DL->>D: create([workspaceId, documentId], input)
DL-->>A: DocumentSummary
A->>D: connect
B->>D: connect
Note over D: createVars rehydrates Y.Doc from KV "yjs:doc"
A->>D: getContent() + getAwareness()
D-->>A: encoded doc + awareness state
Note over A: local edit with origin "local"
A->>D: applyUpdate(update, "sync")
Note over D: apply with origin "client", overwrite KV snapshot
D-->>B: sync event (incremental update)
Note over B: apply with origin "remote", no echo
A->>D: applyUpdate(update, "awareness", clientId)
D-->>B: awareness event
B-->>D: disconnect
Note over D: onDisconnect removes B's awareness clientIds
D-->>A: awareness event (removal)
```
## Security Checklist
The example ships with no authentication or authorization. Harden it with this baseline before production. None of these are implemented in the example.
- **Authenticate before connect**: Anyone who knows or guesses a workspace ID can connect, and because `useActor` implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank `documentList` coordinator. Add connection auth so unauthenticated clients never reach an actor. See [Authentication](/docs/actors/authentication).
- **Per-document access control**: Validate that the authenticated user is allowed to access the specific `[workspaceId, documentId]` key, not just any document.
- **Cap and rate limit `applyUpdate`**: Update payloads are unvalidated `number[]` arrays with no size limit, and the example client sends one action per keystroke and per cursor move with zero throttling. Enforce payload size caps and per-connection rate limits on the server, and debounce on the client.
- **Do not trust client-asserted awareness clientIds**: The `clientId` argument to `applyUpdate` is client-supplied and trusted as-is. Derive or verify presence identity from connection-scoped server state instead.
- **Destroy actors and KV on delete**: `deleteDocument` only filters the entry out of the coordinator's index. The document actor and its KV snapshot are orphaned. On delete, also destroy the document actor and its storage, with a permission check on who may delete.
_Source doc path: /cookbook/collaborative-text-editor_

View File

@@ -0,0 +1,69 @@
# Cron Jobs and Scheduled Tasks
> Source: `src/content/cookbook/cron-jobs.mdx`
> Canonical URL: https://rivet.dev/cookbook/cron-jobs
> Description: Patterns for durable one-shot, calendar, and fixed-interval work on Rivet Actors.
---
Rivet Actor schedules are durable actor-local timers. They survive actor sleep, restarts, upgrades, deploys, and crashes without a separate cron service.
## Choose a schedule type
| API | Use it for |
| --- | --- |
| `c.schedule.after(delayMs, action, ...args)` | One-time work after a relative delay. |
| `c.schedule.at(timestamp, action, ...args)` | One-time work at an exact Unix timestamp in milliseconds. |
| `c.cron.set({ ... })` | Named calendar recurrence in an IANA timezone. |
| `c.cron.every({ ... })` | Named fixed intervals of at least 5 seconds. |
All callbacks are ordinary actions on the same actor. Keep the action name fixed in your code rather than accepting an arbitrary action name from a client.
See [Schedule & Cron](/docs/actors/schedule) for the full API, history, cancellation, failure behavior, and limits.
## Calendar job
Use `cron.set` instead of manually re-arming a one-shot action:
Install fixed background jobs in `onCreate` so setup runs once per actor. The job name remains an upsert key, so a later `cron.set` call updates the existing job rather than creating a duplicate. `cron.set` also handles timezone and daylight-saving transitions.
## Fixed-interval job
Use `cron.every` for frequent work such as presence sweeps or cache refreshes:
```ts
await c.cron.every({
name: "presence-sweep",
interval: 15_000, // Minimum 5 seconds.
action: "sweepPresence",
maxHistory: 25,
});
```
Intervals remain anchored to scheduled deadlines rather than drifting by the action's runtime. If a previous run is still active, the overlapping occurrence is skipped.
## Cancellation and updates
Keep the ID returned by a one-shot schedule when it may need cancellation:
```ts
const id = await c.schedule.after(60_000, "expireSession", sessionId);
await c.schedule.cancel(id);
```
Recurring jobs are managed by name:
```ts
await c.cron.delete("presence-sweep");
```
Calling `cron.set` or `cron.every` again with the same name replaces its configuration.
## Failure and idempotency
Keep scheduled actions idempotent when duplicate work would be harmful. See [Execution behavior](/docs/actors/schedule#execution-behavior) for retry behavior and workflow guidance.
## Topology
Use a singleton actor key for one global job, such as `jobs["daily-report"]`. Use an actor per user or resource for isolated reminders, trials, billing periods, or other per-entity schedules.
_Source doc path: /cookbook/cron-jobs_

View File

@@ -0,0 +1,152 @@
# Live Cursors and Presence
> Source: `src/content/cookbook/live-cursors.mdx`
> Canonical URL: https://rivet.dev/cookbook/live-cursors
> Description: Live cursors and multiplayer presence with Rivet Actors: per-connection cursor state, realtime updates over events or raw WebSockets, and throttling.
---
Patterns for building live cursors, multiplayer presence, and realtime cursor sharing with RivetKit. One room actor fans cursor positions out to every connected client, keyed per room with [actor keys](/docs/actors/keys).
## Starter Code
Start with one of the two working variants on GitHub. Both implement the same collaborative cursor canvas with persistent text labels; they differ only in transport.
| Variant | Starter Code | Transport | Presence Storage |
| --- | --- | --- | --- |
| `cursors` | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/cursors) | Typed [actions](/docs/actors/actions) and [events](/docs/actors/events) over the RivetKit connection | `connState` per connection |
| `cursors-raw-websocket` | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/cursors-raw-websocket) | Raw [`onWebSocket` handler](/docs/actors/websocket-handler) with a custom JSON message protocol | Socket map in `createVars` |
Use `cursors` by default: typed actions, typed events, and automatic connection tracking cover most apps with less code. Use `cursors-raw-websocket` when you need full control of the wire format, for example a custom JSON or binary protocol, or clients that do not use the RivetKit client library.
## Connection State vs Persistent State
Presence is ephemeral by definition. A cursor position is only meaningful while its connection is alive, so it belongs in per-connection storage, not in persistent actor state. Persistent state is reserved for data that must survive disconnects and actor restarts.
| Data | Where It Lives | Why |
| --- | --- | --- |
| Cursor position | `connState` (`cursors`) or the `createVars` socket map (`cursors-raw-websocket`) | Scoped to one connection and discarded with it. Stale presence cannot accumulate in storage. |
| Text labels (`textLabels`) | Persistent actor `state` in both variants | Canvas content must survive disconnects and actor restarts. |
In the `cursors` variant, `updateCursor` writes `c.conn.state.cursor` and `getRoomState` rebuilds the presence snapshot by iterating `c.conns.values()`, so the cursor map is always derived from live connections rather than stored. See [Connections](/docs/actors/connections) for `connState` and [State](/docs/actors/state) for persistence semantics.
## Presence Lifecycle
- **Join**: The `cursors-raw-websocket` variant pushes an `init` message with the current `{ cursors, textLabels }` snapshot as soon as a socket connects. The `cursors` variant has no explicit join broadcast; the client calls the `getRoomState` action once after connecting to seed its local maps, and peers first see a new user on that user's first `cursorMoved` broadcast.
- **Move**: Every `updateCursor` call writes the connection's presence entry, then broadcasts `cursorMoved` to all connections, including the sender.
- **Leave**: The `cursors` variant handles leave in `onDisconnect`, broadcasting `cursorRemoved` with the connection's last cursor. The raw variant does the same from the socket `close` listener, then deletes the session from the `vars.websockets` map. Clients delete that user from their local cursor map, so stale cursors disappear the moment a tab closes.
See [Lifecycle](/docs/actors/lifecycle) for `onDisconnect` and `createVars`.
## Update Throttling
Neither example throttles. Both frontends send a cursor update on every raw `mousemove` event with no debounce or interval cap. That is fine for a demo, but a fast mouse on a high-refresh display can emit hundreds of events per second per user. The patterns below are recommended production hardening on top of the starter code, not something the examples implement.
| Layer | Pattern | Guidance |
| --- | --- | --- |
| Client (smoothness) | Throttle to 20-30Hz | Sample the latest pointer position every 33-50ms and send only that. Drop intermediate moves, but always flush the final position so cursors settle at the true location. Interpolate between received positions on the rendering side. |
| Server (enforcement) | Per-connection rate limit | Track the last accepted update timestamp per connection and drop or coalesce updates arriving faster than your cap. Client throttles are cooperative; the actor is the enforcement boundary. |
## Actors
- **Key**: `cursorRoom[roomId]` (the frontend defaults `roomId` to `"general"`)
- **Responsibility**: Holds per-connection cursor presence in `connState`, persists shared text labels in actor state, and broadcasts cursor and text updates to all connections.
- **Actions**
- `updateCursor`
- `updateText`
- `removeText`
- `getRoomState`
- **Events**
- `cursorMoved`
- `cursorRemoved`
- `textUpdated`
- `textRemoved`
- **Queues**
- None
- **State**
- JSON
- `textLabels` (persistent)
- `connState.cursor` per connection (ephemeral)
- **Key**: `cursorRoom[roomId]` (resolved via `client.cursorRoom.getOrCreate(roomId)`)
- **Responsibility**: Exposes a raw WebSocket endpoint, tracks live sockets and their cursors in a `createVars` map keyed by a `sessionId` query parameter, persists text labels, and manually fans JSON frames out to every socket.
- **Actions**
- `getOrCreate` (stub returning `{ status: "ok" }`; the frontend resolves the actor ID with the client handle's `getOrCreate(roomId).resolve()`, which creates the actor without dispatching this action)
- `getRoomState`
- **Queues**
- None
- **State**
- JSON
- `textLabels` (persistent)
- `vars.websockets` map of `sessionId` to socket and cursor (in-memory, lost on restart)
The raw variant defines no RivetKit events. Its message names are `type` fields on raw JSON frames:
| Direction | Message `type` | Payload |
| --- | --- | --- |
| Client to server | `updateCursor` | `{ userId, x, y }` |
| Client to server | `updateText` | `{ id, userId, text, x, y }` |
| Client to server | `removeText` | `{ id }` |
| Server to client | `init` | `{ cursors, textLabels }` snapshot on connect |
| Server to client | `cursorMoved`, `textUpdated`, `textRemoved`, `cursorRemoved` | The corresponding cursor, label, or ID payload |
## Lifecycle
### cursors (Actions + Events)
```mermaid
sequenceDiagram
participant A as Client A
participant R as cursorRoom
participant B as Other Clients
A->>R: connect via useActor (cursorRoom[roomId])
A->>R: getRoomState()
R-->>A: {cursors, textLabels}
loop every mouse move
A->>R: updateCursor(userId, x, y)
Note over R: write c.conn.state.cursor
R-->>B: cursorMoved (broadcast)
end
A->>R: updateText(id, userId, text, x, y)
Note over R: upsert persistent state.textLabels
R-->>B: textUpdated (broadcast)
Note over A: tab closes
Note over R: onDisconnect reads conn.state.cursor
R-->>B: cursorRemoved (broadcast)
```
### cursors-raw-websocket
```mermaid
sequenceDiagram
participant A as Client A
participant R as cursorRoom
participant B as Other Clients
A->>R: getOrCreate(roomId).resolve()
R-->>A: actorId
A->>R: open WebSocket /gateway/{actorId}/websocket?sessionId=...
Note over R: close 1008 if sessionId is missing
Note over R: store socket in vars.websockets
R-->>A: init {cursors, textLabels}
loop every mouse move
A->>R: {type: "updateCursor"} frame
Note over R: update session cursor in vars
R-->>B: cursorMoved frame
end
Note over A: socket closes
R-->>B: cursorRemoved frame
Note over R: delete session from vars.websockets
```
## Security Checklist
Both examples ship without authentication so the presence pattern stays readable. Everything below is recommended hardening for production, not behavior the examples implement.
- **Identity**: Bind presence identity to the connection (`c.conn.id` in the actions variant, a server-generated session ID in the raw variant). Never trust a client-supplied `userId`; in the examples it is a random client-generated string, so any client can impersonate or remove any cursor.
- **Authorization**: Authorize label mutations by owner. In the examples, `updateText` accepts arbitrary `id` and `userId` arguments and `removeText` accepts an arbitrary `id`, so any client can edit or delete any label.
- **Input validation**: Clamp `x` and `y` to canvas bounds, cap text label length, and cap the total `textLabels` count so persistent state cannot grow unbounded.
- **Rate limiting**: Enforce a per-connection cap on `updateCursor` (for example 30Hz) and on label writes, as described in [Update Throttling](#update-throttling).
- **Protocol strictness (raw variant)**: Validate message shape before use and close the socket on malformed JSON instead of logging and continuing. Reject duplicate `sessionId` values rather than silently overwriting another session's socket entry.
_Source doc path: /cookbook/live-cursors_

View File

@@ -0,0 +1,713 @@
# Multiplayer Game
> Source: `src/content/cookbook/multiplayer-game.mdx`
> Canonical URL: https://rivet.dev/cookbook/multiplayer-game
> Description: Pragmatic patterns for building multiplayer games: matchmaking, tick loops, realtime state, interest management, and validation.
---
Patterns for building multiplayer games with RivetKit, intended as a practical checklist you can adapt per genre.
## Starter Code
Start with one of the working examples on [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/) and adapt it to your game. Do not start from scratch for matchmaking and lifecycle flows.
| Game Classification | Starter Code | Common Examples |
| --- | --- | --- |
| Battle Royale | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/battle-royale/) | Fortnite, Apex Legends, PUBG, Warzone |
| Arena | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/arena/) | Call of Duty TDM/FFA, Halo Slayer, Counter-Strike casual, VALORANT unrated, Overwatch Quick Play, Rocket League |
| IO Style | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/io-style/) | Agar.io, Slither.io, surviv.io |
| Open World | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/open-world/) | Minecraft survival servers, Rust-like worlds, MMO zone/chunk worlds |
| Party | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/party/) | Fall Guys private lobbies, custom game rooms, social party sessions |
| Physics 2D | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/physics-2d/) | Top-down physics brawlers, 2D arena games, platform fighters |
| Physics 3D | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/physics-3d/) | Physics sandbox sessions, 3D arena games, movement playgrounds |
| Ranked | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/ranked/) | Chess ladders, competitive card games, duel arena ranked queues |
| Turn-Based | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/turn-based/) | Chess correspondence, Words With Friends, async board games |
| Idle | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/idle/) | Cookie Clicker, Idle Miner Tycoon, Adventure Capitalist |
## Server Simulation
### Game Loop And Tick Rates
| Pattern | Use When | Implementation Guidance |
| --- | --- | --- |
| Fixed realtime loop | Battle Royale, Arena, IO Style, Open World, Ranked | Run in `run` with `sleep(tickMs)` and exit on `c.aborted`. |
| Action-driven updates | Party, Turn-Based | Mutate and broadcast only on actions/events rather than scheduled ticks. |
| Coarse offline progression | Any mode with idle progression | Use `c.schedule.after(...)` with coarse windows (for example 5 to 15 minutes) and apply catch-up from elapsed wall clock time. |
### Physics
Start with custom kinematic logic for simple games. Switch to a full physics engine when you need joints, stacked bodies, high collision density, or complex shapes (rotated polygons, capsules, convex hulls, triangle meshes).
Pick one engine per simulation. Keep frontend-only libs out of backend simulation paths and treat server state as authoritative.
| Dimension | Primary Engine | Fallback Engines | Example Code |
| --- | --- | --- | --- |
| 2D | `@dimforge/rapier2d` | `planck-js`, `matter-js` | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/physics-2d/) |
| 3D | `@dimforge/rapier3d` | `cannon-es`, `ammo.js` | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/physics-3d/) |
### Spatial Indexing
For non-physics spatial queries, use a dedicated index instead of naive `O(n^2)` checks:
| Index Type | Recommendation |
| --- | --- |
| AABB index | For AOI, visibility, and non-collider entities, use `rbush` for dynamic sets or `flatbush` for static-ish sets. |
| Point index | For nearest-neighbor or within-radius queries, use `d3-quadtree`. |
## Networking & State Sync
### Netcode
| Model | When To Use | Implementation |
| --- | --- | --- |
| Hybrid (client movement, server combat) | Shooters, action sports, ranked duels | Client owns movement and sends capped-rate position updates. Server validates for anti-cheat. Combat (projectiles, hits, damage) is fully server-authoritative. |
| Server-authoritative with interpolation | IO Style, persistent worlds | Client sends input commands. Server simulates on fixed ticks and publishes authoritative snapshots. Client interpolates between snapshots. |
| Server-authoritative (basic logic) | Turn-based, event-driven | Server validates and applies discrete actions (turns, phase transitions, votes). Client displays confirmed state. |
### Realtime Data Model
- **Snapshots and diffs**: Publish state as events. Send a full snapshot on join/resync, then per-tick diffs for regular updates.
- **Batch per tick**: Keep events small and typed. Batch high-frequency updates per tick.
- **Avoid UI framework state for game updates**: Use `requestAnimationFrame` or a Canvas/Three.js loop for simulation, not React state. Reserve UI framework state for menus, HUD, and forms.
- **Broadcast vs per-connection**: Use `c.broadcast(...)` for shared updates and `conn.send(...)` for private/per-player data.
### Shared Simulation Logic
Shared simulation logic runs on both the client and the server. For example, an `applyInput(state, input, dt)` function that integrates velocity and clamps to world bounds can run on the client for prediction and on the server for validation.
- **Hybrid modes**: Client runs shared movement as primary authority, server runs it for anti-cheat validation.
- **Server-authoritative modes**: Client uses shared logic for interpolation and prediction only.
- **Keep it pure**: Movement integration, input transforms, collision helpers, and constants only.
- **Put shared code in `src/shared/`**: Keep deterministic helpers in `src/shared/sim/*` with no side effects.
### Interest Management
Control what each client receives to reduce bandwidth and prevent information leaks.
#### Per-Player Replication Filters
- **Filter by relevance**: Send each client only state relevant to that player (proximity, line-of-sight, team, or game phase).
- **Shooters and action games**: Limit replication by proximity and optional field-of-view checks.
- **Server-side only**: Clients should never receive data they should not see.
#### Sharded Worlds
- **Partition large worlds**: Use chunk actors keyed by `worldId:chunkX:chunkY`.
- **Subscribe to nearby chunks**: Clients connect only to nearby partitions (for example a 3x3 chunk window).
- **Use sparingly**: Only when the world is large and state-heavy (sandbox builders, MMOs), not as a default for small matches.
## Backend Infrastructure
### Persistence
- **In-memory state**: Best for realtime game state that changes every tick (player positions, inputs, match phase, scores).
- **SQLite (`rivetkit/db`)**: Better for large or table-like state that needs queries, indexes, or long-term persistence (tiles, inventory, matchmaking pools). Serialize DB work through a queue since multiple actions can hit the same actor concurrently.
### Matchmaking Patterns
Common building blocks used across the architecture patterns below.
#### Actor Topology
| Primitive | Use When | Typical Ownership |
| --- | --- | --- |
| `matchmaker["main"]` + `match[matchId]` | Session-based multiplayer (battle royale, arena, ranked, party, turn-based) | Matchmaker owns discovery/assignment. Match owns lifecycle and gameplay state. |
| `chunk[worldId,chunkX,chunkY]` | Large continuous worlds that need sharding | Each chunk owns local players, chunk state, and local simulation. |
| `world[playerId]` | Per-player progression loops (idle/solo world state) | Per-player resources, buildings, timers, and progression. |
| `player[username]` | Canonical profile/rating reused across matches | Durable player stats (for example rating and win/loss). |
| `leaderboard["main"]` | Shared rankings across many matches/players | Global ordered score rows and top lists. |
#### Queueing Strategy
- Multiple players can hit the matchmaker at the same time, so actions like find/create, queue/unqueue, and close need to be serialized through actor queues to avoid races.
- Match-local actions (gameplay, scoring) do not need queueing unless they write back to the matchmaker.
## Security And Anti-Cheat
Start with this baseline, then harden further for competitive or high-risk environments.
### Baseline Checklist
- **Identity**: Use `c.conn.id` as the authoritative transport identity. Treat `playerId`/`username` in params as untrusted input and bind through server-issued assignment/join tickets.
- **Authorization**: Validate the caller is allowed to mutate the target entity (room membership, turn ownership, host-only actions).
- **Input validation**: Clamp sizes/lengths, validate enums, and validate usernames (length, allowed chars, avoid unbounded Unicode).
- **Rate limiting**: Per-connection rate limits for spammy actions (chat, join/leave, fire, movement updates).
- **State integrity**: Server recomputes derived state (scores, win conditions, placements). Never allow client-authoritative changes to inventory/currency/leaderboard totals.
### Movement Validation
For any mode with client-authoritative movement (hybrid flows), clients may send position/rotation updates for smoothness, but the server must:
- Enforce max delta per update (speed cap) based on elapsed time.
- Reject or clamp teleports.
- Enforce world bounds (and basic collision if applicable).
- Rate limit update frequency (for example 20Hz max).
## Architecture Patterns
Each game type below starts with a quick summary table, then details actors and lifecycle.
### Battle Royale
| Topic | Summary |
| --- | --- |
| Matchmaking | Immediate routing to the fullest non-started lobby (oldest tie-break); players wait in lobby until capacity, then the match starts. |
| Netcode | Hybrid. Client owns movement, camera, and local prediction. Server owns zone state, projectiles, hit resolution, eliminations, loot, and final placement. |
| Tick Rate | 10 ticks/sec (`100ms`) with a fixed loop for zone progression and lifecycle checks. |
| Physics | Client owns movement with server anti-cheat validation; projectiles, hits, and damage are server-authoritative. Use `@dimforge/rapier3d` for 3D or `@dimforge/rapier2d` for top-down 2D. |
**Actors**
- **Key**: `matchmaker["main"]`
- **Responsibility**: Finds or creates lobbies, tracks pending reservations, and maintains occupancy.
- **Actions**
- `findMatch`
- `pendingPlayerConnected`
- `updateMatch`
- `closeMatch`
- **Queues**
- `findMatch`
- `pendingPlayerConnected`
- `updateMatch`
- `closeMatch`
- **State**
- SQLite
- `matches`
- `pending_players`
- `player_count` includes connected and pending players
- **Key**: `match[matchId]`
- **Responsibility**: Runs lobby/live/finished phases, owns player state, zone progression, and eliminations.
- **Actions**
- `connect`
- Movement and combat actions
- **Queues**
- None
- **State**
- JSON
- `phase`
- `players`
- `zone`
- `eliminations`
- `snapshot data`
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant MM as matchmaker
participant M as match
C->>MM: findMatch()
alt no open lobby
MM->>M: create(matchId)
end
MM-->>C: {matchId, playerId}
C->>M: connect(playerId)
M->>MM: pendingPlayerConnected(matchId, playerId)
MM-->>M: accepted
Note over M: lobby countdown -> live
M-->>C: snapshot + shoot events
M->>MM: closeMatch(matchId)
```
### Arena
| Topic | Summary |
| --- | --- |
| Matchmaking | Mode-based fixed-capacity queues (`duo`, `squad`, `ffa`) that build only full matches and pre-assign teams (except FFA). |
| Netcode | Hybrid. Client owns movement plus prediction and smoothing. Server owns team or FFA assignment, projectiles, hit resolution, phase transitions, and scoring. |
| Tick Rate | 20 ticks/sec (`50ms`) with a tighter loop for live team and FFA snapshots. |
| Physics | Medium to high intensity; client movement with server validation and server-authoritative combat/entities. |
**Actors**
- **Key**: `matchmaker["main"]`
- **Responsibility**: Runs mode queues, builds full matches, assigns teams, and publishes assignments.
- **Actions**
- `queueForMatch`
- `unqueueForMatch`
- `matchCompleted`
- **Queues**
- `queueForMatch`
- `unqueueForMatch`
- `matchCompleted`
- **State**
- SQLite
- `player_pool`
- `matches`
- `assignments` keyed by connection and player
- **Key**: `match[matchId]`
- **Responsibility**: Runs match phases and in-match player/team state for score and win conditions.
- **Actions**
- `connect`
- Gameplay actions
- **Queues**
- None
- **State**
- JSON
- `phase`
- `players`
- `team assignments`
- `score and win state`
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant MM as matchmaker
participant M as match
C->>MM: queueForMatch(mode)
Note over MM: enqueue in player_pool
Note over MM: fill when capacity reached
MM->>M: create(matchId, assignments)
Note over MM: persist assignments
MM-->>C: assignmentReady
C->>M: connect(playerId)
Note over M: waiting -> live when all players connect
M->>MM: matchCompleted(matchId)
```
### IO Style
| Topic | Summary |
| --- | --- |
| Matchmaking | Open-lobby routing to the fullest room below capacity; room counts are heartbeated and new lobbies are auto-created when needed. |
| Netcode | Server-authoritative with interpolation. Client sends input intents and interpolates. Server owns movement, bounds, room membership, and canonical snapshots. |
| Tick Rate | 10 ticks/sec (`100ms`) with lightweight periodic room snapshots. |
| Physics | Low to medium intensity; server-authoritative kinematic movement, escalating to a physics engine only when collisions get complex. |
**Actors**
- **Key**: `matchmaker["main"]`
- **Responsibility**: Routes players into the fullest open lobby and tracks reservations and occupancy.
- **Actions**
- `findLobby`
- `pendingPlayerConnected`
- `updateMatch`
- `closeMatch`
- **Queues**
- `findLobby`
- `pendingPlayerConnected`
- `updateMatch`
- `closeMatch`
- **State**
- SQLite
- `matches`
- `pending_players`
- Occupancy includes pending reservations
- **Key**: `match[matchId]`
- **Responsibility**: Runs per-match movement simulation and broadcasts snapshots.
- **Actions**
- `connect`
- `setInput`
- **Queues**
- None
- **State**
- JSON
- `players`
- `inputs`
- `movement state`
- `snapshot cache`
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant MM as matchmaker
participant M as match
C->>MM: findLobby()
alt no open lobby
MM->>M: create(matchId)
end
MM-->>C: {matchId, playerId}
C->>M: connect(playerId)
M->>MM: pendingPlayerConnected(matchId, playerId)
MM-->>M: accepted
Note over M: fixed tick simulation
M-->>C: snapshot events
M->>MM: closeMatch(matchId)
```
### Open World
| Topic | Summary |
| --- | --- |
| Matchmaking | Client-driven chunk routing from world coordinates, with nearby chunk windows preloaded via adjacent chunk connections. |
| Netcode | Hybrid for sandbox (client movement with validation) or server-authoritative for MMO-like flows. Server owns chunk routing, persistence, and canonical world state. |
| Tick Rate | 10 ticks/sec per chunk actor (`100ms`), so load scales with active chunks. |
| Physics | Medium to high at scale; chunk-local simulation can be server-authoritative (MMO-like) or client movement with server validation (sandbox-like). |
**Actors**
- **Key**: `chunk[worldId,chunkX,chunkY]`
- **Responsibility**: Owns chunk-local players, blocks, movement tick, and chunk membership.
- **Actions**
- `connect`
- `enterChunk`
- `addPlayer`
- `setInput`
- `leaveChunk`
- `removePlayer`
- **Queues**
- None
- **State**
- JSON
- `connections`
- `players`
- `blocks` scoped to one chunk key
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant CH as chunk
Note over C: resolve chunk keys from world position
loop each visible chunk
C->>CH: connect(worldId, chunkX, chunkY, playerId)
Note over CH: store connection metadata
end
C->>CH: enterChunk/addPlayer
loop movement updates
C->>CH: setInput(...)
CH-->>C: snapshot
end
C->>CH: leaveChunk/removePlayer or disconnect
Note over CH: remove membership and metadata
```
### Party
| Topic | Summary |
| --- | --- |
| Matchmaking | Host-created private party flow using party codes and explicit joins. |
| Netcode | Server-authoritative (basic logic). Server owns membership, host permissions, and phase transitions. |
| Tick Rate | No continuous tick; updates are event-driven (`join`, `start`, `finish`). |
| Physics | Low intensity for lobby-first flows; usually no dedicated physics or indexing unless you add realtime mini-games. |
**Actors**
- **Key**: `matchmaker["main"]`
- **Responsibility**: Handles party create/join flow, validates join tickets, and tracks party size.
- **Actions**
- `createParty`
- `joinParty`
- `verifyJoin`
- `updatePartySize`
- `closeParty`
- **Queues**
- `createParty`
- `joinParty`
- `verifyJoin`
- `updatePartySize`
- `closeParty`
- **State**
- SQLite
- `parties`
- `join_tickets` for party lookup and join validation
- **Key**: `match[matchId]`
- **Responsibility**: Owns party members, host role, ready flags, and phase transitions.
- **Actions**
- `connect`
- `startGame`
- `finishGame`
- **Queues**
- None
- **State**
- JSON
- `members`
- `host`
- `ready state`
- `phase`
- `party events`
**Lifecycle**
### Host Flow
```mermaid
sequenceDiagram
participant H as Host Client
participant MM as matchmaker
participant M as match
H->>MM: createParty()
MM-->>H: {matchId, partyCode, playerId, joinToken}
H->>M: connect(playerId, joinToken)
M->>MM: verifyJoin(...)
MM-->>M: allowed
M->>MM: updatePartySize(playerCount)
H->>M: startGame() / finishGame()
M->>MM: closeParty(matchId)
```
### Joiner Flow
```mermaid
sequenceDiagram
participant J as Joiner Client
participant MM as matchmaker
participant M as match
J->>MM: joinParty(partyCode)
MM-->>J: {matchId, playerId, joinToken}
J->>M: connect(playerId, joinToken)
M->>MM: verifyJoin(...)
MM-->>M: allowed / denied
M->>MM: updatePartySize(playerCount)
```
### Ranked
| Topic | Summary |
| --- | --- |
| Matchmaking | ELO-based queue pairing with a widening search window as wait time increases. |
| Netcode | Hybrid. Client owns movement with local prediction and interpolation. Server owns projectiles, hit resolution, match results, and rating updates. |
| Tick Rate | 20 ticks/sec (`50ms`) with fixed live ticks for deterministic pacing and broadcast cadence. |
| Physics | Medium to high intensity; client movement with server validation and server-authoritative combat/hit resolution. |
**Actors**
- **Key**: `matchmaker["main"]`
- **Responsibility**: Runs rating-based queueing, pairing, assignment persistence, and completion fanout.
- **Actions**
- `queueForMatch`
- `unqueueForMatch`
- `matchCompleted`
- **Queues**
- `queueForMatch`
- `unqueueForMatch`
- `matchCompleted`
- **State**
- SQLite
- `player_pool`
- `matches`
- `assignments` with rating window and connection scoping
- **Key**: `match[matchId]`
- **Responsibility**: Runs ranked match phase, score, and winner reporting.
- **Actions**
- `connect`
- Gameplay actions
- **Queues**
- None
- **State**
- JSON
- `phase`
- `players`
- `score`
- `winner`
- `completion payload`
- **Key**: `player[username]`
- **Responsibility**: Stores canonical player MMR and win/loss profile.
- **Actions**
- `initialize`
- `getRating`
- `applyMatchResult`
- **Queues**
- None
- **State**
- JSON
- `rating`
- `wins`
- `losses`
- `match counters`
- **Key**: `leaderboard["main"]`
- **Responsibility**: Stores and serves top-ranked players.
- **Actions**
- `updatePlayer`
- **Queues**
- None
- **State**
- SQLite
- Leaderboard score rows
- Top-list ordering
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant MM as matchmaker
participant P as player
participant M as match
participant LB as leaderboard
C->>MM: queueForMatch(username)
MM->>P: initialize/getRating
P-->>MM: rating
Note over MM: store queue row + retry pairing
MM->>M: create(matchId, assigned players)
MM-->>C: assignmentReady
C->>M: connect(username)
M->>MM: matchCompleted(...)
MM->>P: applyMatchResult(...)
MM->>LB: updatePlayer(...)
Note over MM: remove matches + assignments rows
```
### Turn-Based
| Topic | Summary |
| --- | --- |
| Matchmaking | Async private-invite and public-queue pairing in the same pattern. |
| Netcode | Server-authoritative (basic logic). Client can draft moves before submit. Server owns turn ownership, committed move log, turn order, and completion state. |
| Tick Rate | No continuous tick; move submission and turn transitions drive updates. |
| Physics | Very low intensity; no realtime physics loop, just discrete rules validation. Indexing is optional and mostly for board or query convenience at scale. |
**Actors**
- **Key**: `matchmaker["main"]`
- **Responsibility**: Handles private invite and public queue pairing for async matches.
- **Actions**
- `createGame`
- `joinByCode`
- `queueForMatch`
- `unqueueForMatch`
- `closeMatch`
- **Queues**
- `createGame`
- `joinByCode`
- `queueForMatch`
- `unqueueForMatch`
- `closeMatch`
- **State**
- SQLite
- `matches`
- `player_pool`
- `assignments` for invite and queue mapping
- **Key**: `match[matchId]`
- **Responsibility**: Owns board state, turn order, move validation, and final result.
- **Actions**
- `connect`
- `makeMove`
- **Queues**
- None
- **State**
- JSON
- `board`
- `turns`
- `players`
- `connection presence`
- `result`
**Lifecycle**
### Public Queue
```mermaid
sequenceDiagram
participant A as Client A
participant B as Client B
participant MM as matchmaker
participant M as match
A->>MM: queueForMatch()
B->>MM: queueForMatch()
Note over MM: pair first two queued players
MM->>M: create(matchId) + seed X/O players
MM-->>A: assignment/match info
MM-->>B: assignment/match info
A->>M: connect(playerId)
B->>M: connect(playerId)
A->>M: makeMove()
B->>M: makeMove()
opt all players disconnected for timeout
Note over M: destroy after idle timeout
end
M->>MM: closeMatch(matchId)
```
### Private Invite
```mermaid
sequenceDiagram
participant A as Client A
participant B as Client B
participant MM as matchmaker
participant M as match
A->>MM: createGame()
MM-->>A: {matchId, playerId, inviteCode}
B->>MM: joinByCode(inviteCode)
MM->>M: create(matchId) + seed X/O players
MM-->>A: assignment/match info
MM-->>B: assignment/match info
A->>M: connect(playerId)
B->>M: connect(playerId)
A->>M: makeMove()
B->>M: makeMove()
M->>MM: closeMatch(matchId)
```
### Idle
| Topic | Summary |
| --- | --- |
| Matchmaking | No matchmaker; each player uses a direct per-player actor and a shared leaderboard actor. |
| Netcode | Server-authoritative (basic logic). Client owns UI and build intent. Server owns resources, production rates, building validation, and leaderboard totals. |
| Tick Rate | No continuous tick; use `c.schedule.after(...)` for coarse intervals and compute offline catch-up from elapsed wall time. |
| Physics | None for standard idle loops; transitions are discrete (`build`, `collect`, `upgrade`) and do not need spatial indexing. |
**Actors**
- **Key**: `world[playerId]`
- **Responsibility**: Owns one player's progression, buildings, production scheduling, and state updates.
- **Actions**
- `initialize`
- `build`
- `collectProduction`
- **Queues**
- None
- **State**
- JSON
- Per-player buildings
- `resources`
- `timers`
- `progression state`
- **Key**: `leaderboard["main"]`
- **Responsibility**: Stores global scores and serves leaderboard updates.
- **Actions**
- `updateScore`
- **Queues**
- `updateScore`
- **State**
- SQLite
- `scores` table keyed by player
- Current leaderboard totals
**Lifecycle**
```mermaid
sequenceDiagram
participant C as Client
participant W as world
participant LB as leaderboard
C->>W: getOrCreate(playerId) + initialize()
Note over W: seed state + schedule collection
W-->>C: stateUpdate
loop gameplay loop
C->>W: build() / collectProduction()
W->>LB: updateScore(...)
Note over LB: upsert scores
LB-->>C: leaderboardUpdate
W-->>C: stateUpdate
end
```
_Source doc path: /cookbook/multiplayer-game_

View File

@@ -0,0 +1,119 @@
# Database per Tenant
> Source: `src/content/cookbook/per-tenant-database.mdx`
> Canonical URL: https://rivet.dev/cookbook/per-tenant-database
> Description: Multi-tenant data isolation with one Rivet Actor per tenant: the actor key is the tenant id, so each tenant gets its own isolated dataset and migrations.
---
Patterns for database-per-tenant architectures with RivetKit. Instead of one shared database with a `tenant_id` column on every table, each tenant gets its own Rivet Actor, and that actor owns the tenant's entire dataset.
## Starter Code
Start with the working example on [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/per-tenant-database) and adapt it. The example stores each tenant's dataset in JSON actor state and serves a React dashboard with live event updates.
| Topic | Summary |
| --- | --- |
| Isolation | One `companyDatabase` actor per tenant, keyed by company name. Switching tenants swaps the entire dataset. |
| State | JSON actor state holding `employees` and `projects` arrays plus timestamps. No SQLite, no queues, no scheduling. |
| Realtime | Every write action mutates state, then broadcasts a typed event (`employeeAdded`, `projectAdded`) to all connected clients of that tenant. |
| Auth | None. The sign-in screen is cosmetic. Production guidance is in the [security checklist](#security-checklist). |
## The Isolation Model
The actor key is the tenant id. The client connects with `useActor({ name: "companyDatabase", key: [companyName] })` and the actor reads `c.key[0]` in `createState` to seed that tenant's dataset. This gives you:
- **One actor per tenant**: `companyDatabase[tenantId]` addresses exactly one actor instance. Two tenants can never share an actor.
- **One dataset per tenant**: All reads and writes go through that actor's [state](/docs/actors/state), so there is no shared table with a `tenant_id` column to filter incorrectly. Cross-tenant leaks require constructing the wrong key, not forgetting a `WHERE` clause.
- **No key injection**: Keys are arrays, not interpolated strings. `key: [tenantId]` cannot be escaped the way `"tenant:" + tenantId` string concatenation can. See [Keys](/docs/actors/keys).
The example's test ([tests/per-tenant-database.test.ts](https://github.com/rivet-dev/rivet/tree/main/examples/per-tenant-database/tests/per-tenant-database.test.ts)) proves the isolation: data written to `companyDatabase["Alpha Co"]` never appears in `companyDatabase["Beta Co"]`.
## Choosing a State Backend
The example uses plain JSON actor state. The same key-equals-tenant model works with any actor state backend.
| Backend | Use When | Docs | Working Code |
| --- | --- | --- | --- |
| JSON actor state | Small datasets, simple reads, whole dataset fits comfortably in memory. What the example uses. | [State](/docs/actors/state) | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/per-tenant-database) |
| Actor SQLite (`rivetkit/db`) | Tables, indexes, SQL queries, larger-than-memory data, per-tenant relational schema. | [SQLite](/docs/actors/sqlite) | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/kitchen-sink/src/actors/state/sqlite-raw.ts) |
| SQLite + Drizzle | Typed schema, query builder, and generated migration files on top of actor SQLite. | [SQLite + Drizzle](/docs/actors/sqlite-drizzle) | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/kitchen-sink/src/actors/state/sqlite-drizzle/) |
With either SQLite option, every tenant gets its own embedded SQLite database, since the database is scoped to the actor and the actor is scoped to the tenant.
## Migrations
The per-tenant example has no migrations because JSON state has no schema. When you adopt SQLite, migrations run per tenant database:
- **Raw SQL**: `db({ onMigrate })` runs your migration SQL inside a SQLite savepoint before the actor serves traffic. If `onMigrate` throws, all migration SQL rolls back atomically and the actor does not start. See [SQLite](/docs/actors/sqlite).
- **Drizzle**: `drizzle-kit` generates migration files from your typed schema, and `db({ schema, migrations })` applies them when the actor wakes. See [SQLite + Drizzle](/docs/actors/sqlite-drizzle).
Because each tenant has its own database, migrations roll out per actor as each tenant's actor wakes, rather than as one large migration against a shared database.
## Tenant Id Must Come From Auth
The example's sign-in is cosmetic: the client picks any company string and that string becomes the actor key, so any visitor can read and write any tenant's data. Do not ship this. As a required production extension (not implemented by the example):
- Derive the tenant id from a verified credential, such as a JWT claim, never from user input.
- Validate the credential against `c.key` in `onBeforeConnect` (pass/fail) or `createConnState` (store the verified user on connection state). See [Authentication](/docs/actors/authentication) and [Connections](/docs/actors/connections).
- Add per-action permission checks on top of connection-level auth. See [Access Control](/docs/actors/access-control).
## Actors
- **Key**: `companyDatabase[companyName]` (single-element array key; `c.key[0]` is the company name)
- **Responsibility**: One actor per tenant. Holds that company's employees and projects in persistent state, serves reads and writes via actions, and broadcasts mutations to connected clients.
- **Actions**
- `addEmployee`
- `listEmployees`
- `addProject`
- `listProjects`
- `getStats`
- **Queues**
- None
- **Events**
- `employeeAdded`
- `projectAdded`
- **State**
- JSON
- `company_name`
- `employees`
- `projects`
- `created_at`
- `updated_at`
Every write action follows the same mutate-then-broadcast shape: push the record into `c.state`, bump `updated_at`, broadcast the typed event, return the record. See [Actions](/docs/actors/actions) and [Events](/docs/actors/events).
## Lifecycle
```mermaid
sequenceDiagram
participant A as Tenant A client
participant DA as companyDatabase A
participant B as Tenant B client
participant DB as companyDatabase B
Note over A: authenticate and derive tenant id
A->>DA: connect with key [tenantA]
Note over DA: createState seeds company_name, employees, projects
A->>DA: listEmployees() + listProjects() + getStats()
A->>DA: addEmployee(name, role)
DA-->>A: employeeAdded event
B->>DB: connect with key [tenantB]
Note over DB: separate actor, separate dataset
B->>DB: listEmployees()
DB-->>B: tenant B data only
```
In the example, the "authenticate" step is a free-text company picker. The rest of the flow matches the diagram: `createState` seeds the dataset on first creation, the dashboard loads with `listEmployees`, `listProjects`, and `getStats`, and every connected client of the same tenant receives `employeeAdded` and `projectAdded` events.
## Security Checklist
The example ships with none of these. Apply all of them before production.
- **Tenant identity**: Derive the tenant id from a verified JWT claim, never from a client-supplied string.
- **Connection validation**: In `onBeforeConnect` or `createConnState`, verify the credential's tenant claim matches `c.key` and reject mismatches.
- **Per-action authorization**: Check the caller's role before mutating actions (`addEmployee`, `addProject`), not just at connect time. See [Access Control](/docs/actors/access-control).
- **Input validation**: Clamp name and role lengths and validate enums. The example only trims input and substitutes fallback defaults.
- **Key construction**: Always pass the tenant id as an array element (`key: [tenantId]`). Never interpolate tenant ids into key strings, and never build keys from one tenant's input to address another tenant's actor.
- **Growth limits**: As a recommended extension, cap or paginate the `employees` and `projects` arrays. The example lets them grow unboundedly in JSON state; move to [SQLite](/docs/actors/sqlite) when the dataset outgrows memory.
_Source doc path: /cookbook/per-tenant-database_

View File

@@ -0,0 +1,136 @@
# Deploying Rivet in a VPC or Air-Gapped Network
> Source: `src/content/cookbook/vpc-air-gapped.mdx`
> Canonical URL: https://rivet.dev/cookbook/vpc-air-gapped
> Description: Run Rivet entirely inside your own perimeter: single-binary or Docker Compose install, file system storage with no database infrastructure, and no outbound telemetry by default.
---
Patterns for running self-hosted Rivet inside a private network: a VPC without internet egress, an on-premises rack, or a fully air-gapped environment. The engine is one service, the recommended single-node storage backend is the local file system, and the engine makes no outbound connections by default. Self-hosting is the only Rivet deployment model that supports air-gapped networks; see the [Self-Hosting Overview](/docs/self-hosting) for the full comparison with BYOC.
## What Runs Inside the Perimeter
A self-hosted deployment has three components, all of which live inside your network:
| Component | Role | Inside the perimeter |
| --- | --- | --- |
| Your backend | Your application server, including the runner that executes actor code | Yes |
| Rivet Engine | Orchestration service that manages actor lifecycle, routes messages, and serves the dashboard and APIs | Yes |
| Storage | Persistence for actor state. Local file system for single-node, PostgreSQL or FoundationDB for multi-node | Yes |
There is no license server, no Rivet Cloud account, and no callback to `rivet.dev`. Clients inside the perimeter reach actors through the engine's gateway over your private network. See [Architecture](/docs/self-hosting#architecture).
## Single-Binary Install
The engine compiles to a single `rivet-engine` binary. Build it from source outside the perimeter, then copy the binary across the boundary:
```bash
git clone https://github.com/rivet-dev/rivet.git
cd rivet
cargo build --release -p rivet-engine
# Copy target/release/rivet-engine into the perimeter.
```
Prebuilt binaries are coming soon; see [Installing Rivet Engine](/docs/self-hosting/install) for current options.
Run it with the file system backend, which stores everything on local disk and is the production-ready choice for single-node deployments. The [File System](/docs/self-hosting/filesystem) docs list air-gapped environments as a primary use case because it needs no database infrastructure:
```bash
RIVET__database__file_system__path="/var/lib/rivet/data" ./rivet-engine
```
Configuration can also come from files. The engine discovers config at `/etc/rivet/config.json` on Linux (JSON, JSON5, JSONC, YAML, and YML are all supported), and `--config` overrides the path. Environment variables use the `RIVET__` prefix with `__` as the separator. See [Configuration](/docs/self-hosting/configuration).
The engine serves its own dashboard on port `6420`, so inspection and namespace management work with nothing but a browser inside the perimeter.
## Docker Compose Deployment
For Docker hosts without registry access, move the engine image across the boundary the standard way:
```bash
# Outside the perimeter.
docker pull rivetdev/engine:latest
docker save rivetdev/engine:latest -o rivet-engine.tar
# Inside the perimeter.
docker load -i rivet-engine.tar
```
Then run the engine and your app together in one Compose file:
```yaml
services:
rivet-engine:
image: rivetdev/engine:latest
ports:
- "6420:6420"
volumes:
- rivet-data:/data
environment:
RIVET__FILE_SYSTEM__PATH: "/data"
restart: unless-stopped
my-app:
build: .
environment:
RIVET_ENDPOINT: "http://default:admin@rivet-engine:6420"
depends_on:
- rivet-engine
restart: unless-stopped
volumes:
rivet-data:
```
`RIVET_ENDPOINT` uses the format `http://namespace:token@host:port` and tells your app to connect to the engine as a runner instead of running standalone. After both services start, register your runner with the engine through the dashboard or its API. The full walkthrough, including PostgreSQL setup for multi-node deployments, is in [Docker Compose](/docs/self-hosting/docker-compose).
## No Outbound Telemetry
The engine exports traces and metrics only when you opt in with OpenTelemetry. Export is disabled unless `RIVET_OTEL_ENABLED=1` is set, and the export target defaults to a local collector at `http://localhost:4317`. With no configuration, nothing crosses the perimeter.
When you want observability, keep it inside the network:
- Set `RIVET_OTEL_ENABLED=1` and point `RIVET_OTEL_GRPC_ENDPOINT` at a collector you run inside the perimeter.
- Adjust `RIVET_OTEL_SAMPLER_RATIO` to control trace sampling.
- Use the engine's health endpoint for liveness and readiness probes.
See the [Production Checklist](/docs/self-hosting/production-checklist) for monitoring guidance.
## Embedding Rivet in a Customer's Environment
If you ship software that runs inside your customers' VPCs, the same setup turns Rivet into an internal component of your product rather than a service your customers must reach over the internet:
- **Ship the engine next to your app.** Add `rivetdev/engine` to the Compose file or chart you already deliver. Your app finds it over the private network via `RIVET_ENDPOINT`, so one artifact deploys the whole stack.
- **One namespace per install.** The endpoint URL carries the namespace and token (`http://namespace:token@host:port`), so a single image works across customer deployments. See [Endpoints](/docs/general/endpoints).
- **Generate a strong admin token per install.** Replace the default token and keep it server-side. Never include the admin token in `RIVET_PUBLIC_ENDPOINT` or anywhere clients can read it.
- **Public endpoint only when needed.** `RIVET_PUBLIC_ENDPOINT` with a public (`pk_`) token is only required when browser clients connect to actors in [serverless runtime mode](/docs/general/runtime-modes). Backend-only deployments can skip it entirely.
- **TLS at the customer's edge.** Terminate TLS with the customer's reverse proxy or load balancer in front of the engine.
## Scaling Past One Node
| Backend | Use when | Status |
| --- | --- | --- |
| [File System](/docs/self-hosting/filesystem) (RocksDB-based) | Single-node deployments, including air-gapped installs | Production-ready, single node only |
| [PostgreSQL](/docs/self-hosting/postgres) | Multi-node deployments | Recommended for multi-node today, but experimental |
| FoundationDB | Largest production deployments | [Enterprise](/sales) |
For multi-node deployments, run two or more engine nodes behind a load balancer and add NATS for pub/sub, which replaces the default PostgreSQL `LISTEN`/`NOTIFY` path at high throughput. Neither is needed for a single-node file system install. See the [Production Checklist](/docs/self-hosting/production-checklist).
## Perimeter Checklist
- **Admin token**: Generate a strong, random token for engine authentication and verify it is not exposed to clients.
- **TLS termination**: Encrypt connections to the engine via a reverse proxy or load balancer.
- **No public exposure**: Keep port `6420` reachable only from inside the perimeter unless clients outside it genuinely need access.
- **Health checks**: Configure liveness and readiness probes against the engine health endpoint.
- **Telemetry**: Leave OpenTelemetry export off, or point it at a collector inside the network.
- **Backups**: With the file system backend, back up the data directory. With PostgreSQL, configure automated backups and failover.
## Full Configuration
- [Self-Hosting Overview](/docs/self-hosting) for architecture and the self-host vs BYOC comparison
- [Installing Rivet Engine](/docs/self-hosting/install) for Docker, binary, and source installs
- [Docker Container](/docs/self-hosting/docker-container) and [Docker Compose](/docs/self-hosting/docker-compose) for container deployments
- [Kubernetes](/docs/self-hosting/kubernetes) for cluster deployments
- [Configuration](/docs/self-hosting/configuration) for every option and the full JSON schema
- [Endpoints](/docs/general/endpoints) for connecting your backend and clients
- [Production Checklist](/docs/self-hosting/production-checklist) before going live
_Source doc path: /cookbook/vpc-air-gapped_