skills and photots
This commit is contained in:
127
.agents/skills/multiplayer-game/reference/cookbook/ai-agent.md
Normal file
127
.agents/skills/multiplayer-game/reference/cookbook/ai-agent.md
Normal 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_
|
||||
114
.agents/skills/multiplayer-game/reference/cookbook/chat-room.md
Normal file
114
.agents/skills/multiplayer-game/reference/cookbook/chat-room.md
Normal 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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
Reference in New Issue
Block a user