Inspiration

We wanted to merge creativity with real social impact. Collaborative drawing is fun, but usually the art vanishes. With WePaintCharity.nft, every canvas can become a digital asset that directly benefits charities.

The most expensive NFT is The Merge by the anonymous artist Pak, which sold for $91.8 million in December 2021, and there is poverty around the world, we figured we need to solve the problem of disparity of distribution of wealth.

What it does

Real-time collaborative paintboard where multiple users draw together, powered by SpacetimeDB

A chatbox lets artists coordinate in the same room.

Finished artworks can be minted as NFTs directly into a charity wallet.

A moderator approval system ensures only appropriate art gets minted.

A marketplace page lists NFTs with their token IDs and prices so charities can resell them.

How we built it

Frontend: Next.js + TypeScript + Tailwind CSS (with shadcn/ui).

Realtime: SpacetimeDB to sync brush strokes and chat messages.

Smart contracts: Solidity ERC-721 (CharityImageNFT) on Sepolia, deployed and verified with Hardhat.

Storage: Pinata IPFS for artwork images and metadata.

Wallets: MetaMask Wallet for user authentication + dedicated charity wallet.

Marketplace: Simple listing API (Next.js route) storing tokenId + price

.

Implementation of SpacetimeDB

How we use SpacetimeDB (stroke storage & realtime)

Data model (append-only strokes)

We model drawing as an append-only event log per board (room):

stroke One row per stroke (metadata only).

• board_id : String — room key

• stroke_id : String — UUID per stroke

• color : String — hex (eraser is special-cased)

• width : F32 — brush width

• created_at : I64 — server timestamp

• stroke_chunk The points for a stroke, split into small chunks for streaming.

• board_id : String

• stroke_id : String

• seq : I32 — increasing sequence number per stroke

• points_packed : Array<I16> — [x0,y0,x1,y1,...]

• created_at : I64

Why chunks? It lets us stream at ~12.5Hz while the pointer moves instead of waiting for mouseup, so remote clients see the line “grow”.

Why I16 packing?

Canvas coordinates fit comfortably in signed 16-bit integers for our board size. Packing Pt {x,y} → Int16Array [x,y,...] cuts bandwidth ~50% versus JSON {x,y} objects and makes inserts tiny and fast.

Reducers (authoritative, server-side)

All writes go through three reducers (visible in the SpacetimeDB console screenshot):

• stroke_begin(board_id, stroke_id, color, width)

Inserts a stroke row (metadata).

• stroke_append(board_id, stroke_id, seq, points_packed: Array)

Appends a stroke_chunk row with the given seq.

• stroke_end(board_id, stroke_id)

(Optional) marks logical end — we use it to finalize client history.

Because the server sequences chunks independently of any single client clock, ordering is deterministic via (stroke_id, seq). If a client retries a chunk with the same (stroke_id, seq), the reducer can reject duplicates for idempotency.

Bonus: we also expose chat_send(room_id, msg_id, sender, text) for room chat; same pattern, different table.

Client write path (low-latency batching)

1.  On pointer down → stroke_begin().

2.  While moving → buffer points locally and every 80ms:

pack to Int16Array

stroke_append(board, strokeId, seq++, Array.from(Int16Array))

3.  On pointer up/leave → flush remaining buffer + stroke_end().

This gives a nice trade-off: <100ms perceived latency + minimal DB writes.

Client read path (replay + live stream)

We subscribe to the two tables, scoped by board_id:

conn.subscriptionBuilder()
  .onApplied((ctx) => {
    // 1) Build a style map from stroke rows
    const styles = new Map(strokesInRoom.map(s => [s.stroke_id, {color:s.color, width:s.width}]));

    // 2) Replay all stroke_chunk rows (sorted)
    const chunks = chunksInRoom.sort((a,b) =>
      (a.stroke_id+b.seq).localeCompare(b.stroke_id+b.seq)
    );
    for(const ch of chunks){
      drawSegment(ctx2d, styles.get(ch.stroke_id)!, unpack(Int16Array.from(ch.points_packed)));
    }

    // 3) Hook live inserts so new chunks render immediately
    ctx.db.strokeChunk.onInsert((_, ch) => {
      if (ch.board_id !== roomId) return;
      const st = styles.get(ch.stroke_id) || lookupStrokeStyleFromDB();
      drawSegment(ctx2d, st, unpack(Int16Array.from(ch.points_packed)));
    });
  })
  .subscribe([
    `select * from stroke where board_id='${roomId}'`,
    `select * from stroke_chunk where board_id='${roomId}'`,
  ]);

Because we never re-create the canvas element, resizing/fullscreen just rescales while preserving pixels. Replay uses the same 2D context, so historical strokes + live inserts layer perfectly.

Consistency & recovery • If a device goes offline, it re-subscribes and replays the whole stroke log for that room.

• Duplicate chunks are prevented by (stroke_id, seq) unique constraint on the reducer, so retries are safe.

• Undo/redo is client-local (ImageData stack) and doesn’t mutate the canonical log; that keeps the DB append-only and conflict-free.

Performance notes

Packing to Int16Array cuts payload size and GC pressure.

80ms flush cadence (~12.5 Hz) balances smoothness vs write amplification.

Sorting by (stroke_id, seq) avoids cross-client clock drift issues.

We scope all queries by board_id so subscribers only see what they need.

Moderation & safety

We don’t block drawing in real time; instead, minting to the charity wallet requires a moderator password (“Approve” button), preventing explicit content from being minted.

Challenges we ran into

Handling mobile drawing alignment when the chatbox was visible.

Avoiding duplicate chat messages from optimistic updates.

Integrating Pinata metadata uploads into the mint workflow.

Designing a moderation step to prevent obscene/explicit drawings.

Figuring out SpacetimeDB was the biggest hurdle, I, first, tried to connect through localhost:3000 and then move on to the main cloud. However, localhost:3000 was not working. I thought was it was token issue. However, after wasting 4~5 hours figuring this out, I switched to main cloud and it worked. My theory on this is the University' wifi is blocking data going out, it made sense how localhost did not work even during the workshop, and magically worked when switched to main cloud. Life is odd...

Accomplishments that we're proud of

Deployed and verified CharityImageNFT contract on Sepolia.

Built a working collaborative paintboard with real-time sync.

Integrated NFT minting + IPFS storage pipeline.

Created a basic moderator-gated marketplace for charity listings.

What we learned

How to use SpacetimeDB for live multiplayer apps.

Solidity patterns for ERC-721 with royalties.

IPFS pinning with Pinata and tokenURI handling.

UX trade-offs: we minimized chat on mobile instead of enlarging canvas.

What's next for WePaintCharity.NFT

Connect marketplace to Seaport/Reservoir for secondary sales.

Let artists choose which charity wallet to donate to.

Add AI moderation to filter inappropriate art.

Introduce layers, brushes, and art playback features.

Partner with real nonprofits to raise funds through collaborative art.

Built With

Share this project:

Updates