Build a Live Canvas with Real-Time Multiplayer Drawing
Learn how to build a collaborative drawing canvas where multiple players can draw together in real-time. You’ll use Playroom Kit for multiplayer state synchronization, allowing users to see each other’s strokes as they draw and view remote cursors. This is the same experience you see in tools like Excalidraw, Figma, Miro and many more.
Getting Started
This tutorial shows you how to create a multiplayer drawing canvas application. Players can join a shared room, draw with various tools (pen, eraser, shapes, text), and see each other’s drawings in real-time. The application uses Playroom Kit’s state synchronization to broadcast strokes and cursor positions, with the host managing the shared stroke history.
The app features real-time cursor tracking, multiple drawing tools (pen, eraser, line, arrow, rectangle, circle, text), color and brush size selection, pan and zoom capabilities, and a host-only clear canvas button. All game state is managed through Playroom Kit’s useMultiplayerState and per-player state for live updates.
Vibe Coding System Prompt
Vibe-code friendly
If you are using an AI coding tool, copy this prompt:
You are a senior software engineer at Little Umbrella specializing in building real-time multiplayer applications using Playroom Kit and React.Your task is to help me build a "Live Canvas" - a multiplayer drawing application.The application should be created using React as a frontend framework and Playroom Kit as a multiplayer framework.Do not assume the existence of any Playroom Kit APIs or hooks. Only use APIs documented in the official Playroom Kit documentation.I already have a React project set up.I will provide step-by-step instructions. After each step, clearly explain what was implemented and ask if I want customizations before proceeding.
You can adjust the prompt according to whether or not you have an existing application.
Guide
If you haven’t set up a React project yet, create one before moving to the first step. You’ll need a basic React + TypeScript setup with Tailwind CSS.
If you’re vibe-coding, start by copying the Vibe Coding System prompt. Then copy each step one by one into your coding assistant by clicking “Copy Prompt”.
Step 1: Initialize Playroom Kit
Initialize Playroom Kit using insertCoin to handle multiplayer connections and state synchronization. This enables players to join a shared room and sync their drawing state.
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);Step 2: Create Type Definitions
Define TypeScript interfaces for the drawing canvas data structures including tools, points, strokes, and cursor data.
export type Tool = 'pen' | 'eraser' | 'line' | 'arrow' | 'rectangle' | 'circle' | 'text';
export interface Point {
x: number;
y: number;
}
export interface Stroke {
id: string;
tool: Tool;
points: Point[];
color: string;
size: number;
playerId: string;
startPoint?: Point;
endPoint?: Point;
text?: string;
fontSize?: number;
}
export interface CursorData {
x: number;
y: number;
playerId: string;
color: string;
name: string;
}
export type PLAYER_COLORS = '#ef4444' | '#f97316' | '#eab308' | '#22c55e' | '#06b6d4' | '#3b82f6' | '#8b5cf6' | '#ec4899';Step 3: Create the usePlayroom Hook
Create a custom hook that manages all Playroom Kit functionality including player management, stroke synchronization, and cursor broadcasting. This hook uses insertCoin for initialization, onPlayerJoin to track players, myPlayer to identify the current player, isHost to determine host privileges, getState to read shared state, and setState to broadcast strokes and cursor positions. Player profiles are accessed using the getProfile method from the PlayerState object.
import { useState, useEffect, useCallback, useRef } from 'react';
import { insertCoin, onPlayerJoin, myPlayer, isHost, getState, setState } from 'playroomkit';
import type { Stroke, CursorData, PLAYER_COLORS } from '@/types/canvas';
interface PlayerInfo {
id: string;
name: string;
color: string;
avatar?: string;
isHost: boolean;
}
export function usePlayroom() {
const [isReady, setIsReady] = useState(false);
const [players, setPlayers] = useState<PlayerInfo[]>([]);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [cursors, setCursors] = useState<CursorData[]>([]);
const [error, setError] = useState<string | null>(null);
const playerRefs = useRef<any[]>([]);
const initCalled = useRef(false);
const init = useCallback(async () => {
if (initCalled.current) return;
initCalled.current = true;
setError(null);
setIsReady(false);
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Connection timed out')), 30000)
);
await Promise.race([
insertCoin({
skipLobby: true,
allowGamepads: false,
maxPlayersPerRoom: 10,
}),
timeout,
]);
setIsReady(true);
} catch (err) {
console.error('Playroom init failed:', err);
initCalled.current = false;
setError(err instanceof Error ? err.message : 'Failed to connect to multiplayer session.');
}
}, []);
useEffect(() => {
init();
}, [init]);
useEffect(() => {
if (!isReady) return;
const playerList: PlayerInfo[] = [];
const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
onPlayerJoin((player) => {
const idx = playerList.length % colors.length;
const profile = player.getProfile();
playerList.push({
id: String(player.id),
name: String(profile.name || `Player ${playerList.length + 1}`),
color: String(profile.color?.hex || colors[idx]),
avatar: profile.photo ? String(profile.photo) : undefined,
isHost: isHost(),
});
setPlayers([...playerList]);
playerRefs.current.push(player);
player.onQuit(() => {
const i = playerList.findIndex(p => p.id === player.id);
if (i !== -1) playerList.splice(i, 1);
setPlayers([...playerList]);
playerRefs.current = playerRefs.current.filter(p => p.id !== player.id);
});
});
}, [isReady]);
const getMyPlayer = useCallback(() => {
if (!isReady) return null;
const me = myPlayer();
if (!me) return null;
const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
const profile = me.getProfile();
return {
id: String(me.id),
name: String(profile.name || 'Me'),
color: String(profile.color?.hex || colors[0]),
avatar: profile.photo ? String(profile.photo) : undefined,
isHost: isHost(),
};
}, [isReady]);
const broadcastStroke = useCallback((stroke: Stroke) => {
if (!isReady) return;
const current = getState('strokes') || [];
const updated = [...current, stroke];
setState('strokes', updated, true);
}, [isReady]);
const broadcastCursor = useCallback((cursor: CursorData) => {
if (!isReady) return;
const me = myPlayer();
if (me) {
me.setState('cursor', cursor, true);
}
}, [isReady]);
const clearAllStrokes = useCallback(() => {
if (!isReady || !isHost()) return;
setState('strokes', [], true);
}, [isReady]);
const removeStroke = useCallback((strokeId: string) => {
if (!isReady) return;
const current = getState('strokes') || [];
const updated = current.filter((s: Stroke) => s.id !== strokeId);
setState('strokes', updated, true);
}, [isReady]);
useEffect(() => {
if (!isReady) return;
const pollColors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
const interval = setInterval(() => {
const s = getState('strokes');
if (s) setStrokes(s);
const cursorList: CursorData[] = [];
for (let i = 0; i < playerRefs.current.length; i++) {
const player = playerRefs.current[i];
const cursor = player.getState('cursor');
if (cursor) {
const profile = player.getProfile();
const fallbackColor = pollColors[i % pollColors.length];
cursorList.push({
...cursor,
color: profile.color?.hex || profile.color?.hexString || fallbackColor,
name: profile.name || cursor.name,
});
}
}
setCursors(cursorList);
}, 50);
return () => clearInterval(interval);
}, [isReady]);
return {
isReady,
error,
retry: init,
players,
strokes,
cursors,
getMyPlayer,
broadcastStroke,
broadcastCursor,
clearAllStrokes,
removeStroke,
isHost: isReady ? isHost() : false,
};
}Step 4: Create the DrawingCanvas Component
Create the main canvas component that handles drawing logic, tool rendering, and cursor display.
import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { Tool, Point, Stroke } from '@/types/canvas';
interface DrawingCanvasProps {
strokes: Stroke[];
activeTool: Tool;
activeColor: string;
brushSize: number;
onStrokeComplete: (stroke: Stroke) => void;
onStrokeRemove: (strokeId: string) => void;
onCursorMove: (x: number, y: number) => void;
cursors: { x: number; y: number; color: string; name: string }[];
playerId: string;
onTextPlace: (point: Point) => void;
onViewportChange?: (viewport: { x: number; y: number; zoom: number }) => void;
}
function generateId() {
return Math.random().toString(36).substr(2, 9);
}
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, viewport: { x: number; y: number; zoom: number }) {
ctx.save();
ctx.translate(viewport.x, viewport.y);
ctx.scale(viewport.zoom, viewport.zoom);
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (stroke.tool === 'pen' || stroke.tool === 'eraser') {
if (stroke.tool === 'eraser') {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = stroke.size * 3;
}
if (stroke.points.length < 2) {
ctx.beginPath();
ctx.arc(stroke.points[0]?.x || 0, stroke.points[0]?.y || 0, stroke.size / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
const p0 = stroke.points[i - 1];
const p1 = stroke.points[i];
const mx = (p0.x + p1.x) / 2;
const my = (p0.y + p1.y) / 2;
ctx.quadraticCurveTo(p0.x, p0.y, mx, my);
}
const last = stroke.points[stroke.points.length - 1];
ctx.lineTo(last.x, last.y);
ctx.stroke();
}
} else if (stroke.tool === 'line' && stroke.startPoint && stroke.endPoint) {
ctx.beginPath();
ctx.moveTo(stroke.startPoint.x, stroke.startPoint.y);
ctx.lineTo(stroke.endPoint.x, stroke.endPoint.y);
ctx.stroke();
} else if (stroke.tool === 'arrow' && stroke.startPoint && stroke.endPoint) {
const { startPoint: s, endPoint: e } = stroke;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(e.x, e.y);
ctx.stroke();
const angle = Math.atan2(e.y - s.y, e.x - s.x);
const headLen = Math.max(stroke.size * 3, 12);
ctx.beginPath();
ctx.moveTo(e.x, e.y);
ctx.lineTo(e.x - headLen * Math.cos(angle - Math.PI / 6), e.y - headLen * Math.sin(angle - Math.PI / 6));
ctx.moveTo(e.x, e.y);
ctx.lineTo(e.x - headLen * Math.cos(angle + Math.PI / 6), e.y - headLen * Math.sin(angle + Math.PI / 6));
ctx.stroke();
} else if (stroke.tool === 'rectangle' && stroke.startPoint && stroke.endPoint) {
const { startPoint: s, endPoint: e } = stroke;
ctx.strokeRect(s.x, s.y, e.x - s.x, e.y - s.y);
} else if (stroke.tool === 'circle' && stroke.startPoint && stroke.endPoint) {
const { startPoint: s, endPoint: e } = stroke;
const rx = Math.abs(e.x - s.x) / 2;
const ry = Math.abs(e.y - s.y) / 2;
const cx = s.x + (e.x - s.x) / 2;
const cy = s.y + (e.y - s.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (stroke.tool === 'text' && stroke.text && stroke.startPoint) {
ctx.font = `${stroke.fontSize || 16}px sans-serif`;
ctx.textBaseline = 'top';
ctx.fillText(stroke.text, stroke.startPoint.x, stroke.startPoint.y);
}
ctx.restore();
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
strokes, activeTool, activeColor, brushSize,
onStrokeComplete, onStrokeRemove, onCursorMove,
cursors, playerId, onTextPlace, onViewportChange,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [currentStroke, setCurrentStroke] = useState<Stroke | null>(null);
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Point>({ x: 0, y: 0 });
const animFrameRef = useRef<number>(0);
const toCanvasCoords = useCallback((clientX: number, clientY: number): Point => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: (clientX - rect.left - viewport.x) / viewport.zoom,
y: (clientY - rect.top - viewport.y) / viewport.zoom,
};
}, [viewport]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(viewport.x, viewport.y);
ctx.scale(viewport.zoom, viewport.zoom);
const gridSize = 40;
const startX = Math.floor(-viewport.x / viewport.zoom / gridSize) * gridSize - gridSize;
const startY = Math.floor(-viewport.y / viewport.zoom / gridSize) * gridSize - gridSize;
const endX = startX + (canvas.width / viewport.zoom) + gridSize * 2;
const endY = startY + (canvas.height / viewport.zoom) + gridSize * 2;
ctx.strokeStyle = 'hsl(210, 40%, 96%)';
ctx.lineWidth = 0.5;
for (let x = startX; x < endX; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
ctx.stroke();
}
for (let y = startY; y < endY; y += gridSize) {
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
ctx.restore();
for (const stroke of strokes) {
drawStroke(ctx, stroke, viewport);
}
if (currentStroke) {
drawStroke(ctx, currentStroke, viewport);
}
for (const cursor of cursors) {
ctx.save();
ctx.translate(viewport.x + cursor.x * viewport.zoom, viewport.y + cursor.y * viewport.zoom);
ctx.fillStyle = cursor.color;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 16);
ctx.lineTo(11, 11);
ctx.closePath();
ctx.fill();
ctx.font = '11px sans-serif';
const textW = ctx.measureText(cursor.name).width;
ctx.fillStyle = cursor.color;
ctx.beginPath();
ctx.roundRect(14, 10, textW + 8, 18, 4);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.fillText(cursor.name, 18, 23);
ctx.restore();
}
animFrameRef.current = requestAnimationFrame(render);
};
animFrameRef.current = requestAnimationFrame(render);
return () => cancelAnimationFrame(animFrameRef.current);
}, [strokes, currentStroke, viewport, cursors]);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (e.button === 1 || (e.button === 0 && e.altKey)) {
setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
return;
}
if (activeTool === 'text') {
e.preventDefault();
e.stopPropagation();
const pt = toCanvasCoords(e.clientX, e.clientY);
onTextPlace(pt);
return;
}
const pt = toCanvasCoords(e.clientX, e.clientY);
const isShape = ['line', 'arrow', 'rectangle', 'circle'].includes(activeTool);
const stroke: Stroke = {
id: generateId(),
tool: activeTool,
points: isShape ? [] : [pt],
color: activeTool === 'eraser' ? '#ffffff' : activeColor,
size: brushSize,
playerId,
startPoint: isShape ? pt : undefined,
endPoint: isShape ? pt : undefined,
};
setCurrentStroke(stroke);
setIsDrawing(true);
}, [activeTool, activeColor, brushSize, playerId, toCanvasCoords, viewport, onTextPlace]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
const pt = toCanvasCoords(e.clientX, e.clientY);
onCursorMove(pt.x, pt.y);
if (isPanning) {
setViewport(v => {
const newV = {
...v,
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
};
onViewportChange?.(newV);
return newV;
});
return;
}
if (!isDrawing || !currentStroke) return;
const isShape = ['line', 'arrow', 'rectangle', 'circle'].includes(currentStroke.tool);
if (isShape) {
setCurrentStroke(s => s ? { ...s, endPoint: pt } : null);
} else {
setCurrentStroke(s => s ? { ...s, points: [...s.points, pt] } : null);
}
}, [isDrawing, currentStroke, isPanning, panStart, toCanvasCoords, onCursorMove]);
const handlePointerUp = useCallback(() => {
if (isPanning) {
setIsPanning(false);
return;
}
if (isDrawing && currentStroke) {
onStrokeComplete(currentStroke);
setCurrentStroke(null);
setIsDrawing(false);
}
}, [isDrawing, currentStroke, isPanning, onStrokeComplete]);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? 0.95 : 1.05;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setViewport(v => {
const newZoom = Math.min(Math.max(v.zoom * scaleFactor, 0.1), 5);
const newViewport = {
x: mx - (mx - v.x) * (newZoom / v.zoom),
y: my - (my - v.y) * (newZoom / v.zoom),
zoom: newZoom,
};
onViewportChange?.(newViewport);
return newViewport;
});
}, [onViewportChange]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 cursor-crosshair touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
/>
);
};Step 5: Create Toolbar and Player Components
Create the UI components for tool selection, player list display, and text input for the text tool.
import React from 'react';
import type { Tool } from '@/types/canvas';
interface ToolbarProps {
activeTool: Tool;
onToolChange: (tool: Tool) => void;
activeColor: string;
onColorChange: (color: string) => void;
brushSize: number;
onBrushSizeChange: (size: number) => void;
onClear: () => void;
isHost: boolean;
}
const TOOLS: { id: Tool; label: string }[] = [
{ id: 'pen', label: 'Pen' },
{ id: 'eraser', label: 'Eraser' },
{ id: 'line', label: 'Line' },
{ id: 'arrow', label: 'Arrow' },
{ id: 'rectangle', label: 'Rectangle' },
{ id: 'circle', label: 'Circle' },
{ id: 'text', label: 'Text' },
];
const COLORS = ['#000000', '#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, onToolChange, activeColor, onColorChange,
brushSize, onBrushSizeChange, onClear, isHost,
}) => {
return (
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-background border rounded-lg p-2 shadow-lg">
{TOOLS.map(tool => (
<button
key={tool.id}
onClick={() => onToolChange(tool.id)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === tool.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
}`}
>
{tool.label}
</button>
))}
<div className="w-px h-6 bg-border mx-1" />
{COLORS.map(color => (
<button
key={color}
onClick={() => onColorChange(color)}
className={`w-6 h-6 rounded-full border-2 transition-transform ${
activeColor === color ? 'scale-110 border-foreground' : 'border-transparent'
}`}
style={{ backgroundColor: color }}
/>
))}
<div className="w-px h-6 bg-border mx-1" />
<input
type="range"
min="1"
max="20"
value={brushSize}
onChange={e => onBrushSizeChange(Number(e.target.value))}
className="w-20"
/>
{isHost && (
<>
<div className="w-px h-6 bg-border mx-1" />
<button
onClick={onClear}
className="px-3 py-1.5 rounded-md text-sm font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Clear
</button>
</>
)}
</div>
);
};Step 6: Create the Canvas Page
Create the main canvas page that brings together all the components and handles the multiplayer state.
import React, { useState, useCallback } from 'react';
import { DrawingCanvas } from '@/components/canvas/DrawingCanvas';
import { Toolbar } from '@/components/canvas/Toolbar';
import { PlayerList } from '@/components/canvas/PlayerList';
import { TextInput } from '@/components/canvas/TextInput';
import { usePlayroom } from '@/hooks/usePlayroom';
import type { Tool, Point, Stroke } from '@/types/canvas';
function generateId() {
return Math.random().toString(36).substr(2, 9);
}
const CanvasPage: React.FC = () => {
const {
isReady, error, retry, players, strokes, getMyPlayer,
broadcastStroke, broadcastCursor, clearAllStrokes,
removeStroke, isHost, cursors,
} = usePlayroom();
const [activeTool, setActiveTool] = useState<Tool>('pen');
const [activeColor, setActiveColor] = useState('#000000');
const [brushSize, setBrushSize] = useState(4);
const [textPlacement, setTextPlacement] = useState<Point | null>(null);
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
const me = getMyPlayer();
const handleStrokeComplete = useCallback((stroke: Stroke) => {
broadcastStroke(stroke);
}, [broadcastStroke]);
const handleCursorMove = useCallback((x: number, y: number) => {
if (!me) return;
broadcastCursor({ x, y, playerId: me.id, color: me.color, name: me.name });
}, [me, broadcastCursor]);
const handleTextPlace = useCallback((pt: Point) => {
setTextPlacement(pt);
}, []);
const handleTextSubmit = useCallback((text: string) => {
if (!textPlacement || !me) return;
const fontSize = Math.max(16, brushSize * 3);
const stroke: Stroke = {
id: generateId(),
tool: 'text',
points: [],
color: activeColor,
size: brushSize,
playerId: me.id,
startPoint: { x: textPlacement.x, y: textPlacement.y },
text,
fontSize,
};
broadcastStroke(stroke);
setTextPlacement(null);
}, [textPlacement, me, activeColor, brushSize, broadcastStroke]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center space-y-4">
<p className="text-destructive font-medium">{error}</p>
<button
onClick={retry}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
if (!isReady) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4" />
<p className="text-muted-foreground">Joining room...</p>
</div>
</div>
);
}
return (
<div className="relative w-screen h-screen overflow-hidden bg-background">
<DrawingCanvas
strokes={strokes}
activeTool={activeTool}
activeColor={activeColor}
brushSize={brushSize}
onStrokeComplete={handleStrokeComplete}
onStrokeRemove={removeStroke}
onCursorMove={handleCursorMove}
cursors={cursors.filter(c => c.playerId !== me?.id).map(({ playerId, ...rest }) => rest)}
playerId={me?.id || ''}
onTextPlace={handleTextPlace}
onViewportChange={setViewport}
/>
<Toolbar
activeTool={activeTool}
onToolChange={setActiveTool}
activeColor={activeColor}
onColorChange={setActiveColor}
brushSize={brushSize}
onBrushSizeChange={setBrushSize}
onClear={clearAllStrokes}
isHost={isHost}
/>
<PlayerList players={players} myId={me?.id} />
{textPlacement && (
<TextInput
position={textPlacement}
color={activeColor}
fontSize={Math.max(16, brushSize * 3)}
onSubmit={handleTextSubmit}
onCancel={() => setTextPlacement(null)}
viewport={viewport}
/>
)}
</div>
);
};
export default CanvasPage;Improvements
- Add stroke undo/redo functionality for individual players
- Implement shape locking so multiple players can draw the same shape simultaneously
- Add support for image uploads and stamps
- Create different canvas backgrounds and grid options
- Implement layer management for organizing drawings