diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c334e6cb01556c29d2e001314e3e524aeb943ef1..3739d9e6b63681257df33e1c1ae685225651c4da 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -22,7 +22,7 @@ submodule_fetch:
       - src/signalbuddy
       - src/yjs
 
-node_install:
+npm_install_prod:
   stage: deps
   dependencies:
     - submodule_fetch
@@ -36,7 +36,7 @@ node_install:
       - src/signalbuddy
       - src/yjs
 
-dev_node_install:
+npm_install:
   stage: deps
   dependencies:
     - submodule_fetch
@@ -50,42 +50,42 @@ dev_node_install:
       - src/signalbuddy
       - src/yjs
 
-check_format:
+format_check:
   stage: check
   dependencies:
-    - dev_node_install
+    - npm_install
   script:
-    - npx prettier --ignore-path .gitignore --check "**/*.{html,js,json,md}"
+    - npm run format-check
 
 lint:
   stage: check
   dependencies:
-    - dev_node_install
+    - npm_install
   script:
-    - npx eslint --ignore-path .gitignore "**/*.js"
+    - npm run lint
 
-backend_build:
+build:
   stage: build
   dependencies:
-    - dev_node_install
+    - npm_install
   script:
     - npm run build
   artifacts:
     paths:
       - public/
 
-backend_test:
+test:
   stage: test
   dependencies:
-    - dev_node_install
+    - npm_install
   script:
     - npm test
 
 deploy:
   stage: deploy
   dependencies:
-    - node_install
-    - backend_build
+    - npm_install_prod
+    - build
   only:
     - master
   script:
diff --git a/package-lock.json b/package-lock.json
index 6c9afc89046916182815901a08b5a0c7745102ab..c9fc18114add6658b91187915966d9037935199f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -175,9 +175,9 @@
       }
     },
     "@babel/parser": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.2.tgz",
-      "integrity": "sha512-DDaR5e0g4ZTb9aP7cpSZLkACEBdoLGwJDWgHtBhrGX7Q1RjhdoMOfexICj5cqTAtpowjGQWfcvfnQG7G2kAB5w==",
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.3.tgz",
+      "integrity": "sha512-bqv+iCo9i+uLVbI0ILzKkvMorqxouI+GbV13ivcARXn9NNEabi2IEz912IgNpT/60BNXac5dgcfjb94NjsF33A==",
       "dev": true
     },
     "@babel/plugin-syntax-object-rest-spread": {
@@ -2332,9 +2332,9 @@
       }
     },
     "es-to-primitive": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz",
-      "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==",
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
       "dev": true,
       "requires": {
         "is-callable": "^1.1.4",
@@ -5393,9 +5393,9 @@
       },
       "dependencies": {
         "buffer": {
-          "version": "4.9.1",
-          "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
-          "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
+          "version": "4.9.2",
+          "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
+          "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
           "dev": true,
           "requires": {
             "base64-js": "^1.0.2",
@@ -5542,9 +5542,9 @@
       }
     },
     "object-inspect": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz",
-      "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==",
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
+      "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==",
       "dev": true
     },
     "object-keys": {
@@ -5929,9 +5929,9 @@
       "dev": true
     },
     "prettier": {
-      "version": "1.18.2",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
-      "integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
+      "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
       "dev": true
     },
     "pretty-bytes": {
@@ -5976,9 +5976,9 @@
       "dev": true
     },
     "prompts": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.2.1.tgz",
-      "integrity": "sha512-VObPvJiWPhpZI6C5m60XOzTfnYg/xc/an+r9VYymj9WJW3B/DIH+REzjpAACPf8brwPeP+7vz3bIim3S+AaMjw==",
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.0.tgz",
+      "integrity": "sha512-NfbbPPg/74fT7wk2XYQ7hAIp9zJyZp5Fu19iRbORqqy1BhtrkZ0fPafBU+7bmn8ie69DpT0R6QpJIN2oisYjJg==",
       "dev": true,
       "requires": {
         "kleur": "^3.0.3",
@@ -6660,9 +6660,9 @@
       }
     },
     "sisteransi": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.3.tgz",
