A cooperative, two-player browser whiteboard for long-distance friends: one shared canvas, normal drawing + pixel art, live cursors, chat, and peer-to-peer voice.
Two friends open the same room link and land on one shared canvas — not two canvases stitched together. Everything either of you draws, erases, or places is visible to the other in real time, whether you're free-drawing, using shapes, or working in pixel-art mode.
- Real-time shared drawing: pen, marker, eraser, line, rectangle, circle
- A fully shared eraser — either player can erase anything, regardless of who drew it
- Pixel-art mode (8×8 / 16×16 / 32×32 / 64×64 density) that lives in the same shared world as normal drawing — mix and match freely, switch modes without losing anything
- Exactly two players per room, with a friendly "This room is full" for anyone else
- Live cursors with names and colors
- Text chat with sanitization and rate limiting
- Peer-to-peer voice chat (WebRTC) that degrades gracefully if it can't connect
- Persistent rooms — refreshing, disconnecting, or the server restarting doesn't lose your drawing
- Pan (drag / two-finger touch / space+drag) and zoom (scroll, pinch, ctrl+scroll)
There is exactly one authoritative drawing surface per room, not one per player. Concretely:
- The server keeps an ordered log of operations (
stroke,shape,pixel) per room, each with a server-assigned sequence number. It never receives or stores screenshots/images from clients. - Every client renders the same op log onto an offscreen bitmap ("the world"), in the same order. Erasing is implemented as a real canvas composite operation (
destination-out), so an eraser stroke actually removes pixels underneath it — it doesn't matter who drew them. - New strokes are drawn locally the instant you move your pointer (no waiting on the network), then sent to the server as one batched operation. The server assigns the real sequence number and echoes it back to you (to reconcile bookkeeping) while broadcasting it to the other player.
- Undo/redo is per-player: each player has their own undo stack of their own operations. The shared eraser is not affected by undo/redo semantics — it's just another operation.
- Coordinates are always in world space, not screen pixels, so two players with different zoom levels and window sizes still see everything in exactly the same place (
server/rooms.ts,shared/types.ts,client/src/engine/).
Pen, marker (thicker), eraser, line, rectangle, and circle tools, with a color palette and adjustable brush size. Drawing feels instant locally; sync to the other player typically takes well under 100ms on a normal connection.
Pixel art isn't a separate bounded canvas — it's the same infinite shared world, just quantized to a grid. Switching pixel density (8×8 → 64×64) changes how big each "pixel" is in world units; it doesn't create a new canvas or clear anything. Includes a pixel pen, pixel eraser, eyedropper, and a bounded flood-fill tool (fills up to 500 connected cells at a time, to keep it fast and predictable).
Rooms hold exactly two player slots, assigned on a first-come basis. Each player gets a private reconnect token (stored in sessionStorage) so refreshing or briefly losing connection lets you back into your own slot — even though the room already shows "2/2" — while a third, unrelated visitor is turned away with "This room is full."
Real-time text chat alongside the canvas. Messages are sanitized server-side (HTML/script tags stripped) and rate-limited (8 messages per 10 seconds per player) to prevent spam.
Peer-to-peer audio via WebRTC, with signaling (offer/answer/ICE candidates) relayed over the same WebSocket connection used for drawing — no separate service needed. Uses a public STUN server; a TURN server can be added later by extending the ICE_SERVERS list in client/src/hooks/useVoice.ts, nothing else needs to change. If the microphone is blocked or the connection fails, drawing and chat are completely unaffected — voice is fully decoupled from the rest of the app.
- Frontend: React + TypeScript, Vite, plain Canvas 2D (no heavy canvas library)
- Backend: Node.js + Express,
wsfor WebSockets - Database: PostgreSQL (see "Database setup" below)
- Voice: WebRTC (browser-native), signaling over the existing WebSocket
- Validation: Zod schemas on every inbound WebSocket message
client/ React app
src/engine/ camera math + the canvas rendering engine (canvasEngine.ts)
src/hooks/ useSocket (WebSocket + protocol), useVoice (WebRTC)
src/components/ Toolbar, Chat, TopBar, VoiceControls
src/pages/ Landing, Room
server/ Express + WebSocket server
db.ts Postgres pool + schema
rooms.ts authoritative in-memory room state + persistence
validate.ts Zod schemas, sanitization
ws.ts WebSocket message routing
index.ts entrypoint (dev/prod bootstrapping)
shared/types.ts wire protocol — the single source of truth both sides import
tests/ automated tests (see "Testing" below)
You'll need Node.js 20+ and a PostgreSQL database (see "Database setup").
npm installnpm run db:migrate # creates the database tables (safe to re-run)
npm run dev # starts the dev server with hot reload at http://localhost:5000For a production-like run:
npm run build # bundles the React app
npm run start # runs the server against the built app- Start the server (
npm run dev). - Open
http://localhost:5000in one browser window, click Create Room, enter a nickname. - Copy the invite link (or just note the room code).
- Open the link in a second, separate browser profile or incognito window (a normal second tab in the same profile can also work, since each player has their own session token, but a separate profile/incognito window most closely simulates two different people).
- Draw in one window, watch it appear in the other. Try erasing each other's drawings, switching one window to pixel mode while the other stays in normal mode, and refreshing either window mid-session.
This app uses PostgreSQL for persistence — rooms, drawing history, and chat all survive server restarts and page refreshes. It intentionally does not rely on the local filesystem for storage, since that isn't reliably persistent across deployments on Replit.
Connection is entirely through the DATABASE_URL environment variable (standard postgres://user:pass@host:port/dbname format). The schema is created automatically on startup (and via npm run db:migrate) — there's no manual SQL to run.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
Yes | Postgres connection string. On Replit, the Database tool sets this for you. |
PORT |
No | Port to listen on. Replit sets this automatically; defaults to 5000 locally. |
Copy .env.example to .env for local development. Never commit a real .env file — on Replit, use the Secrets tool instead (see below).
Because this project assumes no coding experience, here's every step spelled out.
- Create a Repl. On replit.com, click Create Repl, choose Import from GitHub (if you've pushed this project there) or Node.js as a blank template and upload/drag in this project's files.
- Add a database. In the left sidebar, find the Database tool (sometimes under a "Tools" menu) and enable it. Replit provisions a PostgreSQL database and automatically sets a
DATABASE_URLsecret for you — you don't need to type a connection string yourself. - Install dependencies. Open the Shell tab (not the "Console") and run:
npm install - Create the database tables. In the same Shell:
npm run db:migrate - Run the app. Click the big green Run button, or type
npm run devin the Shell. - Open the Preview. Replit opens a preview pane/tab automatically — that's your app's URL.
- Test as Player 1. In the preview, click Create Room and enter a nickname.
- Open a second window. Copy the preview URL, open it in a new incognito/private window (this matters — it keeps the two players' sessions separate, just like two different people on two different computers).
- Test as Player 2. Paste in the room link (or the
/room/CODEURL) and enter a different nickname. - Test shared drawing. Draw in one window, confirm it shows up in the other.
- Test pixel mode. Switch one window to Pixel mode and place a few cells.
- Test chat. Send a message from each side.
- Test voice. Click "Enable voice" in both windows and allow microphone access when prompted.
- Build the production version (once you're happy):
npm run build - Deploy. Use Replit's Deploy button (top right) to publish the app. Choose "Autoscale" or "Reserved VM" deployment — either works, since all state lives in Postgres rather than on local disk. Replit will run
npm run buildandnpm run startfor you if asked, or you can set those explicitly in the deployment's build/run commands. - Open the public URL Replit gives you after deploying.
- Send the room link to a friend and start drawing.
- "Something went wrong creating the room" — usually means
DATABASE_URLisn't set or the database tables haven't been created yet. Runnpm run db:migrate. - Voice shows "Microphone blocked" — the browser denied mic access. Check the browser's site settings (usually a padlock/icon in the address bar) and allow the microphone, then click "Enable voice" again. Drawing and chat work fine either way.
- Voice shows "Voice unavailable" — the two browsers couldn't establish a direct peer-to-peer connection (this can happen on some restrictive networks, like certain corporate or campus Wi-Fi, which block the kind of connection WebRTC needs without a TURN server). Drawing and chat are unaffected.
- A third browser window can't join — this is intentional; rooms are capped at two players.
- Drawing looks slightly blocky when zoomed in a lot — normal-mode strokes are rasterized at 1:1 scale and then scaled up without smoothing (so pixel art stays crisp); zooming in far enough on regular strokes will show that scaling. This is a deliberate trade-off, not a bug.
- Undo/redo replays the whole canvas. Because erasing is a real compositing operation, undoing/redoing rebuilds the shared bitmap from the full operation history rather than surgically patching one stroke. For a casual two-person session this is fast and imperceptible; on a very long-running room with thousands of operations, undo could become noticeably slower. There's no automatic history compaction/snapshotting in this version.
- The world is large but not infinite — it spans roughly ±2000 world units in both directions (rendered as one 4000×4000 bitmap). That's a genuinely large whiteboard for two people, but drawing far enough outside that area won't be captured.
- Flood fill is capped at 500 cells per click, to keep it fast and prevent runaway fills on what is otherwise an unbounded pixel grid.
- Op-level spam protection is minimal. Chat has rate limiting; individual drawing operations are validated (size, color, coordinate bounds) but not rate-limited per second. For a trusted two-friends use case this is a reasonable trade-off, but it's not hardened against a deliberately abusive client.
- No TURN server configured. Voice uses a public STUN server only, which works for the large majority of home/mobile networks but can fail on networks with strict NATs or firewalls (common on some corporate/public Wi-Fi). The code is structured so a TURN server can be dropped in later without other changes.
- Browser/device testing was not performed by the AI that built this — see the "What was actually tested" note in the project's final report/handoff message. Please do a real two-browser pass yourself before relying on this for something important (e.g. a friend's birthday plan!).
- Canvas history snapshotting/compaction for long-lived rooms
- TURN server support for voice on restrictive networks
- More brush styles (textures, opacity control for a true highlighter-style marker)
- Optional room passwords or expiry
- Export the canvas as an image