diff --git a/examples/cursors/.gitignore b/examples/cursors/.gitignore new file mode 100644 index 000000000..79b7a1192 --- /dev/null +++ b/examples/cursors/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules \ No newline at end of file diff --git a/examples/cursors/README.md b/examples/cursors/README.md new file mode 100644 index 000000000..b7b2646a0 --- /dev/null +++ b/examples/cursors/README.md @@ -0,0 +1,43 @@ +# Real-time Collaborative Cursors for RivetKit + +Example project demonstrating real-time cursor tracking and collaborative canvas with [RivetKit](https://rivetkit.org). + +[Learn More →](https://github.com/rivet-dev/rivetkit) + +[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues) + +## Getting Started + +### Prerequisites + +- Node.js 18+ + +### Installation + +```sh +git clone https://github.com/rivet-dev/rivetkit +cd rivetkit/examples/cursors +npm install +``` + +### Development + +```sh +npm run dev +``` + +Open your browser to `http://localhost:5173`. Open multiple tabs or windows to see real-time cursor tracking and text placement across different users. + +## Features + +- Real-time cursor position tracking +- Multiple users with color-coded cursors +- Click-to-place text on canvas +- Multiple room support for different collaborative spaces +- Persistent text labels across sessions +- Event-driven architecture with RivetKit actors +- TypeScript support throughout + +## License + +Apache 2.0 diff --git a/examples/cursors/package.json b/examples/cursors/package.json new file mode 100644 index 000000000..0ed3a5955 --- /dev/null +++ b/examples/cursors/package.json @@ -0,0 +1,33 @@ +{ + "name": "example-cursors", + "version": "2.0.20", + "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", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "@types/prompts": "^2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "prompts": "^2.4.2", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0", + "vitest": "^3.1.1", + "@rivetkit/react": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "dependencies": { + "rivetkit": "workspace:*" + }, + "stableVersion": "0.8.0" +} diff --git a/examples/cursors/src/backend/registry.ts b/examples/cursors/src/backend/registry.ts new file mode 100644 index 000000000..106fe603a --- /dev/null +++ b/examples/cursors/src/backend/registry.ts @@ -0,0 +1,68 @@ +import { actor, setup } from "rivetkit"; + +export type CursorPosition = { + userId: string; + x: number; + y: number; + timestamp: number; +}; + +export type TextLabel = { + id: string; + userId: string; + text: string; + x: number; + y: number; + timestamp: number; +}; + +export const cursorRoom = actor({ + // Persistent state that survives restarts: https://rivet.dev/docs/actors/state + state: { + cursors: {} as Record, + textLabels: [] as TextLabel[], + }, + + actions: { + // Update cursor position + updateCursor: (c, userId: string, x: number, y: number) => { + const cursor: CursorPosition = { userId, x, y, timestamp: Date.now() }; + c.state.cursors[userId] = cursor; + // Send events to all connected clients: https://rivet.dev/docs/actors/events + c.broadcast("cursorMoved", cursor); + return cursor; + }, + + // Place text on the canvas + placeText: (c, userId: string, text: string, x: number, y: number) => { + const textLabel: TextLabel = { + id: `${userId}-${Date.now()}`, + userId, + text, + x, + y, + timestamp: Date.now(), + }; + c.state.textLabels.push(textLabel); + c.broadcast("textPlaced", textLabel); + return textLabel; + }, + + // Get all cursors + getCursors: (c) => c.state.cursors, + + // Get all text labels + getTextLabels: (c) => c.state.textLabels, + + // Remove cursor when user disconnects + removeCursor: (c, userId: string) => { + delete c.state.cursors[userId]; + c.broadcast("cursorRemoved", userId); + }, + }, +}); + +// Register actors for use: https://rivet.dev/docs/setup +export const registry = setup({ + use: { cursorRoom }, +}); diff --git a/examples/cursors/src/backend/server.ts b/examples/cursors/src/backend/server.ts new file mode 100644 index 000000000..b51ac47fe --- /dev/null +++ b/examples/cursors/src/backend/server.ts @@ -0,0 +1,8 @@ +import { registry } from "./registry"; + +registry.start({ + cors: { + origin: "http://localhost:5173", + credentials: true, + }, +}); diff --git a/examples/cursors/src/frontend/App.tsx b/examples/cursors/src/frontend/App.tsx new file mode 100644 index 000000000..b0ba214f4 --- /dev/null +++ b/examples/cursors/src/frontend/App.tsx @@ -0,0 +1,308 @@ +import { createRivetKit } from "@rivetkit/react"; +import { useEffect, useRef, useState } from "react"; +import type { + CursorPosition, + TextLabel, + registry, +} from "../backend/registry"; + +const { useActor } = createRivetKit("http://localhost:6420"); + +// Generate a random user ID +const generateUserId = () => + `user-${Math.random().toString(36).substring(2, 9)}`; + +// Cursor colors for different users (darker palette) +const CURSOR_COLORS = [ + "#E63946", + "#2A9D8F", + "#1B8AAE", + "#F77F00", + "#06A77D", + "#D4A017", + "#9B59B6", + "#5DADE2", +]; + +function getColorForUser(userId: string): string { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = userId.charCodeAt(i) + ((hash << 5) - hash); + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; +} + +// Virtual canvas size - all coordinates are in this space +const CANVAS_WIDTH = 1920; +const CANVAS_HEIGHT = 1080; + +export function App() { + const [roomId, setRoomId] = useState("general"); + const [userId] = useState(generateUserId()); + const [cursors, setCursors] = useState>({}); + const [textLabels, setTextLabels] = useState([]); + const [textInput, setTextInput] = useState(""); + const [isTyping, setIsTyping] = useState(false); + const [typingPosition, setTypingPosition] = useState({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const canvasRef = useRef(null); + const containerRef = useRef(null); + + const cursorRoom = useActor({ + name: "cursorRoom", + key: [roomId], + }); + + // Calculate scale factor to fit canvas in viewport + useEffect(() => { + const updateScale = () => { + if (!containerRef.current) return; + + const containerWidth = containerRef.current.clientWidth; + const containerHeight = containerRef.current.clientHeight; + + // Calculate scale to fit canvas while maintaining aspect ratio + const scaleX = containerWidth / CANVAS_WIDTH; + const scaleY = containerHeight / CANVAS_HEIGHT; + const newScale = Math.min(scaleX, scaleY); + + setScale(newScale); + }; + + updateScale(); + window.addEventListener("resize", updateScale); + return () => window.removeEventListener("resize", updateScale); + }, []); + + // Load initial state + useEffect(() => { + if (cursorRoom.connection) { + cursorRoom.connection.getCursors().then(setCursors); + cursorRoom.connection.getTextLabels().then(setTextLabels); + } + }, [cursorRoom.connection]); + + // Listen for cursor movements + cursorRoom.useEvent("cursorMoved", (cursor: CursorPosition) => { + setCursors((prev) => ({ + ...prev, + [cursor.userId]: cursor, + })); + }); + + // Listen for new text + cursorRoom.useEvent("textPlaced", (label: TextLabel) => { + setTextLabels((prev) => [...prev, label]); + }); + + // Listen for cursor removal + cursorRoom.useEvent("cursorRemoved", (removedUserId: string) => { + setCursors((prev) => { + const newCursors = { ...prev }; + delete newCursors[removedUserId]; + return newCursors; + }); + }); + + // Convert screen coordinates to canvas coordinates + const screenToCanvas = (screenX: number, screenY: number) => { + if (!canvasRef.current) return { x: 0, y: 0 }; + + const rect = canvasRef.current.getBoundingClientRect(); + const x = (screenX - rect.left) / scale; + const y = (screenY - rect.top) / scale; + + return { x, y }; + }; + + // Handle mouse movement on canvas + const handleMouseMove = (e: React.MouseEvent) => { + if (cursorRoom.connection && canvasRef.current) { + const { x, y } = screenToCanvas(e.clientX, e.clientY); + cursorRoom.connection.updateCursor(userId, x, y); + } + }; + + // Handle canvas click + const handleCanvasClick = (e: React.MouseEvent) => { + if (!canvasRef.current) return; + + const { x, y } = screenToCanvas(e.clientX, e.clientY); + setTypingPosition({ x, y }); + setIsTyping(true); + setTextInput(""); + }; + + // Handle key press while typing + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && textInput.trim()) { + // Place the text + if (cursorRoom.connection) { + cursorRoom.connection.placeText( + userId, + textInput, + typingPosition.x, + typingPosition.y, + ); + } + setTextInput(""); + setIsTyping(false); + } else if (e.key === "Escape") { + setTextInput(""); + setIsTyping(false); + } + }; + + // Remove cursor when user disconnects + useEffect(() => { + return () => { + if (cursorRoom.connection) { + cursorRoom.connection.removeCursor(userId); + } + }; + }, [cursorRoom.connection, userId]); + + return ( +
+
+
+ + setRoomId(e.target.value)} + placeholder="Enter room name" + /> +
+
+ Your ID: {userId} +
+
+ +
+
+ {/* Render text labels */} + {textLabels.map((label) => ( +
+ {label.text} +
+ ))} + + {/* Render text being typed */} + {isTyping && ( +
+
+ {textInput} + | +
+
+ enter +
+
+ )} + + {/* Render cursors */} + {Object.entries(cursors).map(([id, cursor]) => { + const color = getColorForUser(cursor.userId); + const isOwnCursor = id === userId; + return ( +
+ + + +
+ {isOwnCursor ? "you" : cursor.userId} +
+
+ ); + })} + + {!cursorRoom.connection && ( +
Connecting to room...
+ )} + + {/* Hidden input to capture typing */} + {isTyping && ( + setTextInput(e.target.value)} + onBlur={() => { + if (!textInput.trim()) { + setIsTyping(false); + } + }} + autoFocus + /> + )} +
+
+
+ ); +} diff --git a/examples/cursors/src/frontend/index.html b/examples/cursors/src/frontend/index.html new file mode 100644 index 000000000..e7c7bafc8 --- /dev/null +++ b/examples/cursors/src/frontend/index.html @@ -0,0 +1,191 @@ + + + + + + Cursors Example + + + +
+ + + \ No newline at end of file diff --git a/examples/cursors/src/frontend/main.tsx b/examples/cursors/src/frontend/main.tsx new file mode 100644 index 000000000..bd39f29ee --- /dev/null +++ b/examples/cursors/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/cursors/tests/cursors.test.ts b/examples/cursors/tests/cursors.test.ts new file mode 100644 index 000000000..235b87cb2 --- /dev/null +++ b/examples/cursors/tests/cursors.test.ts @@ -0,0 +1,132 @@ +import { setupTest } from "rivetkit/test"; +import { expect, test } from "vitest"; +import { registry } from "../src/backend/registry"; + +test("Cursor room can handle cursor updates", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-room"]); + + // Test initial state + const initialCursors = await room.getCursors(); + expect(initialCursors).toEqual({}); + + // Update cursor position + const cursor1 = await room.updateCursor("user1", 100, 200); + + // Verify cursor structure + expect(cursor1).toMatchObject({ + userId: "user1", + x: 100, + y: 200, + timestamp: expect.any(Number), + }); + + // Update another cursor + await room.updateCursor("user2", 300, 400); + + // Verify cursors are stored + const cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(2); + expect(cursors.user1).toBeDefined(); + expect(cursors.user2).toBeDefined(); + expect(cursors.user1.x).toBe(100); + expect(cursors.user1.y).toBe(200); + expect(cursors.user2.x).toBe(300); + expect(cursors.user2.y).toBe(400); +}); + +test("Cursor room can place text labels", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-text"]); + + // Test initial state + const initialLabels = await room.getTextLabels(); + expect(initialLabels).toEqual([]); + + // Place text + const label1 = await room.placeText("user1", "Hello", 50, 75); + + // Verify label structure + expect(label1).toMatchObject({ + id: expect.any(String), + userId: "user1", + text: "Hello", + x: 50, + y: 75, + timestamp: expect.any(Number), + }); + + // Place another text + const label2 = await room.placeText("user2", "World", 150, 175); + + // Verify labels are stored in order + const labels = await room.getTextLabels(); + expect(labels).toHaveLength(2); + expect(labels[0]).toEqual(label1); + expect(labels[1]).toEqual(label2); +}); + +test("Cursor room can remove cursors", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-remove"]); + + // Add some cursors + await room.updateCursor("user1", 100, 200); + await room.updateCursor("user2", 300, 400); + await room.updateCursor("user3", 500, 600); + + let cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(3); + + // Remove one cursor + await room.removeCursor("user2"); + + cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(2); + expect(cursors.user1).toBeDefined(); + expect(cursors.user3).toBeDefined(); + expect(cursors.user2).toBeUndefined(); +}); + +test("Cursor updates overwrite previous positions", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-overwrite"]); + + // Update cursor multiple times + await room.updateCursor("user1", 100, 200); + const cursor2 = await room.updateCursor("user1", 300, 400); + const cursor3 = await room.updateCursor("user1", 500, 600); + + const cursors = await room.getCursors(); + expect(Object.keys(cursors)).toHaveLength(1); + expect(cursors.user1.x).toBe(500); + expect(cursors.user1.y).toBe(600); + expect(cursors.user1.timestamp).toBe(cursor3.timestamp); + expect(cursor3.timestamp).toBeGreaterThanOrEqual(cursor2.timestamp); +}); + +test("Multiple users can place text in the same room", async (ctx) => { + const { client } = await setupTest(ctx, registry); + const room = client.cursorRoom.getOrCreate(["test-multiuser-text"]); + + // Multiple users placing text + await room.placeText("Alice", "Hello!", 10, 10); + await room.placeText("Bob", "Hi there!", 50, 50); + await room.placeText("Charlie", "Good day!", 100, 100); + await room.placeText("Alice", "How are you?", 150, 150); + + const labels = await room.getTextLabels(); + expect(labels).toHaveLength(4); + + // Verify users + expect(labels[0].userId).toBe("Alice"); + expect(labels[1].userId).toBe("Bob"); + expect(labels[2].userId).toBe("Charlie"); + expect(labels[3].userId).toBe("Alice"); + + // Verify text content + expect(labels[0].text).toBe("Hello!"); + expect(labels[1].text).toBe("Hi there!"); + expect(labels[2].text).toBe("Good day!"); + expect(labels[3].text).toBe("How are you?"); +}); diff --git a/examples/cursors/tsconfig.json b/examples/cursors/tsconfig.json new file mode 100644 index 000000000..53e062340 --- /dev/null +++ b/examples/cursors/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "esnext", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["esnext", "dom"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "esnext", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + /* Specify type package names to be included without being referenced in a source file. */ + "types": ["node", "vite/client", "vitest"], + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + }, + "include": ["src/**/*", "actors/**/*", "tests/**/*"] +} diff --git a/examples/cursors/turbo.json b/examples/cursors/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/examples/cursors/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/cursors/vite.config.ts b/examples/cursors/vite.config.ts new file mode 100644 index 000000000..a291f2884 --- /dev/null +++ b/examples/cursors/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + root: "src/frontend", + server: { + port: 5173, + }, +}); diff --git a/examples/cursors/vitest.config.ts b/examples/cursors/vitest.config.ts new file mode 100644 index 000000000..f913a97ab --- /dev/null +++ b/examples/cursors/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + server: { + port: 5173, + }, + test: { + include: ["tests/**/*.test.ts"], + }, +});