diff --git a/nix/shell.nix b/nix/shell.nix
index d352c909e762c8621bcc56d1773b7c1eb9a94730..031f9748d5d4d34cd599e36aec62e0f77fda4be5 100644
--- a/nix/shell.nix
+++ b/nix/shell.nix
@@ -20,6 +20,9 @@ in with nixpkgs; pkgs.mkShell {
     cmake ninja
 
     ncurses5
+
+    (python3.withPackages (ps: with ps; [ pygame wasmer wasmer-compiler-cranelift ]))
+    emscripten
   ];
   shellHook = ''
     # For esp.py openocd integration.
diff --git a/sim/README.md b/sim/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e87280050401f6611de831c8276106f21770fd1f
--- /dev/null
+++ b/sim/README.md
@@ -0,0 +1,44 @@
+badg23 simulator
+===
+
+This is a little simulator that allows quicker development iteration on Python code.
+
+It's a (C)Python application which sets up its environment so that it appears similar enough to the Badge's micropython environment, loading scripts from `python_payload` and `python_modules` in the main project directory.
+
+All C-implemented functions are implemented (or maybe just stubbed out) by 'fakes' in the fakes directory. Please try to keep this in sync with the real usermodule implementation.
+
+Of particular interest is how we provide a `ctx`-compatible API: we compile it using emscripten to a WebAssembly bundle, which we then execute using wasmer.
+
+Setting up
+---
+
+If not using nix-shell, you'll need Python3 with the following libraries:
+
+ - pygame
+ - wasmer
+ - wasmer-compiler-cranelift
+
+All of these should be available in PyPI.
+
+Running
+---
+
+From the main badge23-firmware checkout:
+
+```
+python3 sim/run.py
+```
+
+Known Issues
+---
+
+No support for input of any kind yet (captouch, three-way buttons).
+
+No support for audio yet.
+
+No support for LEDs yet.
+
+Hacking
+---
+
+A precompiled WASM bundle for ctx is checked into git. If you wish to rebuild it, run `build.sh` in `sim/wasm`.
diff --git a/sim/background.png b/sim/background.png
new file mode 100644
index 0000000000000000000000000000000000000000..c8f775ad7473755db962478b3f2b8ab4a854a0f3
Binary files /dev/null and b/sim/background.png differ
diff --git a/sim/fakes/ctx.py b/sim/fakes/ctx.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e5ac6c8aeed9c19a8fb36144c1d362d32d50674
--- /dev/null
+++ b/sim/fakes/ctx.py
@@ -0,0 +1,113 @@
+"""
+ctx.py implements a subset of uctx that is backed by a WebAssembly-compiled
+ctx. The interface between our uctx fake and the underlying ctx is the
+serialized ctx protocol as described in [1].
+
+[1] - https://ctx.graphics/protocol/
+"""
+import os
+
+import wasmer
+import wasmer_compiler_cranelift
+
+
+class Wasm:
+    """
+    Wasm wraps access to WebAssembly functions, converting to/from Python types
+    as needed. It's intended to be used as a singleton.
+    """
+    def __init__(self):
+        store = wasmer.Store(wasmer.engine.JIT(wasmer_compiler_cranelift.Compiler))
+        simpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
+        wasmpath = os.path.join(simpath, 'wasm', 'ctx.wasm')
+        module = wasmer.Module(store, open(wasmpath, 'rb').read())
+        wasi_version = wasmer.wasi.get_version(module, strict=False)
+        wasi_env = wasmer.wasi.StateBuilder('badge23sim').finalize()
+        import_object = wasi_env.generate_import_object(store, wasi_version)
+        instance = wasmer.Instance(module, import_object)
+        self._i = instance
+
+    def malloc(self, n):
+        return self._i.exports.malloc(n)
+
+    def free(self, p):
+        self._i.exports.free(p)
+
+    def ctx_parse(self, ctx, s):
+        s = s.encode('utf-8')
+        slen = len(s) + 1
+        p = self.malloc(slen)
+        mem = self._i.exports.memory.uint8_view(p)
+        mem[0:slen] = s
+        mem[slen-1] = 0
+        self._i.exports.ctx_parse(ctx, p)
+        self.free(p)
+
+    def ctx_new_for_framebuffer(self, width, height):
+        """
+        Call ctx_new_for_framebuffer, but also first allocate the underlying
+        framebuffer and return it alongside the Ctx*.
+        """
+        fb = self.malloc(width * height * 2)
+        print('fb', hex(fb))
+        return fb, self._i.exports.ctx_new_for_framebuffer(fb, width, height, width * 2, 7)
+
+    def ctx_apply_transform(self, ctx, *args):
+        args = [float(a) for a in args]
+        return self._i.exports.ctx_apply_transform(ctx, *args)
+
+_wasm = Wasm()
+
+class Ctx:
+    """
+    Ctx implements a subset of uctx [1]. It should be extended as needed as we
+    make use of more and more uctx features in the badge code.
+
+    [1] - https://ctx.graphics/uctx/
+    """
+    LEFT = 'left'
+    RIGHT = 'right'
+    CENTER = 'center'
+    END = 'end'
+    MIDDLE = 'middle'
+
+    def __init__(self):
+        self._fb, self._ctx = _wasm.ctx_new_for_framebuffer(240, 240)
+        # Place (0, 0) in the center of the screen, mathing what the real badge
+        # software does.
+        _wasm.ctx_apply_transform(self._ctx, 1, 0, 120, 0, 1, 120, 0, 0, 1)
+
+        self.text_align = 'start'
+        self.text_baseline = 'alphabetic'
+
+    def _get_fb(self):
+        return _wasm._i.exports.memory.uint8_view(self._fb)
+
+    def _emit(self, text):
+        _wasm.ctx_parse(self._ctx, text)
+
+    def move_to(self, x, y):
+        self._emit(f"moveTo {x} {x}")
+        return self
+
+    def rgb(self, r, g, b):
+        self._emit(f"rgb {r/255} {g/255} {b/255}")
+        return self
+
+    def text(self, s):
+        self._emit(f"textAlign {self.text_align}")
+        self._emit(f"textBaseline {self.text_baseline}")
+        self._emit(f"text \"{s}\"")
+        return self
+
+    def round_rectangle(self, x, y, width, height, radius):
+        self._emit(f"roundRectangle {x} {y} {width} {height} {radius}")
+        return self
+
+    def rectangle(self, x, y, width, height):
+        self._emit(f"rectangle {x} {y} {width} {height}")
+        return self
+
+    def fill(self):
+        self._emit(f"fill")
+        return self
diff --git a/sim/fakes/hardware.py b/sim/fakes/hardware.py
new file mode 100644
index 0000000000000000000000000000000000000000..398a3dfc8a7a0ab0305abc4c3725dd8a7026faf8
--- /dev/null
+++ b/sim/fakes/hardware.py
@@ -0,0 +1,104 @@
+import pygame
+import math
+import os
+
+pygame.init()
+screen_w = 814
+screen_h = 854
+screen = pygame.display.set_mode(size=(screen_w, screen_h))
+simpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
+bgpath = os.path.join(simpath, 'background.png')
+background = pygame.image.load(bgpath)
+
+_deprecated_notified = set()
+def _deprecated(f):
+    def wrapper(*args, **kwargs):
+        if f not in _deprecated_notified:
+            print(f'{f.__name__} is deprecated!')
+            _deprecated_notified.add(f)
+        return f(*args, **kwargs)
+    return wrapper
+
+def init_done():
+    return True
+
+def captouch_autocalib():
+    pass
+
+def captouch_calibration_active():
+    return False
+
+_global_ctx = None
+def get_ctx():
+    global _global_ctx
+    import ctx
+
+    if _global_ctx is None:
+        _global_ctx = ctx.Ctx()
+    return _global_ctx
+
+@_deprecated
+def display_fill(color):
+    """
+    display_fill is deprecated as it doesn't work well with ctx's framebuffer
+    ownership / state diffing. Instead, callers should use plain ctx functions
+    to fill the screen.
+    """
+    r = (color >> 11) & 0b11111
+    g = (color >> 5 ) & 0b111111
+    b =  color        & 0b11111
+    get_ctx().rgb(r << 2, g << 3, b <<2).rectangle(-120, -120, 240, 240).fill()
+
+
+def display_update():
+    fb = get_ctx()._get_fb()
+
+    full = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA)
+    full.blit(background, (0, 0))
+
+    center_x = 408
+    center_y = 426
+    off_x = center_x - (240 // 2)
+    off_y = center_y - (240 // 2)
+
+    oled = pygame.Surface((240, 240), flags=pygame.SRCALPHA)
+    oled_buf = oled.get_buffer()
+    for y in range(240):
+        rgba = bytearray()
+        for x in range(240):
+            dx = (x - 120)
+            dy = (y - 120)
+            dist = math.sqrt(dx**2 + dy**2)
+
+            fbh = fb[y * 240 * 2 + x * 2]
+            fbl = fb[y * 240 * 2 + x * 2 + 1]
+            fbv = (fbh << 8) | fbl
+            r = (fbv >> 11) & 0b11111
+            g = (fbv >>  5) & 0b111111
+            b = (fbv >>  0) & 0b11111
+            if dist > 120:
+                rgba += bytes([255, 255, 255, 0])
+            else:
+                rgba += bytes([b << 3, g << 2, r << 3, 0xff])
+        oled_buf.write(bytes(rgba), y * 240 * 4)
+
+    del oled_buf
+    full.blit(oled, (off_x, off_y))
+
+    screen.blit(full, (0,0))
+    pygame.display.flip()
+
+def set_led_rgb(a, b, c, d):
+    pass
+
+def update_leds():
+    pass
+
+def set_global_volume_dB(a):
+    pass
+
+def get_button(a):
+    return 0
+
+def get_captouch(a):
+    return 0
diff --git a/sim/fakes/synth.py b/sim/fakes/synth.py
new file mode 100644
index 0000000000000000000000000000000000000000..32f7291e217ed9366c63808a6e7792b37e1c8a50
--- /dev/null
+++ b/sim/fakes/synth.py
@@ -0,0 +1,9 @@
+class tinysynth:
+    def __init__(self, a, b):
+        pass
+
+    def decay(self, a):
+        pass
+
+    def waveform(self, a):
+        pass
diff --git a/sim/fakes/time.py b/sim/fakes/time.py
new file mode 100644
index 0000000000000000000000000000000000000000..a14f83f4796760c17e061af0a2a60409c08f050e
--- /dev/null
+++ b/sim/fakes/time.py
@@ -0,0 +1,4 @@
+
+def sleep_ms(ms):
+    import _time
+    _time.sleep(ms * 0.001)
diff --git a/sim/run.py b/sim/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..28a5d14da97a0e59da18b2f8ea11275ea7c84b3e
--- /dev/null
+++ b/sim/run.py
@@ -0,0 +1,44 @@
+import importlib
+import importlib.machinery
+from importlib.machinery import PathFinder, BuiltinImporter
+import importlib.util
+import os
+import sys
+
+
+projectpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
+
+class Hook:
+    """
+    Hook implements a importlib.abc.Finder which overwrites resolution order to
+    more closely match the resolution order on the badge's Micropython
+    environment.
+    """
+    def find_spec(self, fullname, path, target=None):
+        # Attempt to load from python_payload, then python_modules then
+        # sim/fakes. Afterwards, the normal import resolution order kicks in.
+        paths = [
+            os.path.join(projectpath, 'python_payload', fullname+'.py'),
+            os.path.join(projectpath, 'python_payload', fullname),
+            os.path.join(projectpath, 'python_modules', fullname+'.py'),
+            os.path.join(projectpath, 'python_modules', fullname),
+            os.path.join(projectpath, 'sim', 'fakes', fullname+'.py'),
+        ]
+        for p in paths:
+            if os.path.exists(p):
+                root = os.path.split(p)[:-1]
+                return PathFinder.find_spec(fullname, root)
+        # As we provide our own micropython-compatible time library, allow
+        # resolving the original CPython time through _time
+        if fullname == '_time':
+            return BuiltinImporter.find_spec('time')
+        return None
+
+sys.meta_path.insert(0, Hook())
+sys.path_importer_cache.clear()
+
+# Clean up whatever might have already been imported as `time`.
+import time
+importlib.reload(time)
+
+import main
diff --git a/sim/wasm/.gitattributes b/sim/wasm/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..077f4304c7aa8682656d2d26202d93f1d235c5e2
--- /dev/null
+++ b/sim/wasm/.gitattributes
@@ -0,0 +1 @@
+ctx.wasm binary
diff --git a/sim/wasm/build.sh b/sim/wasm/build.sh
new file mode 100755
index 0000000000000000000000000000000000000000..4981d3a244d316a8de628f5185055010e5a41d80
--- /dev/null
+++ b/sim/wasm/build.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+set -e -x
+
+# Work around Nix badness.
+# See: https://github.com/NixOS/nixpkgs/issues/139943
+emscriptenpath="$(dirname $(dirname $(which emcc)))"
+if [[ "${emscriptenpath}" = /nix/store/* ]]; then
+    if [ ! -d ~/.emscripten_cache ]; then
+        cp -rv "$emscriptenpath/share/emscripten/cache" ~/.emscripten_cache
+        chmod u+rwX -R ~/.emscripten_cache
+    fi
+    export EM_CACHE=~/.emscripten_cache
+fi
+
+
+emcc ctx.c \
+    -I ../../usermodule/uctx/uctx/ \
+    -I ../../usermodule/uctx/fonts/ \
+    -s EXPORTED_FUNCTIONS=_ctx_new_for_framebuffer,_ctx_parse,_ctx_apply_transform,_malloc,_free \
+    --no-entry -flto -O3 \
+    -o ctx.wasm
+
diff --git a/sim/wasm/ctx.c b/sim/wasm/ctx.c
new file mode 100644
index 0000000000000000000000000000000000000000..9efafaf0abf52bbdd229f67d132dd4ac39a27c5f
--- /dev/null
+++ b/sim/wasm/ctx.c
@@ -0,0 +1,4 @@
+#include <stdint.h>
+#include "ctx_config.h"
+#define CTX_IMPLEMENTATION
+#include "ctx.h"
diff --git a/sim/wasm/ctx.wasm b/sim/wasm/ctx.wasm
new file mode 100755
index 0000000000000000000000000000000000000000..6e5300ff9f43b2f99051d670f5be14adbf4e08a5
Binary files /dev/null and b/sim/wasm/ctx.wasm differ
diff --git a/sim/wasm/ctx_config.h b/sim/wasm/ctx_config.h
new file mode 100644
index 0000000000000000000000000000000000000000..9e70abc56c2cc71934a96d344754212ed0205b33
--- /dev/null
+++ b/sim/wasm/ctx_config.h
@@ -0,0 +1,104 @@
+#pragma once
+
+// Keep in sync with defines in usermodule/uctx/uctx.c.
+
+#define CTX_TINYVG    1
+#define CTX_TVG_STDIO 0
+#define CTX_DITHER    1
+
+#define CTX_PDF                       0
+#define CTX_PROTOCOL_U8_COLOR         1
+#define CTX_AVOID_CLIPPED_SUBDIVISION 0
+#define CTX_LIMIT_FORMATS             1
+#define CTX_ENABLE_FLOAT              0
+#define CTX_32BIT_SEGMENTS            0
+#define CTX_ENABLE_RGBA8              1
+#define CTX_ENABLE_RGB332             1
+#define CTX_ENABLE_GRAY1              1
+#define CTX_ENABLE_GRAY2              1
+#define CTX_ENABLE_GRAY4              1
+#define CTX_ENABLE_RGB565             1
+#define CTX_ENABLE_RGB565_BYTESWAPPED 1
+#define CTX_ENABLE_CBRLE              0
+#define CTX_BITPACK_PACKER            0
+#define CTX_COMPOSITING_GROUPS        0
+#define CTX_RENDERSTREAM_STATIC       0
+#define CTX_GRADIENT_CACHE            1
+#define CTX_ENABLE_CLIP               1
+#define CTX_MIN_JOURNAL_SIZE        512  // grows dynamically
+#define CTX_MIN_EDGE_LIST_SIZE      512  // is also max and limits complexity
+                                         // of paths that can be filled
+#define CTX_STATIC_OPAQUE       1
+#define CTX_MAX_SCANLINE_LENGTH 512
+#define CTX_1BIT_CLIP           1
+
+#define CTX_MAX_DASHES          10
+#define CTX_MAX_GRADIENT_STOPS  10
+#define CTX_CM                  0
+#define CTX_SHAPE_CACHE         0
+#define CTX_SHAPE_CACHE_DEFAULT 0
+#define CTX_RASTERIZER_MAX_CIRCLE_SEGMENTS 128
+#define CTX_NATIVE_GRAYA8       0
+#define CTX_ENABLE_SHADOW_BLUR  0
+#define CTX_FONTS_FROM_FILE     0
+#define CTX_MAX_KEYDB          16
+#define CTX_FRAGMENT_SPECIALIZE 1
+#define CTX_FAST_FILL_RECT      1
+#define CTX_MAX_TEXTURES        1
+#define CTX_PARSER_MAXLEN       512
+#define CTX_PARSER_FIXED_TEMP   1
+#define CTX_CURRENT_PATH        1
+#define CTX_BLENDING_AND_COMPOSITING 1
+#define CTX_STRINGPOOL_SIZE        256
+#define CTX_AUDIO                    0
+#define CTX_CLIENTS                  0
+
+#define CTX_RAW_KB_EVENTS          0
+#define CTX_MATH                   0
+#define CTX_TERMINAL_EVENTS        0 // gets rid of posix bits and bobs
+#define CTX_THREADS                0
+#define CTX_TILED                  0
+#define CTX_FORMATTER              0  // we want these eventually
+#define CTX_PARSER                 0  // enabled
+#define CTX_BRAILLE_TEXT           0
+
+#define CTX_BAREMETAL              1
+
+#define CTX_EVENTS                 1
+#define CTX_MAX_DEVICES            1
+#define CTX_MAX_KEYBINDINGS       16
+#define CTX_RASTERIZER             1
+#define CTX_MAX_STATES             5
+#define CTX_MAX_EDGES            127
+#define CTX_MAX_PENDING           64
+#define CTX_MAX_CBS                8
+#define CTX_MAX_LISTEN_FDS         1
+
+#define CTX_ONE_FONT_ENGINE        1
+
+#define CTX_STATIC_FONT(font) \
+  ctx_load_font_ctx(ctx_font_##font##_name, \
+                    ctx_font_##font,       \
+                    sizeof (ctx_font_##font))
+
+
+
+#define CTX_MAX_FONTS 10
+
+#include "Arimo-Regular.h"
+#include "Arimo-Bold.h"
+#include "Arimo-Italic.h"
+#include "Arimo-BoldItalic.h"
+#define CTX_FONT_0 CTX_STATIC_FONT(Arimo_Regular)
+#define CTX_FONT_1 CTX_STATIC_FONT(Arimo_Bold)
+#define CTX_FONT_2 CTX_STATIC_FONT(Arimo_Italic)
+#define CTX_FONT_3 CTX_STATIC_FONT(Arimo_BoldItalic)
+
+#include "Tinos-Regular.h"
+#define CTX_FONT_13 CTX_STATIC_FONT(Tinos_Regular)
+
+#include "Cousine-Regular.h"
+#include "Cousine-Bold.h"
+#define CTX_FONT_21 CTX_STATIC_FONT(Cousine_Regular)
+#define CTX_FONT_22 CTX_STATIC_FONT(Cousine_Bold)
+
diff --git a/usermodule/uctx/uctx/ctx.h b/usermodule/uctx/uctx/ctx.h
index aeafa2f93fda6238725f341e56c817b4a9c430ab..aff487f9c3c26915ac98f0fa92771d5c381c1ae8 100644
--- a/usermodule/uctx/uctx/ctx.h
+++ b/usermodule/uctx/uctx/ctx.h
@@ -27717,7 +27717,7 @@ static int is_in_ctx (void)
 }
 #endif
 
-#if EMSCRIPTEN
+#if CTX_WASM_ORIG // Renamed from upstream, as we don't want this code to be compiled in, even though we're using Emscripten.
 
 CTX_EXPORT Ctx *
 ctx_wasm_get_context (int flags);
@@ -40644,7 +40644,7 @@ Ctx *ctx_new_tft (TFT_eSPI *tft,
 }
 #endif
 
-#ifdef EMSCRIPTEN
+#ifdef CTX_WASM_ORIG // Renamed from upstream, as we don't want this code to be compiled in, even though we're using Emscripten.
 #include "emscripten.h"
 
 #include <unistd.h>