diff --git a/sim/README.md b/sim/README.md
index af0a4b495580680d8caa646c30483b5543e22858..04a5e60ba783dd7bf5a916e579819306ea9179d3 100644
--- a/sim/README.md
+++ b/sim/README.md
@@ -32,7 +32,9 @@ python3 sim/run.py
 Known Issues
 ---
 
-No support for input of any kind yet (captouch, three-way buttons).
+Captouch petal input order is wrong.
+
+No support for three-way buttons yet.
 
 No support for audio yet.
 
diff --git a/sim/fakes/ctx.py b/sim/fakes/ctx.py
index 7e5ac6c8aeed9c19a8fb36144c1d362d32d50674..320ab11cf2982136f5265dd9d8e5f1ab20752211 100644
--- a/sim/fakes/ctx.py
+++ b/sim/fakes/ctx.py
@@ -48,9 +48,14 @@ class Wasm:
         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)
+        fb = self.malloc(width * height * 4)
+        # Significant difference between on-device Ctx and simulation Ctx: we
+        # render to a BRGA8 (24bpp color + 8bpp alpha) buffer instead of 16bpp
+        # RGB565 like the device does. This allows us to directly blit the ctx
+        # framebuffer into pygame's surfaces, which is a _huge_ speed benefit
+        # (difference between ~10FPS and 500+FPS!).
+        BRGA8 = 5
+        return fb, self._i.exports.ctx_new_for_framebuffer(fb, width, height, width * 4, BRGA8)
 
     def ctx_apply_transform(self, ctx, *args):
         args = [float(a) for a in args]
@@ -91,7 +96,13 @@ class Ctx:
         return self
 
     def rgb(self, r, g, b):
-        self._emit(f"rgb {r/255} {g/255} {b/255}")
+        # TODO(q3k): dispatch by type instead of value, warn on
+        # ambiguous/unexpected values for type.
+        if r > 1.0 or g > 1.0 or b > 1.0:
+            r /= 255.0
+            g /= 255.0
+            b /= 255.0
+        self._emit(f"rgb {r} {g} {b}")
         return self
 
     def text(self, s):
diff --git a/sim/fakes/hardware.py b/sim/fakes/hardware.py
index 68e3575fd89d9bb515a2ac8760bbf86f63d54505..dd65dc1e1917ef865ed5fc69f5710fd32b9b56ce 100644
--- a/sim/fakes/hardware.py
+++ b/sim/fakes/hardware.py
@@ -1,6 +1,9 @@
-import pygame
 import math
 import os
+import time
+
+import pygame
+
 
 pygame.init()
 screen_w = 814
@@ -10,6 +13,248 @@ simpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
 bgpath = os.path.join(simpath, 'background.png')
 background = pygame.image.load(bgpath)
 
