Vibecode Excalidraw+
track this build4 phases, 12 steps, beginner friendly0%Excalidraw+'s solo core is compact: build a browser-based collaborative whiteboard editor with drag, resize, connect, keyboard shortcuts, autosave, and SVG or PNG export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on interaction polish, collaboration.
You are building a lean indie version of Excalidraw+. 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 ===== # Excalidraw+ · indie build A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want. 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 | | --- | --- | --- | | Canvas | A scene of independent elements, each separately versioned | reconciling per element is tractable where reconciling a whole scene is not | | Transport | WebSocket for live changes, HTTP for scene loading | the two have completely different reliability requirements | | Reconciliation | Per-element versioning with a deterministic tiebreak | two clients must resolve a conflict the same way without asking the server | | Ephemeral state | Cursors and selections broadcast but never stored | pointer positions are a firehose and worthless a second later | | Encryption | Scene encrypted in the browser with a key in the URL fragment | the server storing scenes it cannot read is a much smaller promise to keep | ## 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 socket server and builds the client. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Two machines, or two browsers on one** · free - Why: Every claim about collaboration is untestable with a single client, and two tabs in one browser hide timing problems that two machines expose. - Get it: A second machine, or at minimum two different browsers with the network throttled differently. - Verify: Two clients can join one room and both see each other's changes - [ ] **A large real drawing** · free - Why: Delta transmission, snapshot cost and canvas performance all behave differently at five hundred elements, and a test scene of ten proves nothing. - Get it: Draw one, or generate a scene with several hundred varied elements including freehand paths, which are the expensive ones. - Verify: A scene with five hundred elements including freehand paths exists - [ ] **Whether you are encrypting, decided before phase 1** · free - Why: End-to-end encryption forbids server-side search, thumbnails, export and key recovery, and retrofitting it later means removing features people already use. - Get it: Decide, and write down what you are giving up in exchange. Both answers are defensible; discovering the trade halfway is not. - Verify: A written note states the decision and lists the features it forecloses ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. - large template libraries - high-fidelity import and export - presentation and enterprise controls - multiplayer cursors and comments If one of those is essential to you, that is the reason to keep paying for Excalidraw+, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Excalidraw+ 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 a shared drawing canvas several people can edit at once without treading on each other to replace Excalidraw Plus. 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) - Canvas: A scene of independent elements, each separately versioned · reconciling per element is tractable where reconciling a whole scene is not - Transport: WebSocket for live changes, HTTP for scene loading · the two have completely different reliability requirements - Reconciliation: Per-element versioning with a deterministic tiebreak · two clients must resolve a conflict the same way without asking the server - Ephemeral state: Cursors and selections broadcast but never stored · pointer positions are a firehose and worthless a second later - Encryption: Scene encrypted in the browser with a key in the URL fragment · the server storing scenes it cannot read is a much smaller promise to keep ### Data model (create this before Phase 1) - scenes(id, encrypted_blob, iv, version, updated_at) · the server sees ciphertext and nothing else - elements(id, type, x, y, width, height, angle, points_json, style_json, version, version_nonce, updated_by, deleted) - Elements live inside the encrypted scene. The server schema above is what a self-hosted unencrypted mode would use - rooms(id, scene_id, created_at, last_active_at) · a live session, holding no content - presence(room_id, client_id, name, colour, cursor, selection) · in memory only, never written to disk - A deleted element is a tombstone with a version rather than a removed row, because a delete and a concurrent edit must reconcile. ### Phase 1 · A canvas with versioned elements Build: A canvas with shapes, freehand lines, text and arrows. Every element carries a version and a random tiebreak value. Deleting sets a tombstone rather than removing. Local persistence so a reload loses nothing. Trap: Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. Done when: Shapes, freehand, text and arrows draw and persist across a reload. Every element has a version and a tiebreak value. Deleting produces a tombstone rather than removing the element. Undo and redo restore versions correctly rather than reusing stale ones. Do not build yet: No collaboration, presence or encryption. ### Phase 2 · Two people drawing at once Build: Broadcast element changes over a socket. Reconcile incoming elements against local ones by version, breaking ties with the stored nonce so both sides reach the same answer. Send changed elements only, never the whole scene. Trap: Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. Done when: Two clients editing different elements both see both changes. Two clients editing the same element converge on the same result, tested by driving the reconciler directly with conflicting pairs. Only changed elements are transmitted, verified by inspecting the traffic. A scene of five hundred elements stays responsive with two editors. Do not build yet: No presence, encryption or persistence to a server. ### Phase 3 · Cursors, without drowning the connection Build: Broadcast cursor position and selection at a throttled rate on a separate channel from element changes. Never persist presence. Handle a client disappearing without a clean disconnect so ghost cursors do not accumulate. Trap: Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. Done when: Cursors appear and move smoothly at a throttled rate. Presence is never written to disk, verified by inspecting the store during a session. A client killed without disconnecting has its cursor removed within a timeout. Element changes are not delayed by cursor traffic under load. Do not build yet: No encryption or server persistence yet. ### Phase 4 · Encrypted scenes, and living with the consequences Build: Encrypt the scene in the browser with a key held in the URL fragment so it is never sent to the server. Store ciphertext. Write down every feature this makes impossible, and decide each one deliberately rather than discovering it later. Trap: End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. Done when: The scene is encrypted client side and the server holds only ciphertext, verified by inspecting what is stored. The key travels in the URL fragment and never appears in a request, verified from server logs. A written list names every feature encryption forbids. Losing the key makes the scene unrecoverable, and the interface says so before sharing. Do not build yet: No history or export yet. ### Phase 5 · Saving, history and export Build: Periodic encrypted snapshots with a version history the client can decrypt and restore. Export to a vector format and an image, rendered client side. Handle a scene growing large enough that snapshotting on every change is untenable. Trap: Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. Done when: Snapshots occur on a debounce rather than per change, verified by drawing continuously and counting writes. A past version restores correctly after decryption. Export to vector and image renders client side and matches the canvas. A large scene snapshots without a visible pause. Do not build yet: Nothing after this. ### Out of scope (and why) - Offline editing with later reconciliation, which is a much harder problem than live collaboration and needs a full conflict-free data type. - Server-side rendering, search and thumbnails, which end-to-end encryption forbids and which is why the hosted product offers an unencrypted mode. - Shape recognition and beautification, which is a substantial piece of work orthogonal to collaboration. - Comments, permissions and team libraries, which is the layer the paid product is actually selling. ### README must contain - That deletions are tombstones so a delete and a concurrent edit can reconcile - That reconciliation is a pure function both clients evaluate to the same answer - That presence is throttled, separate and never persisted - That the encryption key lives in the URL fragment and a lost key means a lost scene - That snapshots are debounced because per-change snapshots do not scale ===== AGENTS.md ===== # Agent instructions · Excalidraw+ indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A scene of independent elements, each separately versioned, WebSocket for live changes, HTTP for scene loading, Per-element versioning with a deterministic tiebreak, Cursors and selections broadcast but never stored, Scene encrypted in the browser with a key in the URL fragment. 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 - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ===== BUILD_PLAN.md ===== # Build plan · Excalidraw+ A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want. 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 with versioned elements Drawing works locally and every element carries what reconciliation will need. ### Steps 1. Version every element A counter and a random tiebreak. Both are needed for a deterministic resolution. Files: `src/element.ts` 2. Tombstone deletions A removed row cannot reconcile against a concurrent edit. Files: `src/element.ts` 3. Persist locally on change A reload mid-drawing must lose nothing. Files: `src/persist.ts` ### Done when - [ ] All element kinds draw and survive a reload - [ ] Every element carries a version and tiebreak - [ ] Deleting produces a tombstone - [ ] Undo and redo handle versions correctly ### Watch out - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. ## Phase 2 · Two people drawing at once Simultaneous edits from two clients resolve identically on both. ### Steps 1. Reconcile as a pure function Two elements in, one out, deterministic. Unit test with conflicting pairs. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 2. Break ties with a stored nonce Both clients must reach the same answer without consulting the server. Files: `src/reconcile.ts` 3. Send deltas, never the scene Whole-scene broadcast collapses at a few hundred elements. Files: `src/sync.ts` ### Done when - [ ] Edits to different elements both survive - [ ] Conflicting edits converge identically on both clients - [ ] Only changed elements are transmitted - [ ] Five hundred elements stay responsive with two editors ### Watch out - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. ## Phase 3 · Cursors, without drowning the connection You can see where other people are, and it costs almost nothing. ### Steps 1. Throttle and separate presence Its own channel, a fixed rate. Drawing must never queue behind pointer noise. Files: `src/presence.ts` 2. Never persist presence It is worthless a second later and it is a record of what people looked at. Files: `src/presence.ts` 3. Expire cursors on a timeout Clients vanish without disconnecting. Ghost cursors accumulate otherwise. Files: `src/presence.ts` ### Done when - [ ] Cursors move smoothly at a throttled rate - [ ] Presence never reaches disk - [ ] A killed client's cursor expires - [ ] Element changes are not delayed by cursor traffic ### Watch out - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. ## Phase 4 · Encrypted scenes, and living with the consequences The server stores scenes it cannot read, and you accept what that forbids. ### Steps 1. Encrypt before it leaves the browser The server should never be able to read a scene, even accidentally. Files: `src/crypto.ts` 2. Keep the key in the fragment Fragments are not sent in requests. Verify that in server logs, do not assume it. Files: `src/share.ts` 3. List what encryption forbids Search, thumbnails, recovery. Decide each now rather than discovering it later. Files: `docs/encryption-tradeoffs.md` ### Done when - [ ] The server stores only ciphertext - [ ] The key never appears in server logs - [ ] A written list names what encryption forbids - [ ] The interface warns that a lost key means a lost scene ### Watch out - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ## Not in this build - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. ## After v1, if you want it - Frames and named sections, which is how a large board stops being unusable - A read-only share link, which needs a second key rather than a permission flag - Embedded images, once the scene size and snapshot costs are understood ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Encrypted scene blobs and room records. Contains no readable content by design. DATABASE_URL=postgres://localhost:5432/canvas # Required. The live collaboration channel, separate from scene loading over HTTP. SOCKET_PORT=3001 # Required. How often cursor positions broadcast. Lower feels smoother and floods the channel. PRESENCE_THROTTLE_MS=50 # Required. When a silent client's cursor is removed. Clients vanish without disconnecting. PRESENCE_TIMEOUT_SECONDS=30 # Required. How long after the last change a snapshot is written. Per-change does not scale. SNAPSHOT_DEBOUNCE_MS=2000 # Required. A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. MAX_SCENE_BYTES=10485760
You are building a lean indie version of Excalidraw+. 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 ===== # Excalidraw+ · indie build A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want. 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 | | --- | --- | --- | | Canvas | A scene of independent elements, each separately versioned | reconciling per element is tractable where reconciling a whole scene is not | | Transport | WebSocket for live changes, HTTP for scene loading | the two have completely different reliability requirements | | Reconciliation | Per-element versioning with a deterministic tiebreak | two clients must resolve a conflict the same way without asking the server | | Ephemeral state | Cursors and selections broadcast but never stored | pointer positions are a firehose and worthless a second later | | Encryption | Scene encrypted in the browser with a key in the URL fragment | the server storing scenes it cannot read is a much smaller promise to keep | ## 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 socket server and builds the client. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Two machines, or two browsers on one** · free - Why: Every claim about collaboration is untestable with a single client, and two tabs in one browser hide timing problems that two machines expose. - Get it: A second machine, or at minimum two different browsers with the network throttled differently. - Verify: Two clients can join one room and both see each other's changes - [ ] **A large real drawing** · free - Why: Delta transmission, snapshot cost and canvas performance all behave differently at five hundred elements, and a test scene of ten proves nothing. - Get it: Draw one, or generate a scene with several hundred varied elements including freehand paths, which are the expensive ones. - Verify: A scene with five hundred elements including freehand paths exists - [ ] **Whether you are encrypting, decided before phase 1** · free - Why: End-to-end encryption forbids server-side search, thumbnails, export and key recovery, and retrofitting it later means removing features people already use. - Get it: Decide, and write down what you are giving up in exchange. Both answers are defensible; discovering the trade halfway is not. - Verify: A written note states the decision and lists the features it forecloses ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. - large template libraries - high-fidelity import and export - presentation and enterprise controls - multiplayer cursors and comments If one of those is essential to you, that is the reason to keep paying for Excalidraw+, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Excalidraw+ 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 a shared drawing canvas several people can edit at once without treading on each other to replace Excalidraw Plus. 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) - Canvas: A scene of independent elements, each separately versioned · reconciling per element is tractable where reconciling a whole scene is not - Transport: WebSocket for live changes, HTTP for scene loading · the two have completely different reliability requirements - Reconciliation: Per-element versioning with a deterministic tiebreak · two clients must resolve a conflict the same way without asking the server - Ephemeral state: Cursors and selections broadcast but never stored · pointer positions are a firehose and worthless a second later - Encryption: Scene encrypted in the browser with a key in the URL fragment · the server storing scenes it cannot read is a much smaller promise to keep ### Data model (create this before Phase 1) - scenes(id, encrypted_blob, iv, version, updated_at) · the server sees ciphertext and nothing else - elements(id, type, x, y, width, height, angle, points_json, style_json, version, version_nonce, updated_by, deleted) - Elements live inside the encrypted scene. The server schema above is what a self-hosted unencrypted mode would use - rooms(id, scene_id, created_at, last_active_at) · a live session, holding no content - presence(room_id, client_id, name, colour, cursor, selection) · in memory only, never written to disk - A deleted element is a tombstone with a version rather than a removed row, because a delete and a concurrent edit must reconcile. ### Phase 1 · A canvas with versioned elements Build: A canvas with shapes, freehand lines, text and arrows. Every element carries a version and a random tiebreak value. Deleting sets a tombstone rather than removing. Local persistence so a reload loses nothing. Trap: Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. Done when: Shapes, freehand, text and arrows draw and persist across a reload. Every element has a version and a tiebreak value. Deleting produces a tombstone rather than removing the element. Undo and redo restore versions correctly rather than reusing stale ones. Do not build yet: No collaboration, presence or encryption. ### Phase 2 · Two people drawing at once Build: Broadcast element changes over a socket. Reconcile incoming elements against local ones by version, breaking ties with the stored nonce so both sides reach the same answer. Send changed elements only, never the whole scene. Trap: Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. Done when: Two clients editing different elements both see both changes. Two clients editing the same element converge on the same result, tested by driving the reconciler directly with conflicting pairs. Only changed elements are transmitted, verified by inspecting the traffic. A scene of five hundred elements stays responsive with two editors. Do not build yet: No presence, encryption or persistence to a server. ### Phase 3 · Cursors, without drowning the connection Build: Broadcast cursor position and selection at a throttled rate on a separate channel from element changes. Never persist presence. Handle a client disappearing without a clean disconnect so ghost cursors do not accumulate. Trap: Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. Done when: Cursors appear and move smoothly at a throttled rate. Presence is never written to disk, verified by inspecting the store during a session. A client killed without disconnecting has its cursor removed within a timeout. Element changes are not delayed by cursor traffic under load. Do not build yet: No encryption or server persistence yet. ### Phase 4 · Encrypted scenes, and living with the consequences Build: Encrypt the scene in the browser with a key held in the URL fragment so it is never sent to the server. Store ciphertext. Write down every feature this makes impossible, and decide each one deliberately rather than discovering it later. Trap: End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. Done when: The scene is encrypted client side and the server holds only ciphertext, verified by inspecting what is stored. The key travels in the URL fragment and never appears in a request, verified from server logs. A written list names every feature encryption forbids. Losing the key makes the scene unrecoverable, and the interface says so before sharing. Do not build yet: No history or export yet. ### Phase 5 · Saving, history and export Build: Periodic encrypted snapshots with a version history the client can decrypt and restore. Export to a vector format and an image, rendered client side. Handle a scene growing large enough that snapshotting on every change is untenable. Trap: Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. Done when: Snapshots occur on a debounce rather than per change, verified by drawing continuously and counting writes. A past version restores correctly after decryption. Export to vector and image renders client side and matches the canvas. A large scene snapshots without a visible pause. Do not build yet: Nothing after this. ### Out of scope (and why) - Offline editing with later reconciliation, which is a much harder problem than live collaboration and needs a full conflict-free data type. - Server-side rendering, search and thumbnails, which end-to-end encryption forbids and which is why the hosted product offers an unencrypted mode. - Shape recognition and beautification, which is a substantial piece of work orthogonal to collaboration. - Comments, permissions and team libraries, which is the layer the paid product is actually selling. ### README must contain - That deletions are tombstones so a delete and a concurrent edit can reconcile - That reconciliation is a pure function both clients evaluate to the same answer - That presence is throttled, separate and never persisted - That the encryption key lives in the URL fragment and a lost key means a lost scene - That snapshots are debounced because per-change snapshots do not scale ===== AGENTS.md ===== # Agent instructions · Excalidraw+ indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A scene of independent elements, each separately versioned, WebSocket for live changes, HTTP for scene loading, Per-element versioning with a deterministic tiebreak, Cursors and selections broadcast but never stored, Scene encrypted in the browser with a key in the URL fragment. 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 - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ===== BUILD_PLAN.md ===== # Build plan · Excalidraw+ A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want. 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 with versioned elements Drawing works locally and every element carries what reconciliation will need. ### Steps 1. Version every element A counter and a random tiebreak. Both are needed for a deterministic resolution. Files: `src/element.ts` 2. Tombstone deletions A removed row cannot reconcile against a concurrent edit. Files: `src/element.ts` 3. Persist locally on change A reload mid-drawing must lose nothing. Files: `src/persist.ts` ### Done when - [ ] All element kinds draw and survive a reload - [ ] Every element carries a version and tiebreak - [ ] Deleting produces a tombstone - [ ] Undo and redo handle versions correctly ### Watch out - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. ## Phase 2 · Two people drawing at once Simultaneous edits from two clients resolve identically on both. ### Steps 1. Reconcile as a pure function Two elements in, one out, deterministic. Unit test with conflicting pairs. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 2. Break ties with a stored nonce Both clients must reach the same answer without consulting the server. Files: `src/reconcile.ts` 3. Send deltas, never the scene Whole-scene broadcast collapses at a few hundred elements. Files: `src/sync.ts` ### Done when - [ ] Edits to different elements both survive - [ ] Conflicting edits converge identically on both clients - [ ] Only changed elements are transmitted - [ ] Five hundred elements stay responsive with two editors ### Watch out - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. ## Phase 3 · Cursors, without drowning the connection You can see where other people are, and it costs almost nothing. ### Steps 1. Throttle and separate presence Its own channel, a fixed rate. Drawing must never queue behind pointer noise. Files: `src/presence.ts` 2. Never persist presence It is worthless a second later and it is a record of what people looked at. Files: `src/presence.ts` 3. Expire cursors on a timeout Clients vanish without disconnecting. Ghost cursors accumulate otherwise. Files: `src/presence.ts` ### Done when - [ ] Cursors move smoothly at a throttled rate - [ ] Presence never reaches disk - [ ] A killed client's cursor expires - [ ] Element changes are not delayed by cursor traffic ### Watch out - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. ## Phase 4 · Encrypted scenes, and living with the consequences The server stores scenes it cannot read, and you accept what that forbids. ### Steps 1. Encrypt before it leaves the browser The server should never be able to read a scene, even accidentally. Files: `src/crypto.ts` 2. Keep the key in the fragment Fragments are not sent in requests. Verify that in server logs, do not assume it. Files: `src/share.ts` 3. List what encryption forbids Search, thumbnails, recovery. Decide each now rather than discovering it later. Files: `docs/encryption-tradeoffs.md` ### Done when - [ ] The server stores only ciphertext - [ ] The key never appears in server logs - [ ] A written list names what encryption forbids - [ ] The interface warns that a lost key means a lost scene ### Watch out - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ## Not in this build - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. ## After v1, if you want it - Frames and named sections, which is how a large board stops being unusable - A read-only share link, which needs a second key rather than a permission flag - Embedded images, once the scene size and snapshot costs are understood ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Encrypted scene blobs and room records. Contains no readable content by design. DATABASE_URL=postgres://localhost:5432/canvas # Required. The live collaboration channel, separate from scene loading over HTTP. SOCKET_PORT=3001 # Required. How often cursor positions broadcast. Lower feels smoother and floods the channel. PRESENCE_THROTTLE_MS=50 # Required. When a silent client's cursor is removed. Clients vanish without disconnecting. PRESENCE_TIMEOUT_SECONDS=30 # Required. How long after the last change a snapshot is written. Per-change does not scale. SNAPSHOT_DEBOUNCE_MS=2000 # Required. A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. MAX_SCENE_BYTES=10485760
You are building a production product version of Excalidraw+. 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 ===== # Excalidraw+ · product brief ## Problem Excalidraw+'s solo core is compact: build a browser-based collaborative whiteboard editor with drag, resize, connect, keyboard shortcuts, autosave, and SVG or PNG export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on interaction polish, collaboration. ## Product outcome A shared canvas where two people drawing at once converge on the same picture, cursors cost nothing, and the server stores scenes it genuinely cannot read. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - modern browser - no account required ## Explicit non-goals for v1 - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. - large template libraries - high-fidelity import and export - presentation and enterprise controls - multiplayer cursors and comments ## Success criteria - Conflicting edits are confirmed to converge identically on both clients - Only changed elements are confirmed transmitted - Presence is confirmed absent from disk - The encryption key is confirmed absent from server logs - Snapshots are confirmed debounced rather than per change ===== BRIEF.md ===== # Build brief · Excalidraw+ 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 a shared drawing canvas several people can edit at once without treading on each other to replace Excalidraw Plus. 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) - Canvas: A scene of independent elements, each separately versioned · reconciling per element is tractable where reconciling a whole scene is not - Transport: WebSocket for live changes, HTTP for scene loading · the two have completely different reliability requirements - Reconciliation: Per-element versioning with a deterministic tiebreak · two clients must resolve a conflict the same way without asking the server - Ephemeral state: Cursors and selections broadcast but never stored · pointer positions are a firehose and worthless a second later - Encryption: Scene encrypted in the browser with a key in the URL fragment · the server storing scenes it cannot read is a much smaller promise to keep ### Data model (create this before Phase 1) - scenes(id, encrypted_blob, iv, version, updated_at) · the server sees ciphertext and nothing else - elements(id, type, x, y, width, height, angle, points_json, style_json, version, version_nonce, updated_by, deleted) - Elements live inside the encrypted scene. The server schema above is what a self-hosted unencrypted mode would use - rooms(id, scene_id, created_at, last_active_at) · a live session, holding no content - presence(room_id, client_id, name, colour, cursor, selection) · in memory only, never written to disk - A deleted element is a tombstone with a version rather than a removed row, because a delete and a concurrent edit must reconcile. ### Phase 1 · A canvas with versioned elements Build: A canvas with shapes, freehand lines, text and arrows. Every element carries a version and a random tiebreak value. Deleting sets a tombstone rather than removing. Local persistence so a reload loses nothing. Trap: Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. Done when: Shapes, freehand, text and arrows draw and persist across a reload. Every element has a version and a tiebreak value. Deleting produces a tombstone rather than removing the element. Undo and redo restore versions correctly rather than reusing stale ones. Do not build yet: No collaboration, presence or encryption. ### Phase 2 · Two people drawing at once Build: Broadcast element changes over a socket. Reconcile incoming elements against local ones by version, breaking ties with the stored nonce so both sides reach the same answer. Send changed elements only, never the whole scene. Trap: Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. Done when: Two clients editing different elements both see both changes. Two clients editing the same element converge on the same result, tested by driving the reconciler directly with conflicting pairs. Only changed elements are transmitted, verified by inspecting the traffic. A scene of five hundred elements stays responsive with two editors. Do not build yet: No presence, encryption or persistence to a server. ### Phase 3 · Cursors, without drowning the connection Build: Broadcast cursor position and selection at a throttled rate on a separate channel from element changes. Never persist presence. Handle a client disappearing without a clean disconnect so ghost cursors do not accumulate. Trap: Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. Done when: Cursors appear and move smoothly at a throttled rate. Presence is never written to disk, verified by inspecting the store during a session. A client killed without disconnecting has its cursor removed within a timeout. Element changes are not delayed by cursor traffic under load. Do not build yet: No encryption or server persistence yet. ### Phase 4 · Encrypted scenes, and living with the consequences Build: Encrypt the scene in the browser with a key held in the URL fragment so it is never sent to the server. Store ciphertext. Write down every feature this makes impossible, and decide each one deliberately rather than discovering it later. Trap: End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. Done when: The scene is encrypted client side and the server holds only ciphertext, verified by inspecting what is stored. The key travels in the URL fragment and never appears in a request, verified from server logs. A written list names every feature encryption forbids. Losing the key makes the scene unrecoverable, and the interface says so before sharing. Do not build yet: No history or export yet. ### Phase 5 · Saving, history and export Build: Periodic encrypted snapshots with a version history the client can decrypt and restore. Export to a vector format and an image, rendered client side. Handle a scene growing large enough that snapshotting on every change is untenable. Trap: Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. Done when: Snapshots occur on a debounce rather than per change, verified by drawing continuously and counting writes. A past version restores correctly after decryption. Export to vector and image renders client side and matches the canvas. A large scene snapshots without a visible pause. Do not build yet: Nothing after this. ### Out of scope (and why) - Offline editing with later reconciliation, which is a much harder problem than live collaboration and needs a full conflict-free data type. - Server-side rendering, search and thumbnails, which end-to-end encryption forbids and which is why the hosted product offers an unencrypted mode. - Shape recognition and beautification, which is a substantial piece of work orthogonal to collaboration. - Comments, permissions and team libraries, which is the layer the paid product is actually selling. ### README must contain - That deletions are tombstones so a delete and a concurrent edit can reconcile - That reconciliation is a pure function both clients evaluate to the same answer - That presence is throttled, separate and never persisted - That the encryption key lives in the URL fragment and a lost key means a lost scene - That snapshots are debounced because per-change snapshots do not scale ===== ARCHITECTURE.md ===== # Architecture · Excalidraw+ ## Stack | Part | Choice | Why | | --- | --- | --- | | Canvas | A scene of independent elements, each separately versioned | reconciling per element is tractable where reconciling a whole scene is not | | Transport | WebSocket for live changes, HTTP for scene loading | the two have completely different reliability requirements | | Reconciliation | Per-element versioning with a deterministic tiebreak | two clients must resolve a conflict the same way without asking the server | | Ephemeral state | Cursors and selections broadcast but never stored | pointer positions are a firehose and worthless a second later | | Encryption | Scene encrypted in the browser with a key in the URL fragment | the server storing scenes it cannot read is a much smaller promise to keep | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Elements | Versioned elements and tombstones. | Not swappable for direct deletion, which makes concurrent delete and edit unresolvable. | | Reconciliation | Deterministic conflict resolution. | Any rule, provided it is a pure function both clients evaluate identically. | | Presence | Throttled ephemeral cursors. | Any transport, provided it is separate from element changes and never persisted. | | Encryption | Client-side scene encryption. | Removable only by deciding to, which changes what the product can offer. | | Snapshots | Debounced encrypted persistence. | Any cadence, provided it is not per change. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Encrypted scene blobs and room records. Contains no readable content by design. - `SOCKET_PORT` · required · The live collaboration channel, separate from scene loading over HTTP. - `PRESENCE_THROTTLE_MS` · required · How often cursor positions broadcast. Lower feels smoother and floods the channel. - `PRESENCE_TIMEOUT_SECONDS` · required · When a silent client's cursor is removed. Clients vanish without disconnecting. - `SNAPSHOT_DEBOUNCE_MS` · required · How long after the last change a snapshot is written. Per-change does not scale. - `MAX_SCENE_BYTES` · required · A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. ## 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 · Excalidraw+ product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A scene of independent elements, each separately versioned, WebSocket for live changes, HTTP for scene loading, Per-element versioning with a deterministic tiebreak, Cursors and selections broadcast but never stored, Scene encrypted in the browser with a key in the URL fragment. - 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 - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. - Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. ===== MILESTONES.md ===== # Delivery milestones · Excalidraw+ Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A canvas with versioned elements Drawing works locally and every element carries what reconciliation will need. ### Steps 1. Version every element A counter and a random tiebreak. Both are needed for a deterministic resolution. Files: `src/element.ts` 2. Tombstone deletions A removed row cannot reconcile against a concurrent edit. Files: `src/element.ts` 3. Persist locally on change A reload mid-drawing must lose nothing. Files: `src/persist.ts` ### Done when - [ ] All element kinds draw and survive a reload - [ ] Every element carries a version and tiebreak - [ ] Deleting produces a tombstone - [ ] Undo and redo handle versions correctly ### Watch out - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. ## M2 · Two people drawing at once Simultaneous edits from two clients resolve identically on both. ### Steps 1. Reconcile as a pure function Two elements in, one out, deterministic. Unit test with conflicting pairs. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 2. Break ties with a stored nonce Both clients must reach the same answer without consulting the server. Files: `src/reconcile.ts` 3. Send deltas, never the scene Whole-scene broadcast collapses at a few hundred elements. Files: `src/sync.ts` ### Done when - [ ] Edits to different elements both survive - [ ] Conflicting edits converge identically on both clients - [ ] Only changed elements are transmitted - [ ] Five hundred elements stay responsive with two editors ### Watch out - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. ## M3 · Cursors, without drowning the connection You can see where other people are, and it costs almost nothing. ### Steps 1. Throttle and separate presence Its own channel, a fixed rate. Drawing must never queue behind pointer noise. Files: `src/presence.ts` 2. Never persist presence It is worthless a second later and it is a record of what people looked at. Files: `src/presence.ts` 3. Expire cursors on a timeout Clients vanish without disconnecting. Ghost cursors accumulate otherwise. Files: `src/presence.ts` ### Done when - [ ] Cursors move smoothly at a throttled rate - [ ] Presence never reaches disk - [ ] A killed client's cursor expires - [ ] Element changes are not delayed by cursor traffic ### Watch out - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. ## M4 · Encrypted scenes, and living with the consequences The server stores scenes it cannot read, and you accept what that forbids. ### Steps 1. Encrypt before it leaves the browser The server should never be able to read a scene, even accidentally. Files: `src/crypto.ts` 2. Keep the key in the fragment Fragments are not sent in requests. Verify that in server logs, do not assume it. Files: `src/share.ts` 3. List what encryption forbids Search, thumbnails, recovery. Decide each now rather than discovering it later. Files: `docs/encryption-tradeoffs.md` ### Done when - [ ] The server stores only ciphertext - [ ] The key never appears in server logs - [ ] A written list names what encryption forbids - [ ] The interface warns that a lost key means a lost scene ### Watch out - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ## M5 · Saving, history and export (production only) Scenes persist, past versions can be recovered, and drawings leave as files. ### Steps 1. Snapshot on a debounce and at boundaries Per change is untenable once a scene is large. Files: `src/snapshot.ts` 2. Restore a past version client side Decryption happens in the browser, which means history browsing does too. Files: `src/history.ts` 3. Export client side The server cannot render what it cannot read. This follows from phase 4. Files: `src/export.ts` ### Done when - [ ] Snapshots are debounced, not per change - [ ] A past version restores correctly - [ ] Export matches the canvas - [ ] A large scene snapshots without a visible pause ### Watch out - Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. ===== OPERATIONS.md ===== # Operations · Excalidraw+ ## Backup Encrypted scene blobs are backed up as opaque data, which is straightforward, and entirely useless without the keys, which you do not hold. Make sure users understand that your backup does not protect them from losing a key. ## Restore Restore and confirm scenes still decrypt with existing links, since a restore that loses the initialisation vectors leaves ciphertext nobody can read, including its owner. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Socket message rate split by presence and element traffic, and snapshot write size. Presence overwhelming element traffic is the failure that makes the canvas feel broken. ## Incident checklist Two clients showing different pictures means reconciliation is not deterministic, which is the most serious defect here and is reproducible in the pure function rather than in the browser. Start there. 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 - [ ] Conflicting edits are confirmed to converge identically on both clients - [ ] Only changed elements are confirmed transmitted - [ ] Presence is confirmed absent from disk - [ ] The encryption key is confirmed absent from server logs - [ ] Snapshots are confirmed debounced rather than per change ## Launch constraint Do not market omitted Excalidraw+ 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 · secret. Encrypted scene blobs and room records. Contains no readable content by design. DATABASE_URL=postgres://localhost:5432/canvas # Required. The live collaboration channel, separate from scene loading over HTTP. SOCKET_PORT=3001 # Required. How often cursor positions broadcast. Lower feels smoother and floods the channel. PRESENCE_THROTTLE_MS=50 # Required. When a silent client's cursor is removed. Clients vanish without disconnecting. PRESENCE_TIMEOUT_SECONDS=30 # Required. How long after the last change a snapshot is written. Per-change does not scale. SNAPSHOT_DEBOUNCE_MS=2000 # Required. A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. MAX_SCENE_BYTES=10485760
# Excalidraw+ · indie build A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want. 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 | | --- | --- | --- | | Canvas | A scene of independent elements, each separately versioned | reconciling per element is tractable where reconciling a whole scene is not | | Transport | WebSocket for live changes, HTTP for scene loading | the two have completely different reliability requirements | | Reconciliation | Per-element versioning with a deterministic tiebreak | two clients must resolve a conflict the same way without asking the server | | Ephemeral state | Cursors and selections broadcast but never stored | pointer positions are a firehose and worthless a second later | | Encryption | Scene encrypted in the browser with a key in the URL fragment | the server storing scenes it cannot read is a much smaller promise to keep | ## 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 socket server and builds the client. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Two machines, or two browsers on one** · free - Why: Every claim about collaboration is untestable with a single client, and two tabs in one browser hide timing problems that two machines expose. - Get it: A second machine, or at minimum two different browsers with the network throttled differently. - Verify: Two clients can join one room and both see each other's changes - [ ] **A large real drawing** · free - Why: Delta transmission, snapshot cost and canvas performance all behave differently at five hundred elements, and a test scene of ten proves nothing. - Get it: Draw one, or generate a scene with several hundred varied elements including freehand paths, which are the expensive ones. - Verify: A scene with five hundred elements including freehand paths exists - [ ] **Whether you are encrypting, decided before phase 1** · free - Why: End-to-end encryption forbids server-side search, thumbnails, export and key recovery, and retrofitting it later means removing features people already use. - Get it: Decide, and write down what you are giving up in exchange. Both answers are defensible; discovering the trade halfway is not. - Verify: A written note states the decision and lists the features it forecloses ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. - large template libraries - high-fidelity import and export - presentation and enterprise controls - multiplayer cursors and comments If one of those is essential to you, that is the reason to keep paying for Excalidraw+, and the README should say so rather than pretend.
# Build brief · Excalidraw+ 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 a shared drawing canvas several people can edit at once without treading on each other to replace Excalidraw Plus. 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) - Canvas: A scene of independent elements, each separately versioned · reconciling per element is tractable where reconciling a whole scene is not - Transport: WebSocket for live changes, HTTP for scene loading · the two have completely different reliability requirements - Reconciliation: Per-element versioning with a deterministic tiebreak · two clients must resolve a conflict the same way without asking the server - Ephemeral state: Cursors and selections broadcast but never stored · pointer positions are a firehose and worthless a second later - Encryption: Scene encrypted in the browser with a key in the URL fragment · the server storing scenes it cannot read is a much smaller promise to keep ### Data model (create this before Phase 1) - scenes(id, encrypted_blob, iv, version, updated_at) · the server sees ciphertext and nothing else - elements(id, type, x, y, width, height, angle, points_json, style_json, version, version_nonce, updated_by, deleted) - Elements live inside the encrypted scene. The server schema above is what a self-hosted unencrypted mode would use - rooms(id, scene_id, created_at, last_active_at) · a live session, holding no content - presence(room_id, client_id, name, colour, cursor, selection) · in memory only, never written to disk - A deleted element is a tombstone with a version rather than a removed row, because a delete and a concurrent edit must reconcile. ### Phase 1 · A canvas with versioned elements Build: A canvas with shapes, freehand lines, text and arrows. Every element carries a version and a random tiebreak value. Deleting sets a tombstone rather than removing. Local persistence so a reload loses nothing. Trap: Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. Done when: Shapes, freehand, text and arrows draw and persist across a reload. Every element has a version and a tiebreak value. Deleting produces a tombstone rather than removing the element. Undo and redo restore versions correctly rather than reusing stale ones. Do not build yet: No collaboration, presence or encryption. ### Phase 2 · Two people drawing at once Build: Broadcast element changes over a socket. Reconcile incoming elements against local ones by version, breaking ties with the stored nonce so both sides reach the same answer. Send changed elements only, never the whole scene. Trap: Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. Done when: Two clients editing different elements both see both changes. Two clients editing the same element converge on the same result, tested by driving the reconciler directly with conflicting pairs. Only changed elements are transmitted, verified by inspecting the traffic. A scene of five hundred elements stays responsive with two editors. Do not build yet: No presence, encryption or persistence to a server. ### Phase 3 · Cursors, without drowning the connection Build: Broadcast cursor position and selection at a throttled rate on a separate channel from element changes. Never persist presence. Handle a client disappearing without a clean disconnect so ghost cursors do not accumulate. Trap: Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. Done when: Cursors appear and move smoothly at a throttled rate. Presence is never written to disk, verified by inspecting the store during a session. A client killed without disconnecting has its cursor removed within a timeout. Element changes are not delayed by cursor traffic under load. Do not build yet: No encryption or server persistence yet. ### Phase 4 · Encrypted scenes, and living with the consequences Build: Encrypt the scene in the browser with a key held in the URL fragment so it is never sent to the server. Store ciphertext. Write down every feature this makes impossible, and decide each one deliberately rather than discovering it later. Trap: End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. Done when: The scene is encrypted client side and the server holds only ciphertext, verified by inspecting what is stored. The key travels in the URL fragment and never appears in a request, verified from server logs. A written list names every feature encryption forbids. Losing the key makes the scene unrecoverable, and the interface says so before sharing. Do not build yet: No history or export yet. ### Phase 5 · Saving, history and export Build: Periodic encrypted snapshots with a version history the client can decrypt and restore. Export to a vector format and an image, rendered client side. Handle a scene growing large enough that snapshotting on every change is untenable. Trap: Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. Done when: Snapshots occur on a debounce rather than per change, verified by drawing continuously and counting writes. A past version restores correctly after decryption. Export to vector and image renders client side and matches the canvas. A large scene snapshots without a visible pause. Do not build yet: Nothing after this. ### Out of scope (and why) - Offline editing with later reconciliation, which is a much harder problem than live collaboration and needs a full conflict-free data type. - Server-side rendering, search and thumbnails, which end-to-end encryption forbids and which is why the hosted product offers an unencrypted mode. - Shape recognition and beautification, which is a substantial piece of work orthogonal to collaboration. - Comments, permissions and team libraries, which is the layer the paid product is actually selling. ### README must contain - That deletions are tombstones so a delete and a concurrent edit can reconcile - That reconciliation is a pure function both clients evaluate to the same answer - That presence is throttled, separate and never persisted - That the encryption key lives in the URL fragment and a lost key means a lost scene - That snapshots are debounced because per-change snapshots do not scale
# Agent instructions · Excalidraw+ indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A scene of independent elements, each separately versioned, WebSocket for live changes, HTTP for scene loading, Per-element versioning with a deterministic tiebreak, Cursors and selections broadcast but never stored, Scene encrypted in the browser with a key in the URL fragment. 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 - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset.
# Build plan · Excalidraw+ A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want. 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 with versioned elements Drawing works locally and every element carries what reconciliation will need. ### Steps 1. Version every element A counter and a random tiebreak. Both are needed for a deterministic resolution. Files: `src/element.ts` 2. Tombstone deletions A removed row cannot reconcile against a concurrent edit. Files: `src/element.ts` 3. Persist locally on change A reload mid-drawing must lose nothing. Files: `src/persist.ts` ### Done when - [ ] All element kinds draw and survive a reload - [ ] Every element carries a version and tiebreak - [ ] Deleting produces a tombstone - [ ] Undo and redo handle versions correctly ### Watch out - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. ## Phase 2 · Two people drawing at once Simultaneous edits from two clients resolve identically on both. ### Steps 1. Reconcile as a pure function Two elements in, one out, deterministic. Unit test with conflicting pairs. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 2. Break ties with a stored nonce Both clients must reach the same answer without consulting the server. Files: `src/reconcile.ts` 3. Send deltas, never the scene Whole-scene broadcast collapses at a few hundred elements. Files: `src/sync.ts` ### Done when - [ ] Edits to different elements both survive - [ ] Conflicting edits converge identically on both clients - [ ] Only changed elements are transmitted - [ ] Five hundred elements stay responsive with two editors ### Watch out - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. ## Phase 3 · Cursors, without drowning the connection You can see where other people are, and it costs almost nothing. ### Steps 1. Throttle and separate presence Its own channel, a fixed rate. Drawing must never queue behind pointer noise. Files: `src/presence.ts` 2. Never persist presence It is worthless a second later and it is a record of what people looked at. Files: `src/presence.ts` 3. Expire cursors on a timeout Clients vanish without disconnecting. Ghost cursors accumulate otherwise. Files: `src/presence.ts` ### Done when - [ ] Cursors move smoothly at a throttled rate - [ ] Presence never reaches disk - [ ] A killed client's cursor expires - [ ] Element changes are not delayed by cursor traffic ### Watch out - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. ## Phase 4 · Encrypted scenes, and living with the consequences The server stores scenes it cannot read, and you accept what that forbids. ### Steps 1. Encrypt before it leaves the browser The server should never be able to read a scene, even accidentally. Files: `src/crypto.ts` 2. Keep the key in the fragment Fragments are not sent in requests. Verify that in server logs, do not assume it. Files: `src/share.ts` 3. List what encryption forbids Search, thumbnails, recovery. Decide each now rather than discovering it later. Files: `docs/encryption-tradeoffs.md` ### Done when - [ ] The server stores only ciphertext - [ ] The key never appears in server logs - [ ] A written list names what encryption forbids - [ ] The interface warns that a lost key means a lost scene ### Watch out - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ## Not in this build - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. ## After v1, if you want it - Frames and named sections, which is how a large board stops being unusable - A read-only share link, which needs a second key rather than a permission flag - Embedded images, once the scene size and snapshot costs are understood
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Encrypted scene blobs and room records. Contains no readable content by design. DATABASE_URL=postgres://localhost:5432/canvas # Required. The live collaboration channel, separate from scene loading over HTTP. SOCKET_PORT=3001 # Required. How often cursor positions broadcast. Lower feels smoother and floods the channel. PRESENCE_THROTTLE_MS=50 # Required. When a silent client's cursor is removed. Clients vanish without disconnecting. PRESENCE_TIMEOUT_SECONDS=30 # Required. How long after the last change a snapshot is written. Per-change does not scale. SNAPSHOT_DEBOUNCE_MS=2000 # Required. A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. MAX_SCENE_BYTES=10485760
# Excalidraw+ · product brief ## Problem Excalidraw+'s solo core is compact: build a browser-based collaborative whiteboard editor with drag, resize, connect, keyboard shortcuts, autosave, and SVG or PNG export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on interaction polish, collaboration. ## Product outcome A shared canvas where two people drawing at once converge on the same picture, cursors cost nothing, and the server stores scenes it genuinely cannot read. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - modern browser - no account required ## Explicit non-goals for v1 - Offline editing with later reconciliation. - Server-side rendering, search and thumbnails. - Shape recognition and beautification. - Comments, permissions and team libraries. - large template libraries - high-fidelity import and export - presentation and enterprise controls - multiplayer cursors and comments ## Success criteria - Conflicting edits are confirmed to converge identically on both clients - Only changed elements are confirmed transmitted - Presence is confirmed absent from disk - The encryption key is confirmed absent from server logs - Snapshots are confirmed debounced rather than per change
# Build brief · Excalidraw+ 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 a shared drawing canvas several people can edit at once without treading on each other to replace Excalidraw Plus. 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) - Canvas: A scene of independent elements, each separately versioned · reconciling per element is tractable where reconciling a whole scene is not - Transport: WebSocket for live changes, HTTP for scene loading · the two have completely different reliability requirements - Reconciliation: Per-element versioning with a deterministic tiebreak · two clients must resolve a conflict the same way without asking the server - Ephemeral state: Cursors and selections broadcast but never stored · pointer positions are a firehose and worthless a second later - Encryption: Scene encrypted in the browser with a key in the URL fragment · the server storing scenes it cannot read is a much smaller promise to keep ### Data model (create this before Phase 1) - scenes(id, encrypted_blob, iv, version, updated_at) · the server sees ciphertext and nothing else - elements(id, type, x, y, width, height, angle, points_json, style_json, version, version_nonce, updated_by, deleted) - Elements live inside the encrypted scene. The server schema above is what a self-hosted unencrypted mode would use - rooms(id, scene_id, created_at, last_active_at) · a live session, holding no content - presence(room_id, client_id, name, colour, cursor, selection) · in memory only, never written to disk - A deleted element is a tombstone with a version rather than a removed row, because a delete and a concurrent edit must reconcile. ### Phase 1 · A canvas with versioned elements Build: A canvas with shapes, freehand lines, text and arrows. Every element carries a version and a random tiebreak value. Deleting sets a tombstone rather than removing. Local persistence so a reload loses nothing. Trap: Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. Done when: Shapes, freehand, text and arrows draw and persist across a reload. Every element has a version and a tiebreak value. Deleting produces a tombstone rather than removing the element. Undo and redo restore versions correctly rather than reusing stale ones. Do not build yet: No collaboration, presence or encryption. ### Phase 2 · Two people drawing at once Build: Broadcast element changes over a socket. Reconcile incoming elements against local ones by version, breaking ties with the stored nonce so both sides reach the same answer. Send changed elements only, never the whole scene. Trap: Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. Done when: Two clients editing different elements both see both changes. Two clients editing the same element converge on the same result, tested by driving the reconciler directly with conflicting pairs. Only changed elements are transmitted, verified by inspecting the traffic. A scene of five hundred elements stays responsive with two editors. Do not build yet: No presence, encryption or persistence to a server. ### Phase 3 · Cursors, without drowning the connection Build: Broadcast cursor position and selection at a throttled rate on a separate channel from element changes. Never persist presence. Handle a client disappearing without a clean disconnect so ghost cursors do not accumulate. Trap: Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. Done when: Cursors appear and move smoothly at a throttled rate. Presence is never written to disk, verified by inspecting the store during a session. A client killed without disconnecting has its cursor removed within a timeout. Element changes are not delayed by cursor traffic under load. Do not build yet: No encryption or server persistence yet. ### Phase 4 · Encrypted scenes, and living with the consequences Build: Encrypt the scene in the browser with a key held in the URL fragment so it is never sent to the server. Store ciphertext. Write down every feature this makes impossible, and decide each one deliberately rather than discovering it later. Trap: End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. Done when: The scene is encrypted client side and the server holds only ciphertext, verified by inspecting what is stored. The key travels in the URL fragment and never appears in a request, verified from server logs. A written list names every feature encryption forbids. Losing the key makes the scene unrecoverable, and the interface says so before sharing. Do not build yet: No history or export yet. ### Phase 5 · Saving, history and export Build: Periodic encrypted snapshots with a version history the client can decrypt and restore. Export to a vector format and an image, rendered client side. Handle a scene growing large enough that snapshotting on every change is untenable. Trap: Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever. Done when: Snapshots occur on a debounce rather than per change, verified by drawing continuously and counting writes. A past version restores correctly after decryption. Export to vector and image renders client side and matches the canvas. A large scene snapshots without a visible pause. Do not build yet: Nothing after this. ### Out of scope (and why) - Offline editing with later reconciliation, which is a much harder problem than live collaboration and needs a full conflict-free data type. - Server-side rendering, search and thumbnails, which end-to-end encryption forbids and which is why the hosted product offers an unencrypted mode. - Shape recognition and beautification, which is a substantial piece of work orthogonal to collaboration. - Comments, permissions and team libraries, which is the layer the paid product is actually selling. ### README must contain - That deletions are tombstones so a delete and a concurrent edit can reconcile - That reconciliation is a pure function both clients evaluate to the same answer - That presence is throttled, separate and never persisted - That the encryption key lives in the URL fragment and a lost key means a lost scene - That snapshots are debounced because per-change snapshots do not scale
# Architecture · Excalidraw+ ## Stack | Part | Choice | Why | | --- | --- | --- | | Canvas | A scene of independent elements, each separately versioned | reconciling per element is tractable where reconciling a whole scene is not | | Transport | WebSocket for live changes, HTTP for scene loading | the two have completely different reliability requirements | | Reconciliation | Per-element versioning with a deterministic tiebreak | two clients must resolve a conflict the same way without asking the server | | Ephemeral state | Cursors and selections broadcast but never stored | pointer positions are a firehose and worthless a second later | | Encryption | Scene encrypted in the browser with a key in the URL fragment | the server storing scenes it cannot read is a much smaller promise to keep | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Elements | Versioned elements and tombstones. | Not swappable for direct deletion, which makes concurrent delete and edit unresolvable. | | Reconciliation | Deterministic conflict resolution. | Any rule, provided it is a pure function both clients evaluate identically. | | Presence | Throttled ephemeral cursors. | Any transport, provided it is separate from element changes and never persisted. | | Encryption | Client-side scene encryption. | Removable only by deciding to, which changes what the product can offer. | | Snapshots | Debounced encrypted persistence. | Any cadence, provided it is not per change. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Encrypted scene blobs and room records. Contains no readable content by design. - `SOCKET_PORT` · required · The live collaboration channel, separate from scene loading over HTTP. - `PRESENCE_THROTTLE_MS` · required · How often cursor positions broadcast. Lower feels smoother and floods the channel. - `PRESENCE_TIMEOUT_SECONDS` · required · When a silent client's cursor is removed. Clients vanish without disconnecting. - `SNAPSHOT_DEBOUNCE_MS` · required · How long after the last change a snapshot is written. Per-change does not scale. - `MAX_SCENE_BYTES` · required · A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. ## 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 · Excalidraw+ product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A scene of independent elements, each separately versioned, WebSocket for live changes, HTTP for scene loading, Per-element versioning with a deterministic tiebreak, Cursors and selections broadcast but never stored, Scene encrypted in the browser with a key in the URL fragment. - 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 - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. - Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever.
# Delivery milestones · Excalidraw+ Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A canvas with versioned elements Drawing works locally and every element carries what reconciliation will need. ### Steps 1. Version every element A counter and a random tiebreak. Both are needed for a deterministic resolution. Files: `src/element.ts` 2. Tombstone deletions A removed row cannot reconcile against a concurrent edit. Files: `src/element.ts` 3. Persist locally on change A reload mid-drawing must lose nothing. Files: `src/persist.ts` ### Done when - [ ] All element kinds draw and survive a reload - [ ] Every element carries a version and tiebreak - [ ] Deleting produces a tombstone - [ ] Undo and redo handle versions correctly ### Watch out - Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide. ## M2 · Two people drawing at once Simultaneous edits from two clients resolve identically on both. ### Steps 1. Reconcile as a pure function Two elements in, one out, deterministic. Unit test with conflicting pairs. Files: `src/reconcile.ts`, `src/reconcile.test.ts` 2. Break ties with a stored nonce Both clients must reach the same answer without consulting the server. Files: `src/reconcile.ts` 3. Send deltas, never the scene Whole-scene broadcast collapses at a few hundred elements. Files: `src/sync.ts` ### Done when - [ ] Edits to different elements both survive - [ ] Conflicting edits converge identically on both clients - [ ] Only changed elements are transmitted - [ ] Five hundred elements stay responsive with two editors ### Watch out - Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly. ## M3 · Cursors, without drowning the connection You can see where other people are, and it costs almost nothing. ### Steps 1. Throttle and separate presence Its own channel, a fixed rate. Drawing must never queue behind pointer noise. Files: `src/presence.ts` 2. Never persist presence It is worthless a second later and it is a record of what people looked at. Files: `src/presence.ts` 3. Expire cursors on a timeout Clients vanish without disconnecting. Ghost cursors accumulate otherwise. Files: `src/presence.ts` ### Done when - [ ] Cursors move smoothly at a throttled rate - [ ] Presence never reaches disk - [ ] A killed client's cursor expires - [ ] Element changes are not delayed by cursor traffic ### Watch out - Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested. ## M4 · Encrypted scenes, and living with the consequences The server stores scenes it cannot read, and you accept what that forbids. ### Steps 1. Encrypt before it leaves the browser The server should never be able to read a scene, even accidentally. Files: `src/crypto.ts` 2. Keep the key in the fragment Fragments are not sent in requests. Verify that in server logs, do not assume it. Files: `src/share.ts` 3. List what encryption forbids Search, thumbnails, recovery. Decide each now rather than discovering it later. Files: `docs/encryption-tradeoffs.md` ### Done when - [ ] The server stores only ciphertext - [ ] The key never appears in server logs - [ ] A written list names what encryption forbids - [ ] The interface warns that a lost key means a lost scene ### Watch out - End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset. ## M5 · Saving, history and export (production only) Scenes persist, past versions can be recovered, and drawings leave as files. ### Steps 1. Snapshot on a debounce and at boundaries Per change is untenable once a scene is large. Files: `src/snapshot.ts` 2. Restore a past version client side Decryption happens in the browser, which means history browsing does too. Files: `src/history.ts` 3. Export client side The server cannot render what it cannot read. This follows from phase 4. Files: `src/export.ts` ### Done when - [ ] Snapshots are debounced, not per change - [ ] A past version restores correctly - [ ] Export matches the canvas - [ ] A large scene snapshots without a visible pause ### Watch out - Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever.
# Operations · Excalidraw+ ## Backup Encrypted scene blobs are backed up as opaque data, which is straightforward, and entirely useless without the keys, which you do not hold. Make sure users understand that your backup does not protect them from losing a key. ## Restore Restore and confirm scenes still decrypt with existing links, since a restore that loses the initialisation vectors leaves ciphertext nobody can read, including its owner. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Socket message rate split by presence and element traffic, and snapshot write size. Presence overwhelming element traffic is the failure that makes the canvas feel broken. ## Incident checklist Two clients showing different pictures means reconciliation is not deterministic, which is the most serious defect here and is reproducible in the pure function rather than in the browser. Start there. 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 - [ ] Conflicting edits are confirmed to converge identically on both clients - [ ] Only changed elements are confirmed transmitted - [ ] Presence is confirmed absent from disk - [ ] The encryption key is confirmed absent from server logs - [ ] Snapshots are confirmed debounced rather than per change ## Launch constraint Do not market omitted Excalidraw+ 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 · secret. Encrypted scene blobs and room records. Contains no readable content by design. DATABASE_URL=postgres://localhost:5432/canvas # Required. The live collaboration channel, separate from scene loading over HTTP. SOCKET_PORT=3001 # Required. How often cursor positions broadcast. Lower feels smoother and floods the channel. PRESENCE_THROTTLE_MS=50 # Required. When a silent client's cursor is removed. Clients vanish without disconnecting. PRESENCE_TIMEOUT_SECONDS=30 # Required. How long after the last change a snapshot is written. Per-change does not scale. SNAPSHOT_DEBOUNCE_MS=2000 # Required. A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. MAX_SCENE_BYTES=10485760
$ choose a build depth, inspect the files, then open the complete pack in your agent
Excalidraw+: The shapes are easy; customers pay for low-friction editing, reliable exports, collaboration, and a canvas that never fights them.
xlarge template libraries
xhigh-fidelity import and export
xpresentation and enterprise controls
xmultiplayer cursors and comments
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Excalidraw+ →· no votes, no pay-to-list · just what's real
Excalidraw+ pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 1 infinite browser-local scene; unlimited link collaborators; PNG, SVG and Excalidraw-file export; browser-local E2EE; limited generative AI |
| plus | $6/user | — | Unlimited cloud-synced scenes and folders; access controls, comments, presentations, voice/screen sharing, extended AI and team management |
free tier1 browser-local infinite scene, unlimited collaborators, exports and libraries; no cloud storage, folders, account workspace or server sync
billingfree + $6/user monthly; annual billing is offered but its current effective monthly price was not exposed; card payment only
hidden costsEvery paid workspace member is billable, although link guests are free; purchases are non-refundable and tax may be added
verified 2026-08-14 · source ↗
Vibecode Excalidraw+
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Excalidraw+ replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Excalidraw+ cost?
Excalidraw+'s pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Excalidraw+?
Honestly: large template libraries; high-fidelity import and export; presentation and enterprise controls; multiplayer cursors and comments. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Excalidraw+?
Yes: Excalidraw (The same drawing engine, free: multiplayer, share links, libraries, local-first files, and end-to-end encrypted collaboration; cloud workspace management is leaner.) AFFiNE (Persistent team workspaces, an infinite canvas, diagrams, comments, docs, and databases; heavier than Excalidraw and heavier to self-host.) CryptPad (A persistent encrypted drive with collaborative whiteboards, folders, sharing, and access controls; less delightful drawing, stronger workspace bones.) All 5 curated free alternatives are at vibecodeit.com/excalidraw-plus/alternatives. The prompt is for when you want it exactly your way.