diff --git a/client/client_recv.js b/client/client_recv.js
index aabeda8..93ad814 100644
--- a/client/client_recv.js
+++ b/client/client_recv.js
@@ -484,6 +484,7 @@ async function handle_message(state, context, d) {
 
             console.timeEnd('init');
 
+            update_cursor(state);
             draw_html(state);
 
             break;
diff --git a/client/default.css b/client/default.css
index e20f8bb..0fcfa4b 100644
--- a/client/default.css
+++ b/client/default.css
@@ -40,7 +40,7 @@ canvas {
     width: 100%;
     height: 100%;
     display: block;
-    /* */
+    cursor: url('icons/crosshair.svg') 16 16, crosshair;
 }
 
 canvas.picker {
@@ -59,6 +59,14 @@ canvas.mousemoving {
     cursor: move;
 }
 
+.brush-dom {
+    position: absolute;
+    pointer-events: none;
+    user-select: none;
+    top: 0;
+    left: 0;
+}
+
 .html-hud {
     position: fixed;
     top: 0;
@@ -344,14 +352,6 @@ canvas.mousemoving {
     }
 }
 
-#stroke-preview {
-    position: absolute;
-    border-radius: 50%;
-    left: 50%;
-    top: 96px;
-    transform: translate(-50%, -50%);
-}
-
 .offline-toast {
     position: fixed;
     top: 50%;
diff --git a/client/icons/crosshair.svg b/client/icons/crosshair.svg
new file mode 100644
index 0000000..f46cd47
--- /dev/null
+++ b/client/icons/crosshair.svg
@@ -0,0 +1,281 @@
+
+
+
+
diff --git a/client/index.html b/client/index.html
index 170bbf3..e8c381d 100644
--- a/client/index.html
+++ b/client/index.html
@@ -27,14 +27,12 @@
     
     
     
-
-    
 
 
     
         
         
+        
 
         
 
diff --git a/client/tools.js b/client/tools.js
index cb4b6f2..09be8ea 100644
--- a/client/tools.js
+++ b/client/tools.js
@@ -71,37 +71,17 @@ function set_color_u32(state, color_u32) {
     select_color(state, major_color, color_u32);
 
     state.players[state.me].color = color_u32
+    update_cursor(state);
     fire_event(state, color_event(color_u32));
 }
 
-function show_stroke_preview(state, size) {
-    const preview = document.querySelector('#stroke-preview');
-
-    preview.style.width = size * state.canvas.zoom + 'px';
-    preview.style.height = size * state.canvas.zoom + 'px';
-    preview.style.background = color_from_u32(state.players[state.me].color);
-
-    preview.classList.remove('dhide');
-}
-
-function hide_stroke_preview() {
-    document.querySelector('#stroke-preview').classList.add('dhide');
-}
-
 function switch_stroke_width(e, state) {
     if (!state.online) return;
     
     const value = parseInt(e.target.value);
 
     state.players[state.me].width = value;
-    show_stroke_preview(state, value);
     update_cursor(state);
-
-    if (state.hide_preview) {
-        clearTimeout(state.hide_preview);
-    }
-
-    state.hide_preview = setTimeout(hide_stroke_preview, config.brush_preview_timeout);
 }
 
 function broadcast_stroke_width(e, state) {
diff --git a/client/webgl_draw.js b/client/webgl_draw.js
index 39a9b5a..9a0d34f 100644
--- a/client/webgl_draw.js
+++ b/client/webgl_draw.js
@@ -75,6 +75,7 @@ function draw_html(state) {
     }
 }
 
+
 async function draw(state, context) {
     const cpu_before = performance.now();
 
@@ -82,9 +83,6 @@ async function draw(state, context) {
     const width = window.innerWidth;
     const height = window.innerHeight;
 
-    locations = context.locations['sdf'].main;
-    buffers = context.buffers['sdf'];
-
     bvh_clip(state, context); 
 
     const segment_count = await geometry_write_instances(state, context);
@@ -104,7 +102,34 @@ async function draw(state, context) {
     gl.clearDepth(0.0);
     gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
 
+    // Draw the background pattern
+    gl.useProgram(context.programs['pattern'].dots);
+    buffers = context.buffers['pattern'];
+    locations = context.locations['pattern'].dots;
+    {
+        // Reused data
+        gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_dot']);
+        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([10, 0, 20, 0, 10, 10]), gl.STREAM_DRAW);
+        gl.enableVertexAttribArray(locations['a_xy']);
+        gl.vertexAttribPointer(locations['a_xy'],     2, gl.FLOAT, false, 2 * 4, 0);
+
+        // Per-instance data
+        gl.bindBuffer(gl.ARRAY_BUFFER, buffers['b_instance']);
+        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([100, 100, 150, 150, 10, 10, 200, 10]), gl.STREAM_DRAW);
+        gl.enableVertexAttribArray(locations['a_center']);
+        gl.vertexAttribPointer(locations['a_center'], 2, gl.FLOAT, false, 2 * 4, 0);
+        gl.vertexAttribDivisor(locations['a_center'], 1);
+
+        gl.uniform2f(locations['u_res'], context.canvas.width, context.canvas.height);
+        gl.uniform2f(locations['u_scale'], state.canvas.zoom, state.canvas.zoom);
+        gl.uniform2f(locations['u_translation'], state.canvas.offset.x, state.canvas.offset.y);
+
+        gl.drawArraysInstanced(gl.TRIANGLES, 0, 3, 4); 
+    }
+
     gl.useProgram(context.programs['sdf'].main);
+    buffers = context.buffers['sdf'];
+    locations = context.locations['sdf'].main;
 
     // "Static" data upload
     if (segment_count > 0) {
@@ -147,8 +172,14 @@ async function draw(state, context) {
 
         // Static draw (everything already bound)
         gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, segment_count); 
-    }
 
+        // I don't really know why I need to do this, but it
+        // makes background patter drawcall work properly
+        gl.vertexAttribDivisor(locations['a_a'], 0);
+        gl.vertexAttribDivisor(locations['a_b'], 0);
+        gl.vertexAttribDivisor(locations['a_stroke_id'], 0);
+        gl.vertexAttribDivisor(locations['a_pressure'], 0);
+    }
     // Dynamic strokes should be drawn above static strokes
     gl.clear(gl.DEPTH_BUFFER_BIT);
 
@@ -190,6 +221,11 @@ async function draw(state, context) {
         gl.vertexAttribDivisor(locations['a_pressure'], 1);
 
         gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, dynamic_segment_count);
+
+        gl.vertexAttribDivisor(locations['a_a'], 0);
+        gl.vertexAttribDivisor(locations['a_b'], 0);
+        gl.vertexAttribDivisor(locations['a_stroke_id'], 0);
+        gl.vertexAttribDivisor(locations['a_pressure'], 0);
     }
 
     document.getElementById('debug-stats').innerHTML = `
diff --git a/client/webgl_listeners.js b/client/webgl_listeners.js
index 10e686b..c99d8b7 100644
--- a/client/webgl_listeners.js
+++ b/client/webgl_listeners.js
@@ -182,6 +182,7 @@ function mousedown(e, state, context) {
     if (state.colorpicking) {
         const color_u32 = color_to_u32(state.color_picked.substring(1));
         state.players[state.me].color = color_u32;
+        update_cursor(state);
         fire_event(state, color_event(color_u32));
         return;
     }
@@ -243,6 +244,14 @@ function mousemove(e, state, context) {
     const screenp = {'x': window.devicePixelRatio * e.clientX, 'y': window.devicePixelRatio * e.clientY};
     const canvasp = screen_to_canvas(state, screenp);
     
+    if (state.me in state.players) {
+        const me = state.players[state.me];
+        const width = Math.max(me.width * state.canvas.zoom, 2.0);
+        const brush_x = screenp.x - width / 2 - 2;
+        const brush_y = screenp.y - width / 2 - 2; 
+        document.querySelector('.brush-dom').style.transform = `translate(${Math.round(brush_x)}px, ${Math.round(brush_y)}px)`; 
+    }
+
     if (state.me in state.players && dist_v2(state.players[state.me].cursor, canvasp) > 5) {
         state.players[state.me].cursor = canvasp;
         fire_event(state, movecursor_event(canvasp.x, canvasp.y));
@@ -352,20 +361,32 @@ function mouseup(e, state, context) {
 }
 
 function mouseleave(e, state, context) {
+    if (state.moving) {
+        state.moving = false;
+        context.canvas.classList.remove('movemode');
+    }
+
     exit_picker_mode(state);
     // something else?
 }
 
 function update_cursor(state) {
-    const style = document.querySelector('#cursor-style');  
-    const width = Math.max(state.players[state.me].width * state.canvas.zoom, 2.0);
+    const me = state.players[state.me];
+
+    const width = Math.max(me.width * state.canvas.zoom, 2.0);
     const radius = width / 2;
-    const svg = `