Thumbnail image

Real-Time Multi-User Drawing App With Node.js

Table of contents

Timeline:May 8-13, 2015
Languages Used:JavaScript (Node.js), HTML, CSS
School:Colorado College
Course:CP215 Application Design

Watch the Demo

Ever wanted to draw online together with your friends? This app is perfect for that! … but not much else. It doesn’t even have a function to save/download the masterpieces you create, let alone do most things expected of sophisticated drawing applications these days.

Why not? This app was both an experiment in node.js, as well as my collaborative final project for my CP215 Application Design class. So essentially, it was made in a weekend by two people. But that’s not to say it isn’t a noteworthy project!

View Source Code

Without using websockets or anything of the sort, this application synchronizes a drawing canvas across two or more devices almost instantaneously, while gracefully resolving conflicts individually on each pixel, by prioritizing recency of each user’s paint instruction. How? Glad you asked. Well, to start, it’s arguably very memory inefficient. But remember, for a weekend project, it’s speed of implementation that really counts.

Screenshot of collaborative canvas app

Side by side view of two clients communicating bidirectionally across one server

Client-Side Operations

In order to more easily understand these operations, it’s best to think of each client’s canvas as a child, and the server’s canvas as the parent, where the parent reflects the “real” current state of the canvas.

Painting on the Child Canvas

We completed this project so quickly by first choosing to create a “canvas” which is actually an HTML `` element, made up of thousands of tiny cells, each representing a pixel with a simple background-color CSS attribute. A painted pixel is just a cell with a non-transparent background color, while a blank pixel is a cell with a transparent background color. On mouse-click, a certain number of cells (determined by the current brush size) around the active cell are all painted the color of the currently selected brush.

The setupTable function in paint_client.js is responsible for dynamically building this HTML table structure:

 1// From paint_client.js
 2function setupTable()
 3{
 4  table = document.getElementById("tbody");
 5  for(var i = 0; i < canvas_size; i++)
 6  {
 7    var rowArray = [];
 8    var row = document.createElement("tr");
 9    for (var j = 0; j < canvas_size; j++)
10    {
11      var cell = document.createElement("td");
12      cell.x = i; // Custom property to store X coordinate
13      cell.y = j; // Custom property to store Y coordinate
14      cell.lastUpdated = new Date().getTime(); // Timestamp for conflict resolution
15      cell.style.backgroundColor = "#ffffff"; // Default background color (white)
16      row.appendChild(cell);
17      rowArray.push(cell);
18    }
19    table.appendChild(row);
20    tableArray.push(rowArray); // Stores references to actual DOM cells
21  }
22}

When a user clicks or drags, the clickCell function is triggered. It iterates through the cells within the brush’s radius and updates their backgroundColor and lastUpdated timestamp directly on the client’s DOM:

 1// From paint_client.js, within clickCell function
 2for(var i=0; i<pen_size/2; i++) {
 3  for(var j=0; j<pen_size/2; j++) {
 4    var cellIndex = x + (pen_size/4 - i);
 5    var rowIndex = y + (pen_size/4 - j);
 6    try {
 7      var tableCell = tableArray[rowIndex][cellIndex];
 8      tableCell.style.backgroundColor = c; // Apply selected pen color
 9      tableCell.lastUpdated = new Date().getTime(); // Mark last local update time
10      if(clickedCells.indexOf(tableCell) === -1)
11      {
12        clickedCells.push(tableCell); // Add to a temporary array of currently clicked cells
13      }
14    } catch(e) {
15      console.log("Error: Couldn't paint cell ["+rowIndex+", "+cellIndex+"]:"+e);
16    }
17  }
18}

Polling for Pixels

On the client-side, the browser sends an XHR request to the server every 500 milliseconds (this is called polling) for an updated version of the parent canvas. Regardless of whether any changes have been made, the server will respond to the client with a JSON object containing an array of more objects representing the table’s thousands of cells (thus the aforementioned memory inefficiency; remember that this occurs a couple times per second). Upon receiving this response, the client immediately updates the color of every single cell on the local child canvas, corresponding directly to the colored cells in the array sent by the server.

The client’s polling mechanism is implemented in the pollColors function:

1// From paint_client.js
2function pollColors()
3{
4  var request_colors = new XMLHttpRequest();
5  request_colors.startTime = new Date().getTime(); // Record request start time for recency check
6  request_colors.onload = colorsListener; // Callback when response is received
7  request_colors.open( "get", "get_changed_cells" ); // Request all cells from the server
8  request_colors.send();
9}

