After diving into reactive programming basics and building a real-time dashboard, let's take things up a notch! Today we're going to build something super fun - a drawing app where multiple people can draw on the same canvas at the same time.
Remember when you were a kid and would fight with your siblings over who gets to color which part of the drawing? Well, this app solves that problem - everyone can draw at once!
The complete source code for this tutorial is available in my GitHub repository.
Let's create a simple server that will host our app and handle WebSocket connections:
js93 lines
1const express =require("express");2const http =require("http");3const socketIo =require("socket.io");4const path =require("path");56// Create our Express app, HTTP server, and Socket.io server7const app =express();8const server = http.createServer(app);9const io =socketIo(server);1011// Serve static files from the public directory12app.use(express.static(path.join(__dirname,"public")));1314// Keep track of all drawing points to replay for new users15let drawHistory =[];16// Keep track of connected users17let users ={};1819// When a client connects20io.on("connection",(socket)=>{21console.log("A user connected:", socket.id);2223// Create a random color for this user24const color =getRandomColor();25 users[socket.id]={26id: socket.id,27 color,28isDrawing:false,29};3031// Send the current state to the new user32 socket.emit("init",{33 drawHistory,34 users,35});3637// Let everyone know about the new user38 io.emit("userJoined", users[socket.id]);3940// When user sends draw data41 socket.on("draw",(data)=>{42 data.color= users[socket.id].color;43 data.userId= socket.id;4445// Store in history (so new users can see existing drawing)46 drawHistory.push(data);4748// Keep history from getting too big49if(drawHistory.length>1000){50 drawHistory = drawHistory.slice(drawHistory.length-1000);51}5253// Send to all OTHER clients (broadcasting)54 socket.broadcast.emit("draw", data);55});5657// When user starts/stops drawing58 socket.on("drawingState",(isDrawing)=>{59 users[socket.id].isDrawing= isDrawing;60 io.emit("userStateChanged",{userId: socket.id, isDrawing });61});6263// Handle clear canvas request64 socket.on("clearCanvas",()=>{65// Clear the drawing history66 drawHistory =[];67// Broadcast to all clients to clear their canvases68 io.emit("clearCanvas");69});7071// When user disconnects72 socket.on("disconnect",()=>{73console.log("User disconnected:", socket.id);74delete users[socket.id];75 io.emit("userLeft", socket.id);76});77});7879// Start server80constPORT= process.env.PORT||3000;81server.listen(PORT,()=>{82console.log(`Server running on port ${PORT}`);83});8485// Helper to generate random colors86functiongetRandomColor(){87const letters ="0123456789ABCDEF";88let color ="#";89for(let i =0; i <6; i++){90 color += letters[Math.floor(Math.random()*16)];91}92return color;93}
The server code is pretty straightforward:
We set up Socket.IO to handle WebSocket connections
We track drawing history and connected users
For each new user, we send them the current state and give them a random color
We handle drawing events and broadcast them to other users
We manage when users connect, disconnect, and change drawing state
1<!DOCTYPEhtml>2<htmllang="en">3<head>4<metacharset="UTF-8"/>5<metaname="viewport"content="width=device-width, initial-scale=1.0"/>6<title>Collaborative Drawing App</title>7<linkrel="stylesheet"href="style.css"/>8</head>9<body>10<divclass="container">11<divclass="header">12<h1>Let's Draw Together!</h1>13<divclass="tools">14<buttonid="clear-btn">Clear Canvas</button>15<divclass="user-list">16<h3>Who's Drawing:</h3>17<ulid="users"></ul>18</div>19</div>20</div>2122<divclass="canvas-container">23<canvasid="drawing-canvas"></canvas>24</div>2526<divclass="footer">27<p>Your color: <spanid="my-color">loading...</span></p>28<pclass="help-text">29 Start drawing by clicking and dragging on the canvas
30</p>31</div>32</div>3334<scriptsrc="/socket.io/socket.io.js"></script>35<scriptsrc="https://unpkg.com/rxjs@7/dist/bundles/rxjs.umd.min.js"></script>36<scriptsrc="app.js"></script>37</body>38</html>
1// Get RxJS from the UMD bundle2const{ fromEvent, merge }= rxjs;3const{ map, switchMap, takeUntil, tap, filter, share, pairwise, startWith }=4 rxjs.operators;56// Set up canvas and context7const canvas =document.getElementById("drawing-canvas");8const context = canvas.getContext("2d");9const usersElement =document.getElementById("users");10const clearButton =document.getElementById("clear-btn");11const myColorElement =document.getElementById("my-color");1213// Socket.io is loaded from the server14const socket =io();1516// Set the canvas size to match its display size17functionsetupCanvas(){18const rect = canvas.getBoundingClientRect();19 canvas.width= rect.width;20 canvas.height= rect.height;21 context.lineCap="round";22 context.lineJoin="round";23 context.lineWidth=3;24}2526// Set up the canvas when page loads and when window resizes27window.addEventListener("load", setupCanvas);28window.addEventListener("resize", setupCanvas);2930// Keep track of our user31let myColor;32let myUserId;33let users ={};3435// Process drawing events from the server36socket.on("draw", drawLine);3738// Process initial state when connecting39socket.on("init",(data)=>{40// Save user info41 myUserId = socket.id;42 myColor = data.users[myUserId].color;43 users = data.users;4445// Display my color46 myColorElement.textContent= myColor;47 myColorElement.style.color= myColor;4849// Draw the existing drawing50 data.drawHistory.forEach(drawLine);5152// Update the user list53updateUserList();54});5556// Handle user joining57socket.on("userJoined",(user)=>{58 users[user.id]= user;59updateUserList();60});6162// Handle user leaving63socket.on("userLeft",(userId)=>{64delete users[userId];65updateUserList();66});6768// Handle user state change (drawing or not)69socket.on("userStateChanged",(data)=>{70 users[data.userId].isDrawing= data.isDrawing;71updateUserList();72});7374// Clear button handler75clearButton.addEventListener("click",()=>{76// Clear locally77 context.clearRect(0,0, canvas.width, canvas.height);78// Tell server to clear for everyone79 socket.emit("clearCanvas");80});8182// Server tells us to clear83socket.on("clearCanvas",()=>{84 context.clearRect(0,0, canvas.width, canvas.height);85});8687// Draw a line segment based on coordinates88functiondrawLine(data){89 context.strokeStyle= data.color;90 context.beginPath();91 context.moveTo(data.prevX, data.prevY);92 context.lineTo(data.currX, data.currY);93 context.stroke();94}9596// Update the user list display97functionupdateUserList(){98 usersElement.innerHTML="";99Object.values(users).forEach((user)=>{100const li =document.createElement("li");101 li.innerHTML=`102<spanclass="user-color"style="background-color:${user.color}"></span>103<spanclass="${user.isDrawing?"user-drawing":""}">${104 user.id=== myUserId ?"You":`User ${user.id.substr(0,5)}`105}</span>106${user.isDrawing?" (drawing)":""}107`;108 usersElement.appendChild(li);109});110}111112// Now for the reactive part!113// Let's create observables from mouse events on the canvas114const mouseMove$ =fromEvent(canvas,"mousemove");115const mouseDown$ =fromEvent(canvas,"mousedown");116const mouseUp$ =fromEvent(canvas,"mouseup");117const mouseLeave$ =fromEvent(canvas,"mouseleave");118119// This is where reactive programming really shines for drawing apps120// We'll track mouse movements while the mouse is down121const drawing$ = mouseDown$.pipe(122tap(()=>{123// Tell everyone we're drawing124 socket.emit("drawingState",true);125}),126// switchMap lets us switch to a new observable sequence127switchMap((down)=>{128// Get the starting position129const startX = down.offsetX;130const startY = down.offsetY;131132// Create the initial point133const startPoint ={134offsetX: startX,135offsetY: startY,136};137138// Return the movements until mouse up or leave139return mouseMove$.pipe(140// Start with the initial point to connect properly141startWith(startPoint),142// Use pairwise to get the previous and current points143pairwise(),144// Map to the format our drawing function expects145map(([prev, curr])=>({146prevX: prev.offsetX,147prevY: prev.offsetY,148currX: curr.offsetX,149currY: curr.offsetY,150})),151// Stop tracking when mouse up or leave occurs152takeUntil(merge(mouseUp$, mouseLeave$))153);154}),155// This is important! Share makes this a hot observable156// so multiple subscribers don't recreate the events157share()158);159160// When we finish drawing161merge(mouseUp$, mouseLeave$).subscribe(()=>{162 socket.emit("drawingState",false);163});164165// Subscribe to the drawing stream166drawing$.subscribe((coords)=>{167// Draw locally168drawLine({169...coords,170color: myColor,171});172173// Emit to the server174 socket.emit("draw", coords);175});176177// Debug when we connect/disconnect178socket.on("connect",()=>console.log("Connected to server"));179socket.on("disconnect",()=>console.log("Disconnected from server"));
Capture their mouse movements as line segments using RxJS
Draw those segments locally on their canvas
Send the segment data to the server via Socket.IO
The server broadcasts to all other clients
Other clients receive the data and draw the same segment
This direct sharing of drawing actions (not just the final picture) means everyone can see the drawing happen stroke by stroke. It's much more interactive than just sharing a final image!
When drawing, you might notice gaps between line segments. This happens because we're only capturing mouse movements at certain intervals. To fix this, change how we draw lines:
js32 lines
1// Original drawing function2functiondrawLine(data){3 context.strokeStyle= data.color;4 context.beginPath();5 context.moveTo(data.prevX, data.prevY);6 context.lineTo(data.currX, data.currY);7 context.stroke();8}910// Improved version that tracks the last position11let lastX =null;12let lastY =null;1314functiondrawLine(data){15 context.strokeStyle= data.color;16 context.beginPath();1718// If this is part of an ongoing drawing, connect from the last point19if(lastX && data.userId=== lastUserId){20 context.moveTo(lastX, lastY);21}else{22 context.moveTo(data.prevX, data.prevY);23}2425 context.lineTo(data.currX, data.currY);26 context.stroke();2728// Remember the last position and user29 lastX = data.currX;30 lastY = data.currY;31 lastUserId = data.userId;32}
The canvas can get sluggish when there's too much drawing history. Two fixes:
Limit history on the server (we already do this)
Periodically convert the canvas to an image:
js18 lines
1// Take a snapshot every 100 drawing actions2let drawCount =0;34drawing$.subscribe((coords)=>{5 drawCount++;67if(drawCount %100===0){8takeCanvasSnapshot();9}10});1112functiontakeCanvasSnapshot(){13// Temporarily store the canvas data14const imageData = canvas.toDataURL("image/png");1516// Optionally send to server to update baseline for new users17 socket.emit("canvasSnapshot", imageData);18}
WebSockets sometimes miss messages if there's network lag. Add acknowledgments and retries:
js15 lines
1// When sending drawing data2socket.emit("draw", coords,(ack)=>{3if(!ack){4// If no acknowledgment, try again5 socket.emit("draw", coords);6}7});89// On the server10socket.on("draw",(data, callback)=>{11// Process the data...1213// Send acknowledgment14if(callback)callback(true);15});
1// On the client2const roomButton =document.getElementById("create-room");34roomButton.addEventListener("click",()=>{5const roomName =prompt("Enter a room name:");6if(roomName){7 socket.emit("joinRoom", roomName);8}9});1011// On the server12socket.on("joinRoom",(roomName)=>{13// Leave current rooms14Object.keys(socket.rooms).forEach((room)=>{15if(room !== socket.id) socket.leave(room);16});1718// Join new room19 socket.join(roomName);2021// Get room history22const roomHistory = drawHistoryByRoom[roomName]||[];23 socket.emit("roomJoined",{room: roomName,history: roomHistory });2425// Broadcast to room members only26 socket.to(roomName).emit("userJoined", users[socket.id]);27});
With traditional event listeners, each listener runs independently. But with RxJS, we can create a pipeline where events flow through transformations and can be shared between multiple subscribers.
The share() operator is key here - it turns a "cold" observable (which runs separately for each subscriber) into a "hot" observable (which runs once and broadcasts to all subscribers).
What happens if messages arrive out of order? In our app, it doesn't matter much! Each line segment is drawn where it should be, regardless of when it arrives.
This is called "eventual consistency" - even if there are temporary network delays, everyone's canvas will eventually look the same.
Building a collaborative drawing app with reactive programming shows how powerful this approach can be for real-time, interactive applications. The beauty is in how clean and maintainable the code stays, even as complexity grows.
The real win here is that we've created something that would be pretty complicated with traditional approaches, but becomes straightforward when thinking in terms of streams and transformations.
I hope this tutorial has shown you not just how to build a drawing app, but how reactive programming can tackle real-world problems in an elegant way.
Next time you're building something where multiple users need to interact in real-time, remember the patterns we've used here. Whether it's a collaborative text editor, a multiplayer game, or a shared dashboard - reactive programming has you covered!
Happy coding (and drawing)!
MB
Mehrshad Baqerzadegan
Sharing thoughts on technology and best practices.