Code‑and.Coffee

A collaborative drawing canvas with node.js and socket.io

In this post I’ll explain how to build a very basic node.js server and client that receives lines drawn on a canvas in the browser and sends them to the other connected clients so everyone can see and scribble the same doodle.

The result

Minidoodle demo

What do we need?

Node.js

First of all you need to install Node.js and the npm package manager which is used to fetch the node.js-libraries we need. Don’t get confused though, npm is part of the node.js download so you only have to install that.

Webserver: Express.js

We need a webserver so we can open our site in the browser. Node.js comes with the built-in http-package, but to serve files we need a bit more than that (or have to write a lot more code). For this project we are going to use the express-package. It comes with a lot of features we don’t need, but it’s very easy to use and requires pretty much no configuration for serving static files.

Websockets: socket.io

The second package we need is socket.io It allows us to use websockets in a very convenient manner and has various fallbacks if native websockets aren’t supported in the browser.



Creating the package.js

First, create a directory for the project somewhere (e.g. ~/project/MiniDoodle). Now create a new file called package.json. It contains a list of dependencies (packages) we need for our project.

package.json

{
   "name": "MiniDoodle",
   "description": "Collaborative drawing canvas",
   "author": "Code-and.Coffee",
   "dependencies" : {
      "express": "4.x",
      "socket.io": "*"
   }
}

After you created the file, open a terminal, navigate to your project directory and run npm.

$ cd ./MiniDoodle
$ npm install

You should now have the express and socket.io package installed.



The HTML and CSS

All the files that we want the webserver to serve go into a separate directory. I use ./MiniDoodle/public, but you can use whatever you prefer (./htdocs, ./static, etc.).

To draw something in the browser we need a <canvas> element. We also want the canvas to use the full browser width and height so we’ll add some CSS, too.

Create the following files:

  • ./public/index.html
  • ./public/style.css

./public/index.html

<!DOCTYPE html>
<html>
   <head>
      <title>MiniDoodle</title>
      <meta charset="utf-8">
      <link rel="stylesheet" href="style.css" />
      <script src="socket.io/socket.io.js"></script>
      <script src="client.js"></script>
   </head>
   <body>
      <canvas id="drawing"></canvas>
   </body>
</html>

./public/style.css

* { 
   margin: 0;
   padding: 0;
}
body, html{
   width: 100%;
   height: 100%;
}
#canvas {
   display: block;
}



The Server

Lets go through the server line by line and see what’s going on.

Require

Here we import the modules we added to our dependencies earlier (package.json).
We also import the built-in http module to create a webserver.

var express = require('express'), 
    app = express(),
    http = require('http'),
    io = require('socket.io');

HTTP-Server

Next up we start the webserver and initialize socket.io. We also tell express to look in our ./public-directory for requested files.

var server = http.createServer(app);
var io = socketIo.listen(server);
server.listen(8080);
app.use(express.static(__dirname + '/public'));
console.log("Server running on 127.0.0.1:8080");

At this point you should already be able to to run the server and open http://127.0.0.1:8080.
If you get a blank white page in the browser and no errors everything’s working so far.

$ node server.js
Server running on 127.0.0.1:8080

Socket sending/receiving

We declare an array line_history where we keep track of all lines ever drawn.

var line_history = [];

Here a handler for new incoming connections is registered. Whenever a new client connects, this function is called and the socket of the new client is passed as an argument.

io.on('connection', function (socket) {
Bringing new clients up to date

Inside the handler we first send all the lines in our line_history to the new client. That way a client who joins late will see the whole doodle and not just the lines drawn since they joined.

   for (var i in line_history) {
      socket.emit('draw_line', { line: line_history[i] } );
   }

Finally we add a handler for our own message-type draw_line to the new client.
Each time we receive a line we add it to the line_history and send it to all connected clients so they can update their canvases.

   socket.on('draw_line', function (data) {
      line_history.push(data.line);
      io.emit('draw_line', { line: data.line });
   });
});




That’s it for the server! Like I said before it’s very basic and far from efficient, but it works. Here’s the full server script:

./server.js

var express = require('express'), 
    app = express(),
    http = require('http'),
    socketIo = require('socket.io');

// start webserver on port 8080
var server =  http.createServer(app);
var io = socketIo.listen(server);
server.listen(8080);
// add directory with our static files
app.use(express.static(__dirname + '/public'));
console.log("Server running on 127.0.0.1:8080");

// array of all lines drawn
var line_history = [];

// event-handler for new incoming connections
io.on('connection', function (socket) {

   // first send the history to the new client
   for (var i in line_history) {
      socket.emit('draw_line', { line: line_history[i] } );
   }

   // add handler for message type "draw_line".
   socket.on('draw_line', function (data) {
      // add received line to history 
      line_history.push(data.line);
      // send line to all clients
      io.emit('draw_line', { line: data.line });
   });
});



The Client

Now for the last part: the client-script.
We already have a server running but there is nothing talking to it.

What do we need on the client side?

We need the client to resize the canvas to the users screen, connect to the server and draw lines for all incoming draw_line messages. On click the client should send a draw_line message to the server when we are moving the mouse.

Normalizing the mouse position