On the server side, the sendChangedCells function simply serializes and sends the entire canvas array:

1// From paint_server.js
2function sendChangedCells(req, res)
3{
4    res.writeHead(200, {'Content-Type':'application/json'});
5    res.end(JSON.stringify(canvas)); // Sends the entire server's canvas state
6}

Listening to Changes

Meanwhile, the client employs event listeners to wait for input (clicks) from the user, and updates pixels on the child canvas accordingly in real-time (i.e. without client-server communication.) The client keeps track of all the changes being made locally to the canvas, via a dynamic array (changed_cells_array) containing only altered “pixels” (JSON objects with x, y, and color properties).

Additionally, every 100 milliseconds, the client will (in a sense) poll itself, to determine whether or not anything has changed on its child canvas. If the local changed_cells_array contains any objects (meaning those corresponding pixels have been somehow locally altered since the last request), then the client sends an XHR to the server containing this array of ONLY altered pixels (as opposed to all pixels in the table) in the form of a JSON object.

The sendColors function is responsible for gathering and sending these local changes to the server:

 1// From paint_client.js
 2function sendColors()
 3{
 4  if(changedCells.length > 0) {
 5    var url = "change_cells?";
 6    // Loop through all locally changed cells and append them to the URL query string
 7    for(var i=0; i<changedCells.length; i++) {
 8      var cell = changedCells[i];
 9      var newData = "c"+i+"="+cell.x+"-"+cell.y+"-"+pen_color+"&"; // Format: c0=x-y-color
10      url += newData;
11    }
12    changedCells = []; // Clear the array after preparing to send
13    var send_change = new XMLHttpRequest();
14    send_change.open("get", url);
15    send_change.send();
16  }
17}

Pixel Conflict Resolution

At any point during the user experience - particularly when the user makes a long brush stroke - it is entirely possible that the following scenario will occur:

💡 Scenario:

During the time period between the mousedown event and the mouseup event, the client has requested and received an updated parent canvas from the server which contains new color values for the same cells currently being painted over by the user. In other words, in the current brushstroke, the user has painted Cell [43, 586] green, but at some point during the brushstroke (perhaps even after said pixel has been painted), the server has informed the client that Cell [43, 586] should be painted blue in accordance with another user’s changes.

These events can occur in any order, and to more than one cell simultaneously. How does the client know what to do? Can it choose between green and blue in a predictable way? Or does it just crash?

Thankfully, it does not crash. In accordance with our rules of prioritizing recency, if the client’s child canvas contains local cells which are currently being manipulated by the user, then the client will not apply the server’s changes to those cells until the mouseup event has fired, signaling the end of the user’s brushstroke.

Remember the changed_cells_array? This array is only ever filled with the altered cells after the brushstroke ends. This ensures that every time the server asks for altered cells during a brushstroke, this array will always be empty, and therefore nothing will change in the parent canvas on the server. Once the stroke ends, however, the array is updated and the changes are echoed to the parent canvas and all its children.

This delayed response is important for two reasons:

  1. All cells currently being changed but not yet inserted into the changed_cells_array take priority over changes from the server, because they are considered more recent.
  2. If two or more clients manipulate the same cells at the same time, the client who waits the longest to end the brushstroke with a mouseup event is the “winner.”

The colorsListener function on the client side incorporates this conflict resolution logic:

 1// From paint_client.js, within colorsListener function
 2// This function runs when the client receives an updated canvas from the server.
 3function colorsListener()
 4{
 5  this.endTime = new Date().getTime(); // Time when server response was received
 6  var canvas = JSON.parse(this.responseText); // The server's full canvas state
 7
 8  // ... (looping through canvas data)
 9
10          if(tableCell) {
11            var currentColor = tableCell.style.backgroundColor;
12            if(!colorsSame(currentColor, cellColor) && // If the colors are different
13                this.startTime - tableCell.lastUpdated > 0 && // AND server update is more recent than local client's last paint
14                changedCells.indexOf(tableCell) === -1 &&    // AND the cell is NOT queued to be sent to server
15                clickedCells.indexOf(tableCell) === -1) {     // AND the cell is NOT currently being painted by the user
16
17              changedCellCount++;
18              tableCell.style.backgroundColor = cellColor; // Then, apply the server's change
19            } else {
20              // Otherwise, log a warning (or silently ignore):
21              // The client's local change is more recent or in progress, so it takes priority.
22              // console.log("Warning: Cell ["+i+", "+j+"] has been updated on the client more recently than the server. Won't overwrite.");
23            }
24          }
25// ... (rest of function)
26}

Server-Side Operations

