diff --git a/Nibriboard/Client/Messages/ClientStateMessage.cs b/Nibriboard/Client/Messages/ClientStateMessage.cs
new file mode 100644
index 0000000..6a15a63
--- /dev/null
+++ b/Nibriboard/Client/Messages/ClientStateMessage.cs
@@ -0,0 +1,20 @@
+﻿using System;
+using System.Collections.Generic;
+
+using RippleSpace;
+
+namespace Nibriboard.Client.Messages
+{
+	/// <summary>
+	/// Represents an update of where a group of connected clients are.
+	/// </summary>
+	public class ClientStateMessage : Message
+	{
+		public List<ClientState> ClientStates = new List<ClientState>();
+
+		public ClientStateMessage()
+		{
+		}
+	}
+}
+
diff --git a/Nibriboard/Client/Messages/CursorPositionMessage.cs b/Nibriboard/Client/Messages/CursorPositionMessage.cs
new file mode 100644
index 0000000..4dfc768
--- /dev/null
+++ b/Nibriboard/Client/Messages/CursorPositionMessage.cs
@@ -0,0 +1,21 @@
+﻿using System;
+using System.Drawing;
+
+namespace Nibriboard.Client.Messages
+{
+	/// <summary>
+	/// Represents an update by the client of their cursor position.
+	/// </summary>
+	public class CursorPositionMessage
+	{
+		/// <summary>
+		/// The absolute cursor position.
+		/// </summary>
+		public Point AbsCursorPosition;
+
+		public CursorPositionMessage()
+		{
+		}
+	}
+}
+
diff --git a/Nibriboard/Client/Messages/HandshakeRequestMessage.cs b/Nibriboard/Client/Messages/HandshakeRequestMessage.cs
index 26c9641..b673fc5 100644
--- a/Nibriboard/Client/Messages/HandshakeRequestMessage.cs
+++ b/Nibriboard/Client/Messages/HandshakeRequestMessage.cs
@@ -1,6 +1,10 @@
 ﻿using System;
 using System.Drawing;
 