People have all kinds of screen resolutions. If the script would send the absolute mouse position of user A to some user B with a smaller screen, poor B wouldn’t see it since it’s outside the visible area of his screen. Mouse normalization
To get around that, we normalize the mouse coordinates to range from 0.0 to 1.0 by dividing the mouse coordinates by the browser window dimensions.

x = mouse.x / width;
x = mouse.y / height;

The clients simply multiply the received coordinates by their own browser width and height and get the absolute position for their screen.

The base

As usual we wait for our (tiny) HTML page to load and ask the browser to tell us when it’s ready using the DOMContentLoaded-event. When this event is fired the client-script is run.
Let’s first declare a mouse object with the properties click, move, pos and pos_prev that we use to keep track of the mouse status.

document.addEventListener("DOMContentLoaded", function() {
   var mouse = { 
      click: false,
      move: false,
      pos: {x:0, y:0},
      pos_prev: false
   };

Then we get the <canvas> element and create a 2D drawing context that we need to draw anything at all. The window dimensions are stored in the width and height variables for later.
Lastly we tell socket.io to connect to our server.

   var canvas  = document.getElementById('drawing');
   var context = canvas.getContext('2d');
   var width   = window.innerWidth;
   var height  = window.innerHeight;
   var socket  = io.connect()

This sets the canvas width and height properties to the browser width and height;

   canvas.width = width;
   canvas.height = height;

Listening for mouse events

Here we tell the browser what to do on mousedown, mouseup and mousemove events on the canvas. The mouse.click value will be true as long as we keep a mouse button clicked.

   canvas.onmousedown = function(e){
      mouse.click = true;
   };
   canvas.onmouseup = function(e){
      mouse.click = false;
   };

The mouse.pos fields .x/.y will be updated everytime we move the mouse. This is where the mouse position normalization takes place. We also set the mouse.move to true, so we can check if the mouse was moved at all.

   canvas.onmousemove = function(e) {
      mouse.pos.x = e.clientX / width;
      mouse.pos.y = e.clientY / height;
      mouse.move = true;
   };

Listening for messages from the server

Since we want to draw the lines the server is sending, we listen for the draw_line message that we used in the server.js before. Again, this code is far from efficient. We draw every single line individually, but for now that’s okay. With beginPath we start a new path, move to the first point (moveTo), then draw a line to the second received point (lineTo).
At last we call context.stroke() to actually draw the line on the canvas.

   socket.on('draw_line', function (data) {
      var line = data.line;
      context.beginPath();
      context.lineWidth = 2;
      context.moveTo(line[0].x * width, line[0].y * height);
      context.lineTo(line[1].x * width, line[1].y * height);
      context.stroke();
   });

Is this it?

The most important thing is still missing. Without these lines we can’t draw anything. We check every 25ms if the mouse is clicked, was moved and has a previous position. We need two points to draw a line after all
If all three conditions are true, we send a draw_line message to the server and reset the mouse.move to false. The current mouse position mouse.pos is stored in the previous position mouse.pos_prev for the next time the mainLoop is run.

   function mainLoop() {
      if (mouse.click && mouse.move && mouse.pos_prev) {
         socket.emit('draw_line', { line: [ mouse.pos, mouse.pos_prev ] });
         mouse.move = false;
      }
      mouse.pos_prev = {x: mouse.pos.x, y: mouse.pos.y};
      setTimeout(mainLoop, 25);
   }
   mainLoop();
});




For the lazy efficient people among us here’s the full client.js:
(everyone knows programmers are lazy)

./public/client.js

document.addEventListener("DOMContentLoaded", function() {
   var mouse = { 
      click: false,
      move: false,
      pos: {x:0, y:0},
      pos_prev: false
   };
   // get canvas element and create context
   var canvas  = document.getElementById('drawing');
   var context = canvas.getContext('2d');
   var width   = window.innerWidth;
   var height  = window.innerHeight;
   var socket  = io.connect();

   // set canvas to full browser width/height
   canvas.width = width;
   canvas.height = height;

   // register mouse event handlers
   canvas.onmousedown = function(e){ mouse.click = true; };
   canvas.onmouseup = function(e){ mouse.click = false; };

   canvas.onmousemove = function(e) {
      // normalize mouse position to range 0.0 - 1.0
      mouse.pos.x = e.clientX / width;
      mouse.pos.y = e.clientY / height;
      mouse.move = true;
   };

   // draw line received from server
	socket.on('draw_line', function (data) {
      var line = data.line;
      context.beginPath();
      context.moveTo(line[0].x * width, line[0].y * height);
      context.lineTo(line[1].x * width, line[1].y * height);
      context.stroke();
   });
   
   // main loop, running every 25ms
   function mainLoop() {
      // check if the user is drawing
      if (mouse.click && mouse.move && mouse.pos_prev) {
         // send line to to the server
         socket.emit('draw_line', { line: [ mouse.pos, mouse.pos_prev ] });
         mouse.move = false;
      }
      mouse.pos_prev = {x: mouse.pos.x, y: mouse.pos.y};
      setTimeout(mainLoop, 25);
   }
   mainLoop();
});

Conclusion

If you made it till here you should have a little multi-user drawing app running in node.js and your browser. There are a lot of things that could be done better, but I wanted the code to be as compact as possible but still functional for this post.

Thoughts

This was the first post like this I ever wrote and I hope I wasn’t too verbose and that you could follow. Feel free to ask any questions in the comments or contact me on twitter @CodeAndCoffeee