-      "integrity": "sha512-SbEG75TzH8G7eVXFSN5f9EExILKfly7SUvVY5DhhYLvfhKqhDFY0OzevWa/zwak0RLRfWS5AvfMWpd9gJvr5Yg==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.4.tgz",
+      "integrity": "sha512-/ekMoM4NJ59ivGSfKapeG+FWtrmWvA1p6FBZwXrqojw90vJu8lBmrTxCMuBCydKtkaUe2zt4PlxeTKpjwMbyig==",
       "dev": true
     },
     "slash": {
@@ -7246,9 +7246,9 @@
       "dev": true
     },
     "terser": {
-      "version": "4.3.9",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-4.3.9.tgz",
-      "integrity": "sha512-NFGMpHjlzmyOtPL+fDw3G7+6Ueh/sz4mkaUYa4lJCxOPTNzd0Uj0aZJOmsDYoSQyfuVoWDMSWTPU3huyOm2zdA==",
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.0.tgz",
+      "integrity": "sha512-oDG16n2WKm27JO8h4y/w3iqBGAOSCtq7k8dRmrn4Wf9NouL0b2WpMHGChFGZq4nFAQy1FsNJrVQHfurXOSTmOA==",
       "dev": true,
       "requires": {
         "commander": "^2.20.0",
diff --git a/package.json b/package.json
index 7ba1899f33eb7f487e3977d7780476ac98985855..022fbe40914f4f187cb5994b1661c9cea97c6770 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
     "test": "jest --testPathIgnorePatterns src/liowebrtc src/rtcpeerconnection src/signalbuddy src/yjs",
     "test-changed": "jest --only-changed --testPathIgnorePatterns src/liowebrtc src/rtcpeerconnection src/signalbuddy src/yjs",
     "test-coverage": "jest --coverage --testPathIgnorePatterns src/liowebrtc src/rtcpeerconnection src/signalbuddy src/yjs",
-    "lint": "jshint .",
+    "format": "prettier --ignore-path .gitignore --check --write '**/*.{html,js,json,md}'",
+    "format-check": "prettier --ignore-path .gitignore --check '**/*.{html,js,json,md}'",
+    "lint": "eslint --ignore-path .gitignore '**/*.js'",
     "validate": "npm ls"
   },
   "dependencies": {
diff --git a/public/index.html b/public/index.html
index 2786830805f63ca7436be14656a952ed34ce5496..313dd88b7aed19bb13894317da3112fafce5c56d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,7 @@
     <meta charset="UTF-8" />
     <link rel="manifest" href="manifest.json" />
     <link rel="shortcut icon" href="logo.png" />
+    <link rel="apple-touch-icon" href="logo.png" />
     <link rel="stylesheet" href="styles.css" />
 
     <link
@@ -193,7 +194,7 @@
                 <b>Other colours</b>
               </div>
               <label id="colours">
-                <input id="other-colours" type="color" value="blue" />
+                <input id="other-colours" type="color" value="#0000ff" />
               </label>
             </div>
           </div>
diff --git a/public/quality-high.svg b/public/quality-high.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4ad23ecaea7a2bdf6704352e16c64f0b6d5c0d8c
--- /dev/null
+++ b/public/quality-high.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="green" stroke="green" d="M8.213 16.984c.97-1.028 2.308-1.664 3.787-1.664s2.817.636 3.787 1.664l-3.787 4.016-3.787-4.016zm-1.747-1.854c1.417-1.502 3.373-2.431 5.534-2.431s4.118.929 5.534 2.431l2.33-2.472c-2.012-2.134-4.793-3.454-7.864-3.454s-5.852 1.32-7.864 3.455l2.33 2.471zm-4.078-4.325c2.46-2.609 5.859-4.222 9.612-4.222s7.152 1.613 9.612 4.222l2.388-2.533c-3.071-3.257-7.313-5.272-12-5.272s-8.929 2.015-12 5.272l2.388 2.533z"/></svg>
diff --git a/public/quality-low.svg b/public/quality-low.svg
new file mode 100644
index 0000000000000000000000000000000000000000..258dfb67e42c9de932a87a771cfa02035db0b74f
--- /dev/null
+++ b/public/quality-low.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="red" d="M8.213 16.984c.97-1.028 2.308-1.664 3.787-1.664s2.817.636 3.787 1.664l-3.787 4.016-3.787-4.016zm3.787-6.78c2.387 0 4.648.876 6.461 2.485l-.969 1.028c-1.556-1.308-3.472-2.018-5.492-2.018-2.021 0-3.937.71-5.492 2.018l-.969-1.028c1.813-1.609 4.075-2.485 6.461-2.485zm0-1c-3.071 0-5.852 1.32-7.864 3.455l2.33 2.472c1.417-1.502 3.373-2.431 5.534-2.431s4.117.929 5.534 2.431l2.33-2.472c-2.012-2.135-4.793-3.455-7.864-3.455zm0-5.204c3.949 0 7.682 1.517 10.607 4.291l-1.021 1.083c-2.656-2.452-6.023-3.791-9.586-3.791s-6.93 1.339-9.586 3.791l-1.021-1.083c2.926-2.774 6.658-4.291 10.607-4.291zm0-1c-4.687 0-8.929 2.015-12 5.272l2.388 2.533c2.46-2.609 5.859-4.222 9.612-4.222 3.754 0 7.152 1.613 9.611 4.222l2.389-2.533c-3.071-3.257-7.313-5.272-12-5.272z"/></svg>
diff --git a/public/quality-medium.svg b/public/quality-medium.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b17cacdf887e379dfc57b7a85133813eefde66c5
--- /dev/null
+++ b/public/quality-medium.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="orange" d="M8.213 16.984c.97-1.028 2.308-1.664 3.787-1.664s2.817.636 3.787 1.664l-3.787 4.016-3.787-4.016zm-1.747-1.854c1.417-1.502 3.373-2.431 5.534-2.431s4.118.929 5.534 2.431l2.33-2.472c-2.012-2.134-4.793-3.454-7.864-3.454s-5.852 1.32-7.864 3.455l2.33 2.471zm5.534-11.13c3.949 0 7.681 1.517 10.607 4.291l-1.021 1.083c-2.656-2.452-6.023-3.791-9.586-3.791s-6.93 1.339-9.586 3.791l-1.021-1.083c2.926-2.774 6.658-4.291 10.607-4.291zm0-1c-4.687 0-8.929 2.015-12 5.272l2.388 2.533c2.46-2.609 5.859-4.222 9.612-4.222s7.152 1.613 9.612 4.222l2.388-2.533c-3.071-3.257-7.313-5.272-12-5.272z"/></svg>
diff --git a/public/service-worker.js b/public/service-worker.js
index 971cfcd807b0b91b549b816f7b290d37174a8c4e..fd494a34e21a5d00a8c8200bb8e685e6753e57bf 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -22,6 +22,9 @@ const FILES_TO_CACHE = [
   "/assets/fonts/martel-v4-latin/martel-v4-latin-regular.woff2",
   "/synchronising.svg",
   "/synchronised.svg",
+  "/quality-high.svg",
+  "/quality-medium.svg",
+  "/quality-low.svg",
 ]
 const FILE_ALIASES = new Map([["/", "/index.html"]])
 
diff --git a/public/styles.css b/public/styles.css
index b8f3cc401e328a0a7e53753024afd5b171e7965c..e8fa5b3781bdabf27f4f90fa6ceb156d2c793126 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -111,6 +111,8 @@ button.selected {
   border-bottom: 1px solid black;
   padding: 4px 0;
   opacity: 0;
+  display: flex;
+  align-items: center;
 }
 
 #connected-peers li:nth-child(2) {
@@ -145,24 +147,47 @@ button.selected {
   border-bottom-width: 0px;
 }
 
+.peer-quality,
 .peer-status {
   width: 15px;
   height: 15px;
-  margin-left: 5px;
-  display: inline-block;
+  margin-right: 5px;
+}
+
+.peer-status {
   border-radius: 10px;
 }
 
+.peer-status::after {
+  font-size: 0.5em;
+  margin-left: 10px;
+  background-color: white;
+  border-radius: 50%;
+}
+
+.peer-status.upload::after {
+  content: "â–²";
+  padding: 0.5px;
+}
+
+.peer-status.download::after {
+  content: "â–¼";
+  padding: 1px;
+}
+
 .peer-status.unsynced {
+  color: gray;
   background-color: gray;
 }
 
 @keyframes peer-status-negotiating {
   from {
+    color: gray;
     background-color: gray;
   }
   50%,
   to {
+    color: orange;
     background-color: orange;
   }
 }
@@ -170,9 +195,11 @@ button.selected {
 .peer-status.negotiating {
   animation: peer-status-negotiating 0.5s step-end infinite;
   background-color: orange;
+  color: orange;
 }
 
 .peer-status.synced {
+  color: green;
   background-color: green;
 }
 
diff --git a/src/app.js b/src/app.js
index d909baf29730517c6984afad5422d5f6f1540f03..c0e3d85d1d07160b5f635a812d3145c6630b888f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,15 +1,35 @@
 // Room connection and synchronisation.
-// Translate local canvas input events to draw messages and send to the room.
+// Translate local canvas input events to draw messages in light of current tool
+// selections and send to the room.
 // Get back room updates and invoke the local canvas renderer.
 
 import * as canvas from "./canvas.js"
 import * as HTML from "./elements.js"
 import { computeErasureIntervals, combineErasureIntervals } from "./erasure.js"
 import { connect } from "./room.js"
+import * as toolSelection from "./tool-selection.js"
+
+const DEFAULT_ROOM = "imperial"
+
+const MIN_PRESSURE_FACTOR = 0.1
+const MAX_PRESSURE_FACTOR = 1.5
+
+// This is a quadratic such that:
+// - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR
+// - getPressureFactor(0.5) = 1.0
+// - getPressureFactor(1.0) = MAX_PRESSURE_FACTOR
+// For sensible results, maintain that:
+// - 0.0 <= MIN_PRESSURE_FACTOR <= 1.0
+// - 1.0 <= MAX_PRESSURE_FACTOR
+// For intuitive results, maintain that:
+// - MAX_PRESSURE_FACTOR <= ~2.0
+const getPressureFactor = (pressure) => {
+  const a = 2 * (MAX_PRESSURE_FACTOR + MIN_PRESSURE_FACTOR) - 4
+  const b = -MAX_PRESSURE_FACTOR - 3 * MIN_PRESSURE_FACTOR + 4
+  const c = MIN_PRESSURE_FACTOR
+  return a * pressure ** 2 + b * pressure + c
+}
 
-const TEST_ROOM = "imperial"
-
-const ERASER_RADIUS = 20
 let room = null
 
 function eraseEverythingAtPosition(x, y, radius, room) {
@@ -69,18 +89,41 @@ const onRoomConnect = (room_) => {
     updateOverallStatusIcon()
   })
 
+  room.addEventListener("userConnection", ({ detail: { id, quality } }) => {
+    const high = "/quality-high.svg"
+    const medium = "/quality-medium.svg"
+    const low = "/quality-low.svg"
+
+    const peer = getOrInsertPeerById(id).children[0]
+    if (quality < 0.33) {
+      if (!peer.src.includes(high)) {
+        peer.src = high
+      }
+    } else if (quality < 0.66) {
+      if (!peer.src.includes(medium)) {
+        peer.src = medium
+      }
+    } else {
+      if (!peer.src.includes(low)) {
+        peer.src = low
+      }
+    }
+  })
+
   room.addEventListener("weSyncedWithPeer", ({ detail: id }) => {
-    getOrInsertPeerById(id).children[1].className = "peer-status synced"
+    getOrInsertPeerById(id).children[1].className = "peer-status upload synced"
     updateOverallStatusIcon()
   })
 
   room.addEventListener("waitingForSyncStep", ({ detail: id }) => {
-    getOrInsertPeerById(id).children[2].className = "peer-status negotiating"
+    getOrInsertPeerById(id).children[2].className =
+      "peer-status download negotiating"
     updateOverallStatusIcon()
   })
 
   room.addEventListener("peerSyncedWithUs", ({ detail: id }) => {
-    getOrInsertPeerById(id).children[2].className = "peer-status synced"
+    getOrInsertPeerById(id).children[2].className =
+      "peer-status download synced"
     updateOverallStatusIcon()
   })
 
@@ -106,101 +149,8 @@ const tryRoomConnect = async (roomID) => {
     .catch((err) => alert(`Error connecting to a room:\n${err}`))
 }
 
-const tools = {
-  PEN: Symbol("pen"),
-  ERASER: Symbol("eraser"),
-}
-let currentTool = tools.PEN
 const pathIDsByPointerID = new Map()
 
-HTML.penButton.addEventListener("click", () => {
-  if (currentTool == tools.PEN) {
-    showElement(HTML.penProperties)
-  } else {
-    currentTool = tools.PEN
-    HTML.penButton.classList.add("selected")
-    HTML.eraserButton.classList.remove("selected")
-  }
-})
-
-HTML.closeButton.forEach((element) => {
-  element.addEventListener("click", () => {
-    hideElement(element.parentNode.parentNode.parentNode)
-  })
-})
-
-window.addEventListener("click", (event) => {
-  if (event.target == HTML.penProperties) {
-    hideElement(HTML.penProperties)
-  } else if (event.target == HTML.palette) {
-    hideElement(HTML.palette)
-    hideElement(HTML.penProperties)
-  }
-})
-
-HTML.rectangle.addEventListener("click", () => {
-  showElement(HTML.palette)
-})
-
-const svg = HTML.wheel.children
-for (let i = 1; i < svg.length; i++) {
-  svg[i].addEventListener("click", (event) => {
-    const paletteColour = event.target.getAttribute("fill")
-    HTML.rectangle.style.backgroundColor = paletteColour
-    HTML.picker.value = paletteColour
-    HTML.labelColours.style.backgroundColor = paletteColour
-    canvas.setStrokeColour(paletteColour)
-    hideElement(HTML.palette)
-  })
-}
-
-function showElement(element) {
-  element.style.display = "block"
-}
-
-function hideElement(element) {
-  element.style.display = "none"
-}
-
-HTML.picker.addEventListener("change", () => {
-  const paletteColour = event.target.value
-  HTML.rectangle.style.backgroundColor = paletteColour
-  HTML.labelColours.style.backgroundColor = paletteColour
-  canvas.setStrokeColour(paletteColour)
-})
-
-HTML.output.innerHTML = HTML.slider.value
-
-HTML.slider.oninput = function() {
-  HTML.output.innerHTML = this.value
-  canvas.setStrokeRadius(this.value / 10)
-}
-
-const x = window.matchMedia(
-  "only screen and (orientation: landscape) and (max-width: 600px)",
-)
-x.addListener(() => {
-  if (x.matches) {
-    HTML.wheel.setAttribute("viewBox", "-50 10 200 100")
-    HTML.palette.setAttribute("style", "padding-top: 50px")
-  } else {
-    HTML.wheel.setAttribute("viewBox", "0 10 100 100")
-  }
-})
-
-HTML.picker.addEventListener("change", () => {
-  const paletteColour = event.target.value
-  HTML.rectangle.style.backgroundColor = paletteColour
-  HTML.labelColours.style.backgroundColor = paletteColour
-  canvas.setStrokeColour(paletteColour)
-})
-
-HTML.eraserButton.addEventListener("click", () => {
-  currentTool = tools.ERASER
-  HTML.penButton.classList.remove("selected")
-  HTML.eraserButton.classList.add("selected")
-})
-
 HTML.peerButton.addEventListener("click", () => {
   const peerID = HTML.peerIDElem.value
   if (room == null || peerID == "") {
@@ -239,26 +189,32 @@ HTML.roomIDElem.addEventListener("keydown", (event) => {
 
 const getOrInsertPeerById = (id) => {
   for (const peerElem of HTML.connectedPeers.children) {
-    const peerId = peerElem.children[0].innerHTML
+    const peerId = peerElem.children[3].innerHTML
     if (peerId == id) {
       return peerElem
     }
   }
 
   const peerElem = document.createElement("li")
-  const peerId = document.createElement("div")
-  peerId.style.display = "inline"
-  peerId.innerHTML = id
+  const quality = document.createElement("img")
+  quality.src = "/quality-low.svg"
+  quality.alt = "Peer quality icon"
+  quality.className = "peer-quality"
 
   const ourStatus = document.createElement("div")
-  ourStatus.className = "peer-status unsynced"
+  ourStatus.className = "peer-status upload unsynced"
 
   const theirStatus = document.createElement("div")
-  theirStatus.className = "peer-status unsynced"
+  theirStatus.className = "peer-status download unsynced"
 
-  peerElem.appendChild(peerId)
+  const peerId = document.createElement("div")
+  peerId.style.marginLeft = "5px"
+  peerId.innerHTML = id
+
+  peerElem.appendChild(quality)
   peerElem.appendChild(ourStatus)
   peerElem.appendChild(theirStatus)
+  peerElem.appendChild(peerId)
 
   HTML.connectedPeers.appendChild(peerElem)
 
@@ -284,20 +240,24 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => {
   if (room == null) {
     return
   }
-
+  const currentTool = toolSelection.getTool()
   const mousePos = [e.offsetX, e.offsetY]
-
-  if (currentTool == tools.PEN) {
+  if (currentTool == toolSelection.Tools.PEN) {
     pathIDsByPointerID.set(
       e.pointerId,
       room.addPath([
         ...mousePos,
-        canvas.getStrokeRadius(e.pressure),
-        canvas.getStrokeColour(),
+        toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
+        toolSelection.getStrokeColour(),
       ]),
     )
-  } else if (currentTool == tools.ERASER) {
-    eraseEverythingAtPosition(mousePos[0], mousePos[1], ERASER_RADIUS, room)
+  } else if (currentTool == toolSelection.Tools.ERASER) {
+    eraseEverythingAtPosition(
+      mousePos[0],
+      mousePos[1],
+      toolSelection().getEraseRadius(),
+      room,
+    )
   }
 })
 
@@ -309,18 +269,22 @@ canvas.input.addEventListener("strokemove", ({ detail: e }) => {
   if (room == null) {
     return
   }
-
+  const currentTool = toolSelection.getTool()
   const mousePos = [e.offsetX, e.offsetY]
-
-  if (currentTool == tools.PEN) {
+  if (currentTool == toolSelection.Tools.PEN) {
     room.extendPath(pathIDsByPointerID.get(e.pointerId), [
       ...mousePos,
-      canvas.getStrokeRadius(e.pressure),
-      canvas.getStrokeColour(),
+      toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
+      toolSelection.getStrokeColour(),
     ])
-  } else if (currentTool == tools.ERASER) {
-    eraseEverythingAtPosition(mousePos[0], mousePos[1], ERASER_RADIUS, room)
+  } else if (currentTool == toolSelection.Tools.ERASER) {
+    eraseEverythingAtPosition(
+      mousePos[0],
+      mousePos[1],
+      toolSelection.getEraseRadius(),
+      room,
+    )
   }
 })
 
-tryRoomConnect(TEST_ROOM)
+tryRoomConnect(DEFAULT_ROOM)
diff --git a/src/canvas.js b/src/canvas.js
index 042f5d8d960208487ba70dbeb4df9771ad19b720..b2ea47b763fc24c9cafa6df9a71ce2aec0d82b64 100644
--- a/src/canvas.js
+++ b/src/canvas.js
@@ -21,17 +21,12 @@ const smoothLine = line()
 
 const pathGroupElems = new Map()
 
-let strokeColour = "blue"
-let strokeRadius = 1
-export const MIN_PRESSURE = 0.1
-export const MAX_PRESSURE = 1.0
-
 const MAX_POINT_DISTANCE = 5
 const MAX_RADIUS_DELTA = 0.05
 
 // Interpolate a path so that:
 // - The distance between two adjacent points is capped at MAX_POINT_DISTANCE.
-// - The pressure delta between two adjacent points is capped at
+// - The radius delta between two adjacent points is capped at
 //   MAX_RADIUS_DELTA
 // If paths are too choppy, try decreasing these constants.
 const smoothPath = ([...path]) => {
@@ -291,23 +286,3 @@ canvas.addEventListener("pointerup", dispatchPointerEvent("strokeend"))
 canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
 canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
 canvas.addEventListener("touchmove", (e) => e.preventDefault())
-
-export const setStrokeColour = (colour) => {
-  strokeColour = colour
-}
-
-export const getStrokeColour = () => {
-  return strokeColour
-}
-
-export const setStrokeRadius = (radius) => {
-  strokeRadius = radius
-}
-
-const calculateStrokeRadius = (pressure, radius) => {
-  return radius * (MIN_PRESSURE + pressure * (MAX_PRESSURE - MIN_PRESSURE))
-}
-
-export const getStrokeRadius = (pressure) => {
-  return calculateStrokeRadius(pressure, strokeRadius)
-}
diff --git a/src/room.js b/src/room.js
index 1e3ce2ff317108446f190b97be3ca6c06217bded..84c1928e87125292f9f858e9dd04c0916ac46861 100644
--- a/src/room.js
+++ b/src/room.js
@@ -116,9 +116,9 @@ class Room extends EventTarget {
         },
         onUserEvent: (event) => {
           if (event.action == "userConnection") {
-            const { quality } = event
+            const { id, quality } = event
             this.dispatchEvent(
-              new CustomEvent("userConnection", { detail: quality }),
+              new CustomEvent("userConnection", { detail: { id, quality } }),
             )
           } else if (event.action == "userID") {
             const { user: id } = event
diff --git a/src/tool-selection.js b/src/tool-selection.js
new file mode 100644
index 0000000000000000000000000000000000000000..0daf77d92955e64cf65ce103d4f8f14092fd58f3
--- /dev/null
+++ b/src/tool-selection.js
@@ -0,0 +1,98 @@
+import * as HTML from "./elements.js"
+
+export const Tools = Object.freeze({
+  PEN: Symbol("pen"),
+  ERASER: Symbol("eraser"),
+})
+
+let tool = Tools.PEN
+let strokeColour = "#0000ff"
+let strokeRadius = 5
+// TODO: The erase radius should also be selectable.
+const ERASE_RADIUS = 20
+
+export const getTool = () => tool
+export const getStrokeColour = () => strokeColour
+export const getStrokeRadius = () => strokeRadius
+export const getEraseRadius = () => ERASE_RADIUS
+
+const showElement = (element) => {
+  element.style.display = "block"
+}
+
+const hideElement = (element) => {
+  element.style.display = "none"
+}
+
+HTML.penButton.addEventListener("click", () => {
+  if (tool == Tools.PEN) {
+    showElement(HTML.penProperties)
+  } else {
+    tool = Tools.PEN
+    HTML.penButton.classList.add("selected")
+    HTML.eraserButton.classList.remove("selected")
+  }
+})
+
+HTML.eraserButton.addEventListener("click", () => {
+  tool = Tools.ERASER
+  HTML.penButton.classList.remove("selected")
+  HTML.eraserButton.classList.add("selected")
+})
+
+HTML.picker.addEventListener("change", () => {
+  const paletteColour = event.target.value
+  HTML.rectangle.style.backgroundColor = paletteColour
+  HTML.labelColours.style.backgroundColor = paletteColour
+  strokeColour = paletteColour
+})
+
+HTML.slider.oninput = function() {
+  HTML.output.innerHTML = this.value
+  strokeRadius = this.value / 10
+}
+
+HTML.output.innerHTML = HTML.slider.value
+
+const x = window.matchMedia(
+  "only screen and (orientation: landscape) and (max-width: 600px)",
+)
+x.addListener(() => {
+  if (x.matches) {
+    HTML.wheel.setAttribute("viewBox", "-50 10 200 100")
+    HTML.palette.setAttribute("style", "padding-top: 50px")
+  } else {
+    HTML.wheel.setAttribute("viewBox", "0 10 100 100")
+  }
+})
+
+HTML.closeButton.forEach((element) => {
+  element.addEventListener("click", () => {
+    hideElement(element.parentNode.parentNode.parentNode)
+  })
+})
+
+window.addEventListener("click", (event) => {
+  if (event.target == HTML.penProperties) {
+    hideElement(HTML.penProperties)
+  } else if (event.target == HTML.palette) {
+    hideElement(HTML.palette)
+    hideElement(HTML.penProperties)
+  }
+})
+
+HTML.rectangle.addEventListener("click", () => {
+  showElement(HTML.palette)
+})
+
+const svg = HTML.wheel.children
+for (let i = 1; i < svg.length; i++) {
+  svg[i].addEventListener("click", (event) => {
+    const paletteColour = event.target.getAttribute("fill")
+    HTML.rectangle.style.backgroundColor = paletteColour
+    HTML.picker.value = paletteColour
+    HTML.labelColours.style.backgroundColor = paletteColour
+    strokeColour = paletteColour
+    hideElement(HTML.palette)
+  })
+}
diff --git a/src/y-webrtc/index.js b/src/y-webrtc/index.js
index ba19f1032078541c92eb502ba87b81012f637b11..3ef2c68d222b15dd7d18d028e451dad5b83d0be6 100644
--- a/src/y-webrtc/index.js
+++ b/src/y-webrtc/index.js
@@ -255,6 +255,7 @@ function extend(Y) {
             }
 
             this.raiseUserEvent("userConnection", {
+              id: peer.id,
               quality:
                 1.0 -
                 (self.webrtcOptions.heartbeat.timeout -