diff --git a/engine/artifacts/openapi.json b/engine/artifacts/openapi.json index f0acd08474..b19e23adf6 100644 --- a/engine/artifacts/openapi.json +++ b/engine/artifacts/openapi.json @@ -11,7 +11,7 @@ "name": "Apache-2.0", "identifier": "Apache-2.0" }, - "version": "2.0.28" + "version": "2.0.29-rc.1" }, "paths": { "/actors": { diff --git a/examples/game-agario/README.md b/examples/game-agario/README.md new file mode 100644 index 0000000000..b4b1bc9bfc --- /dev/null +++ b/examples/game-agario/README.md @@ -0,0 +1,45 @@ +# Agario Clone - Game Example + +A real-time multiplayer Agario-style game demonstrating how to use Rivet Actors for server-authoritative game state with many concurrent players. + +## Getting Started + +```bash +# Install dependencies +pnpm install + +# Start the development server +pnpm dev +``` + +This will start both the backend actor server and the frontend Vite dev server. Open multiple browser windows to test multiplayer. + +## Features + +- **Server-authoritative game loop**: Game physics run on the actor using `setInterval` in `onWake` +- **Player management**: Players join/leave via `onConnect`/`onDisconnect` +- **Real-time state synchronization**: Game state broadcasts to all connected clients at 60 FPS +- **Collision detection**: Players can eat smaller players to grow + +## Implementation + +This example demonstrates a single game room actor: + +**Game Actor** - Handles all gameplay: +- Uses `onWake` to start a `setInterval` game loop +- Uses `onSleep` to clean up the interval +- Uses `onConnect` to spawn new players +- Uses `onDisconnect` to remove players +- Broadcasts game state updates to all connected clients + +The game update function receives an `ActorContextOf` to access state and broadcast events. + +See the implementation in [`src/backend/registry.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/game-agario/src/backend/registry.ts). + +## Resources + +Read more about [lifecycle hooks](/docs/actors/lifecycle), [connection events](/docs/actors/connections), and [helper types](/docs/actors/helper-types). + +## License + +MIT diff --git a/examples/game-agario/package.json b/examples/game-agario/package.json new file mode 100644 index 0000000000..614ccf7e59 --- /dev/null +++ b/examples/game-agario/package.json @@ -0,0 +1,42 @@ +{ + "name": "game-agario", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "@rivetkit/react": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "dependencies": { + "rivetkit": "workspace:*" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": [ + "react", + "typescript" + ], + "tags": [ + "real-time", + "game" + ], + "priority": 100, + "frontendPort": 5174 + }, + "license": "MIT" +} diff --git a/examples/game-agario/src/backend/registry.ts b/examples/game-agario/src/backend/registry.ts new file mode 100644 index 0000000000..d1c10b261c --- /dev/null +++ b/examples/game-agario/src/backend/registry.ts @@ -0,0 +1,246 @@ +import { type ActorContextOf, actor, setup } from "rivetkit"; +import { + WORLD_WIDTH, + WORLD_HEIGHT, + MIN_PLAYER_RADIUS, + PLAYER_SPEED, + TICK_RATE, + PLAYER_COLORS, + FOOD_COUNT, + FOOD_RADIUS, + FOOD_VALUE, + MAX_LOBBY_SIZE, + type Player, + type Food, + type GameStateEvent, + type LobbyInfo, +} from "../shared/constants"; + +type GameState = { + players: Record; + food: Food[]; + nextFoodId: number; +}; + +// Matchmaker - assigns players to lobbies +export const matchmaker = actor({ + state: { + lobbies: {} as Record, // lobbyId -> playerCount + nextLobbyId: 0, + }, + + actions: { + findLobby: (c): LobbyInfo => { + // Find a lobby with space + for (const lobbyId in c.state.lobbies) { + if (c.state.lobbies[lobbyId] < MAX_LOBBY_SIZE) { + return { lobbyId }; + } + } + + // No lobby with space, create new one + const lobbyId = `lobby-${c.state.nextLobbyId++}`; + c.state.lobbies[lobbyId] = 0; + return { lobbyId }; + }, + + setPlayerCount: (c, lobbyId: string, count: number) => { + if (count <= 0) { + delete c.state.lobbies[lobbyId]; + } else { + c.state.lobbies[lobbyId] = count; + } + }, + }, +}); + +// Game room - actual gameplay +export const gameRoom = actor({ + state: createInitialState(), + + createVars: () => { + return { gameInterval: null as ReturnType | null }; + }, + + onWake: (c) => { + c.vars.gameInterval = setInterval(() => { + updateGame(c as ActorContextOf); + }, TICK_RATE); + }, + + onSleep: (c) => { + if (c.vars.gameInterval) { + clearInterval(c.vars.gameInterval); + } + }, + + onConnect: async (c, conn) => { + // Spawn new player at random position + const player: Player = { + id: conn.id, + x: Math.random() * (WORLD_WIDTH - 100) + 50, + y: Math.random() * (WORLD_HEIGHT - 100) + 50, + radius: MIN_PLAYER_RADIUS, + color: PLAYER_COLORS[ + Math.floor(Math.random() * PLAYER_COLORS.length) + ], + targetX: WORLD_WIDTH / 2, + targetY: WORLD_HEIGHT / 2, + }; + c.state.players[conn.id] = player; + + c.broadcast("playerJoined", { playerId: conn.id }); + + // Notify matchmaker of player count change + const lobbyId = c.key[0]; + const count = Object.keys(c.state.players).length; + await c.client().matchmaker.getOrCreate(["global"]).setPlayerCount(lobbyId, count); + }, + + onDisconnect: async (c, conn) => { + delete c.state.players[conn.id]; + c.broadcast("playerLeft", { playerId: conn.id }); + + // Notify matchmaker of player count change + const lobbyId = c.key[0]; + const count = Object.keys(c.state.players).length; + await c.client().matchmaker.getOrCreate(["global"]).setPlayerCount(lobbyId, count); + }, + + actions: { + setTarget: (c, targetX: number, targetY: number) => { + const player = c.state.players[c.conn.id]; + if (player) { + player.targetX = targetX; + player.targetY = targetY; + } + }, + + getState: (c): GameStateEvent => ({ + players: Object.values(c.state.players), + food: c.state.food, + }), + + getPlayerId: (c): string => { + return c.conn.id; + }, + + getPlayerCount: (c): number => { + return Object.keys(c.state.players).length; + }, + }, +}); + +function createInitialState(): GameState { + const food: Food[] = []; + for (let i = 0; i < FOOD_COUNT; i++) { + food.push(createFood(i)); + } + return { + players: {}, + food, + nextFoodId: FOOD_COUNT, + }; +} + +function updateGame(c: ActorContextOf) { + const players = Object.values(c.state.players); + + // Update player positions - move towards target + for (const player of players) { + const dx = player.targetX - player.x; + const dy = player.targetY - player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 5) { + // Speed decreases as player gets larger + const speed = PLAYER_SPEED * (MIN_PLAYER_RADIUS / player.radius); + const moveX = (dx / distance) * speed; + const moveY = (dy / distance) * speed; + + player.x = Math.max( + player.radius, + Math.min(WORLD_WIDTH - player.radius, player.x + moveX), + ); + player.y = Math.max( + player.radius, + Math.min(WORLD_HEIGHT - player.radius, player.y + moveY), + ); + } + } + + // Check for food collisions + for (const player of players) { + for (let i = c.state.food.length - 1; i >= 0; i--) { + const food = c.state.food[i]; + const dx = food.x - player.x; + const dy = food.y - player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < player.radius + FOOD_RADIUS) { + // Eat the food + player.radius += FOOD_VALUE; + // Replace with new food + c.state.food[i] = createFood(c.state.nextFoodId++); + } + } + } + + // Check for collisions between players + for (let i = 0; i < players.length; i++) { + for (let j = i + 1; j < players.length; j++) { + const p1 = players[i]; + const p2 = players[j]; + + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Check if circles overlap significantly (one center inside the other) + if (distance < Math.max(p1.radius, p2.radius)) { + // Larger player eats smaller player + if (p1.radius > p2.radius * 1.1) { + // Must be 10% bigger to eat + // Absorb mass (area-based) + p1.radius = Math.sqrt( + p1.radius * p1.radius + p2.radius * p2.radius, + ); + // Respawn eaten player + respawnPlayer(p2); + } else if (p2.radius > p1.radius * 1.1) { + p2.radius = Math.sqrt( + p2.radius * p2.radius + p1.radius * p1.radius, + ); + respawnPlayer(p1); + } + } + } + } + + // Broadcast game state to all connected clients + c.broadcast("gameState", { + players: players, + food: c.state.food, + } satisfies GameStateEvent); +} + +function createFood(id: number): Food { + return { + id, + x: Math.random() * WORLD_WIDTH, + y: Math.random() * WORLD_HEIGHT, + color: PLAYER_COLORS[Math.floor(Math.random() * PLAYER_COLORS.length)], + }; +} + +function respawnPlayer(player: Player) { + player.x = Math.random() * (WORLD_WIDTH - 100) + 50; + player.y = Math.random() * (WORLD_HEIGHT - 100) + 50; + player.radius = MIN_PLAYER_RADIUS; + player.targetX = player.x; + player.targetY = player.y; +} + +export const registry = setup({ + use: { matchmaker, gameRoom }, +}); diff --git a/examples/game-agario/src/backend/server.ts b/examples/game-agario/src/backend/server.ts new file mode 100644 index 0000000000..aa0ee6ed61 --- /dev/null +++ b/examples/game-agario/src/backend/server.ts @@ -0,0 +1,3 @@ +import { registry } from "./registry"; + +registry.start(); diff --git a/examples/game-agario/src/frontend/App.tsx b/examples/game-agario/src/frontend/App.tsx new file mode 100644 index 0000000000..2c39459fd4 --- /dev/null +++ b/examples/game-agario/src/frontend/App.tsx @@ -0,0 +1,243 @@ +import { createRivetKit } from "@rivetkit/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + WORLD_WIDTH, + WORLD_HEIGHT, + FOOD_RADIUS, + type GameStateEvent, + type LobbyInfo, +} from "../shared/constants"; + +const { useActor } = createRivetKit("http://localhost:6420"); + +export function App() { + const [lobbyId, setLobbyId] = useState(null); + const [gameState, setGameState] = useState(null); + const [myPlayerId, setMyPlayerId] = useState(null); + const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight }); + const canvasRef = useRef(null); + const mousePos = useRef({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); + + // Handle window resize + useEffect(() => { + const handleResize = () => { + setViewport({ width: window.innerWidth, height: window.innerHeight }); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + // Connect to matchmaker + const matchmaker = useActor({ + name: "matchmaker", + key: ["global"], + }); + + // Get lobby assignment from matchmaker + useEffect(() => { + if (matchmaker.connection && !lobbyId) { + matchmaker.connection.findLobby().then((info: LobbyInfo) => { + setLobbyId(info.lobbyId); + }); + } + }, [matchmaker.connection, lobbyId]); + + // Connect to the assigned game room + const game = useActor({ + name: "gameRoom", + key: [lobbyId ?? ""], + enabled: !!lobbyId, + }); + + + // Listen for game state updates + game.useEvent("gameState", (state: GameStateEvent) => { + setGameState(state); + }); + + // Fetch initial state and player ID when connected + useEffect(() => { + if (game.connection) { + game.connection.getState().then((state: GameStateEvent) => { + setGameState(state); + }); + game.connection.getPlayerId().then((id: string) => { + setMyPlayerId(id); + }); + } + }, [game.connection]); + + // Handle mouse movement + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || !game.connection || !gameState || !myPlayerId) return; + + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + mousePos.current = { x: mouseX, y: mouseY }; + + // Find my player to calculate world coordinates + const myPlayer = gameState.players.find((p) => p.id === myPlayerId); + if (!myPlayer) return; + + // Convert screen coordinates to world coordinates + const cameraX = myPlayer.x - viewport.width / 2; + const cameraY = myPlayer.y - viewport.height / 2; + + const worldX = cameraX + mouseX; + const worldY = cameraY + mouseY; + + game.connection.setTarget(worldX, worldY); + }, + [game.connection, gameState, myPlayerId, viewport], + ); + + // Render game + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const { width, height } = viewport; + + // Find my player for camera + const myPlayer = gameState?.players.find((p) => p.id === myPlayerId); + + // Calculate camera position (centered on player) + const cameraX = myPlayer ? myPlayer.x - width / 2 : 0; + const cameraY = myPlayer ? myPlayer.y - height / 2 : 0; + + // Clear canvas + ctx.fillStyle = "#1a1a2e"; + ctx.fillRect(0, 0, width, height); + + // Draw grid + ctx.strokeStyle = "#2a2a4e"; + ctx.lineWidth = 1; + const gridSize = 50; + + const startX = -((cameraX % gridSize) + gridSize) % gridSize; + const startY = -((cameraY % gridSize) + gridSize) % gridSize; + + for (let x = startX; x < width; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + + for (let y = startY; y < height; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + // Draw world boundaries + ctx.strokeStyle = "#ff4444"; + ctx.lineWidth = 3; + ctx.strokeRect(-cameraX, -cameraY, WORLD_WIDTH, WORLD_HEIGHT); + + if (!gameState) { + ctx.fillStyle = "#666"; + ctx.font = "24px monospace"; + ctx.textAlign = "center"; + ctx.fillText("Connecting...", width / 2, height / 2); + return; + } + + // Draw food + for (const food of gameState.food) { + const screenX = food.x - cameraX; + const screenY = food.y - cameraY; + + // Skip if off screen + if ( + screenX + FOOD_RADIUS < 0 || + screenX - FOOD_RADIUS > width || + screenY + FOOD_RADIUS < 0 || + screenY - FOOD_RADIUS > height + ) { + continue; + } + + ctx.beginPath(); + ctx.arc(screenX, screenY, FOOD_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = food.color; + ctx.fill(); + } + + // Sort players by size (draw smaller ones on top) + const sortedPlayers = [...gameState.players].sort((a, b) => b.radius - a.radius); + + // Draw players + for (const player of sortedPlayers) { + const screenX = player.x - cameraX; + const screenY = player.y - cameraY; + + // Skip if off screen + if ( + screenX + player.radius < 0 || + screenX - player.radius > width || + screenY + player.radius < 0 || + screenY - player.radius > height + ) { + continue; + } + + // Draw player circle + ctx.beginPath(); + ctx.arc(screenX, screenY, player.radius, 0, Math.PI * 2); + ctx.fillStyle = player.color; + ctx.fill(); + + // Draw outline for current player + if (player.id === myPlayerId) { + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 3; + ctx.stroke(); + } else { + ctx.strokeStyle = "rgba(0,0,0,0.3)"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Draw player mass (area as score) + const mass = Math.floor(player.radius * player.radius / 100); + ctx.fillStyle = "#fff"; + ctx.font = `${Math.max(12, player.radius / 3)}px monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(mass), screenX, screenY); + } + + // Draw player count + ctx.fillStyle = "#888"; + ctx.font = "14px monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(`Players: ${gameState.players.length}`, 10, 10); + + // Draw my mass + if (myPlayer) { + const myMass = Math.floor(myPlayer.radius * myPlayer.radius / 100); + ctx.fillText(`Mass: ${myMass}`, 10, 30); + } + + }, [gameState, myPlayerId, viewport]); + + return ( + + ); +} diff --git a/examples/game-agario/src/frontend/index.html b/examples/game-agario/src/frontend/index.html new file mode 100644 index 0000000000..80145fcc8d --- /dev/null +++ b/examples/game-agario/src/frontend/index.html @@ -0,0 +1,30 @@ + + + + + + Agario Clone - Game Example + + + +
+ + + diff --git a/examples/game-agario/src/frontend/main.tsx b/examples/game-agario/src/frontend/main.tsx new file mode 100644 index 0000000000..1961235a6b --- /dev/null +++ b/examples/game-agario/src/frontend/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/game-agario/src/shared/constants.ts b/examples/game-agario/src/shared/constants.ts new file mode 100644 index 0000000000..ecf633c35c --- /dev/null +++ b/examples/game-agario/src/shared/constants.ts @@ -0,0 +1,58 @@ +export const WORLD_WIDTH = 2000; +export const WORLD_HEIGHT = 2000; +// Viewport will be set dynamically based on window size +export const VIEWPORT_WIDTH = 800; // Default, overridden by frontend +export const VIEWPORT_HEIGHT = 600; // Default, overridden by frontend +export const MIN_PLAYER_RADIUS = 20; +export const PLAYER_SPEED = 5; +export const TICK_RATE = 1000 / 60; // 60 FPS + +// Generate random colors for players +export const PLAYER_COLORS = [ + "#ff6b6b", + "#4ecdc4", + "#45b7d1", + "#96ceb4", + "#ffeaa7", + "#dfe6e9", + "#fd79a8", + "#a29bfe", + "#6c5ce7", + "#00b894", +]; + +export type Player = { + id: string; + x: number; + y: number; + radius: number; + color: string; + targetX: number; + targetY: number; +}; + +export type Food = { + id: number; + x: number; + y: number; + color: string; +}; + +export type GameStateEvent = { + players: Player[]; + food: Food[]; +}; + +export const FOOD_COUNT = 200; +export const FOOD_RADIUS = 5; +export const FOOD_VALUE = 3; // How much radius increases when eating food +export const MAX_LOBBY_SIZE = 10; + +export type PlayerInput = { + targetX: number; + targetY: number; +}; + +export type LobbyInfo = { + lobbyId: string; +}; diff --git a/examples/game-agario/tsconfig.json b/examples/game-agario/tsconfig.json new file mode 100644 index 0000000000..d2370b2ff1 --- /dev/null +++ b/examples/game-agario/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/game-agario/vite.config.ts b/examples/game-agario/vite.config.ts new file mode 100644 index 0000000000..e1e0346f80 --- /dev/null +++ b/examples/game-agario/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + server: { + port: 5174, + }, +}); diff --git a/examples/game-pong/README.md b/examples/game-pong/README.md new file mode 100644 index 0000000000..15b69e3851 --- /dev/null +++ b/examples/game-pong/README.md @@ -0,0 +1,51 @@ +# Pong - Game Physics Example + +A real-time multiplayer Pong game demonstrating how to use Rivet Actors for matchmaking and server-authoritative game state management. + +## Getting Started + +```bash +# Install dependencies +pnpm install + +# Start the development server +pnpm dev +``` + +This will start both the backend actor server and the frontend Vite dev server. Open two browser windows to test multiplayer. + +## Features + +- **Matchmaking system**: A dedicated matchmaker actor pairs players together before starting games +- **Server-authoritative game loop**: Game physics run on the actor using `setInterval` in `onWake` +- **Player assignment**: Players are automatically assigned to left/right paddles via `onConnect` +- **Real-time state synchronization**: Game state broadcasts to all connected clients at 60 FPS +- **Spectator support**: Additional connections beyond 2 players watch as spectators + +## Implementation + +This example demonstrates two actor patterns working together: + +**Matchmaker Actor** - Coordinates player matchmaking: +- Maintains a queue of waiting players +- Pairs players and creates unique match IDs +- Tracks active matches + +**Pong Game Actor** - Handles actual gameplay: +- Uses `onWake` to start a `setInterval` game loop +- Uses `onSleep` to clean up the interval +- Uses `onConnect` to assign players to paddles (first = left, second = right) +- Uses `onDisconnect` to handle player departures +- Broadcasts game state updates to all connected clients + +The game update function receives an `ActorContextOf` to access state and broadcast events. + +See the implementation in [`src/backend/registry.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/game-physics/src/backend/registry.ts). + +## Resources + +Read more about [lifecycle hooks](/docs/actors/lifecycle), [connection events](/docs/actors/connections), and [helper types](/docs/actors/helper-types). + +## License + +MIT diff --git a/examples/game-pong/package.json b/examples/game-pong/package.json new file mode 100644 index 0000000000..6fe2126d21 --- /dev/null +++ b/examples/game-pong/package.json @@ -0,0 +1,42 @@ +{ + "name": "game-pong", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --watch src/backend/server.ts", + "dev:frontend": "vite", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "@rivetkit/react": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "dependencies": { + "rivetkit": "workspace:*" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": [ + "react", + "typescript" + ], + "tags": [ + "real-time", + "game" + ], + "priority": 100, + "frontendPort": 5173 + }, + "license": "MIT" +} diff --git a/examples/game-pong/src/backend/registry.ts b/examples/game-pong/src/backend/registry.ts new file mode 100644 index 0000000000..0fc88df230 --- /dev/null +++ b/examples/game-pong/src/backend/registry.ts @@ -0,0 +1,269 @@ +import { type ActorContextOf, actor, setup } from "rivetkit"; +import { + BALL_SPEED, + BALL_SIZE, + CANVAS_HEIGHT, + CANVAS_WIDTH, + PADDLE_HEIGHT, + PADDLE_SPEED, + PADDLE_WIDTH, + TICK_RATE, + type GameState, + type MatchResult, + type PlayerSide, +} from "../shared/constants"; + +// Matchmaking coordinator - pairs players together +export const matchmaker = actor({ + state: { + waitingConnId: null as string | null, + }, + + onDisconnect: (c, conn) => { + if (c.state.waitingConnId === conn.id) { + c.state.waitingConnId = null; + } + }, + + actions: { + findMatch: (c): MatchResult => { + const waitingConnId = c.state.waitingConnId; + + if (waitingConnId && waitingConnId !== c.conn.id) { + // Found opponent - create match and notify waiting player + const matchId = `match-${Date.now()}`; + c.state.waitingConnId = null; + c.conns.get(waitingConnId)?.send("matched", { matchId }); + return { matchId, status: "matched" }; + } + + // No opponent - wait in queue + c.state.waitingConnId = c.conn.id; + return { matchId: null, status: "waiting" }; + }, + + cancelSearch: (c) => { + if (c.state.waitingConnId === c.conn.id) { + c.state.waitingConnId = null; + } + }, + }, +}); + +// Pong game room - actual gameplay +export const pongGame = actor({ + state: createInitialState(), + + createVars: () => { + // Interval will be set in onWake + return { gameInterval: null as ReturnType | null }; + }, + + onWake: (c) => { + // Start the game loop + c.vars.gameInterval = setInterval(() => { + if (c.state.gameStarted) { + updateGame(c as ActorContextOf); + } + }, TICK_RATE); + }, + + onSleep: (c) => { + if (c.vars.gameInterval) { + clearInterval(c.vars.gameInterval); + } + }, + + onConnect: (c, conn) => { + if (!c.state.player1) { + c.state.player1 = conn.id; + c.broadcast("playerJoined", { + player: "left", + playersConnected: 1, + }); + } else if (!c.state.player2) { + c.state.player2 = conn.id; + c.state.gameStarted = true; + c.broadcast("gameStart", { + ball: c.state.ball, + leftPaddle: c.state.leftPaddle, + rightPaddle: c.state.rightPaddle, + score: c.state.score, + gameStarted: true, + }); + } + }, + + onDisconnect: (c, conn) => { + if (c.state.player1 === conn.id) { + c.state.player1 = null; + c.state.gameStarted = false; + c.broadcast("playerLeft", { player: "left" }); + } else if (c.state.player2 === conn.id) { + c.state.player2 = null; + c.state.gameStarted = false; + c.broadcast("playerLeft", { player: "right" }); + } + }, + + actions: { + setInput: (c, direction: "up" | "down" | null) => { + // Determine which player based on connection + if (c.state.player1 === c.conn.id) { + c.state.leftInput = direction; + } else if (c.state.player2 === c.conn.id) { + c.state.rightInput = direction; + } + }, + + getState: (c) => ({ + ball: c.state.ball, + leftPaddle: c.state.leftPaddle, + rightPaddle: c.state.rightPaddle, + score: c.state.score, + gameStarted: c.state.gameStarted, + }), + + getPlayerAssignment: (c): PlayerSide | "spectator" | null => { + if (c.state.player1 === c.conn.id) return "left"; + if (c.state.player2 === c.conn.id) return "right"; + if (c.state.player1 && c.state.player2) return "spectator"; + return null; + }, + + resetGame: (c) => { + if (!c.state.gameStarted) return; + const initial = createInitialState(); + c.state.ball = initial.ball; + c.state.leftPaddle = initial.leftPaddle; + c.state.rightPaddle = initial.rightPaddle; + c.state.score = initial.score; + c.state.leftInput = null; + c.state.rightInput = null; + // Keep players and gameStarted state + }, + }, +}); + +function createInitialState(): GameState { + return { + ball: { + x: CANVAS_WIDTH / 2, + y: CANVAS_HEIGHT / 2, + vx: BALL_SPEED * (Math.random() > 0.5 ? 1 : -1), + vy: BALL_SPEED * (Math.random() - 0.5) * 2, + }, + leftPaddle: { y: CANVAS_HEIGHT / 2 - PADDLE_HEIGHT / 2 }, + rightPaddle: { y: CANVAS_HEIGHT / 2 - PADDLE_HEIGHT / 2 }, + score: { left: 0, right: 0 }, + leftInput: null, + rightInput: null, + player1: null, + player2: null, + gameStarted: false, + }; +} + +function updateGame(c: ActorContextOf) { + const state = c.state; + + // Update paddle positions based on input + if (state.leftInput === "up") { + state.leftPaddle.y = Math.max(0, state.leftPaddle.y - PADDLE_SPEED); + } else if (state.leftInput === "down") { + state.leftPaddle.y = Math.min( + CANVAS_HEIGHT - PADDLE_HEIGHT, + state.leftPaddle.y + PADDLE_SPEED, + ); + } + + if (state.rightInput === "up") { + state.rightPaddle.y = Math.max(0, state.rightPaddle.y - PADDLE_SPEED); + } else if (state.rightInput === "down") { + state.rightPaddle.y = Math.min( + CANVAS_HEIGHT - PADDLE_HEIGHT, + state.rightPaddle.y + PADDLE_SPEED, + ); + } + + // Update ball position + state.ball.x += state.ball.vx; + state.ball.y += state.ball.vy; + + // Ball collision with top/bottom walls + if (state.ball.y <= 0 || state.ball.y >= CANVAS_HEIGHT - BALL_SIZE) { + state.ball.vy = -state.ball.vy; + state.ball.y = Math.max( + 0, + Math.min(CANVAS_HEIGHT - BALL_SIZE, state.ball.y), + ); + } + + // Ball collision with left paddle + if ( + state.ball.x <= PADDLE_WIDTH + 20 && + state.ball.y + BALL_SIZE >= state.leftPaddle.y && + state.ball.y <= state.leftPaddle.y + PADDLE_HEIGHT && + state.ball.vx < 0 + ) { + state.ball.vx = -state.ball.vx * 1.05; + state.ball.x = PADDLE_WIDTH + 20; + const hitPos = + (state.ball.y - state.leftPaddle.y) / PADDLE_HEIGHT - 0.5; + state.ball.vy += hitPos * 3; + } + + // Ball collision with right paddle + if ( + state.ball.x >= CANVAS_WIDTH - PADDLE_WIDTH - 20 - BALL_SIZE && + state.ball.y + BALL_SIZE >= state.rightPaddle.y && + state.ball.y <= state.rightPaddle.y + PADDLE_HEIGHT && + state.ball.vx > 0 + ) { + state.ball.vx = -state.ball.vx * 1.05; + state.ball.x = CANVAS_WIDTH - PADDLE_WIDTH - 20 - BALL_SIZE; + const hitPos = + (state.ball.y - state.rightPaddle.y) / PADDLE_HEIGHT - 0.5; + state.ball.vy += hitPos * 3; + } + + // Clamp ball velocity + const maxVelocity = 15; + state.ball.vx = Math.max( + -maxVelocity, + Math.min(maxVelocity, state.ball.vx), + ); + state.ball.vy = Math.max( + -maxVelocity, + Math.min(maxVelocity, state.ball.vy), + ); + + // Score detection + if (state.ball.x <= 0) { + state.score.right += 1; + resetBall(state); + } else if (state.ball.x >= CANVAS_WIDTH) { + state.score.left += 1; + resetBall(state); + } + + // Broadcast game state to all connected clients + c.broadcast("gameState", { + ball: state.ball, + leftPaddle: state.leftPaddle, + rightPaddle: state.rightPaddle, + score: state.score, + gameStarted: state.gameStarted, + }); +} + +function resetBall(state: GameState) { + state.ball.x = CANVAS_WIDTH / 2; + state.ball.y = CANVAS_HEIGHT / 2; + state.ball.vx = BALL_SPEED * (Math.random() > 0.5 ? 1 : -1); + state.ball.vy = BALL_SPEED * (Math.random() - 0.5) * 2; +} + +export const registry = setup({ + use: { matchmaker, pongGame }, +}); diff --git a/examples/game-pong/src/backend/server.ts b/examples/game-pong/src/backend/server.ts new file mode 100644 index 0000000000..aa0ee6ed61 --- /dev/null +++ b/examples/game-pong/src/backend/server.ts @@ -0,0 +1,3 @@ +import { registry } from "./registry"; + +registry.start(); diff --git a/examples/game-pong/src/frontend/App.tsx b/examples/game-pong/src/frontend/App.tsx new file mode 100644 index 0000000000..1c6b95cfb5 --- /dev/null +++ b/examples/game-pong/src/frontend/App.tsx @@ -0,0 +1,325 @@ +import { createRivetKit } from "@rivetkit/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + BALL_SIZE, + CANVAS_HEIGHT, + CANVAS_WIDTH, + PADDLE_HEIGHT, + PADDLE_WIDTH, + type GameStateEvent, + type MatchResult, + type PlayerSide, +} from "../shared/constants"; + +const { useActor } = createRivetKit("http://localhost:6420"); + +type GamePhase = "menu" | "searching" | "playing"; + +export function App() { + const [phase, setPhase] = useState("menu"); + const [matchId, setMatchId] = useState(null); + const [mySide, setMySide] = useState(null); + const [gameState, setGameState] = useState(null); + const [statusMessage, setStatusMessage] = useState(""); + const canvasRef = useRef(null); + + // Connect to matchmaker (always connected for searching) + const matchmaker = useActor({ + name: "matchmaker", + key: ["global"], + }); + + // Connect to game room only when we have a matchId + const pongGame = useActor({ + name: "pongGame", + key: [matchId || "disconnected"], + enabled: !!matchId, + }); + + // Listen for match event from matchmaker (when another player joins) + matchmaker.useEvent("matched", (data: { matchId: string }) => { + console.log("[frontend] received matched event", data); + setMatchId(data.matchId); + setPhase("playing"); + setStatusMessage("Match found! Connecting..."); + }); + + // Handle matchmaker events + const findMatch = async () => { + if (!matchmaker.connection) return; + + setPhase("searching"); + setStatusMessage("Searching for opponent..."); + + const result: MatchResult = await matchmaker.connection.findMatch(); + + if (result.status === "matched" && result.matchId) { + setMatchId(result.matchId); + setPhase("playing"); + setStatusMessage("Match found! Connecting..."); + } else { + setStatusMessage("Waiting for opponent..."); + // No polling needed - we'll receive a "matched" event when paired + } + }; + + const cancelSearch = async () => { + if (matchmaker.connection) { + await matchmaker.connection.cancelSearch(); + } + setPhase("menu"); + setStatusMessage(""); + }; + + // Handle game connection events + pongGame.useEvent("playerJoined", (data: { player: PlayerSide; playersConnected: number }) => { + if (data.playersConnected === 1) { + setStatusMessage("Waiting for second player..."); + } + }); + + pongGame.useEvent("gameStart", (state: GameStateEvent) => { + setGameState(state); + setStatusMessage("Game started!"); + }); + + pongGame.useEvent("gameState", (state: GameStateEvent) => { + setGameState(state); + }); + + pongGame.useEvent("playerLeft", (data: { player: PlayerSide }) => { + setStatusMessage(`Player ${data.player} left the game`); + }); + + // Fetch initial state and player assignment when connected + useEffect(() => { + if (pongGame.connection) { + pongGame.connection.getState().then((state) => { + setGameState(state); + if (!state.gameStarted) { + setStatusMessage("Waiting for second player..."); + } + }); + pongGame.connection.getPlayerAssignment().then((side: PlayerSide | "spectator" | null) => { + if (side) { + setMySide(side); + if (side === "spectator") { + setStatusMessage("Game is full - watching as spectator"); + } else { + setStatusMessage(`You are player ${side === "left" ? "1 (Left)" : "2 (Right)"}`); + } + } + }); + } + }, [pongGame.connection]); + + // Handle keyboard input + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!pongGame.connection || mySide === "spectator" || !mySide) return; + + if (e.key === "ArrowUp" || e.key === "w") { + pongGame.connection.setInput("up"); + } else if (e.key === "ArrowDown" || e.key === "s") { + pongGame.connection.setInput("down"); + } + }, + [pongGame.connection, mySide], + ); + + const handleKeyUp = useCallback( + (e: KeyboardEvent) => { + if (!pongGame.connection || mySide === "spectator" || !mySide) return; + + if ( + e.key === "ArrowUp" || + e.key === "w" || + e.key === "ArrowDown" || + e.key === "s" + ) { + pongGame.connection.setInput(null); + } + }, + [pongGame.connection, mySide], + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [handleKeyDown, handleKeyUp]); + + // Render game + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Clear canvas + ctx.fillStyle = "#1a1a2e"; + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Draw center line + ctx.strokeStyle = "#333"; + ctx.setLineDash([10, 10]); + ctx.beginPath(); + ctx.moveTo(CANVAS_WIDTH / 2, 0); + ctx.lineTo(CANVAS_WIDTH / 2, CANVAS_HEIGHT); + ctx.stroke(); + ctx.setLineDash([]); + + if (!gameState) { + // Draw waiting message + ctx.fillStyle = "#666"; + ctx.font = "24px monospace"; + ctx.textAlign = "center"; + ctx.fillText("Waiting for players...", CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2); + return; + } + + // Draw paddles + ctx.fillStyle = mySide === "left" ? "#4ade80" : "#60a5fa"; + ctx.fillRect(20, gameState.leftPaddle.y, PADDLE_WIDTH, PADDLE_HEIGHT); + + ctx.fillStyle = mySide === "right" ? "#4ade80" : "#60a5fa"; + ctx.fillRect( + CANVAS_WIDTH - 20 - PADDLE_WIDTH, + gameState.rightPaddle.y, + PADDLE_WIDTH, + PADDLE_HEIGHT, + ); + + // Draw ball (only if game started) + if (gameState.gameStarted) { + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.arc( + gameState.ball.x + BALL_SIZE / 2, + gameState.ball.y + BALL_SIZE / 2, + BALL_SIZE / 2, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + + // Draw score + ctx.fillStyle = "#fff"; + ctx.font = "48px monospace"; + ctx.textAlign = "center"; + ctx.fillText( + `${gameState.score.left} - ${gameState.score.right}`, + CANVAS_WIDTH / 2, + 60, + ); + + // Draw "waiting" overlay if game not started + if (!gameState.gameStarted) { + ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + ctx.fillStyle = "#fff"; + ctx.font = "24px monospace"; + ctx.fillText("Waiting for opponent...", CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2); + } + }, [gameState, mySide]); + + const resetGame = () => { + if (pongGame.connection) { + pongGame.connection.resetGame(); + } + }; + + const leaveGame = () => { + setMatchId(null); + setMySide(null); + setGameState(null); + setPhase("menu"); + setStatusMessage(""); + }; + + // Menu phase + if (phase === "menu") { + return ( +
+

Pong

+

Real-time multiplayer with Rivet Actors

+ +
+ +
+ +
+

Click "Find Match" to play against another player

+
+
+ ); + } + + // Searching phase + if (phase === "searching") { + return ( +
+

