A simple multiplayer whiteboard is doable; Miro's value is scale, templates, integrations, facilitation, permissions, and reliability with many users.
You are building a lean indie version of Miro. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Miro · indie build A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Vite, rendering to a canvas element | at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not | | Collaboration | Yjs, a conflict-free replicated data type | concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy | | Transport | A WebSocket relay that persists the document | the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did | | Persistence | The Yjs document state plus periodic snapshots | loading a board should not mean replaying every edit since it was created | | Spatial index | A quadtree over object bounds | hit testing and viewport culling both need to answer what is here quickly | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the relay and the build. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Two browsers or two machines for testing** · free - Why: Every collaboration claim in Phases 3 and 4 needs two participants. A single browser with two tabs is a weaker test but is the minimum. - Get it: Two different browsers on one machine, or two machines on the same network. Two machines catch clock and latency issues that two tabs hide. - Verify: Two clients connect to the same board simultaneously - [ ] **Understanding of why a conflict-free data type is used** · free - Why: It is the central architectural decision, and retrofitting it after building on last write wins means rewriting every mutation. - Get it: Read the Yjs documentation on shared types before Phase 3, not during it. Half an hour saves a rewrite. - Verify: You can explain why two concurrent text edits merge rather than one winning - [ ] **A generated board with ten thousand objects** · free - Why: Phase 1's performance work is meaningless without it, and the naive approach feels perfect until this fixture exists. - Get it: Write the generator in Phase 1 before optimising anything. - Verify: The generator produces a board that loads and renders - [ ] **A small server for the product phase** (optional) · 5 to 10 USD a month - Why: Only needed once other people use it. Everything before that runs locally. - Get it: Any small virtual server with a domain, or a machine at home behind a tunnel. - Verify: The relay is reachable over TLS ## Quick start ```sh npm create vite@latest . -- --template vanilla-ts ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. - large-board performance - templates - integrations - facilitation tools - permissions - enterprise controls If one of those is essential to you, that is the reason to keep paying for Miro, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Miro The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an infinite collaborative canvas with sticky notes, shapes and real multiplayer editing to replace Miro. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Vite, rendering to a canvas element · at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not - Collaboration: Yjs, a conflict-free replicated data type · concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy - Transport: A WebSocket relay that persists the document · the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did - Persistence: The Yjs document state plus periodic snapshots · loading a board should not mean replaying every edit since it was created - Spatial index: A quadtree over object bounds · hit testing and viewport culling both need to answer what is here quickly ### Data model (create this before Phase 1) - The board is one Yjs document. These are the shared types inside it, not database tables. - objects · a shared map of id to object: kind, x, y, w, h, rotation, z, style and content - text · per-object shared text so two people editing the same sticky note merge rather than clobber - awareness · ephemeral cursor position, selection and name per participant, never persisted - Server side: boards(id, name, created_at), updates(board_id, seq, payload, at), snapshots(board_id, seq, state, at) - The rule that keeps multiplayer honest: every change goes through the shared document, including your own local edits. There is no separate local path. ### Phase 1 · A canvas that stays fast Build: Render to a canvas element with a viewport transform. Build a quadtree over object bounds and draw only what intersects the viewport. Generate ten thousand objects before you optimise, so the numbers are real. Trap: Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. Done when: A board with ten thousand objects pans at a smooth frame rate, measured rather than judged by eye. The number of objects drawn per frame is close to the number actually on screen. Zooming out to show everything degrades predictably rather than freezing. Do not build yet: No editing, no collaboration, no persistence. ### Phase 2 · Objects and editing Build: Create, move, resize, rotate and delete objects. Sticky notes with text that shrinks to fit. Shapes with fill and stroke. Connectors attached to objects rather than to points. Freehand ink with pressure if the device reports it. Trap: Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. Done when: A sticky note's text shrinks to fit and stops at a readable minimum. Moving an object re-routes its connectors. A freehand stroke stores far fewer points after simplification than the raw input, with no visible change. Undo restores the previous state for every object kind. Do not build yet: No collaboration yet, so this is single player. ### Phase 3 · Real multiplayer Build: Move all state into a Yjs document. Run a WebSocket relay that persists and rebroadcasts updates. Route your own local edits through the shared document too, so there is one code path rather than two that can diverge. Trap: Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. Done when: Two browsers editing different objects both retain their changes. Two browsers editing the same sticky note's text concurrently converge to a merged result rather than one overwriting the other. Disconnecting one browser, editing offline in both, and reconnecting converges without losing either side's work. Do not build yet: No presence, no persistence tuning, no permissions. ### Phase 4 · Presence and cursors Build: Broadcast cursor position, selection and name through the awareness channel, which is ephemeral and never persisted. Throttle cursor updates. Remove a participant when their connection drops rather than leaving a ghost. Trap: Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. Done when: Two browsers each see the other's cursor moving with names attached. Closing one browser removes its cursor within a few seconds rather than leaving it frozen on the canvas. Cursor messages are throttled to a measured rate well below the pointer event rate. Presence data never appears in a persisted snapshot. Do not build yet: No persistence tuning or export yet. ### Phase 5 · Persistence and loading a big board Build: Persist updates and write periodic snapshots. On load, send the newest snapshot plus the updates after it rather than the full history. Compact old updates once a snapshot covers them. Export the board as an image and as JSON. Trap: Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. Done when: A board with a long synthetic update history loads in under a second. Compaction reduces stored size without changing the loaded document, verified by comparing state before and after. An image export matches what is on screen for the visible region. A JSON export re-imports into an empty board. Do not build yet: Permissions and hosting are the last phase. ### Phase 6 · Permissions, hosting and backups Build: Authentication, per-board access with view and edit roles enforced on the relay, TLS, and backups of both the update log and the snapshots. Restore a board onto a clean server and open it. Trap: Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. Done when: A viewer's update sent directly to the relay is rejected, tested with a raw client rather than through the interface. An uninvited user cannot open a board. A restore onto a clean server reproduces a board with its content, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Performance at the scale of a board with a hundred thousand objects and forty simultaneous editors, which is real engineering the hosted product has done and you have not. - The template library, which is content accumulated over years. - The integrations that put boards inside other tools, each of which is an API and a maintenance burden. - Video calls, timers, voting and the workshop features that make it a facilitation product rather than a canvas. ### README must contain - That the canvas culls with a spatial index, and the measured frame rate at ten thousand objects - That collaboration uses a conflict-free replicated data type, and why last write wins was rejected - That presence is ephemeral and never persisted - How snapshots and compaction keep load time flat as history grows - That view-only is enforced on the relay, not in the client ===== AGENTS.md ===== # Agent instructions · Miro indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Vite, rendering to a canvas element, Yjs, a conflict-free replicated data type, A WebSocket relay that persists the document, The Yjs document state plus periodic snapshots, A quadtree over object bounds. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ===== BUILD_PLAN.md ===== # Build plan · Miro A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A canvas that stays fast Ten thousand objects pan and zoom smoothly because most of them are never drawn. ### Steps 1. Set up the canvas and viewport transform One transform from world coordinates to screen. Every coordinate conversion goes through it. Files: `src/viewport.ts` ```sh npm create vite@latest . -- --template vanilla-ts ``` 2. Build the quadtree Insert object bounds, query by rectangle. It serves both viewport culling and hit testing. Files: `src/quadtree.ts` 3. Generate ten thousand objects and measure A fixture board plus a frame time counter. Optimising against two hundred objects teaches you nothing. Files: `src/fixtures.ts` ### Done when - [ ] Ten thousand objects pan at a measured smooth frame rate - [ ] Objects drawn per frame approximates objects on screen - [ ] Hit testing at any zoom selects the object under the cursor - [ ] Zooming to fit everything degrades rather than freezes ### Watch out - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. ## Phase 2 · Objects and editing Sticky notes, shapes, connectors and ink all behave, and text fits its note. ### Steps 1. Implement the object kinds Sticky note, shape, connector, ink and text. Each is data plus a draw function. Files: `src/objects/` 2. Fit text to sticky notes Binary search the font size until the wrapped text fits, with a readable floor. Files: `src/text.ts` 3. Simplify ink on stroke end A line simplification pass with a tolerance. Compare point counts before and after and look at the result. Files: `src/ink.ts` ### Done when - [ ] Sticky note text fits and stops at a readable minimum - [ ] Connectors re-route when their objects move - [ ] A simplified stroke has far fewer points with no visible change - [ ] Undo works for every object kind ### Watch out - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. ## Phase 3 · Real multiplayer Two people editing at once both keep their work. ### Steps 1. Move state into the shared document Objects as a shared map, sticky text as shared text. Local edits go through it exactly like remote ones. Files: `src/doc.ts` ```sh npm install yjs y-websocket ``` 2. Run the relay Accept updates, persist them, rebroadcast. It stores and forwards; it never resolves conflicts. Files: `server/relay.ts` 3. Test the offline divergence case Disconnect, edit both sides, reconnect. This is the case that separates real convergence from a demo. Files: `test/converge.test.ts` ### Done when - [ ] Two browsers editing different objects both keep their changes - [ ] Concurrent edits to one sticky note's text merge rather than overwrite - [ ] Offline edits on both sides converge on reconnect - [ ] Local edits use the same path as remote ones ### Watch out - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. ## Phase 4 · Presence and cursors You can see where other people are and what they are working on. ### Steps 1. Use the awareness channel Cursor, selection and name. Ephemeral by design, so it must never reach the persisted document. Files: `src/presence.ts` 2. Throttle cursor updates A fixed rate that still reads as live. Measure the resulting message rate. Files: `src/presence.ts` 3. Clean up on disconnect Remove participants on connection loss. A frozen cursor is worse than no cursor. Files: `server/relay.ts` ### Done when - [ ] Each browser sees the other's cursor with a name - [ ] A closed browser's cursor disappears within seconds - [ ] Cursor message rate is measured and well below pointer event rate - [ ] No presence data appears in a persisted snapshot ### Watch out - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. ## Phase 5 · Persistence and loading a big board Opening a board with a long history is fast. ### Steps 1. Snapshot and compact Write a snapshot periodically, then drop the updates it covers. Never drop updates a snapshot does not cover. Files: `server/persist.ts` 2. Load from snapshot plus tail The client receives one snapshot and the recent updates. Measure load time against a long history. Files: `server/load.ts` 3. Export image and JSON Render the board region to an image, and serialise the document to JSON that re-imports. Files: `src/export.ts` ### Done when - [ ] A board with a long history loads in under a second - [ ] Compaction does not change the loaded document state - [ ] An image export matches the visible region - [ ] A JSON export re-imports into an empty board ### Watch out - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ## Not in this build - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. ## After v1, if you want it - Comments anchored to objects, which need their own resolution state - Frames and simple presentation mode, which is cheap once the viewport is solid - A read-only public share link rendered from a snapshot ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where clients connect. Must be a secure WebSocket in anything but local development. RELAY_URL=wss://boards.example.com # Required. Where update logs and snapshots are written. Back up both together. PERSIST_DIR=./data/boards # Required. How many updates before a snapshot is written. Lower means faster loads and more storage. SNAPSHOT_INTERVAL_UPDATES=500 # Required. Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. CURSOR_THROTTLE_MS=50 # Required. A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. MAX_OBJECTS_PER_BOARD=20000
You are building a lean indie version of Miro. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Miro · indie build A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Vite, rendering to a canvas element | at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not | | Collaboration | Yjs, a conflict-free replicated data type | concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy | | Transport | A WebSocket relay that persists the document | the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did | | Persistence | The Yjs document state plus periodic snapshots | loading a board should not mean replaying every edit since it was created | | Spatial index | A quadtree over object bounds | hit testing and viewport culling both need to answer what is here quickly | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the relay and the build. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Two browsers or two machines for testing** · free - Why: Every collaboration claim in Phases 3 and 4 needs two participants. A single browser with two tabs is a weaker test but is the minimum. - Get it: Two different browsers on one machine, or two machines on the same network. Two machines catch clock and latency issues that two tabs hide. - Verify: Two clients connect to the same board simultaneously - [ ] **Understanding of why a conflict-free data type is used** · free - Why: It is the central architectural decision, and retrofitting it after building on last write wins means rewriting every mutation. - Get it: Read the Yjs documentation on shared types before Phase 3, not during it. Half an hour saves a rewrite. - Verify: You can explain why two concurrent text edits merge rather than one winning - [ ] **A generated board with ten thousand objects** · free - Why: Phase 1's performance work is meaningless without it, and the naive approach feels perfect until this fixture exists. - Get it: Write the generator in Phase 1 before optimising anything. - Verify: The generator produces a board that loads and renders - [ ] **A small server for the product phase** (optional) · 5 to 10 USD a month - Why: Only needed once other people use it. Everything before that runs locally. - Get it: Any small virtual server with a domain, or a machine at home behind a tunnel. - Verify: The relay is reachable over TLS ## Quick start ```sh npm create vite@latest . -- --template vanilla-ts ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. - large-board performance - templates - integrations - facilitation tools - permissions - enterprise controls If one of those is essential to you, that is the reason to keep paying for Miro, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Miro The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an infinite collaborative canvas with sticky notes, shapes and real multiplayer editing to replace Miro. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Vite, rendering to a canvas element · at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not - Collaboration: Yjs, a conflict-free replicated data type · concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy - Transport: A WebSocket relay that persists the document · the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did - Persistence: The Yjs document state plus periodic snapshots · loading a board should not mean replaying every edit since it was created - Spatial index: A quadtree over object bounds · hit testing and viewport culling both need to answer what is here quickly ### Data model (create this before Phase 1) - The board is one Yjs document. These are the shared types inside it, not database tables. - objects · a shared map of id to object: kind, x, y, w, h, rotation, z, style and content - text · per-object shared text so two people editing the same sticky note merge rather than clobber - awareness · ephemeral cursor position, selection and name per participant, never persisted - Server side: boards(id, name, created_at), updates(board_id, seq, payload, at), snapshots(board_id, seq, state, at) - The rule that keeps multiplayer honest: every change goes through the shared document, including your own local edits. There is no separate local path. ### Phase 1 · A canvas that stays fast Build: Render to a canvas element with a viewport transform. Build a quadtree over object bounds and draw only what intersects the viewport. Generate ten thousand objects before you optimise, so the numbers are real. Trap: Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. Done when: A board with ten thousand objects pans at a smooth frame rate, measured rather than judged by eye. The number of objects drawn per frame is close to the number actually on screen. Zooming out to show everything degrades predictably rather than freezing. Do not build yet: No editing, no collaboration, no persistence. ### Phase 2 · Objects and editing Build: Create, move, resize, rotate and delete objects. Sticky notes with text that shrinks to fit. Shapes with fill and stroke. Connectors attached to objects rather than to points. Freehand ink with pressure if the device reports it. Trap: Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. Done when: A sticky note's text shrinks to fit and stops at a readable minimum. Moving an object re-routes its connectors. A freehand stroke stores far fewer points after simplification than the raw input, with no visible change. Undo restores the previous state for every object kind. Do not build yet: No collaboration yet, so this is single player. ### Phase 3 · Real multiplayer Build: Move all state into a Yjs document. Run a WebSocket relay that persists and rebroadcasts updates. Route your own local edits through the shared document too, so there is one code path rather than two that can diverge. Trap: Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. Done when: Two browsers editing different objects both retain their changes. Two browsers editing the same sticky note's text concurrently converge to a merged result rather than one overwriting the other. Disconnecting one browser, editing offline in both, and reconnecting converges without losing either side's work. Do not build yet: No presence, no persistence tuning, no permissions. ### Phase 4 · Presence and cursors Build: Broadcast cursor position, selection and name through the awareness channel, which is ephemeral and never persisted. Throttle cursor updates. Remove a participant when their connection drops rather than leaving a ghost. Trap: Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. Done when: Two browsers each see the other's cursor moving with names attached. Closing one browser removes its cursor within a few seconds rather than leaving it frozen on the canvas. Cursor messages are throttled to a measured rate well below the pointer event rate. Presence data never appears in a persisted snapshot. Do not build yet: No persistence tuning or export yet. ### Phase 5 · Persistence and loading a big board Build: Persist updates and write periodic snapshots. On load, send the newest snapshot plus the updates after it rather than the full history. Compact old updates once a snapshot covers them. Export the board as an image and as JSON. Trap: Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. Done when: A board with a long synthetic update history loads in under a second. Compaction reduces stored size without changing the loaded document, verified by comparing state before and after. An image export matches what is on screen for the visible region. A JSON export re-imports into an empty board. Do not build yet: Permissions and hosting are the last phase. ### Phase 6 · Permissions, hosting and backups Build: Authentication, per-board access with view and edit roles enforced on the relay, TLS, and backups of both the update log and the snapshots. Restore a board onto a clean server and open it. Trap: Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. Done when: A viewer's update sent directly to the relay is rejected, tested with a raw client rather than through the interface. An uninvited user cannot open a board. A restore onto a clean server reproduces a board with its content, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Performance at the scale of a board with a hundred thousand objects and forty simultaneous editors, which is real engineering the hosted product has done and you have not. - The template library, which is content accumulated over years. - The integrations that put boards inside other tools, each of which is an API and a maintenance burden. - Video calls, timers, voting and the workshop features that make it a facilitation product rather than a canvas. ### README must contain - That the canvas culls with a spatial index, and the measured frame rate at ten thousand objects - That collaboration uses a conflict-free replicated data type, and why last write wins was rejected - That presence is ephemeral and never persisted - How snapshots and compaction keep load time flat as history grows - That view-only is enforced on the relay, not in the client ===== AGENTS.md ===== # Agent instructions · Miro indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Vite, rendering to a canvas element, Yjs, a conflict-free replicated data type, A WebSocket relay that persists the document, The Yjs document state plus periodic snapshots, A quadtree over object bounds. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ===== BUILD_PLAN.md ===== # Build plan · Miro A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A canvas that stays fast Ten thousand objects pan and zoom smoothly because most of them are never drawn. ### Steps 1. Set up the canvas and viewport transform One transform from world coordinates to screen. Every coordinate conversion goes through it. Files: `src/viewport.ts` ```sh npm create vite@latest . -- --template vanilla-ts ``` 2. Build the quadtree Insert object bounds, query by rectangle. It serves both viewport culling and hit testing. Files: `src/quadtree.ts` 3. Generate ten thousand objects and measure A fixture board plus a frame time counter. Optimising against two hundred objects teaches you nothing. Files: `src/fixtures.ts` ### Done when - [ ] Ten thousand objects pan at a measured smooth frame rate - [ ] Objects drawn per frame approximates objects on screen - [ ] Hit testing at any zoom selects the object under the cursor - [ ] Zooming to fit everything degrades rather than freezes ### Watch out - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. ## Phase 2 · Objects and editing Sticky notes, shapes, connectors and ink all behave, and text fits its note. ### Steps 1. Implement the object kinds Sticky note, shape, connector, ink and text. Each is data plus a draw function. Files: `src/objects/` 2. Fit text to sticky notes Binary search the font size until the wrapped text fits, with a readable floor. Files: `src/text.ts` 3. Simplify ink on stroke end A line simplification pass with a tolerance. Compare point counts before and after and look at the result. Files: `src/ink.ts` ### Done when - [ ] Sticky note text fits and stops at a readable minimum - [ ] Connectors re-route when their objects move - [ ] A simplified stroke has far fewer points with no visible change - [ ] Undo works for every object kind ### Watch out - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. ## Phase 3 · Real multiplayer Two people editing at once both keep their work. ### Steps 1. Move state into the shared document Objects as a shared map, sticky text as shared text. Local edits go through it exactly like remote ones. Files: `src/doc.ts` ```sh npm install yjs y-websocket ``` 2. Run the relay Accept updates, persist them, rebroadcast. It stores and forwards; it never resolves conflicts. Files: `server/relay.ts` 3. Test the offline divergence case Disconnect, edit both sides, reconnect. This is the case that separates real convergence from a demo. Files: `test/converge.test.ts` ### Done when - [ ] Two browsers editing different objects both keep their changes - [ ] Concurrent edits to one sticky note's text merge rather than overwrite - [ ] Offline edits on both sides converge on reconnect - [ ] Local edits use the same path as remote ones ### Watch out - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. ## Phase 4 · Presence and cursors You can see where other people are and what they are working on. ### Steps 1. Use the awareness channel Cursor, selection and name. Ephemeral by design, so it must never reach the persisted document. Files: `src/presence.ts` 2. Throttle cursor updates A fixed rate that still reads as live. Measure the resulting message rate. Files: `src/presence.ts` 3. Clean up on disconnect Remove participants on connection loss. A frozen cursor is worse than no cursor. Files: `server/relay.ts` ### Done when - [ ] Each browser sees the other's cursor with a name - [ ] A closed browser's cursor disappears within seconds - [ ] Cursor message rate is measured and well below pointer event rate - [ ] No presence data appears in a persisted snapshot ### Watch out - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. ## Phase 5 · Persistence and loading a big board Opening a board with a long history is fast. ### Steps 1. Snapshot and compact Write a snapshot periodically, then drop the updates it covers. Never drop updates a snapshot does not cover. Files: `server/persist.ts` 2. Load from snapshot plus tail The client receives one snapshot and the recent updates. Measure load time against a long history. Files: `server/load.ts` 3. Export image and JSON Render the board region to an image, and serialise the document to JSON that re-imports. Files: `src/export.ts` ### Done when - [ ] A board with a long history loads in under a second - [ ] Compaction does not change the loaded document state - [ ] An image export matches the visible region - [ ] A JSON export re-imports into an empty board ### Watch out - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ## Not in this build - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. ## After v1, if you want it - Comments anchored to objects, which need their own resolution state - Frames and simple presentation mode, which is cheap once the viewport is solid - A read-only public share link rendered from a snapshot ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where clients connect. Must be a secure WebSocket in anything but local development. RELAY_URL=wss://boards.example.com # Required. Where update logs and snapshots are written. Back up both together. PERSIST_DIR=./data/boards # Required. How many updates before a snapshot is written. Lower means faster loads and more storage. SNAPSHOT_INTERVAL_UPDATES=500 # Required. Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. CURSOR_THROTTLE_MS=50 # Required. A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. MAX_OBJECTS_PER_BOARD=20000
You are building a production product version of Miro. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Miro · product brief ## Problem A simple multiplayer whiteboard is doable; Miro's value is scale, templates, integrations, facilitation, permissions, and reliability with many users. ## Product outcome A canvas that stays smooth at ten thousand objects, where concurrent edits genuinely merge, presence is live without flooding the connection, boards load fast regardless of history, and view-only means something because the server enforces it. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - canvas library - WebSocket/collab backend - database - auth - hosting - optional template library ## Explicit non-goals for v1 - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. - large-board performance - templates - integrations - facilitation tools - permissions - enterprise controls ## Success criteria - Frame rate at ten thousand objects is measured and has not regressed - The offline divergence test converges without losing either side - A viewer's direct update is rejected by the relay - Load time against a long-history board is under a second - A restore of updates and snapshots has been performed and dated ===== BRIEF.md ===== # Build brief · Miro The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an infinite collaborative canvas with sticky notes, shapes and real multiplayer editing to replace Miro. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Vite, rendering to a canvas element · at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not - Collaboration: Yjs, a conflict-free replicated data type · concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy - Transport: A WebSocket relay that persists the document · the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did - Persistence: The Yjs document state plus periodic snapshots · loading a board should not mean replaying every edit since it was created - Spatial index: A quadtree over object bounds · hit testing and viewport culling both need to answer what is here quickly ### Data model (create this before Phase 1) - The board is one Yjs document. These are the shared types inside it, not database tables. - objects · a shared map of id to object: kind, x, y, w, h, rotation, z, style and content - text · per-object shared text so two people editing the same sticky note merge rather than clobber - awareness · ephemeral cursor position, selection and name per participant, never persisted - Server side: boards(id, name, created_at), updates(board_id, seq, payload, at), snapshots(board_id, seq, state, at) - The rule that keeps multiplayer honest: every change goes through the shared document, including your own local edits. There is no separate local path. ### Phase 1 · A canvas that stays fast Build: Render to a canvas element with a viewport transform. Build a quadtree over object bounds and draw only what intersects the viewport. Generate ten thousand objects before you optimise, so the numbers are real. Trap: Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. Done when: A board with ten thousand objects pans at a smooth frame rate, measured rather than judged by eye. The number of objects drawn per frame is close to the number actually on screen. Zooming out to show everything degrades predictably rather than freezing. Do not build yet: No editing, no collaboration, no persistence. ### Phase 2 · Objects and editing Build: Create, move, resize, rotate and delete objects. Sticky notes with text that shrinks to fit. Shapes with fill and stroke. Connectors attached to objects rather than to points. Freehand ink with pressure if the device reports it. Trap: Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. Done when: A sticky note's text shrinks to fit and stops at a readable minimum. Moving an object re-routes its connectors. A freehand stroke stores far fewer points after simplification than the raw input, with no visible change. Undo restores the previous state for every object kind. Do not build yet: No collaboration yet, so this is single player. ### Phase 3 · Real multiplayer Build: Move all state into a Yjs document. Run a WebSocket relay that persists and rebroadcasts updates. Route your own local edits through the shared document too, so there is one code path rather than two that can diverge. Trap: Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. Done when: Two browsers editing different objects both retain their changes. Two browsers editing the same sticky note's text concurrently converge to a merged result rather than one overwriting the other. Disconnecting one browser, editing offline in both, and reconnecting converges without losing either side's work. Do not build yet: No presence, no persistence tuning, no permissions. ### Phase 4 · Presence and cursors Build: Broadcast cursor position, selection and name through the awareness channel, which is ephemeral and never persisted. Throttle cursor updates. Remove a participant when their connection drops rather than leaving a ghost. Trap: Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. Done when: Two browsers each see the other's cursor moving with names attached. Closing one browser removes its cursor within a few seconds rather than leaving it frozen on the canvas. Cursor messages are throttled to a measured rate well below the pointer event rate. Presence data never appears in a persisted snapshot. Do not build yet: No persistence tuning or export yet. ### Phase 5 · Persistence and loading a big board Build: Persist updates and write periodic snapshots. On load, send the newest snapshot plus the updates after it rather than the full history. Compact old updates once a snapshot covers them. Export the board as an image and as JSON. Trap: Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. Done when: A board with a long synthetic update history loads in under a second. Compaction reduces stored size without changing the loaded document, verified by comparing state before and after. An image export matches what is on screen for the visible region. A JSON export re-imports into an empty board. Do not build yet: Permissions and hosting are the last phase. ### Phase 6 · Permissions, hosting and backups Build: Authentication, per-board access with view and edit roles enforced on the relay, TLS, and backups of both the update log and the snapshots. Restore a board onto a clean server and open it. Trap: Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. Done when: A viewer's update sent directly to the relay is rejected, tested with a raw client rather than through the interface. An uninvited user cannot open a board. A restore onto a clean server reproduces a board with its content, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Performance at the scale of a board with a hundred thousand objects and forty simultaneous editors, which is real engineering the hosted product has done and you have not. - The template library, which is content accumulated over years. - The integrations that put boards inside other tools, each of which is an API and a maintenance burden. - Video calls, timers, voting and the workshop features that make it a facilitation product rather than a canvas. ### README must contain - That the canvas culls with a spatial index, and the measured frame rate at ten thousand objects - That collaboration uses a conflict-free replicated data type, and why last write wins was rejected - That presence is ephemeral and never persisted - How snapshots and compaction keep load time flat as history grows - That view-only is enforced on the relay, not in the client ===== ARCHITECTURE.md ===== # Architecture · Miro ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Vite, rendering to a canvas element | at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not | | Collaboration | Yjs, a conflict-free replicated data type | concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy | | Transport | A WebSocket relay that persists the document | the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did | | Persistence | The Yjs document state plus periodic snapshots | loading a board should not mean replaying every edit since it was created | | Spatial index | A quadtree over object bounds | hit testing and viewport culling both need to answer what is here quickly | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Viewport and index | The transform and spatial queries. | The quadtree could be another spatial structure. What must not change is that culling happens before drawing. | | Document | All shared state through the conflict-free type. | Not swappable without rewriting every mutation, which is exactly why it is chosen in Phase 3 rather than added later. | | Relay | Transport, persistence and authorisation. | Any transport works. Authorisation must stay server side whatever the transport. | | Presence | Ephemeral awareness state. | Independent of the document by design, so it can be replaced or removed without touching persistence. | | Persistence | Update log, snapshots and compaction. | Storage backend is replaceable. The snapshot plus tail loading strategy is what keeps load time flat. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `RELAY_URL` · required · Where clients connect. Must be a secure WebSocket in anything but local development. - `PERSIST_DIR` · required · Where update logs and snapshots are written. Back up both together. - `SNAPSHOT_INTERVAL_UPDATES` · required · How many updates before a snapshot is written. Lower means faster loads and more storage. - `CURSOR_THROTTLE_MS` · required · Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. - `MAX_OBJECTS_PER_BOARD` · required · A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Miro product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Vite, rendering to a canvas element, Yjs, a conflict-free replicated data type, A WebSocket relay that persists the document, The Yjs document state plus periodic snapshots, A quadtree over object bounds. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. - Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. ===== MILESTONES.md ===== # Delivery milestones · Miro Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A canvas that stays fast Ten thousand objects pan and zoom smoothly because most of them are never drawn. ### Steps 1. Set up the canvas and viewport transform One transform from world coordinates to screen. Every coordinate conversion goes through it. Files: `src/viewport.ts` ```sh npm create vite@latest . -- --template vanilla-ts ``` 2. Build the quadtree Insert object bounds, query by rectangle. It serves both viewport culling and hit testing. Files: `src/quadtree.ts` 3. Generate ten thousand objects and measure A fixture board plus a frame time counter. Optimising against two hundred objects teaches you nothing. Files: `src/fixtures.ts` ### Done when - [ ] Ten thousand objects pan at a measured smooth frame rate - [ ] Objects drawn per frame approximates objects on screen - [ ] Hit testing at any zoom selects the object under the cursor - [ ] Zooming to fit everything degrades rather than freezes ### Watch out - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. ## M2 · Objects and editing Sticky notes, shapes, connectors and ink all behave, and text fits its note. ### Steps 1. Implement the object kinds Sticky note, shape, connector, ink and text. Each is data plus a draw function. Files: `src/objects/` 2. Fit text to sticky notes Binary search the font size until the wrapped text fits, with a readable floor. Files: `src/text.ts` 3. Simplify ink on stroke end A line simplification pass with a tolerance. Compare point counts before and after and look at the result. Files: `src/ink.ts` ### Done when - [ ] Sticky note text fits and stops at a readable minimum - [ ] Connectors re-route when their objects move - [ ] A simplified stroke has far fewer points with no visible change - [ ] Undo works for every object kind ### Watch out - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. ## M3 · Real multiplayer Two people editing at once both keep their work. ### Steps 1. Move state into the shared document Objects as a shared map, sticky text as shared text. Local edits go through it exactly like remote ones. Files: `src/doc.ts` ```sh npm install yjs y-websocket ``` 2. Run the relay Accept updates, persist them, rebroadcast. It stores and forwards; it never resolves conflicts. Files: `server/relay.ts` 3. Test the offline divergence case Disconnect, edit both sides, reconnect. This is the case that separates real convergence from a demo. Files: `test/converge.test.ts` ### Done when - [ ] Two browsers editing different objects both keep their changes - [ ] Concurrent edits to one sticky note's text merge rather than overwrite - [ ] Offline edits on both sides converge on reconnect - [ ] Local edits use the same path as remote ones ### Watch out - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. ## M4 · Presence and cursors You can see where other people are and what they are working on. ### Steps 1. Use the awareness channel Cursor, selection and name. Ephemeral by design, so it must never reach the persisted document. Files: `src/presence.ts` 2. Throttle cursor updates A fixed rate that still reads as live. Measure the resulting message rate. Files: `src/presence.ts` 3. Clean up on disconnect Remove participants on connection loss. A frozen cursor is worse than no cursor. Files: `server/relay.ts` ### Done when - [ ] Each browser sees the other's cursor with a name - [ ] A closed browser's cursor disappears within seconds - [ ] Cursor message rate is measured and well below pointer event rate - [ ] No presence data appears in a persisted snapshot ### Watch out - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. ## M5 · Persistence and loading a big board Opening a board with a long history is fast. ### Steps 1. Snapshot and compact Write a snapshot periodically, then drop the updates it covers. Never drop updates a snapshot does not cover. Files: `server/persist.ts` 2. Load from snapshot plus tail The client receives one snapshot and the recent updates. Measure load time against a long history. Files: `server/load.ts` 3. Export image and JSON Render the board region to an image, and serialise the document to JSON that re-imports. Files: `src/export.ts` ### Done when - [ ] A board with a long history loads in under a second - [ ] Compaction does not change the loaded document state - [ ] An image export matches the visible region - [ ] A JSON export re-imports into an empty board ### Watch out - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ## M6 · Permissions, hosting and backups (production only) Boards are private to the people you invited, and you can restore one. ### Steps 1. Authenticate and authorise on the relay Check the role on connection and reject updates from viewers server side. Files: `server/auth.ts` 2. Test with a raw client Connect directly and attempt an update as a viewer. Testing through your own interface proves nothing. Files: `test/authz.test.ts` 3. Back up both halves and restore Update log and snapshots together. Restore onto a clean server, open a board, record the date. Files: `scripts/backup.sh`, `README.md` ### Done when - [ ] A viewer's direct update is rejected by the relay - [ ] An uninvited user cannot open a board - [ ] A restore reproduces a board on a clean server - [ ] The restore date is recorded ### Watch out - Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. ===== OPERATIONS.md ===== # Operations · Miro ## Backup Update logs and snapshots together, off the machine. A snapshot without its subsequent updates loses recent work; updates without a snapshot make loading slow but lose nothing, so if you must choose, keep the updates. ## Restore Restore both, start the relay, open a board and confirm recent edits are present. Record the date. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Connected client count, update rate per board, persisted size per board and load time. Load time climbing usually means compaction has stopped running rather than that boards got bigger. ## Incident checklist For a board that will not load, check whether compaction dropped updates a snapshot did not cover, which is the one way this design loses data. For a relay under load, the object ceiling and connection limits are the levers. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Frame rate at ten thousand objects is measured and has not regressed - [ ] The offline divergence test converges without losing either side - [ ] A viewer's direct update is rejected by the relay - [ ] Load time against a long-history board is under a second - [ ] A restore of updates and snapshots has been performed and dated ## Launch constraint Do not market omitted Miro capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where clients connect. Must be a secure WebSocket in anything but local development. RELAY_URL=wss://boards.example.com # Required. Where update logs and snapshots are written. Back up both together. PERSIST_DIR=./data/boards # Required. How many updates before a snapshot is written. Lower means faster loads and more storage. SNAPSHOT_INTERVAL_UPDATES=500 # Required. Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. CURSOR_THROTTLE_MS=50 # Required. A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. MAX_OBJECTS_PER_BOARD=20000
# Miro · indie build A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Vite, rendering to a canvas element | at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not | | Collaboration | Yjs, a conflict-free replicated data type | concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy | | Transport | A WebSocket relay that persists the document | the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did | | Persistence | The Yjs document state plus periodic snapshots | loading a board should not mean replaying every edit since it was created | | Spatial index | A quadtree over object bounds | hit testing and viewport culling both need to answer what is here quickly | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the relay and the build. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Two browsers or two machines for testing** · free - Why: Every collaboration claim in Phases 3 and 4 needs two participants. A single browser with two tabs is a weaker test but is the minimum. - Get it: Two different browsers on one machine, or two machines on the same network. Two machines catch clock and latency issues that two tabs hide. - Verify: Two clients connect to the same board simultaneously - [ ] **Understanding of why a conflict-free data type is used** · free - Why: It is the central architectural decision, and retrofitting it after building on last write wins means rewriting every mutation. - Get it: Read the Yjs documentation on shared types before Phase 3, not during it. Half an hour saves a rewrite. - Verify: You can explain why two concurrent text edits merge rather than one winning - [ ] **A generated board with ten thousand objects** · free - Why: Phase 1's performance work is meaningless without it, and the naive approach feels perfect until this fixture exists. - Get it: Write the generator in Phase 1 before optimising anything. - Verify: The generator produces a board that loads and renders - [ ] **A small server for the product phase** (optional) · 5 to 10 USD a month - Why: Only needed once other people use it. Everything before that runs locally. - Get it: Any small virtual server with a domain, or a machine at home behind a tunnel. - Verify: The relay is reachable over TLS ## Quick start ```sh npm create vite@latest . -- --template vanilla-ts ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. - large-board performance - templates - integrations - facilitation tools - permissions - enterprise controls If one of those is essential to you, that is the reason to keep paying for Miro, and the README should say so rather than pretend.
# Build brief · Miro The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an infinite collaborative canvas with sticky notes, shapes and real multiplayer editing to replace Miro. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Vite, rendering to a canvas element · at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not - Collaboration: Yjs, a conflict-free replicated data type · concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy - Transport: A WebSocket relay that persists the document · the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did - Persistence: The Yjs document state plus periodic snapshots · loading a board should not mean replaying every edit since it was created - Spatial index: A quadtree over object bounds · hit testing and viewport culling both need to answer what is here quickly ### Data model (create this before Phase 1) - The board is one Yjs document. These are the shared types inside it, not database tables. - objects · a shared map of id to object: kind, x, y, w, h, rotation, z, style and content - text · per-object shared text so two people editing the same sticky note merge rather than clobber - awareness · ephemeral cursor position, selection and name per participant, never persisted - Server side: boards(id, name, created_at), updates(board_id, seq, payload, at), snapshots(board_id, seq, state, at) - The rule that keeps multiplayer honest: every change goes through the shared document, including your own local edits. There is no separate local path. ### Phase 1 · A canvas that stays fast Build: Render to a canvas element with a viewport transform. Build a quadtree over object bounds and draw only what intersects the viewport. Generate ten thousand objects before you optimise, so the numbers are real. Trap: Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. Done when: A board with ten thousand objects pans at a smooth frame rate, measured rather than judged by eye. The number of objects drawn per frame is close to the number actually on screen. Zooming out to show everything degrades predictably rather than freezing. Do not build yet: No editing, no collaboration, no persistence. ### Phase 2 · Objects and editing Build: Create, move, resize, rotate and delete objects. Sticky notes with text that shrinks to fit. Shapes with fill and stroke. Connectors attached to objects rather than to points. Freehand ink with pressure if the device reports it. Trap: Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. Done when: A sticky note's text shrinks to fit and stops at a readable minimum. Moving an object re-routes its connectors. A freehand stroke stores far fewer points after simplification than the raw input, with no visible change. Undo restores the previous state for every object kind. Do not build yet: No collaboration yet, so this is single player. ### Phase 3 · Real multiplayer Build: Move all state into a Yjs document. Run a WebSocket relay that persists and rebroadcasts updates. Route your own local edits through the shared document too, so there is one code path rather than two that can diverge. Trap: Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. Done when: Two browsers editing different objects both retain their changes. Two browsers editing the same sticky note's text concurrently converge to a merged result rather than one overwriting the other. Disconnecting one browser, editing offline in both, and reconnecting converges without losing either side's work. Do not build yet: No presence, no persistence tuning, no permissions. ### Phase 4 · Presence and cursors Build: Broadcast cursor position, selection and name through the awareness channel, which is ephemeral and never persisted. Throttle cursor updates. Remove a participant when their connection drops rather than leaving a ghost. Trap: Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. Done when: Two browsers each see the other's cursor moving with names attached. Closing one browser removes its cursor within a few seconds rather than leaving it frozen on the canvas. Cursor messages are throttled to a measured rate well below the pointer event rate. Presence data never appears in a persisted snapshot. Do not build yet: No persistence tuning or export yet. ### Phase 5 · Persistence and loading a big board Build: Persist updates and write periodic snapshots. On load, send the newest snapshot plus the updates after it rather than the full history. Compact old updates once a snapshot covers them. Export the board as an image and as JSON. Trap: Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. Done when: A board with a long synthetic update history loads in under a second. Compaction reduces stored size without changing the loaded document, verified by comparing state before and after. An image export matches what is on screen for the visible region. A JSON export re-imports into an empty board. Do not build yet: Permissions and hosting are the last phase. ### Phase 6 · Permissions, hosting and backups Build: Authentication, per-board access with view and edit roles enforced on the relay, TLS, and backups of both the update log and the snapshots. Restore a board onto a clean server and open it. Trap: Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. Done when: A viewer's update sent directly to the relay is rejected, tested with a raw client rather than through the interface. An uninvited user cannot open a board. A restore onto a clean server reproduces a board with its content, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Performance at the scale of a board with a hundred thousand objects and forty simultaneous editors, which is real engineering the hosted product has done and you have not. - The template library, which is content accumulated over years. - The integrations that put boards inside other tools, each of which is an API and a maintenance burden. - Video calls, timers, voting and the workshop features that make it a facilitation product rather than a canvas. ### README must contain - That the canvas culls with a spatial index, and the measured frame rate at ten thousand objects - That collaboration uses a conflict-free replicated data type, and why last write wins was rejected - That presence is ephemeral and never persisted - How snapshots and compaction keep load time flat as history grows - That view-only is enforced on the relay, not in the client
# Agent instructions · Miro indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Vite, rendering to a canvas element, Yjs, a conflict-free replicated data type, A WebSocket relay that persists the document, The Yjs document state plus periodic snapshots, A quadtree over object bounds. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one.
# Build plan · Miro A canvas that stays smooth with ten thousand objects because it only draws what is on screen, with sticky notes, shapes, connectors and freehand ink, and genuine concurrent editing built on a conflict-free replicated data type so two people typing at once converge rather than overwrite each other. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A canvas that stays fast Ten thousand objects pan and zoom smoothly because most of them are never drawn. ### Steps 1. Set up the canvas and viewport transform One transform from world coordinates to screen. Every coordinate conversion goes through it. Files: `src/viewport.ts` ```sh npm create vite@latest . -- --template vanilla-ts ``` 2. Build the quadtree Insert object bounds, query by rectangle. It serves both viewport culling and hit testing. Files: `src/quadtree.ts` 3. Generate ten thousand objects and measure A fixture board plus a frame time counter. Optimising against two hundred objects teaches you nothing. Files: `src/fixtures.ts` ### Done when - [ ] Ten thousand objects pan at a measured smooth frame rate - [ ] Objects drawn per frame approximates objects on screen - [ ] Hit testing at any zoom selects the object under the cursor - [ ] Zooming to fit everything degrades rather than freezes ### Watch out - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. ## Phase 2 · Objects and editing Sticky notes, shapes, connectors and ink all behave, and text fits its note. ### Steps 1. Implement the object kinds Sticky note, shape, connector, ink and text. Each is data plus a draw function. Files: `src/objects/` 2. Fit text to sticky notes Binary search the font size until the wrapped text fits, with a readable floor. Files: `src/text.ts` 3. Simplify ink on stroke end A line simplification pass with a tolerance. Compare point counts before and after and look at the result. Files: `src/ink.ts` ### Done when - [ ] Sticky note text fits and stops at a readable minimum - [ ] Connectors re-route when their objects move - [ ] A simplified stroke has far fewer points with no visible change - [ ] Undo works for every object kind ### Watch out - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. ## Phase 3 · Real multiplayer Two people editing at once both keep their work. ### Steps 1. Move state into the shared document Objects as a shared map, sticky text as shared text. Local edits go through it exactly like remote ones. Files: `src/doc.ts` ```sh npm install yjs y-websocket ``` 2. Run the relay Accept updates, persist them, rebroadcast. It stores and forwards; it never resolves conflicts. Files: `server/relay.ts` 3. Test the offline divergence case Disconnect, edit both sides, reconnect. This is the case that separates real convergence from a demo. Files: `test/converge.test.ts` ### Done when - [ ] Two browsers editing different objects both keep their changes - [ ] Concurrent edits to one sticky note's text merge rather than overwrite - [ ] Offline edits on both sides converge on reconnect - [ ] Local edits use the same path as remote ones ### Watch out - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. ## Phase 4 · Presence and cursors You can see where other people are and what they are working on. ### Steps 1. Use the awareness channel Cursor, selection and name. Ephemeral by design, so it must never reach the persisted document. Files: `src/presence.ts` 2. Throttle cursor updates A fixed rate that still reads as live. Measure the resulting message rate. Files: `src/presence.ts` 3. Clean up on disconnect Remove participants on connection loss. A frozen cursor is worse than no cursor. Files: `server/relay.ts` ### Done when - [ ] Each browser sees the other's cursor with a name - [ ] A closed browser's cursor disappears within seconds - [ ] Cursor message rate is measured and well below pointer event rate - [ ] No presence data appears in a persisted snapshot ### Watch out - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. ## Phase 5 · Persistence and loading a big board Opening a board with a long history is fast. ### Steps 1. Snapshot and compact Write a snapshot periodically, then drop the updates it covers. Never drop updates a snapshot does not cover. Files: `server/persist.ts` 2. Load from snapshot plus tail The client receives one snapshot and the recent updates. Measure load time against a long history. Files: `server/load.ts` 3. Export image and JSON Render the board region to an image, and serialise the document to JSON that re-imports. Files: `src/export.ts` ### Done when - [ ] A board with a long history loads in under a second - [ ] Compaction does not change the loaded document state - [ ] An image export matches the visible region - [ ] A JSON export re-imports into an empty board ### Watch out - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ## Not in this build - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. ## After v1, if you want it - Comments anchored to objects, which need their own resolution state - Frames and simple presentation mode, which is cheap once the viewport is solid - A read-only public share link rendered from a snapshot
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where clients connect. Must be a secure WebSocket in anything but local development. RELAY_URL=wss://boards.example.com # Required. Where update logs and snapshots are written. Back up both together. PERSIST_DIR=./data/boards # Required. How many updates before a snapshot is written. Lower means faster loads and more storage. SNAPSHOT_INTERVAL_UPDATES=500 # Required. Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. CURSOR_THROTTLE_MS=50 # Required. A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. MAX_OBJECTS_PER_BOARD=20000
# Miro · product brief ## Problem A simple multiplayer whiteboard is doable; Miro's value is scale, templates, integrations, facilitation, permissions, and reliability with many users. ## Product outcome A canvas that stays smooth at ten thousand objects, where concurrent edits genuinely merge, presence is live without flooding the connection, boards load fast regardless of history, and view-only means something because the server enforces it. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - canvas library - WebSocket/collab backend - database - auth - hosting - optional template library ## Explicit non-goals for v1 - Performance at very large boards with many simultaneous editors. - The template library. - Integrations that embed boards in other tools. - Video calls, timers, voting and the facilitation features. - large-board performance - templates - integrations - facilitation tools - permissions - enterprise controls ## Success criteria - Frame rate at ten thousand objects is measured and has not regressed - The offline divergence test converges without losing either side - A viewer's direct update is rejected by the relay - Load time against a long-history board is under a second - A restore of updates and snapshots has been performed and dated
# Build brief · Miro The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an infinite collaborative canvas with sticky notes, shapes and real multiplayer editing to replace Miro. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Vite, rendering to a canvas element · at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not - Collaboration: Yjs, a conflict-free replicated data type · concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy - Transport: A WebSocket relay that persists the document · the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did - Persistence: The Yjs document state plus periodic snapshots · loading a board should not mean replaying every edit since it was created - Spatial index: A quadtree over object bounds · hit testing and viewport culling both need to answer what is here quickly ### Data model (create this before Phase 1) - The board is one Yjs document. These are the shared types inside it, not database tables. - objects · a shared map of id to object: kind, x, y, w, h, rotation, z, style and content - text · per-object shared text so two people editing the same sticky note merge rather than clobber - awareness · ephemeral cursor position, selection and name per participant, never persisted - Server side: boards(id, name, created_at), updates(board_id, seq, payload, at), snapshots(board_id, seq, state, at) - The rule that keeps multiplayer honest: every change goes through the shared document, including your own local edits. There is no separate local path. ### Phase 1 · A canvas that stays fast Build: Render to a canvas element with a viewport transform. Build a quadtree over object bounds and draw only what intersects the viewport. Generate ten thousand objects before you optimise, so the numbers are real. Trap: Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. Done when: A board with ten thousand objects pans at a smooth frame rate, measured rather than judged by eye. The number of objects drawn per frame is close to the number actually on screen. Zooming out to show everything degrades predictably rather than freezing. Do not build yet: No editing, no collaboration, no persistence. ### Phase 2 · Objects and editing Build: Create, move, resize, rotate and delete objects. Sticky notes with text that shrinks to fit. Shapes with fill and stroke. Connectors attached to objects rather than to points. Freehand ink with pressure if the device reports it. Trap: Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. Done when: A sticky note's text shrinks to fit and stops at a readable minimum. Moving an object re-routes its connectors. A freehand stroke stores far fewer points after simplification than the raw input, with no visible change. Undo restores the previous state for every object kind. Do not build yet: No collaboration yet, so this is single player. ### Phase 3 · Real multiplayer Build: Move all state into a Yjs document. Run a WebSocket relay that persists and rebroadcasts updates. Route your own local edits through the shared document too, so there is one code path rather than two that can diverge. Trap: Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. Done when: Two browsers editing different objects both retain their changes. Two browsers editing the same sticky note's text concurrently converge to a merged result rather than one overwriting the other. Disconnecting one browser, editing offline in both, and reconnecting converges without losing either side's work. Do not build yet: No presence, no persistence tuning, no permissions. ### Phase 4 · Presence and cursors Build: Broadcast cursor position, selection and name through the awareness channel, which is ephemeral and never persisted. Throttle cursor updates. Remove a participant when their connection drops rather than leaving a ghost. Trap: Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. Done when: Two browsers each see the other's cursor moving with names attached. Closing one browser removes its cursor within a few seconds rather than leaving it frozen on the canvas. Cursor messages are throttled to a measured rate well below the pointer event rate. Presence data never appears in a persisted snapshot. Do not build yet: No persistence tuning or export yet. ### Phase 5 · Persistence and loading a big board Build: Persist updates and write periodic snapshots. On load, send the newest snapshot plus the updates after it rather than the full history. Compact old updates once a snapshot covers them. Export the board as an image and as JSON. Trap: Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. Done when: A board with a long synthetic update history loads in under a second. Compaction reduces stored size without changing the loaded document, verified by comparing state before and after. An image export matches what is on screen for the visible region. A JSON export re-imports into an empty board. Do not build yet: Permissions and hosting are the last phase. ### Phase 6 · Permissions, hosting and backups Build: Authentication, per-board access with view and edit roles enforced on the relay, TLS, and backups of both the update log and the snapshots. Restore a board onto a clean server and open it. Trap: Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real. Done when: A viewer's update sent directly to the relay is rejected, tested with a raw client rather than through the interface. An uninvited user cannot open a board. A restore onto a clean server reproduces a board with its content, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - Performance at the scale of a board with a hundred thousand objects and forty simultaneous editors, which is real engineering the hosted product has done and you have not. - The template library, which is content accumulated over years. - The integrations that put boards inside other tools, each of which is an API and a maintenance burden. - Video calls, timers, voting and the workshop features that make it a facilitation product rather than a canvas. ### README must contain - That the canvas culls with a spatial index, and the measured frame rate at ten thousand objects - That collaboration uses a conflict-free replicated data type, and why last write wins was rejected - That presence is ephemeral and never persisted - How snapshots and compaction keep load time flat as history grows - That view-only is enforced on the relay, not in the client
# Architecture · Miro ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Vite, rendering to a canvas element | at ten thousand objects the DOM stops being viable and an immediate-mode canvas does not | | Collaboration | Yjs, a conflict-free replicated data type | concurrent edits converge without a server deciding a winner, which is the property that makes multiplayer correct rather than lossy | | Transport | A WebSocket relay that persists the document | the relay stores and rebroadcasts updates; it does not resolve conflicts, because the data type already did | | Persistence | The Yjs document state plus periodic snapshots | loading a board should not mean replaying every edit since it was created | | Spatial index | A quadtree over object bounds | hit testing and viewport culling both need to answer what is here quickly | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Viewport and index | The transform and spatial queries. | The quadtree could be another spatial structure. What must not change is that culling happens before drawing. | | Document | All shared state through the conflict-free type. | Not swappable without rewriting every mutation, which is exactly why it is chosen in Phase 3 rather than added later. | | Relay | Transport, persistence and authorisation. | Any transport works. Authorisation must stay server side whatever the transport. | | Presence | Ephemeral awareness state. | Independent of the document by design, so it can be replaced or removed without touching persistence. | | Persistence | Update log, snapshots and compaction. | Storage backend is replaceable. The snapshot plus tail loading strategy is what keeps load time flat. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `RELAY_URL` · required · Where clients connect. Must be a secure WebSocket in anything but local development. - `PERSIST_DIR` · required · Where update logs and snapshots are written. Back up both together. - `SNAPSHOT_INTERVAL_UPDATES` · required · How many updates before a snapshot is written. Lower means faster loads and more storage. - `CURSOR_THROTTLE_MS` · required · Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. - `MAX_OBJECTS_PER_BOARD` · required · A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Miro product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Vite, rendering to a canvas element, Yjs, a conflict-free replicated data type, A WebSocket relay that persists the document, The Yjs document state plus periodic snapshots, A quadtree over object bounds. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. - Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real.
# Delivery milestones · Miro Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A canvas that stays fast Ten thousand objects pan and zoom smoothly because most of them are never drawn. ### Steps 1. Set up the canvas and viewport transform One transform from world coordinates to screen. Every coordinate conversion goes through it. Files: `src/viewport.ts` ```sh npm create vite@latest . -- --template vanilla-ts ``` 2. Build the quadtree Insert object bounds, query by rectangle. It serves both viewport culling and hit testing. Files: `src/quadtree.ts` 3. Generate ten thousand objects and measure A fixture board plus a frame time counter. Optimising against two hundred objects teaches you nothing. Files: `src/fixtures.ts` ### Done when - [ ] Ten thousand objects pan at a measured smooth frame rate - [ ] Objects drawn per frame approximates objects on screen - [ ] Hit testing at any zoom selects the object under the cursor - [ ] Zooming to fit everything degrades rather than freezes ### Watch out - Rendering every object each frame and relying on the canvas to clip is fast at two hundred objects and unusable at ten thousand, because the cost is in the per-object work rather than the pixels. Cull with the spatial index before drawing, and measure frame time rather than trusting that it feels fine. ## M2 · Objects and editing Sticky notes, shapes, connectors and ink all behave, and text fits its note. ### Steps 1. Implement the object kinds Sticky note, shape, connector, ink and text. Each is data plus a draw function. Files: `src/objects/` 2. Fit text to sticky notes Binary search the font size until the wrapped text fits, with a readable floor. Files: `src/text.ts` 3. Simplify ink on stroke end A line simplification pass with a tolerance. Compare point counts before and after and look at the result. Files: `src/ink.ts` ### Done when - [ ] Sticky note text fits and stops at a readable minimum - [ ] Connectors re-route when their objects move - [ ] A simplified stroke has far fewer points with no visible change - [ ] Undo works for every object kind ### Watch out - Freehand ink recorded as every raw pointer sample produces enormous objects and a slow canvas, because a single stroke can be hundreds of points. Simplify each stroke on completion with a tolerance you choose, which typically removes most of the points and no visible detail. ## M3 · Real multiplayer Two people editing at once both keep their work. ### Steps 1. Move state into the shared document Objects as a shared map, sticky text as shared text. Local edits go through it exactly like remote ones. Files: `src/doc.ts` ```sh npm install yjs y-websocket ``` 2. Run the relay Accept updates, persist them, rebroadcast. It stores and forwards; it never resolves conflicts. Files: `server/relay.ts` 3. Test the offline divergence case Disconnect, edit both sides, reconnect. This is the case that separates real convergence from a demo. Files: `test/converge.test.ts` ### Done when - [ ] Two browsers editing different objects both keep their changes - [ ] Concurrent edits to one sticky note's text merge rather than overwrite - [ ] Offline edits on both sides converge on reconnect - [ ] Local edits use the same path as remote ones ### Watch out - Last write wins over a socket looks correct in a demo with one person and destroys work with two, because the loser's edit vanishes with no error. A conflict-free replicated data type is not over-engineering here; it is what makes concurrent editing correct, and retrofitting it later means rewriting every mutation. ## M4 · Presence and cursors You can see where other people are and what they are working on. ### Steps 1. Use the awareness channel Cursor, selection and name. Ephemeral by design, so it must never reach the persisted document. Files: `src/presence.ts` 2. Throttle cursor updates A fixed rate that still reads as live. Measure the resulting message rate. Files: `src/presence.ts` 3. Clean up on disconnect Remove participants on connection loss. A frozen cursor is worse than no cursor. Files: `server/relay.ts` ### Done when - [ ] Each browser sees the other's cursor with a name - [ ] A closed browser's cursor disappears within seconds - [ ] Cursor message rate is measured and well below pointer event rate - [ ] No presence data appears in a persisted snapshot ### Watch out - Sending a cursor update on every pointer move floods the connection with dozens of messages a second per person, and with five participants the transport spends more time on cursors than on edits. Throttle to a rate that still feels live, which is far lower than the pointer event rate. ## M5 · Persistence and loading a big board Opening a board with a long history is fast. ### Steps 1. Snapshot and compact Write a snapshot periodically, then drop the updates it covers. Never drop updates a snapshot does not cover. Files: `server/persist.ts` 2. Load from snapshot plus tail The client receives one snapshot and the recent updates. Measure load time against a long history. Files: `server/load.ts` 3. Export image and JSON Render the board region to an image, and serialise the document to JSON that re-imports. Files: `src/export.ts` ### Done when - [ ] A board with a long history loads in under a second - [ ] Compaction does not change the loaded document state - [ ] An image export matches the visible region - [ ] A JSON export re-imports into an empty board ### Watch out - Replaying every update since a board was created works for a week and then takes several seconds, because the update log grows without bound. Snapshot on a schedule and compact behind it, and test load time against a board with a long synthetic history rather than a fresh one. ## M6 · Permissions, hosting and backups (production only) Boards are private to the people you invited, and you can restore one. ### Steps 1. Authenticate and authorise on the relay Check the role on connection and reject updates from viewers server side. Files: `server/auth.ts` 2. Test with a raw client Connect directly and attempt an update as a viewer. Testing through your own interface proves nothing. Files: `test/authz.test.ts` 3. Back up both halves and restore Update log and snapshots together. Restore onto a clean server, open a board, record the date. Files: `scripts/backup.sh`, `README.md` ### Done when - [ ] A viewer's direct update is rejected by the relay - [ ] An uninvited user cannot open a board - [ ] A restore reproduces a board on a clean server - [ ] The restore date is recorded ### Watch out - Enforcing read-only in the client is decoration, because anyone can connect to the WebSocket directly and send updates. The relay must reject updates from viewers, and that rejection is the only thing that makes a view role real.
# Operations · Miro ## Backup Update logs and snapshots together, off the machine. A snapshot without its subsequent updates loses recent work; updates without a snapshot make loading slow but lose nothing, so if you must choose, keep the updates. ## Restore Restore both, start the relay, open a board and confirm recent edits are present. Record the date. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Connected client count, update rate per board, persisted size per board and load time. Load time climbing usually means compaction has stopped running rather than that boards got bigger. ## Incident checklist For a board that will not load, check whether compaction dropped updates a snapshot did not cover, which is the one way this design loses data. For a relay under load, the object ceiling and connection limits are the levers. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Frame rate at ten thousand objects is measured and has not regressed - [ ] The offline divergence test converges without losing either side - [ ] A viewer's direct update is rejected by the relay - [ ] Load time against a long-history board is under a second - [ ] A restore of updates and snapshots has been performed and dated ## Launch constraint Do not market omitted Miro capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where clients connect. Must be a secure WebSocket in anything but local development. RELAY_URL=wss://boards.example.com # Required. Where update logs and snapshots are written. Back up both together. PERSIST_DIR=./data/boards # Required. How many updates before a snapshot is written. Lower means faster loads and more storage. SNAPSHOT_INTERVAL_UPDATES=500 # Required. Cursor broadcast rate. Low enough to feel live, far below the pointer event rate. CURSOR_THROTTLE_MS=50 # Required. A ceiling you enforce, because performance degrades and it is better to say so than to become unusable. MAX_OBJECTS_PER_BOARD=20000
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay for workshops where dozens of people can join and everything still works.
xlarge-board performance
xtemplates
xintegrations
xfacilitation tools
xpermissions
xenterprise controls
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Miro →· no votes, no pay-to-list · just what's real
Miro pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | Unlimited members and boards, but only the 3 most recently created boards remain editable; 10 AI credits/month; 100 MCP calls/day |
| starter | $10/user | $8/user | Unlimited private boards and project spaces; visitor editing; 25 AI credits/member/month; 500 MCP calls/day |
| business | $25/user | $20/user | Unlimited workspaces and guests; 50 AI credits/member/month; 2,000 MCP calls/day; SSO, advanced diagramming and two-way integrations |
| enterprise | custom | — | 30-member minimum; starts at 2,500 pooled AI credits/month; 10,000 MCP calls/day; SCIM, data residency and organization governance |
free tierUnlimited members and boards, but only 3 most recent boards editable; 10 AI credits/month and 100 MCP calls/day; no guests or visitors
billingfree + per-member monthly or annual billing; annual saves 20% and is prepaid; automated card billing has no seat minimum, while invoicing is annual-only with 10 Starter or 5 Business licenses
hidden costsNew paid members are prorated immediately, while removed seats stay billable until renewal; extra AI-credit packs, Enterprise Guard and Premium Support cost extra; some data-residency migrations carry fees
verified 2026-08-14 · source ↗
Vibecode Miro
Kinda. The core of Miro is buildable in a weekend with the prompt on this page, but there are real gaps: large-board performance, templates. Read the honest list above before committing.
How much does Miro cost?
Miro costs about $10/month (Starter, checked 2026-07-30), which is $120 per year.
What do I lose by replacing Miro?
Honestly: large-board performance; templates; integrations; facilitation tools; permissions; enterprise controls. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Miro?
Yes: Excalidraw (The best simple escape hatch for collaborative diagrams, workshops, and planning: fast, free, local-first, and pleasantly uninterested in becoming Salesforce.) AFFiNE (An integrated canvas, docs, databases, and planning workspace for teams willing to accept a younger product and real self-hosting chores.) CryptPad (Encrypted collaborative whiteboards with persistent folders, permissions, and sharing; fewer templates, far better data boundaries.) All 5 curated free alternatives are at vibecodeit.com/miro/alternatives. The prompt is for when you want it exactly your way.