+using Newtonsoft.Json;
+// TODO: In C# you can either have namespaces or types in a namespace - not both.
+using Nibriboard.Utilities.JsonConverters;
+
 namespace Nibriboard.Client.Messages
 {
 	public class HandshakeRequestMessage : Message
@@ -9,10 +13,12 @@ namespace Nibriboard.Client.Messages
 		/// The initial visible area on the client's screen.
 		/// Very useful for determining which chunks we should send a client when they first connect.
 		/// </summary>
+		[JsonConverter(typeof(RectangleConverter))]
 		public Rectangle InitialViewport = Rectangle.Empty;
 		/// <summary>
 		/// The initial position of the user's cursor.
 		/// </summary>
+		[JsonConverter(typeof(RectangleConverter))]
 		public Point InitialAbsCursorPosition = Point.Empty;
 
 		public HandshakeRequestMessage()
diff --git a/Nibriboard/Client/NibriClient.cs b/Nibriboard/Client/NibriClient.cs
index 51a8a06..5950d66 100644
--- a/Nibriboard/Client/NibriClient.cs
+++ b/Nibriboard/Client/NibriClient.cs
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
 using System.Text;
 using System.Collections.Generic;
 using System.Drawing;
+using System.Linq;
 
 using IotWeb.Common.Http;
 using SBRL.Utilities;
@@ -88,12 +89,19 @@ namespace Nibriboard.Client
 		{
 			string eventName = JsonUtilities.DeserializeProperty<string>(frame, "event");
 
-			if (!messageEventTypes.ContainsKey(eventName))
-				Log.WriteLine("Received message with invalid event {1} from Client #{0}", Id, eventName);
+			if(eventName == null) {
+				Log.WriteLine("[NibriClient#{0}] Received message that didn't have an event.", Id);
+				return;
+			}
+
+			if (!messageEventTypes.ContainsKey(eventName)) {
+				Log.WriteLine("[NibriClient#{0}] Received message with invalid event {1}.", Id, eventName);
+				return;
+			}
 
 			Type messageType = messageEventTypes[eventName];
 			Type jsonNet = typeof(JsonConvert);
-			MethodInfo deserialiserInfo = jsonNet.GetMethod("DeserailizeObject");
+			MethodInfo deserialiserInfo = jsonNet.GetMethods().First(method => method.Name == "DeserializeObject" && method.IsGenericMethod);
 			MethodInfo genericInfo = deserialiserInfo.MakeGenericMethod(messageType);
 			var decodedMessage = genericInfo.Invoke(null, new object[] { frame });
 
@@ -147,6 +155,7 @@ namespace Nibriboard.Client
 			CurrentViewPort = message.InitialViewport;
 			AbsoluteCursorPosition = message.InitialAbsCursorPosition;
 
+			// Tell everyone else about the new client
 			Send(GenerateClientStateUpdate());
 
 			return Task.CompletedTask;
@@ -158,6 +167,11 @@ namespace Nibriboard.Client
 		protected Task handleCursorPositionMessage(CursorPositionMessage message) {
 			AbsoluteCursorPosition = message.AbsCursorPosition;
 
+			// Send the update to the other clients
+			ClientStateMessage updateMessage = new ClientStateMessage();
+			updateMessage.ClientStates.Add(this.GenerateStateSnapshot());
+			manager.Broadcast(this, updateMessage);
+
 			return Task.CompletedTask;
 		}
 		#endregion
diff --git a/Nibriboard/Client/NibriClientManager.cs b/Nibriboard/Client/NibriClientManager.cs
index af3377f..ebb1fe8 100644
--- a/Nibriboard/Client/NibriClientManager.cs
+++ b/Nibriboard/Client/NibriClientManager.cs
@@ -3,6 +3,7 @@ using IotWeb.Common.Http;
 using System.Threading.Tasks;
 using System.Collections.Generic;
 using System.Diagnostics;
+using Nibriboard.Client.Messages;
 
 namespace Nibriboard.Client
 {
@@ -51,7 +52,8 @@ namespace Nibriboard.Client
 			Clients.Add(client);
 		}
 
-		public void Broadcast(NibriClient sendingClient, string message)
+
+		public void Broadcast(NibriClient sendingClient, Message message)
 		{
 			foreach(NibriClient client in Clients)
 			{
@@ -59,7 +61,7 @@ namespace Nibriboard.Client
 				if (client == sendingClient)
 					continue;
 				
-				client.SendRaw(message);
+				client.Send(message);
 			}
 		}
 	}
diff --git a/Nibriboard/ClientFiles/BoardWindow.js b/Nibriboard/ClientFiles/BoardWindow.js
index 9942b3f..5a4754f 100644
--- a/Nibriboard/ClientFiles/BoardWindow.js
+++ b/Nibriboard/ClientFiles/BoardWindow.js
@@ -3,6 +3,7 @@
 // npm modules
 window.EventEmitter = require("event-emitter-es6");
 window.FaviconNotification = require("favicon-notification");
+window.panzoom = require("pan-zoom");
 
 // Our files
 import RippleLink from './RippleLink';
@@ -14,18 +15,31 @@ class BoardWindow extends EventEmitter
 	{
 		super(); // Run the parent constructor
 		
+		// The maximum target fps.
 		this.maxFps = 60;
+		// Setup the fps indicator in the corner
 		this.renderTimeIndicator = document.createElement("span");
 		this.renderTimeIndicator.innerHTML = "0ms";
 		document.querySelector(".fps").appendChild(this.renderTimeIndicator);
 		
+		// Setup the canvas
 		this.canvas = canvas;
 		this.context = canvas.getContext("2d");
+		
+		// --~~~--
+		
+		// Setup the favicon thingy
+		
 		FaviconNotification.init({
 			color: '#ff6333'
 		});
 		FaviconNotification.add();
 		
+		// Setup the input controls
+		window.panzoom(canvas, this.handleCanvasMovement.bind(this));
+		
+		// Fetch the RippleLink connection information and other settings from
+		// the server
 		get("/Settings.json").then(JSON.parse).then((function(settings) {
 			console.info("[setup]", "Obtained settings from server:", settings);
 			this.settings = settings;
@@ -34,13 +48,39 @@ class BoardWindow extends EventEmitter
 			console.error(`Error: Failed to fetch settings from server! Response: ${errorMessage}`);
 		});
 		
+		// Make the canvas track the window size
 		this.trackWindowSize();
+		// Track the mouse position
+		this.trackMousePosition();
 	}
 	
+	/**
+	 * Setup ready for user input.
+	 * This mainly consists of establishing the RippleLink connection to the server.
+	 */
 	setup() {
 		this.rippleLink = new RippleLink(this.settings.WebsocketUri, this);
+		this.rippleLink.on("connect", (function(event) {
+			// Send the handshake request
+			this.rippleLink.send({
+				event: "handshakeRequest",
+				InitialViewport: { // TODO: Add support for persisting this between sessions
+					X: 0,
+					Y: 0,
+					Width: window.innerWidth,
+					Height: window.innerHeight
+				},
+				InitialAbsCursorPosition: this.cursorPosition
+			});
+		}).bind(this));
+		
+		// RippleLink message bindings
+		
 	}
 	
+	/**
+	 * Renders the next frame.
+	 */
 	nextFrame()
 	{
 		// The time at which the current frame started rendering.
@@ -63,11 +103,17 @@ class BoardWindow extends EventEmitter
 		requestAnimationFrame(this.nextFrame.bind(this));
 	}
 	
+	/**
+	 * Updates everything ready for the next frame to be rendered.
+	 */
 	update()
 	{
 		
 	}
 	
+	/**
+	 * Renders the next frame.
+	 */
 	render(canvas, context)
 	{
 		context.clearRect(0, 0, canvas.width, canvas.height);
@@ -93,6 +139,23 @@ class BoardWindow extends EventEmitter
 		this.matchWindowSize();
 		window.addEventListener("resize", this.matchWindowSize.bind(this));
 	}
+	
+	trackMousePosition() {
+		document.addEventListener("mousemove", (function(event) {
+			this.cursorPosition = {
+				X: event.clientX,
+				Y: event.clientY
+			};
+		}).bind(this));
+	}
+	
+	/**
+	 * Handles events generated by pan-zoom, the package that handles the
+	 * dragging and zooming of the whiteboard.
+	 */
+	handleCanvasMovement(event) {
+		this.viewportState = event; // Store the viewport information for later
+	}
 }
 
 export default BoardWindow;
diff --git a/Nibriboard/ClientFiles/NibriClient.js b/Nibriboard/ClientFiles/NibriClient.js
index cc7f22a..22ea971 100644
--- a/Nibriboard/ClientFiles/NibriClient.js
+++ b/Nibriboard/ClientFiles/NibriClient.js
@@ -1,15 +1,85 @@
 (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
 'use strict';
 
-class RippleLink
+/**
+ * Constants for the different readyStates that a WebSocket can be in.
+ * @type {Object}
+ */
+const WebSocketStates = {
+	/**
+	 * Indicates that the WebSocket is connecting to the remote server.
+	 * @type {Number}
+	 */
+	connecting: 0,
+	/**
+	 * Indicates that the WebSocket is connected to the remote server and ready to send / receive data.
+	 * @type {Number}
+	 */
+	ready: 1,
+	/**
+	 * Indicates that the WebSocket is in the process of closing the connection to the remote server.
+	 * @type {Number}
+	 */
+	closing: 2,
+	/**
+	 * Indicates that hte WebSocket is not connected to the remote server (either because the connection was closed, or dropped by the remote server).
+	 * @type {Number}
+	 */
+	closed: 3
+};
+
+const EventEmitter$1 = require("event-emitter-es6");
+
+class RippleLink extends EventEmitter$1
 {
 	constructor(inSocketUrl, inBoardWindow)
 	{
+		super();
+		
 		this.socketUrl = inSocketUrl;
 		this.boardWindow = inBoardWindow;
 		this.settings = this.boardWindow.settings;
 		
-		this.websocket = new WebSocket( this.socketUrl, [ this.settings.WebsocketProtocol ] );
+		// Create the websocket and commect to the server
+		this.websocket = new WebSocket(this.socketUrl, [ this.settings.WebsocketProtocol ]);
+		this.websocket.addEventListener("open", this.handleConnection.bind(this));
+		this.websocket.addEventListener("message", this.handleMessage.bind(this));
+		this.websocket.addEventListener("close", this.handleDisconnection.bind(this));
+		
+	}
+	
+	handleConnection(event) {
+		console.info("[ripple link] Established connection successfully.");
+		// Tell everyone about it
+		this.emit("connect", event);
+	}
+	
+	handleDisconnection(event) {
+		console.error("[ripple link] Lost connection.");
+		this.emit("disconnect", event);
+	}
+	
+	handleMessage(event) {
+		// Decode the message form the server
+		var message = JSON.parse(event.data);
+		console.debug(message);
+		
+		// Pass it on to the board manager by triggering the appropriate event
+		this.emit(message.event, message);
+	}
+	
+	/**
+	 * Sends a message object to the server.
+	 */
+	send(message) {
+		if(this.websocket.readyState !== WebSocketStates.ready)
+		{
+			console.error(`Attempt to send data on the RippleLine when it is not ready (state ${this.websocket.readyState})`);
+			return false;
+		}
+		
+		this.websocket.send(JSON.stringify(message));
+		return true;
 	}
 }
 
@@ -18,6 +88,7 @@ function get(u){return new Promise(function(r,t,a){a=new XMLHttpRequest();a.onlo
 // npm modules
 window.EventEmitter = require("event-emitter-es6");
 window.FaviconNotification = require("favicon-notification");
+window.panzoom = require("pan-zoom");
 
 // Our files
 class BoardWindow extends EventEmitter
@@ -26,18 +97,31 @@ class BoardWindow extends EventEmitter
 	{
 		super(); // Run the parent constructor
 		
+		// The maximum target fps.
 		this.maxFps = 60;
+		// Setup the fps indicator in the corner
 		this.renderTimeIndicator = document.createElement("span");
 		this.renderTimeIndicator.innerHTML = "0ms";
 		document.querySelector(".fps").appendChild(this.renderTimeIndicator);
 		
+		// Setup the canvas
 		this.canvas = canvas;
 		this.context = canvas.getContext("2d");
+		
+		// --~~~--
+		
+		// Setup the favicon thingy
+		
 		FaviconNotification.init({
 			color: '#ff6333'
 		});
 		FaviconNotification.add();
 		
+		// Setup the input controls
+		window.panzoom(canvas, this.handleCanvasMovement.bind(this));
+		
+		// Fetch the RippleLink connection information and other settings from
+		// the server
 		get("/Settings.json").then(JSON.parse).then((function(settings) {
 			console.info("[setup]", "Obtained settings from server:", settings);
 			this.settings = settings;
@@ -46,13 +130,39 @@ class BoardWindow extends EventEmitter
 			console.error(`Error: Failed to fetch settings from server! Response: ${errorMessage}`);
 		});
 		
+		// Make the canvas track the window size
 		this.trackWindowSize();
+		// Track the mouse position
+		this.trackMousePosition();
 	}
 	
+	/**
+	 * Setup ready for user input.
+	 * This mainly consists of establishing the RippleLink connection to the server.
+	 */
 	setup() {
 		this.rippleLink = new RippleLink(this.settings.WebsocketUri, this);
+		this.rippleLink.on("connect", (function(event) {
+			// Send the handshake request
+			this.rippleLink.send({
+				event: "handshakeRequest",
+				InitialViewport: { // TODO: Add support for persisting this between sessions
+					X: 0,
+					Y: 0,
+					Width: window.innerWidth,
+					Height: window.innerHeight
+				},
+				InitialAbsCursorPosition: this.cursorPosition
+			});
+		}).bind(this));
+		
+		// RippleLink message bindings
+		
 	}
 	
+	/**
+	 * Renders the next frame.
+	 */
 	nextFrame()
 	{
 		// The time at which the current frame started rendering.
@@ -75,11 +185,17 @@ class BoardWindow extends EventEmitter
 		requestAnimationFrame(this.nextFrame.bind(this));
 	}
 	
+	/**
+	 * Updates everything ready for the next frame to be rendered.
+	 */
 	update()
 	{
 		
 	}
 	
+	/**
+	 * Renders the next frame.
+	 */
 	render(canvas, context)
 	{
 		context.clearRect(0, 0, canvas.width, canvas.height);
@@ -105,6 +221,23 @@ class BoardWindow extends EventEmitter
 		this.matchWindowSize();
 		window.addEventListener("resize", this.matchWindowSize.bind(this));
 	}
+	
+	trackMousePosition() {
+		document.addEventListener("mousemove", (function(event) {
+			this.cursorPosition = {
+				X: event.clientX,
+				Y: event.clientY
+			};
+		}).bind(this));
+	}
+	
+	/**
+	 * Handles events generated by pan-zoom, the package that handles the
+	 * dragging and zooming of the whiteboard.
+	 */
+	handleCanvasMovement(event) {
+		this.viewportState = event; // Store the viewport information for later
+	}
 }
 
 window.FpsIndicator = require("fps-indicator");
@@ -122,7 +255,19 @@ window.addEventListener("load", function (event) {
 	window.boardWindow = boardWindow;
 });
 
-},{"event-emitter-es6":2,"favicon-notification":3,"fps-indicator":4}],2:[function(require,module,exports){
+},{"event-emitter-es6":3,"favicon-notification":4,"fps-indicator":5,"pan-zoom":10}],2:[function(require,module,exports){
+module.exports = defaultProperty
+
+function defaultProperty (get, set) {
+  return {
+    configurable: true,
+    enumerable: true,
+    get: get,
+    set: set
+  }
+}
+
+},{}],3:[function(require,module,exports){
 'use strict';
 
 var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
@@ -373,7 +518,7 @@ var EventEmitter = function () {
 
 module.exports = EventEmitter;
 
-},{}],3:[function(require,module,exports){
+},{}],4:[function(require,module,exports){
 (function (root, factory) {
     if (typeof define === 'function' && define.amd) {
         // AMD. Register as an anonymous module.
@@ -513,7 +658,7 @@ module.exports = EventEmitter;
 
 }));
 
-},{}],4:[function(require,module,exports){
+},{}],5:[function(require,module,exports){
 /**
  * @module fps-indicator
  */
@@ -623,7 +768,615 @@ function fps (opts) {
 		raf(measure);
 	});
 }
-},{"raf":6,"right-now":7}],5:[function(require,module,exports){
+},{"raf":13,"right-now":14}],6:[function(require,module,exports){
+module.exports = distance
+
+/**
+ * Calculates the euclidian distance between two vec2's
+ *
+ * @param {vec2} a the first operand
+ * @param {vec2} b the second operand
+ * @returns {Number} distance between a and b
+ */
+function distance(a, b) {
+    var x = b[0] - a[0],
+        y = b[1] - a[1]
+    return Math.sqrt(x*x + y*y)
+}
+},{}],7:[function(require,module,exports){
+(function (global, factory) {
+	if (typeof define === 'function' && define.amd) {
+		define(['exports', 'module'], factory);
+	} else if (typeof exports !== 'undefined' && typeof module !== 'undefined') {
+		factory(exports, module);
+	} else {
+		var mod = {
+			exports: {}
+		};
+		factory(mod.exports, mod);
+		global.Impetus = mod.exports;
+	}
+})(this, function (exports, module) {
+	'use strict';
+
+	function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
+
+	var stopThresholdDefault = 0.3;
+	var bounceDeceleration = 0.04;
+	var bounceAcceleration = 0.11;
+
+	var Impetus = function Impetus(_ref) {
+		var _ref$source = _ref.source;
+		var sourceEl = _ref$source === undefined ? document : _ref$source;
+		var updateCallback = _ref.update;
+		var _ref$multiplier = _ref.multiplier;
+		var multiplier = _ref$multiplier === undefined ? 1 : _ref$multiplier;
+		var _ref$friction = _ref.friction;
+		var friction = _ref$friction === undefined ? 0.92 : _ref$friction;
+		var initialValues = _ref.initialValues;
+		var boundX = _ref.boundX;
+		var boundY = _ref.boundY;
+		var _ref$bounce = _ref.bounce;
+		var bounce = _ref$bounce === undefined ? true : _ref$bounce;
+
+		_classCallCheck(this, Impetus);
+
+		var boundXmin, boundXmax, boundYmin, boundYmax, pointerLastX, pointerLastY, pointerCurrentX, pointerCurrentY, pointerId, decVelX, decVelY;
+		var targetX = 0;
+		var targetY = 0;
+		var stopThreshold = stopThresholdDefault * multiplier;
+		var ticking = false;
+		var pointerActive = false;
+		var paused = false;
+		var decelerating = false;
+		var trackingPoints = [];
+
+		/**
+   * Initialize instance
+   */
+		(function init() {
+			sourceEl = typeof sourceEl === 'string' ? document.querySelector(sourceEl) : sourceEl;
+			if (!sourceEl) {
+				throw new Error('IMPETUS: source not found.');
+			}
+
+			if (!updateCallback) {
+				throw new Error('IMPETUS: update function not defined.');
+			}
+
+			if (initialValues) {
+				if (initialValues[0]) {
+					targetX = initialValues[0];
+				}
+				if (initialValues[1]) {
+					targetY = initialValues[1];
+				}
+				callUpdateCallback();
+			}
+
+			// Initialize bound values
+			if (boundX) {
+				boundXmin = boundX[0];
+				boundXmax = boundX[1];
+			}
+			if (boundY) {
+				boundYmin = boundY[0];
+				boundYmax = boundY[1];
+			}
+
+			sourceEl.addEventListener('touchstart', onDown);
+			sourceEl.addEventListener('mousedown', onDown);
+		})();
+
+		/**
+   * Disable movement processing
+   * @public
+   */
+		this.pause = function () {
+			pointerActive = false;
+			paused = true;
+		};
+
+		/**
+   * Enable movement processing
+   * @public
+   */
+		this.resume = function () {
+			paused = false;
+		};
+
+		/**
+   * Update the current x and y values
+   * @public
+   * @param {Number} x
+   * @param {Number} y
+   */
+		this.setValues = function (x, y) {
+			if (typeof x === 'number') {
+				targetX = x;
+			}
+			if (typeof y === 'number') {
+				targetY = y;
+			}
+		};
+
+		/**
+   * Update the multiplier value
+   * @public
+   * @param {Number} val
+   */
+		this.setMultiplier = function (val) {
+			multiplier = val;
+			stopThreshold = stopThresholdDefault * multiplier;
+		};
+
+		/**
+   * Executes the update function
+   */
+		function callUpdateCallback() {
+			updateCallback.call(sourceEl, targetX, targetY);
+		}
+
+		/**
+   * Creates a custom normalized event object from touch and mouse events
+   * @param  {Event} ev
+   * @returns {Object} with x, y, and id properties
+   */
+		function normalizeEvent(ev) {
+			if (ev.type === 'touchmove' || ev.type === 'touchstart' || ev.type === 'touchend') {
+				var touch = ev.targetTouches[0] || ev.changedTouches[0];
+				return {
+					x: touch.clientX,
+					y: touch.clientY,
+					id: touch.identifier
+				};
+			} else {
+				// mouse events
+				return {
+					x: ev.clientX,
+					y: ev.clientY,
+					id: null
+				};
+			}
+		}
+
+		/**
+   * Initializes movement tracking
+   * @param  {Object} ev Normalized event
+   */
+		function onDown(ev) {
+			var event = normalizeEvent(ev);
+			if (!pointerActive && !paused) {
+				pointerActive = true;
+				decelerating = false;
+				pointerId = event.id;
+
+				pointerLastX = pointerCurrentX = event.x;
+				pointerLastY = pointerCurrentY = event.y;
+				trackingPoints = [];
+				addTrackingPoint(pointerLastX, pointerLastY);
+
+				document.addEventListener('touchmove', onMove);
+				document.addEventListener('touchend', onUp);
+				document.addEventListener('touchcancel', stopTracking);
+				document.addEventListener('mousemove', onMove);
+				document.addEventListener('mouseup', onUp);
+			}
+		}
+
+		/**
+   * Handles move events
+   * @param  {Object} ev Normalized event
+   */
+		function onMove(ev) {
+			ev.preventDefault();
+			var event = normalizeEvent(ev);
+
+			if (pointerActive && event.id === pointerId) {
+				pointerCurrentX = event.x;
+				pointerCurrentY = event.y;
+				addTrackingPoint(pointerLastX, pointerLastY);
+				requestTick();
+			}
+		}
+
+		/**
+   * Handles up/end events
+   * @param {Object} ev Normalized event
+   */
+		function onUp(ev) {
+			var event = normalizeEvent(ev);
+
+			if (pointerActive && event.id === pointerId) {
+				stopTracking();
+			}
+		}
+
+		/**
+   * Stops movement tracking, starts animation
+   */
+		function stopTracking() {
+			pointerActive = false;
+			addTrackingPoint(pointerLastX, pointerLastY);
+			startDecelAnim();
+
+			document.removeEventListener('touchmove', onMove);
+			document.removeEventListener('touchend', onUp);
+			document.removeEventListener('touchcancel', stopTracking);
+			document.removeEventListener('mouseup', onUp);
+			document.removeEventListener('mousemove', onMove);
+		}
+
+		/**
+   * Records movement for the last 100ms
+   * @param {number} x
+   * @param {number} y [description]
+   */
+		function addTrackingPoint(x, y) {
+			var time = Date.now();
+			while (trackingPoints.length > 0) {
+				if (time - trackingPoints[0].time <= 100) {
+					break;
+				}
+				trackingPoints.shift();
+			}
+
+			trackingPoints.push({ x: x, y: y, time: time });
+		}
+
+		/**
+   * Calculate new values, call update function
+   */
+		function updateAndRender() {
+			var pointerChangeX = pointerCurrentX - pointerLastX;
+			var pointerChangeY = pointerCurrentY - pointerLastY;
+
+			targetX += pointerChangeX * multiplier;
+			targetY += pointerChangeY * multiplier;
+
+			if (bounce) {
+				var diff = checkBounds();
+				if (diff.x !== 0) {
+					targetX -= pointerChangeX * dragOutOfBoundsMultiplier(diff.x) * multiplier;
+				}
+				if (diff.y !== 0) {
+					targetY -= pointerChangeY * dragOutOfBoundsMultiplier(diff.y) * multiplier;
+				}
+			} else {
+				checkBounds(true);
+			}
+
+			callUpdateCallback();
+
+			pointerLastX = pointerCurrentX;
+			pointerLastY = pointerCurrentY;
+			ticking = false;
+		}
+
+		/**
+   * Returns a value from around 0.5 to 1, based on distance
+   * @param {Number} val
+   */
+		function dragOutOfBoundsMultiplier(val) {
+			return 0.000005 * Math.pow(val, 2) + 0.0001 * val + 0.55;
+		}
+
+		/**
+   * prevents animating faster than current framerate
+   */
+		function requestTick() {
+			if (!ticking) {
+				requestAnimFrame(updateAndRender);
+			}
+			ticking = true;
+		}
+
+		/**
+   * Determine position relative to bounds
+   * @param {Boolean} restrict Whether to restrict target to bounds
+   */
+		function checkBounds(restrict) {
+			var xDiff = 0;
+			var yDiff = 0;
+
+			if (boundXmin !== undefined && targetX < boundXmin) {
+				xDiff = boundXmin - targetX;
+			} else if (boundXmax !== undefined && targetX > boundXmax) {
+				xDiff = boundXmax - targetX;
+			}
+
+			if (boundYmin !== undefined && targetY < boundYmin) {
+				yDiff = boundYmin - targetY;
+			} else if (boundYmax !== undefined && targetY > boundYmax) {
+				yDiff = boundYmax - targetY;
+			}
+
+			if (restrict) {
+				if (xDiff !== 0) {
+					targetX = xDiff > 0 ? boundXmin : boundXmax;
+				}
+				if (yDiff !== 0) {
+					targetY = yDiff > 0 ? boundYmin : boundYmax;
+				}
+			}
+
+			return {
+				x: xDiff,
+				y: yDiff,
+				inBounds: xDiff === 0 && yDiff === 0
+			};
+		}
+
+		/**
+   * Initialize animation of values coming to a stop
+   */
+		function startDecelAnim() {
+			var firstPoint = trackingPoints[0];
+			var lastPoint = trackingPoints[trackingPoints.length - 1];
+
+			var xOffset = lastPoint.x - firstPoint.x;
+			var yOffset = lastPoint.y - firstPoint.y;
+			var timeOffset = lastPoint.time - firstPoint.time;
+
+			var D = timeOffset / 15 / multiplier;
+
+			decVelX = xOffset / D || 0; // prevent NaN
+			decVelY = yOffset / D || 0;
+
+			var diff = checkBounds();
+
+			if (Math.abs(decVelX) > 1 || Math.abs(decVelY) > 1 || !diff.inBounds) {
+				decelerating = true;
+				requestAnimFrame(stepDecelAnim);
+			}
+		}
+
+		/**
+   * Animates values slowing down
+   */
+		function stepDecelAnim() {
+			if (!decelerating) {
+				return;
+			}
+
+			decVelX *= friction;
+			decVelY *= friction;
+
+			targetX += decVelX;
+			targetY += decVelY;
+
+			var diff = checkBounds();
+
+			if (Math.abs(decVelX) > stopThreshold || Math.abs(decVelY) > stopThreshold || !diff.inBounds) {
+
+				if (bounce) {
+					var reboundAdjust = 2.5;
+
+					if (diff.x !== 0) {
+						if (diff.x * decVelX <= 0) {
+							decVelX += diff.x * bounceDeceleration;
+						} else {
+							var adjust = diff.x > 0 ? reboundAdjust : -reboundAdjust;
+							decVelX = (diff.x + adjust) * bounceAcceleration;
+						}
+					}
+					if (diff.y !== 0) {
+						if (diff.y * decVelY <= 0) {
+							decVelY += diff.y * bounceDeceleration;
+						} else {
+							var adjust = diff.y > 0 ? reboundAdjust : -reboundAdjust;
+							decVelY = (diff.y + adjust) * bounceAcceleration;
+						}
+					}
+				} else {
+					if (diff.x !== 0) {
+						if (diff.x > 0) {
+							targetX = boundXmin;
+						} else {
+							targetX = boundXmax;
+						}
+						decVelX = 0;
+					}
+					if (diff.y !== 0) {
+						if (diff.y > 0) {
+							targetY = boundYmin;
+						} else {
+							targetY = boundYmax;
+						}
+						decVelY = 0;
+					}
+				}
+
+				callUpdateCallback();
+
+				requestAnimFrame(stepDecelAnim);
+			} else {
+				decelerating = false;
+			}
+		}
+	}
+
+	/**
+  * @see http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
+  */
+	;
+
+	module.exports = Impetus;
+	var requestAnimFrame = (function () {
+		return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback) {
+			window.setTimeout(callback, 1000 / 60);
+		};
+	})();
+});
+
+},{}],8:[function(require,module,exports){
+var rootPosition = { left: 0, top: 0 }
+
+module.exports = mouseEventOffset
+function mouseEventOffset (ev, target, out) {
+  target = target || ev.currentTarget || ev.srcElement
+  if (!Array.isArray(out)) {
+    out = [ 0, 0 ]
+  }
+  var cx = ev.clientX || 0
+  var cy = ev.clientY || 0
+  var rect = getBoundingClientOffset(target)
+  out[0] = cx - rect.left
+  out[1] = cy - rect.top
+  return out
+}
+
+function getBoundingClientOffset (element) {
+  if (element === window ||
+      element === document ||
+      element === document.body) {
+    return rootPosition
+  } else {
+    return element.getBoundingClientRect()
+  }
+}
+
+},{}],9:[function(require,module,exports){
+'use strict'
+
+var toPX = require('to-px')
+
+module.exports = mouseWheelListen
+
+function mouseWheelListen(element, callback, noScroll) {
+  if(typeof element === 'function') {
+    noScroll = !!callback
+    callback = element
+    element = window
+  }
+  var lineHeight = toPX('ex', element)
+  var listener = function(ev) {
+    if(noScroll) {
+      ev.preventDefault()
+    }
+    var dx = ev.deltaX || 0
+    var dy = ev.deltaY || 0
+    var dz = ev.deltaZ || 0
+    var mode = ev.deltaMode
+    var scale = 1
+    switch(mode) {
+      case 1:
+        scale = lineHeight
+      break
+      case 2:
+        scale = window.innerHeight
+      break
+    }
+    dx *= scale
+    dy *= scale
+    dz *= scale
+    if(dx || dy || dz) {
+      return callback(dx, dy, dz, ev)
+    }
+  }
+  element.addEventListener('wheel', listener)
+  return listener
+}
+
+},{"to-px":15}],10:[function(require,module,exports){
+/**
+ * @module  pan-zoom
+ *
+ * Events for pan and zoom
+ */
+'use strict';
+
+
+const Impetus = require('impetus');
+const wheel = require('mouse-wheel');
+const touchPinch = require('touch-pinch');
+const position = require('touch-position');
+
+
+module.exports = panzoom;
+
+
+function panzoom (target, cb) {
+	if (!target || !(cb instanceof Function)) return false;
+
+
+	//enable panning
+	let pos = position({
+		element: target
+	});
+
+	let impetus;
+
+	let lastY = 0, lastX = 0;
+	impetus = new Impetus({
+		source: target,
+		update: (x, y) => {
+			let e = {
+				type: 'mouse',
+				dx: x-lastX, dy: y-lastY, dz: 0,
+				x: pos[0], y: pos[1]
+			};
+
+			lastX = x;
+			lastY = y;
+
+			cb(e);
+		},
+		multiplier: 1,
+		friction: .75
+	});
+
+
+	//enable zooming
+	wheel(target, (dx, dy, dz, e) => {
+		e.preventDefault();
+		cb({
+			type: 'mouse',
+			dx: 0, dy: 0, dz: dy,
+			x: pos[0], y: pos[1]
+		});
+	});
+
+	//mobile pinch zoom
+	let pinch = touchPinch(target);
+	let mult = 2;
+	let initialCoords;
+
+	pinch.on('start', (curr) => {
+		impetus && impetus.pause();
+
+		let [f1, f2] = pinch.fingers;
+
+		initialCoords = [f2.position[0]*.5 + f1.position[0]*.5, f2.position[1]*.5 + f1.position[1]*.5];
+	});
+	pinch.on('end', () => {
+		initialCoords = null;
+
+		impetus && impetus.resume();
+	});
+	pinch.on('change', (curr, prev) => {
+		if (!pinch.pinching || !initialCoords) return;
+
+		cb({
+			type: 'touch',
+			dx: 0, dy: 0, dz: -(curr - prev)*mult,
+			x: initialCoords[0], y: initialCoords[1]
+		});
+	});
+}
+
+},{"impetus":7,"mouse-wheel":9,"touch-pinch":16,"touch-position":17}],11:[function(require,module,exports){
+module.exports = function parseUnit(str, out) {
+    if (!out)
+        out = [ 0, '' ]
+
+    str = String(str)
+    var num = parseFloat(str, 10)
+    out[0] = num
+    out[1] = str.match(/[\d.\-\+]*\s*(.*)/)[1] || ''
+    return out
+}
+},{}],12:[function(require,module,exports){
 (function (process){
 // Generated by CoffeeScript 1.7.1
 (function() {
@@ -659,7 +1412,7 @@ function fps (opts) {
 }).call(this);
 
 }).call(this,require('_process'))
-},{"_process":8}],6:[function(require,module,exports){
+},{"_process":19}],13:[function(require,module,exports){
 (function (global){
 var now = require('performance-now')
   , root = typeof window === 'undefined' ? global : window
@@ -735,7 +1488,7 @@ module.exports.polyfill = function() {
 }
 
 }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
-},{"performance-now":5}],7:[function(require,module,exports){
+},{"performance-now":12}],14:[function(require,module,exports){
 (function (global){
 module.exports =
   global.performance &&
@@ -746,7 +1499,571 @@ module.exports =
   }
 
 }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
-},{}],8:[function(require,module,exports){
+},{}],15:[function(require,module,exports){
+'use strict'
+
+var parseUnit = require('parse-unit')
+
+module.exports = toPX
+
+var PIXELS_PER_INCH = 96
+
+function getPropertyInPX(element, prop) {
+  var parts = parseUnit(getComputedStyle(element).getPropertyValue(prop))
+  return parts[0] * toPX(parts[1], element)
+}
+
+//This brutal hack is needed
+function getSizeBrutal(unit, element) {
+  var testDIV = document.createElement('div')
+  testDIV.style['font-size'] = '128' + unit
+  element.appendChild(testDIV)
+  var size = getPropertyInPX(testDIV, 'font-size') / 128
+  element.removeChild(testDIV)
+  return size
+}
+
+function toPX(str, element) {
+  element = element || document.body
+  str = (str || 'px').trim().toLowerCase()
+  if(element === window || element === document) {
+    element = document.body 
+  }
+  switch(str) {
+    case '%':  //Ambiguous, not sure if we should use width or height
+      return element.clientHeight / 100.0
+    case 'ch':
+    case 'ex':
+      return getSizeBrutal(str, element)
+    case 'em':
+      return getPropertyInPX(element, 'font-size')
+    case 'rem':
+      return getPropertyInPX(document.body, 'font-size')
+    case 'vw':
+      return window.innerWidth/100
+    case 'vh':
+      return window.innerHeight/100
+    case 'vmin':
+      return Math.min(window.innerWidth, window.innerHeight) / 100
+    case 'vmax':
+      return Math.max(window.innerWidth, window.innerHeight) / 100
+    case 'in':
+      return PIXELS_PER_INCH
+    case 'cm':
+      return PIXELS_PER_INCH / 2.54
+    case 'mm':
+      return PIXELS_PER_INCH / 25.4
+    case 'pt':
+      return PIXELS_PER_INCH / 72
+    case 'pc':
+      return PIXELS_PER_INCH / 6
+  }
+  return 1
+}
+},{"parse-unit":11}],16:[function(require,module,exports){
+var getDistance = require('gl-vec2/distance')
+var EventEmitter = require('events').EventEmitter
+var dprop = require('dprop')
+var eventOffset = require('mouse-event-offset')
+
+module.exports = touchPinch
+function touchPinch (target) {
+  target = target || window
+
+  var emitter = new EventEmitter()
+  var fingers = [ null, null ]
+  var activeCount = 0
+
+  var lastDistance = 0
+  var ended = false
+  var enabled = false
+
+  // some read-only values
+  Object.defineProperties(emitter, {
+    pinching: dprop(function () {
+      return activeCount === 2
+    }),
+
+    fingers: dprop(function () {
+      return fingers
+    })
+  })
+
+  enable()
+  emitter.enable = enable
+  emitter.disable = disable
+  emitter.indexOfTouch = indexOfTouch
+  return emitter
+
+  function indexOfTouch (touch) {
+    var id = touch.identifier
+    for (var i = 0; i < fingers.length; i++) {
+      if (fingers[i] &&
+        fingers[i].touch &&
+        fingers[i].touch.identifier === id) {
+        return i
+      }
+    }
+    return -1
+  }
+
+  function enable () {
+    if (enabled) return
+    enabled = true
+    target.addEventListener('touchstart', onTouchStart, false)
+    target.addEventListener('touchmove', onTouchMove, false)
+    target.addEventListener('touchend', onTouchRemoved, false)
+    target.addEventListener('touchcancel', onTouchRemoved, false)
+  }
+
+  function disable () {
+    if (!enabled) return
+    enabled = false
+    target.removeEventListener('touchstart', onTouchStart, false)
+    target.removeEventListener('touchmove', onTouchMove, false)
+    target.removeEventListener('touchend', onTouchRemoved, false)
+    target.removeEventListener('touchcancel', onTouchRemoved, false)
+  }
+
+  function onTouchStart (ev) {
+    for (var i = 0; i < ev.changedTouches.length; i++) {
+      var newTouch = ev.changedTouches[i]
+      var id = newTouch.identifier
+      var idx = indexOfTouch(id)
+
+      if (idx === -1 && activeCount < 2) {
+        var first = activeCount === 0
+
+        // newest and previous finger (previous may be undefined)
+        var newIndex = fingers[0] ? 1 : 0
+        var oldIndex = fingers[0] ? 0 : 1
+        var newFinger = new Finger()
+
+        // add to stack
+        fingers[newIndex] = newFinger
+        activeCount++
+
+        // update touch event & position
+        newFinger.touch = newTouch
+        eventOffset(newTouch, target, newFinger.position)
+
+        var oldTouch = fingers[oldIndex] ? fingers[oldIndex].touch : undefined
+        emitter.emit('place', newTouch, oldTouch)
+
+        if (!first) {
+          var initialDistance = computeDistance()
+          ended = false
+          emitter.emit('start', initialDistance)
+          lastDistance = initialDistance
+        }
+      }
+    }
+  }
+
+  function onTouchMove (ev) {
+    var changed = false
+    for (var i = 0; i < ev.changedTouches.length; i++) {
+      var movedTouch = ev.changedTouches[i]
+      var idx = indexOfTouch(movedTouch)
+      if (idx !== -1) {
+        changed = true
+        fingers[idx].touch = movedTouch // avoid caching touches
+        eventOffset(movedTouch, target, fingers[idx].position)
+      }
+    }
+
+    if (activeCount === 2 && changed) {
+      var currentDistance = computeDistance()
+      emitter.emit('change', currentDistance, lastDistance)
+      lastDistance = currentDistance
+    }
+  }
+
+  function onTouchRemoved (ev) {
+    for (var i = 0; i < ev.changedTouches.length; i++) {
+      var removed = ev.changedTouches[i]
+      var idx = indexOfTouch(removed)
+
+      if (idx !== -1) {
+        fingers[idx] = null
+        activeCount--
+        var otherIdx = idx === 0 ? 1 : 0
+        var otherTouch = fingers[otherIdx] ? fingers[otherIdx].touch : undefined
+        emitter.emit('lift', removed, otherTouch)
+      }
+    }
+
+    if (!ended && activeCount !== 2) {
+      ended = true
+      emitter.emit('end')
+    }
+  }
+
+  function computeDistance () {
+    if (activeCount < 2) return 0
+    return getDistance(fingers[0].position, fingers[1].position)
+  }
+}
+
+function Finger () {
+  this.position = [0, 0]
+  this.touch = null
+}
+
+},{"dprop":2,"events":18,"gl-vec2/distance":6,"mouse-event-offset":8}],17:[function(require,module,exports){
+var offset = require('mouse-event-offset');
+var EventEmitter = require('events').EventEmitter;
+
+function attach (opt) {
+  opt = opt || {};
+  var element = opt.element || window;
+
+  var emitter = new EventEmitter();
+
+  var position = opt.position || [0, 0];
+  if (opt.touchstart !== false) {
+    element.addEventListener('mousedown', update, false);
+    element.addEventListener('touchstart', updateTouch, false);
+  }
+
+  element.addEventListener('mousemove', update, false);
+  element.addEventListener('touchmove', updateTouch, false);
+
+  emitter.position = position;
+  emitter.dispose = dispose;
+  return emitter;
+
+  function updateTouch (ev) {
+    var touch = ev.targetTouches[0];
+    update(touch);
+  }
+
+  function update (ev) {
+    offset(ev, element, position);
+    emitter.emit('move', ev);
+  }
+
+  function dispose () {
+    element.removeEventListener('mousemove', update, false);
+    element.removeEventListener('mousedown', update, false);
+    element.removeEventListener('touchmove', updateTouch, false);
+    element.removeEventListener('touchstart', updateTouch, false);
+  }
+}
+
+module.exports = function (opt) {
+  return attach(opt).position;
+};
+
+module.exports.emitter = function (opt) {
+  return attach(opt);
+};
+
+},{"events":18,"mouse-event-offset":8}],18:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+function EventEmitter() {
+  this._events = this._events || {};
+  this._maxListeners = this._maxListeners || undefined;
+}
+module.exports = EventEmitter;
+
+// Backwards-compat with node 0.10.x
+EventEmitter.EventEmitter = EventEmitter;
+
+EventEmitter.prototype._events = undefined;
+EventEmitter.prototype._maxListeners = undefined;
+
+// By default EventEmitters will print a warning if more than 10 listeners are
+// added to it. This is a useful default which helps finding memory leaks.
+EventEmitter.defaultMaxListeners = 10;
+
+// Obviously not all Emitters should be limited to 10. This function allows
+// that to be increased. Set to zero for unlimited.
+EventEmitter.prototype.setMaxListeners = function(n) {
+  if (!isNumber(n) || n < 0 || isNaN(n))
+    throw TypeError('n must be a positive number');
+  this._maxListeners = n;
+  return this;
+};
+
+EventEmitter.prototype.emit = function(type) {
+  var er, handler, len, args, i, listeners;
+
+  if (!this._events)
+    this._events = {};
+
+  // If there is no 'error' event listener then throw.
+  if (type === 'error') {
+    if (!this._events.error ||
+        (isObject(this._events.error) && !this._events.error.length)) {
+      er = arguments[1];
+      if (er instanceof Error) {
+        throw er; // Unhandled 'error' event
+      } else {
+        // At least give some kind of context to the user
+        var err = new Error('Uncaught, unspecified "error" event. (' + er + ')');
+        err.context = er;
+        throw err;
+      }
+    }
+  }
+
+  handler = this._events[type];
+
+  if (isUndefined(handler))
+    return false;
+
+  if (isFunction(handler)) {
+    switch (arguments.length) {
+      // fast cases
+      case 1:
+        handler.call(this);
+        break;
+      case 2:
+        handler.call(this, arguments[1]);
+        break;
+      case 3:
+        handler.call(this, arguments[1], arguments[2]);
+        break;
+      // slower
+      default:
+        args = Array.prototype.slice.call(arguments, 1);
+        handler.apply(this, args);
+    }
+  } else if (isObject(handler)) {
+    args = Array.prototype.slice.call(arguments, 1);
+    listeners = handler.slice();
+    len = listeners.length;
+    for (i = 0; i < len; i++)
+      listeners[i].apply(this, args);
+  }
+
+  return true;
+};
+
+EventEmitter.prototype.addListener = function(type, listener) {
+  var m;
+
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  if (!this._events)
+    this._events = {};
+
+  // To avoid recursion in the case that type === "newListener"! Before
+  // adding it to the listeners, first emit "newListener".
+  if (this._events.newListener)
+    this.emit('newListener', type,
+              isFunction(listener.listener) ?
+              listener.listener : listener);
+
+  if (!this._events[type])
+    // Optimize the case of one listener. Don't need the extra array object.
+    this._events[type] = listener;
+  else if (isObject(this._events[type]))
+    // If we've already got an array, just append.
+    this._events[type].push(listener);
+  else
+    // Adding the second element, need to change to array.
+    this._events[type] = [this._events[type], listener];
+
+  // Check for listener leak
+  if (isObject(this._events[type]) && !this._events[type].warned) {
+    if (!isUndefined(this._maxListeners)) {
+      m = this._maxListeners;
+    } else {
+      m = EventEmitter.defaultMaxListeners;
+    }
+
+    if (m && m > 0 && this._events[type].length > m) {
+      this._events[type].warned = true;
+      console.error('(node) warning: possible EventEmitter memory ' +
+                    'leak detected. %d listeners added. ' +
+                    'Use emitter.setMaxListeners() to increase limit.',
+                    this._events[type].length);
+      if (typeof console.trace === 'function') {
+        // not supported in IE 10
+        console.trace();
+      }
+    }
+  }
+
+  return this;
+};
+
+EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+
+EventEmitter.prototype.once = function(type, listener) {
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  var fired = false;
+
+  function g() {
+    this.removeListener(type, g);
+
+    if (!fired) {
+      fired = true;
+      listener.apply(this, arguments);
+    }
+  }
+
+  g.listener = listener;
+  this.on(type, g);
+
+  return this;
+};
+
+// emits a 'removeListener' event iff the listener was removed
+EventEmitter.prototype.removeListener = function(type, listener) {
+  var list, position, length, i;
+
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  if (!this._events || !this._events[type])
+    return this;
+
+  list = this._events[type];
+  length = list.length;
+  position = -1;
+
+  if (list === listener ||
+      (isFunction(list.listener) && list.listener === listener)) {
+    delete this._events[type];
+    if (this._events.removeListener)
+      this.emit('removeListener', type, listener);
+
+  } else if (isObject(list)) {
+    for (i = length; i-- > 0;) {
+      if (list[i] === listener ||
+          (list[i].listener && list[i].listener === listener)) {
+        position = i;
+        break;
+      }
+    }
+
+    if (position < 0)
+      return this;
+
+    if (list.length === 1) {
+      list.length = 0;
+      delete this._events[type];
+    } else {
+      list.splice(position, 1);
+    }
+
+    if (this._events.removeListener)
+      this.emit('removeListener', type, listener);
+  }
+
+  return this;
+};
+
+EventEmitter.prototype.removeAllListeners = function(type) {
+  var key, listeners;
+
+  if (!this._events)
+    return this;
+
+  // not listening for removeListener, no need to emit
+  if (!this._events.removeListener) {
+    if (arguments.length === 0)
+      this._events = {};
+    else if (this._events[type])
+      delete this._events[type];
+    return this;
+  }
+
+  // emit removeListener for all listeners on all events
+  if (arguments.length === 0) {
+    for (key in this._events) {
+      if (key === 'removeListener') continue;
+      this.removeAllListeners(key);
+    }
+    this.removeAllListeners('removeListener');
+    this._events = {};
+    return this;
+  }
+
+  listeners = this._events[type];
+
+  if (isFunction(listeners)) {
+    this.removeListener(type, listeners);
+  } else if (listeners) {
+    // LIFO order
+    while (listeners.length)
+      this.removeListener(type, listeners[listeners.length - 1]);
+  }
+  delete this._events[type];
+
+  return this;
+};
+
+EventEmitter.prototype.listeners = function(type) {
+  var ret;
+  if (!this._events || !this._events[type])
+    ret = [];
+  else if (isFunction(this._events[type]))
+    ret = [this._events[type]];
+  else
+    ret = this._events[type].slice();
+  return ret;
+};
+
+EventEmitter.prototype.listenerCount = function(type) {
+  if (this._events) {
+    var evlistener = this._events[type];
+
+    if (isFunction(evlistener))
+      return 1;
+    else if (evlistener)
+      return evlistener.length;
+  }
+  return 0;
+};
+
+EventEmitter.listenerCount = function(emitter, type) {
+  return emitter.listenerCount(type);
+};
+
+function isFunction(arg) {
+  return typeof arg === 'function';
+}
+
+function isNumber(arg) {
+  return typeof arg === 'number';
+}
+
+function isObject(arg) {
+  return typeof arg === 'object' && arg !== null;
+}
+
+function isUndefined(arg) {
+  return arg === void 0;
+}
+
+},{}],19:[function(require,module,exports){
 // shim for using process in browser
 var process = module.exports = {};
 
diff --git a/Nibriboard/ClientFiles/RippleLink.js b/Nibriboard/ClientFiles/RippleLink.js
index fe34c7c..f9fce27 100644
--- a/Nibriboard/ClientFiles/RippleLink.js
+++ b/Nibriboard/ClientFiles/RippleLink.js
@@ -1,15 +1,59 @@
 "use strict";
 
+import WebSocketStates from './Utilities/WebsocketStates';
 
-class RippleLink
+const EventEmitter = require("event-emitter-es6");
+
+class RippleLink extends EventEmitter
 {
 	constructor(inSocketUrl, inBoardWindow)
 	{
+		super();
+		
 		this.socketUrl = inSocketUrl;
 		this.boardWindow = inBoardWindow;
 		this.settings = this.boardWindow.settings;
 		
-		this.websocket = new WebSocket( this.socketUrl, [ this.settings.WebsocketProtocol ] );
+		// Create the websocket and commect to the server
+		this.websocket = new WebSocket(this.socketUrl, [ this.settings.WebsocketProtocol ]);
+		this.websocket.addEventListener("open", this.handleConnection.bind(this));
+		this.websocket.addEventListener("message", this.handleMessage.bind(this));
+		this.websocket.addEventListener("close", this.handleDisconnection.bind(this));
+		
+	}
+	
+	handleConnection(event) {
+		console.info("[ripple link] Established connection successfully.");
+		// Tell everyone about it
+		this.emit("connect", event);
+	}
+	
+	handleDisconnection(event) {
+		console.error("[ripple link] Lost connection.");
+		this.emit("disconnect", event);
+	}
+	
+	handleMessage(event) {
+		// Decode the message form the server
+		var message = JSON.parse(event.data);
+		console.debug(message);
+		
+		// Pass it on to the board manager by triggering the appropriate event
+		this.emit(message.event, message);
+	}
+	
+	/**
+	 * Sends a message object to the server.
+	 */
+	send(message) {
+		if(this.websocket.readyState !== WebSocketStates.ready)
+		{
+			console.error(`Attempt to send data on the RippleLine when it is not ready (state ${this.websocket.readyState})`);
+			return false;
+		}
+		
+		this.websocket.send(JSON.stringify(message));
+		return true;
 	}
 }
 
diff --git a/Nibriboard/ClientFiles/Utilities/WebsocketStates.js b/Nibriboard/ClientFiles/Utilities/WebsocketStates.js
new file mode 100644
index 0000000..d1fe937
--- /dev/null
+++ b/Nibriboard/ClientFiles/Utilities/WebsocketStates.js
@@ -0,0 +1,30 @@
+"use strict";
+
+/**
+ * Constants for the different readyStates that a WebSocket can be in.
+ * @type {Object}
+ */
+const WebSocketStates = {
+	/**
+	 * Indicates that the WebSocket is connecting to the remote server.
+	 * @type {Number}
+	 */
+	connecting: 0,
+	/**
+	 * Indicates that the WebSocket is connected to the remote server and ready to send / receive data.
+	 * @type {Number}
+	 */
+	ready: 1,
+	/**
+	 * Indicates that the WebSocket is in the process of closing the connection to the remote server.
+	 * @type {Number}
+	 */
+	closing: 2,
+	/**
+	 * Indicates that hte WebSocket is not connected to the remote server (either because the connection was closed, or dropped by the remote server).
+	 * @type {Number}
+	 */
+	closed: 3
+};
+
+export default WebSocketStates;
diff --git a/Nibriboard/ClientFiles/package.json b/Nibriboard/ClientFiles/package.json
index a525bd6..2e5300a 100644
--- a/Nibriboard/ClientFiles/package.json
+++ b/Nibriboard/ClientFiles/package.json
@@ -5,9 +5,9 @@
   "homepage": "https://git.starbeamrainbowlabs.com/sbrl/Nibriboard#nibriboard",
   "main": "index.js",
   "dependencies": {
+    "pan-zoom": "^2.0.0",
     "rollupify": "^0.3.8"
   },
-  
   "devDependencies": {
     "rollupify": "^0.3.8"
   },
diff --git a/Nibriboard/Nibriboard.csproj b/Nibriboard/Nibriboard.csproj
index 033eff5..c097082 100644
--- a/Nibriboard/Nibriboard.csproj
+++ b/Nibriboard/Nibriboard.csproj
@@ -77,6 +77,8 @@
     <Compile Include="Client\Messages\CursorPositionMessage.cs" />
     <Compile Include="Client\Messages\ClientStateMessage.cs" />
     <Compile Include="RippleSpace\ClientState.cs" />
+    <Compile Include="Utilities\JsonConverters\RectangleConverter.cs" />
+    <Compile Include="Utilities\JsonConverters\PointConverter.cs" />
   </ItemGroup>
   <ItemGroup>
     <EmbeddedResource Include="ClientFiles\index.html" />
@@ -90,6 +92,7 @@
     <Folder Include="ClientFiles\" />
     <Folder Include="Client\" />
     <Folder Include="Client\Messages\" />
+    <Folder Include="Utilities\JsonConverters\" />
   </ItemGroup>
   <ItemGroup>
     <None Include="packages.config" />
diff --git a/Nibriboard/RippleSpace/ClientState.cs b/Nibriboard/RippleSpace/ClientState.cs
new file mode 100644
index 0000000..426bb01
--- /dev/null
+++ b/Nibriboard/RippleSpace/ClientState.cs
@@ -0,0 +1,40 @@
+﻿using System;
+using System.Drawing;
+
+namespace RippleSpace
+{
+	/// <summary>
+	/// Represents a client's state at a particular point in time.
+	/// </summary>
+	public class ClientState
+	{
+		/// <summary>
+		/// The id of the client.
+		/// </summary>
+		public int Id;
+
+		/// <summary>
+		/// The date and time at which this client state snapshot was captured.
+		/// </summary>
+		public DateTime TimeCaptured = DateTime.Now;
+
+		/// <summary>
+		/// The name the client chose to identify themselves with.
+		/// </summary>
+		public string Name;
+
+		/// <summary>
+		/// The size and position of the client's viewport.
+		/// </summary>
+		public Rectangle Viewport = Rectangle.Empty;
+		/// <summary>
+		/// The absolute position of the client's cursor.
+		/// </summary>
+		public Point AbsCursorPosition = Point.Empty;
+
+		public ClientState()
+		{
+		}
+	}
+}
+
diff --git a/Nibriboard/Utilities/JsonConverters/PointConverter.cs b/Nibriboard/Utilities/JsonConverters/PointConverter.cs
new file mode 100644
index 0000000..4ad1b27
--- /dev/null
+++ b/Nibriboard/Utilities/JsonConverters/PointConverter.cs
@@ -0,0 +1,37 @@
+﻿using System;
+using System.Drawing;
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Nibriboard.Utilities.JsonConverters
+{
+	/// <summary>
+	/// Deserialises objects into points from the System.Drawing namespace.
+	/// </summary>
+	public class PointConverter : JsonConverter
+	{
+		public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+		{
+			throw new NotImplementedException();
+		}
+
+		public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+		{
+			JObject jsonObject = JObject.Load(reader);
+
+			return new Point(
+				jsonObject.Value<int>("X"),
+				jsonObject.Value<int>("Y")
+			);
+		}
+
+		public override bool CanConvert(Type objectType)
+		{
+			if (objectType != typeof(Rectangle))
+				return false;
+			return true;
+		}
+	}
+}
+
diff --git a/Nibriboard/Utilities/JsonConverters/RectangleConverter.cs b/Nibriboard/Utilities/JsonConverters/RectangleConverter.cs
new file mode 100644
index 0000000..5c0d805
--- /dev/null
+++ b/Nibriboard/Utilities/JsonConverters/RectangleConverter.cs
@@ -0,0 +1,39 @@
+﻿using System;
+using System.Drawing;
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Nibriboard.Utilities.JsonConverters
+{
+	/// <summary>
+	/// Deserialises objects into rectangles from the System.Drawing namespace.
+	/// </summary>
+	public class RectangleConverter : JsonConverter
+	{
+		public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+		{
+			throw new NotImplementedException();
+		}
+
+		public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+		{
+			JObject jsonObject = JObject.Load(reader);
+
+			return new Rectangle(
+				jsonObject.Value<int>("X"),
+				jsonObject.Value<int>("Y"),
+				jsonObject.Value<int>("Width"),
+				jsonObject.Value<int>("Height")
+			);
+		}
+
+		public override bool CanConvert(Type objectType)
+		{
+			if (objectType != typeof(Rectangle))
+				return false;
+			return true;
+		}
+	}
+}
+