The Parent Canvas

Similar to changed_cells_array, the parent canvas on the server is nothing more than an array of objects corresponding to pixels, each with three properties (x, y, and color).

The server maintains its “master” version of the canvas as a simple 2D JavaScript array, initialized with white pixels:

 1// From paint_server.js
 2var CANVAS_SIZE = 200;
 3
 4/* Multidimensional Array storing all columns and rows of colors in the table. */
 5var canvas = [];
 6
 7function resetCanvas()
 8{
 9    canvas = [];
10    for(var i=0; i<CANVAS_SIZE; i++)
11    {
12        canvas[i] = [];
13        for(var j=0; j<CANVAS_SIZE; j++) {
14            canvas[i][j] = "#ffffff"; // Initialize all cells to white
15        }
16    }
17}

The main serverFn handles incoming HTTP requests and routes them based on the URL. This demonstrates how the server distinguishes between requests for the current canvas state, requests to change pixels, and requests to serve static files:

 1// From paint_server.js
 2function serverFn(req,res)
 3{
 4    var filename = req.url.substring( 1, req.url.length );
 5
 6    if(filename === "") {
 7        filename = "./index.html"; // Default to index.html for root requests
 8    }
 9
10    if(filename.indexOf("get_changed_cells") > -1) {
11        sendChangedCells( req, res ); // Handle requests for the canvas state
12    }
13    else if(filename.indexOf("change_cells") > -1) {
14        // Handle requests to update pixels
15        var urlData = filename.split("change_cells")[1];
16        if(urlData.indexOf("?") > -1) {
17            getCellsFromUrl( urlData ); // Parse and apply incoming pixel changes
18            serveFile( "./index.html", req, res ); // Then serve the main page
19        } else {
20            serveFile( "./index.html", req, res );
21        }
22    }
23    else if(filename.indexOf("clear_canvas") > -1) {
24        resetCanvas(); // Clear the server's canvas
25        serveFile(filename, req, res);
26    }
27    else {
28        serveFile(filename, req, res); // Serve other static files (HTML, JS, CSS)
29    }
30}

When a client sends changes via change_cells, the getCellsFromUrl function on the server parses the URL’s query parameters and updates the server’s canvas array accordingly:

 1// From paint_server.js
 2/* Parse URL into an array of cells and update the server's canvas. */
 3function getCellsFromUrl( urlData )
 4{
 5    var queryData = urlData.split("?")[1];
 6    var fields = queryData.split("&"); // Each field represents a changed cell (e.g., "c0=x-y-color")
 7    for(var i=0; i<fields.length; i++) {
 8        var fieldSplit = fields[i].split("=");
 9        if(fieldSplit.length > 1) {
10            var fieldValue = fieldSplit[1];
11            var cellCoords = fieldValue.split("-"); // Splits x, y, and color
12            if(cellCoords.length === 3) {
13                var x = parseInt(cellCoords[0]);
14                var y = parseInt(cellCoords[1]);
15                var color = cellCoords[2];
16                // Note: Actual implementation includes color parsing/conversion logic here
17                try {
18                    canvas[x][y] = color; // Update the server's parent canvas with the new color
19                } catch(e) {
20                    console.log("Error: Couldn't find specified cell from URL in the canvas.");
21                }
22            }
23        }
24    }
25}

Reflections & Lessons Learned

Developing Collaborative Canvas was an invaluable weekend project, especially in real-time web applications. Key lessons included:

  • Polling’s Trade-offs: XHR polling enabled rapid prototyping but quickly revealed severe inefficiency (sending 40,000 cells every 500ms). This underscored the necessity of robust real-time protocols like WebSockets for scalable production.
  • Complexity of Distributed State: Synchronizing multiple clients and a central server is inherently difficult. Our “recency” conflict resolution and careful client-side state management (e.g., clickedCells) were pragmatic solutions to manage race conditions and ensure predictable behavior.
  • Prototype Speed vs. Performance: Opting for quick implementation (HTML <table>, full canvas polls) over optimal performance highlighted the crucial balance between project constraints (weekend deadline) and ideal architectural choices.
  • Value of Granular Updates: Sending only changed_cells_array to the server was a vital optimization, demonstrating significant bandwidth savings achieved by sending “deltas” rather than full state.
  • Mastering Event Lifecycle: Managing mousedown, mousemove, and mouseup events was crucial. Delaying server updates until mouseup ensured continuous local brushstrokes were prioritized, improving responsiveness and user experience.

Let me know in the comments if there’s anything else you would have done differently!

Related Posts

Comments