Pong

+

Real-time multiplayer with Rivet Actors

+ +
+
+

{statusMessage}

+ +
+
+ ); + } + + // Playing phase + return ( +
+

Pong

+

Real-time multiplayer with Rivet Actors

+ +
+ + {mySide === "spectator" + ? "Spectating" + : mySide === "left" + ? "You: Player 1 (Left)" + : "You: Player 2 (Right)"} + +
+ + +
+
+ + + +
+ {mySide !== "spectator" && ( +

+ Use W/S or Arrow Up/ + Arrow Down to move your paddle +

+ )} +

{statusMessage}

+
+
+ ); +} diff --git a/examples/game-pong/src/frontend/index.html b/examples/game-pong/src/frontend/index.html new file mode 100644 index 0000000000..13358c0dc7 --- /dev/null +++ b/examples/game-pong/src/frontend/index.html @@ -0,0 +1,187 @@ + + + + + + Pong - Game Physics Example + + + +
+ + + diff --git a/examples/game-pong/src/frontend/main.tsx b/examples/game-pong/src/frontend/main.tsx new file mode 100644 index 0000000000..bd39f29eec --- /dev/null +++ b/examples/game-pong/src/frontend/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + +); diff --git a/examples/game-pong/src/shared/constants.ts b/examples/game-pong/src/shared/constants.ts new file mode 100644 index 0000000000..f395a51b49 --- /dev/null +++ b/examples/game-pong/src/shared/constants.ts @@ -0,0 +1,38 @@ +export const CANVAS_WIDTH = 800; +export const CANVAS_HEIGHT = 400; +export const PADDLE_HEIGHT = 80; +export const PADDLE_WIDTH = 10; +export const BALL_SIZE = 10; +export const PADDLE_SPEED = 8; +export const BALL_SPEED = 5; +export const TICK_RATE = 1000 / 60; // 60 FPS + +export type PlayerSide = "left" | "right"; + +export type GameState = { + ball: { x: number; y: number; vx: number; vy: number }; + leftPaddle: { y: number }; + rightPaddle: { y: number }; + score: { left: number; right: number }; + leftInput: "up" | "down" | null; + rightInput: "up" | "down" | null; + player1: string | null; + player2: string | null; + gameStarted: boolean; +}; + +export type GameStateEvent = { + ball: { x: number; y: number; vx: number; vy: number }; + leftPaddle: { y: number }; + rightPaddle: { y: number }; + score: { left: number; right: number }; + gameStarted: boolean; +}; + +export type MatchResult = + | { matchId: string; status: "matched" } + | { matchId: null; status: "waiting" }; + +export type AssignedPlayer = { + player: PlayerSide; +}; diff --git a/examples/game-pong/tsconfig.json b/examples/game-pong/tsconfig.json new file mode 100644 index 0000000000..bf825cc1b4 --- /dev/null +++ b/examples/game-pong/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node", "vite/client"], + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/examples/game-pong/vite.config.ts b/examples/game-pong/vite.config.ts new file mode 100644 index 0000000000..19155bde35 --- /dev/null +++ b/examples/game-pong/vite.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + server: { + host: "0.0.0.0", + port: 5173, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22b361483c..f8e1904819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -880,6 +880,86 @@ importers: specifier: ^5.5.2 version: 5.9.2 + examples/game-agario: + dependencies: + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + '@rivetkit/react': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/react + '@types/node': + specifier: ^22.13.9 + version: 22.19.1 + '@types/react': + specifier: ^19 + version: 19.2.2 + '@types/react-dom': + specifier: ^19 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.2.0 + version: 4.7.0(vite@5.4.20(@types/node@22.19.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.62.0)(terser@5.44.0)) + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + tsx: + specifier: ^3.12.7 + version: 3.14.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.20(@types/node@22.19.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.62.0)(terser@5.44.0) + + examples/game-pong: + dependencies: + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + '@rivetkit/react': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/react + '@types/node': + specifier: ^22.13.9 + version: 22.19.1 + '@types/react': + specifier: ^19 + version: 19.2.2 + '@types/react-dom': + specifier: ^19 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.2.0 + version: 4.7.0(vite@5.4.20(@types/node@22.19.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.62.0)(terser@5.44.0)) + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + tsx: + specifier: ^3.12.7 + version: 3.14.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.20(@types/node@22.19.1)(less@4.4.1)(lightningcss@1.30.2)(sass@1.93.2)(stylus@0.62.0)(terser@5.44.0) + examples/hono: dependencies: '@hono/node-server': diff --git a/rivetkit-asyncapi/asyncapi.json b/rivetkit-asyncapi/asyncapi.json index 137d447f03..20af7ee5dd 100644 --- a/rivetkit-asyncapi/asyncapi.json +++ b/rivetkit-asyncapi/asyncapi.json @@ -2,7 +2,7 @@ "asyncapi": "3.0.0", "info": { "title": "RivetKit WebSocket Protocol", - "version": "2.0.28", + "version": "2.0.29-rc.1", "description": "WebSocket protocol for bidirectional communication between RivetKit clients and actors" }, "channels": { diff --git a/rivetkit-openapi/openapi.json b/rivetkit-openapi/openapi.json index 702b6a5e79..146c91f8da 100644 --- a/rivetkit-openapi/openapi.json +++ b/rivetkit-openapi/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.0", "info": { - "version": "2.0.28", + "version": "2.0.29-rc.1", "title": "RivetKit API" }, "components": {