+
+class Simulation:
+    """
+    Simulation implements the state and logic of the on-host pygame-based badge
+    simulator.
+    """
+
+    # Pixel coordinates of each petal 'marker'.
+    # TODO(q3k): document order
+    PETAL_POSITIONS = [
+        (114, 302), (163, 112), (204, 587), ( 49, 477), (504, 587), (352, 696), (602, 298), (660, 477), (356, 122), (547, 117),
+    ]
+    # Size of each petal 'marker' rendered in overlay.
+    PETAL_MARKER_SIZE = 50
+
+    # Pixel coordinates of each LED. The order is the same as the hardware
+    # WS2812 chain, not the order as expected by the micropython API!
+    LED_POSITIONS = [
+        (660, 455), (608, 490), (631, 554), (648, 618), (659, 690), (655, 770), (571, 746), (502, 711),
+        (452, 677), (401, 639), (352, 680), (299, 713), (241, 745), (151, 771), (147, 682), (160, 607),
+        (176, 549), (197, 491), (147, 453), ( 98, 416), ( 43, 360), (  0, 292), ( 64, 267), (144, 252),
+        (210, 248), (276, 249), (295, 190), (318, 129), (351,  65), (404,   0), (456,  64), (490, 131),
+        (511, 186), (529, 250), (595, 247), (663, 250), (738, 264), (810, 292), (755, 371), (705, 419),
+    ]
+
+    def __init__(self):
+        # Buffered LED state. Will be propagated to led_state when the
+        # simulated update_leds function gets called.
+        self.led_state_buf = [(0, 0, 0) for _ in self.LED_POSITIONS]
+        # Actual LED state as rendered.
+        self.led_state = [(0, 0, 0) for _ in self.LED_POSITIONS]
+        # Position of the simulation cursor in window pixel coordinates.
+        self.mouse_x, self.mouse_y = (0, 0)
+        # ID of petal being held (clicked), or None if no petal is being held.
+        self.petal_held = None
+        # ID of petal being hovered over, or None if no petal is being hovered
+        # over.
+        self.petal_hover = None
+        # Timestamp of last GUI render. Used by the lazy render GUI
+        # functionality.
+        self.last_gui_render = None
+
+        # Surfaces for different parts of the simulator render. Some of them
+        # have a dirty bit which is an optimization to skip rendering the
+        # corresponding surface when there was no change to its render data.
+        self._led_surface = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA)
+        self._led_surface_dirty = True
+        self._petal_surface = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA)
+        self._petal_surface_dirty = True
+        self._full_surface = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA)
+        self._oled_surface = pygame.Surface((240, 240), flags=pygame.SRCALPHA)
+
+        # Calculate OLED per-row offset.
+        #
+        # The OLED disc (240px diameter) will be written into a 240px x 240px
+        # axis-aligned bounding box. The rendering routine iterates over the
+        # bounding box row-per-row, and we only want to write that row's disc
+        # fragment for each row into the square bounding box. This fragment
+        # will be offset by some pixels from the left edge, and will be also
+        # shortened by the same count of pixels from the right edge.
+        #
+        # The way we calculate these offsets is quite naïve, but it's easy to
+        # reason about. First, we start off by calculating a 240x240px bitmask
+        # that is True if the pixel corresponding to this mask's bit is part of
+        # the OLED disc, and false otherwise.
+        mask = [
+            [
+                math.sqrt((x - 120)**2 + (y - 120)**2) <= 120
+                for x in range(240)
+            ]
+            for y in range(240)
+        ]
+        # Now, we iterate the mask row-by-row and find the first True bit in
+        # it. The offset within that row is our per-row offset for the
+        # rendering routine.
+        self._oled_offset = [
+            m.index(True)
+            for m in mask
+        ]
+
+    def process_events(self):
+        """
+        Process pygame events and update mouse_{x,y}, petal_held and
+        petal_hover.
+        """
+        prev_petal_hover = self.petal_hover
+        evs = pygame.event.get()
+        for ev in evs:
+            if ev.type == pygame.MOUSEMOTION:
+                self.mouse_x, self.mouse_y = ev.pos
+                self.petal_hover = self._mouse_coords_to_petal_id()
+            if ev.type == pygame.MOUSEBUTTONDOWN:
+                self._process_mouse_down()
+            if ev.type == pygame.MOUSEBUTTONUP:
+                self._process_mouse_up()
+
+        if prev_petal_hover != self.petal_hover:
+            self._petal_surface_dirty = True
+
+    def _mouse_coords_to_petal_id(self):
+        if self.mouse_x is None or self.mouse_y is None:
+            return None
+
+        for i, pos in enumerate(self.PETAL_POSITIONS):
+            (px, py) = pos
+            # TODO(q3k): pre-apply to PETAL_POSITIONS.
+            px += 50
+            py += 50
+            dx = self.mouse_x - px
+            dy = self.mouse_y - py
+            if math.sqrt(dx**2 + dy**2) < self.PETAL_MARKER_SIZE:
+                return i
+        return None
+
+    def _process_mouse_up(self):
+        if self.petal_held is not None:
+            self.petal_held = None
+            self._petal_surface_dirty = True
+
+    def _process_mouse_down(self):
+        if self.petal_held != self.petal_hover:
+            self.petal_held = self.petal_hover
+            self._petal_surface_dirty = True
+
+    def _render_petal_markers(self, surface):
+        for i, pos in enumerate(self.PETAL_POSITIONS):
+            x, y = pos
+            # TODO(q3k): pre-apply to PETAL_POSITIONS
+            x += 50
+            y += 50
+
+            if i == self.petal_held:
+                pygame.draw.circle(surface, (0x8b, 0x8b, 0x8b, 0x80), (x, y), 50)
+            elif i == self.petal_hover:
+                pygame.draw.circle(surface, (0x6b, 0x6b, 0x6b, 0xa0), (x, y), 50)
+            else:
+                pygame.draw.circle(surface, (0x5b, 0x5b, 0x5b, 0xa0), (x, y), 50)
+
+    def _render_leds(self, surface):
+        for pos, state in zip(self.LED_POSITIONS, self.led_state):
+            # TODO(q3k): pre-apply to LED_POSITIONS
+            x = pos[0] + 3.0
+            y = pos[1] + 3.0
+            r, g, b = state
+            for i in range(20):
+                radius = 26 - i
+                r2 = r / (20 - i)
+                g2 = g / (20 - i)
+                b2 = b / (20 - i)
+                pygame.draw.circle(surface, (r2, g2, b2), (x, y), radius)
+
+    def _render_oled(self, surface):
+        surface.fill((0, 0, 0, 0))
+        buf = surface.get_buffer()
+
+        fb = get_ctx()._get_fb()
+        fb = fb[:240*240*4]
+        for y in range(240):
+            # Use precalculated row offset to turn OLED disc into square
+            # bounded plane.
+            offset = self._oled_offset[y]
+            start_offs_bytes = y * 240 * 4
+            start_offs_bytes += offset * 4
+            end_offs_bytes = (y+1) * 240 * 4
+            end_offs_bytes -= offset * 4
+            buf.write(bytes(fb[start_offs_bytes:end_offs_bytes]), start_offs_bytes)
+
+    def render_gui_now(self):
+        """
+        Render the GUI elements, skipping overlay elements that aren't dirty.
+
+        This does _not_ render the Ctx state into the OLED surface. For that,
+        call render_display.
+        """
+        self.last_gui_render = time.time()
+
+        full = self._full_surface
+        need_overlays = False
+        if self._led_surface_dirty or self._petal_surface_dirty:
+            full.fill((0, 0, 0, 255))
+            full.blit(background, (0, 0))
+            need_overlays = True
+
+        if self._led_surface_dirty:
+            self._render_leds(self._led_surface)
+            self._led_surface_dirty = False
+        if need_overlays:
+            full.blit(self._led_surface, (0, 0), special_flags=pygame.BLEND_ADD)
+
+        if self._petal_surface_dirty:
+            self._render_petal_markers(self._petal_surface)
+            self._petal_surface_dirty = False
+        if need_overlays:
+            full.blit(self._petal_surface, (0, 0))
+
+        # Always blit oled. Its' alpha blending is designed in a way that it
+        # can be repeatedly applied to a dirty _full_surface without artifacts.
+        center_x = 408
+        center_y = 426
+        off_x = center_x - (240 // 2)
+        off_y = center_y - (240 // 2)
+        full.blit(self._oled_surface, (off_x, off_y))
+
+        screen.blit(full, (0,0))
+        pygame.display.flip()
+
+    def render_gui_lazy(self):
+        """
+        Render the GUI elements if needed to maintain a responsive 60fps of the
+        GUI itself. As with render_gui_now, the OLED surface is not rendered by
+        this call.
+        """
+        target_fps = 60.0
+        d = 1/target_fps
+
+        if self.last_gui_render is None:
+            self.render_gui_now()
+        elif time.time() - self.last_gui_render > d:
+            self.render_gui_now()
+
+    def render_display(self):
+        """
+        Render the OLED surface from Ctx state.
+
+        Afterwards, render_gui_{lazy,now} should still be called to actually
+        present the new OLED surface state to the user.
+        """
+        self._render_oled(self._oled_surface)
+
+    def set_led_rgb(self, ix, r, g, b):
+        self.led_state_buf[ix] = (r, g, b)
+
+    def leds_update(self):
+        for i, s in enumerate(_sim.led_state_buf):
+            if _sim.led_state[i] != s:
+                _sim.led_state[i] = s
+                self._led_surface_dirty = True
+
+
+_sim = Simulation()
+
+
 _deprecated_notified = set()
 def _deprecated(f):
     def wrapper(*args, **kwargs):
@@ -19,15 +264,19 @@ def _deprecated(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
@@ -37,6 +286,7 @@ def get_ctx():
         _global_ctx = ctx.Ctx()
     return _global_ctx
 
+
 @_deprecated
 def display_fill(color):
     """
@@ -51,67 +301,10 @@ def display_fill(color):
 
 
 def display_update():
-    fb = get_ctx()._get_fb()
-
-    full = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA)
-    full.fill((0, 0, 0, 255))
-    full.blit(background, (0, 0))
-
-    leds = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA)
-    for pos, state in zip(leds_positions, leds_state):
-        x = pos[0] + 3.0
-        y = pos[1] + 3.0
-        r, g, b = state
-        for i in range(20):
-            radius = 26 - i
-            r2 = r / (20 - i)
-            g2 = g / (20 - i)
-            b2 = b / (20 - i)
-            pygame.draw.circle(leds, (r2, g2, b2), (x, y), radius)
-    full.blit(leds, (0, 0), special_flags=pygame.BLEND_ADD)
-
-    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.fill((0, 0, 0, 255))
-    screen.blit(full, (0,0))
-    pygame.display.flip()
-
-leds_positions = [
-    (660, 455), (608, 490), (631, 554), (648, 618), (659, 690), (655, 770), (571, 746), (502, 711),
-    (452, 677), (401, 639), (352, 680), (299, 713), (241, 745), (151, 771), (147, 682), (160, 607),
-    (176, 549), (197, 491), (147, 453), ( 98, 416), ( 43, 360), (  0, 292), ( 64, 267), (144, 252),
-    (210, 248), (276, 249), (295, 190), (318, 129), (351,  65), (404,   0), (456,  64), (490, 131),
-    (511, 186), (529, 250), (595, 247), (663, 250), (738, 264), (810, 292), (755, 371), (705, 419),
-]
-leds_state_buf = [(0, 0, 0) for _ in leds_positions]
-leds_state = [(0, 0, 0) for _ in leds_positions]
+    _sim.process_events()
+    _sim.render_display()
+    _sim.render_gui_now()
+
 
 def set_led_rgb(ix, r, g, b):
     ix = ((39-ix) + 1 + 32)%40;
@@ -125,17 +318,34 @@ def set_led_rgb(ix, r, g, b):
         g = 255
     if b > 255:
         b = 255
-    leds_state_buf[ix] = (r, g, b)
+    _sim.set_led_rgb(ix, r, g, b)
+
+
+def set_led_hsv(ix, h, s, v):
+    color = pygame.Color(0)
+    h /= 255.0
+    color.hsva = (h, s, v, 1.0)
+    r, g, b = color.r, color.g, color.b
+    r *= 255
+    g *= 255
+    b *= 255
+    _sim.set_led_rgb(ix, r, g, b)
+
 
 def update_leds():
-    for i, s in enumerate(leds_state_buf):
-        leds_state[i] = s
+    _sim.leds_update()
+    _sim.render_gui_lazy()
+
 
 def set_global_volume_dB(a):
     pass
 
+
 def get_button(a):
     return 0
 
+
 def get_captouch(a):
-    return 0
+    _sim.process_events()
+    _sim.render_gui_lazy()
+    return _sim.petal_held == a