Repository: Allen-Synthesis/EuroPi
Pull request #432: New script: Bouncing Pixels
Author: login: jorins
Created: 2025-04-12T14:43:34Z | Merged: 2025-05-31T20:22:55Z

This is a script I've been working on for a while that has a bunch of pixels bouncing around the display, triggering a gate upon hitting each wall. It's highly customisable to spice things up beyond its inspiration, the classic bouncing DVD logo. Big props to @chrisib for helping me during the development process :)

# TODO

- [x] Adhere coding style and formatting to project
- [x] Code review
- [x] Code documentation
- [x] ~~Move `Event` and `exponential_interpolation` into libraries~~
- [x] Write user-facing documentation
- [x] Stability testing*
- [x] UX testing


\* During development I had some rare crashes in my render thread with this trace:
```
Unhandled exception in thread started by <bound_method>
Traceback (most recent call last):
  File "<stdin>", line 551, in render_thread
  File "<stdin>", line 522, in render
  File "/lib/ssd1306.py", line 101, in show
  File "/lib/ssd1306.py", line 119, in write_data
OSError: [Errno 110] ETIMEDOUT
```
This was pointing to an `oled.fill(0)` call. @chrisib suggested moving the render thread to the first core as that might be where the I2C communications with the monitor happen, which I did. I also added a frame rate limiter (defaulting to 30Hz). Hopefully this fixes the issue. I haven't seen it since, but it was pretty rare before implementing these measures so I have no good way of assessing if it's truly fixed.


Diff:
diff --git a/software/contrib/README.md b/software/contrib/README.md
index ca5b22b36..48f2da994 100644
--- a/software/contrib/README.md
+++ b/software/contrib/README.md
@@ -42,6 +42,13 @@ Mirrors a gate/trigger input, with adjustable skip probability across channels.
 <i>Author: [awonak](https://github.com/awonak)</i>
 <br><i>Labels: random, triggers</i>
 
+### Bouncing Pixels \[ [documentation](/software/contrib/bouncing_pixels.md) | [script](/software/contrib/bouncing_pixels.py) \]
+
+Pixels bounce around on the display and trigger gates when hitting the edges.
+
+<i>Author: [Jorin](https://github.com/jorins)</i>
+<br><i>Labels: gates, random, simulation, triggers</i>
+
 ### Clock Modifier \[ [documentation](/software/contrib/clock_mod.md) | [script](/software/contrib/clock_mod.md) \]
 
 A clock multiplier or divider. Each channel has an independently-controllable modifier, multiplying or dividing an external clock signal on `din`.
diff --git a/software/contrib/bouncing_pixels.md b/software/contrib/bouncing_pixels.md
new file mode 100644
index 000000000..516df0eb3
--- /dev/null
+++ b/software/contrib/bouncing_pixels.md
@@ -0,0 +1,201 @@
+# Bouncing Pixels
+
+author: [Jorin](https://github.com/jorins)
+
+date: 2025-04-12
+
+labels: gates, random, simulation, triggers
+
+Pixels bounce around the display and trigger gates when hitting the edges.
+Inspired by the classic bouncing DVD logo!
+
+## Usage
+
+Just boot up the script and you're already going! You can use the knobs to
+adjust several parameters. The analogue input can be bound to any of them.
+By default, it affects the speed. The analogue input is summed with the knob
+value of its affected parameter, meaning that the knob sets a minimum value
+for the analogue input to add to. Note that since the EuroPi takes analogue
+inputs between 0V and 10V, AC signals must be biased properly or the bottom
+half of the input will be clipped.
+
+### Parameters
+
+* *Speed* affects how fast the simulation runs. It translates to how fast the
+  pixels move.
+* *Width* controls how wide the playing field is.
+* *Impulse speed* controls how much speed is added to each pixel when an impulse
+  occurs.
+* *Ball count* controls how many pixels are in play.
+
+### Impulses
+
+Impulses can be caused globally by pressing B2 or sending a signal to the
+digital input (assuming it has not been reconfigured). When an impulse occurs,
+velocity in a random direction. The speed added is the product of the impulse
+speed parameter and a random number (between 0.5 and 2.0 by default).
+
+### Deactivation
+
+One possible over and under speed behaviour is that pixels are deactivated.
+When deactivated, a pixel will not be processed or rendered until reset. Resets
+can be manually triggered by pressing B1 or using the digital input (must be
+configured). A reset is automatically triggered when all pixels in play are
+deactivated. 
+
+## Outputs
+
+* CV1: collision with top edge (25ms)
+* CV2: collision with left edge (25ms)
+* CV3: collision with right edge (25ms)
+* CV4: collision with bottom edge (25ms)
+* CV5: collision with any edge (10ms)
+* CV6: collision with corner (100ms)
+
+Denoted gate hold lengths are defaults. They can be changed in the
+[configuration file](#Configuration).
+
+## Controls
+
+* K1: simulation speed
+* K2: width
+* Either button + K1: ball count
+* Either button + K2: impulse speed
+* B1 short press: reset
+* B2 short press: send impulse
+* Digital input: send impulse (customisable)
+* Analogue input: speed (customisable)
+
+Second layer knobs are "locking", which is to say you need to physically set the
+knob to a position close to the registered value in order for it to latch and
+start changing. This is to prevent abrupt changes in values when you press the
+buttons. If you lose track of a knob position, try sweeping the full range to
+get it to latch.
+
+The short press threshold is set to 500ms by default. You can adjust this
+length in the [configuration file](#Configuration).
+
+The digital input can be set to trigger a reset instead of an impulse. Likewise,
+the analogue input can be set to control any of the parameters controlled by
+knobs. These behaviours can be defined [configuration file](#Configuration).
+
+## Configuration
+
+The script can be thoroughly customised using a configuration file. The
+configuration file must be located at `/config/BouncingPixels.json` on the micro
+controller.
+
+### Sample configuration file
+
+```json
+{
+    "LONG_PRESS_LENGTH": 500.0,
+    "TIMESCALE_MIN": 0.0,
+    "TIMESCALE_MAX": 100.0,
+    "KNOB_CHANGE_THRESHOLD": 0.01,
+    "DIN_FUNCTION": "impulse",
+    "AIN_FUNCTION": "speed",
+    "GATE_HOLD_LENGTH_TOP": 25.0,
+    "GATE_HOLD_LENGTH_LEFT": 25.0,
+    "GATE_HOLD_LENGTH_RIGHT": 25.0,
+    "GATE_HOLD_LENGTH_BOTTOM": 25.0,
+    "GATE_HOLD_LENGTH_ANY": 10.0,
+    "GATE_HOLD_LENGTH_CORNER": 100.0,
+    "ARENA_WIDTH_MIN": 30.0,
+    "ARENA_WIDTH_MAX": 1920.0,
+    "BALL_COUNT_MAX": 100,
+    "BALL_COUNT_MIN": 1,
+    "CORNER_COLLISION_MARGIN": 15.0,
+    "START_SPEED_MIN": 10.0,
+    "START_SPEED_MAX": 100.0,
+    "ACCEL_MIN": -5.0,
+    "ACCEL_MAX": 5.0,
+    "BOUNCINESS_MIN": 0.8,
+    "BOUNCINESS_MAX": 1.2,
+    "BOUNCE_ANGLE_DEVIATION_MAX": 15.0,
+    "UNDER_SPEED_BEHAVIOUR": "reset",
+    "OVER_SPEED_BEHAVIOUR": "reset",
+    "UNDER_SPEED_THRESHOLD": 5.0,
+    "OVER_SPEED_THRESHOLD": 1000000,
+    "IMPULSE_SPEED_MIN": 0,
+    "IMPULSE_SPEED_MAX": 100000,
+    "IMPULSE_SPEED_VARIATION_MIN": 0.5,
+    "IMPULSE_SPEED_VARIATION_MAX": 2.0,
+}
+```
+
+### Configuration values
+
+| Key                           | Type                  | Possible values                                        | Default value | Description |
+|-------------------------------|-----------------------|--------------------------------------------------------|---------------|-------------|
+| `POLL_FREQUENCY`              | Floating point number | >= 5                                                   | 30            | How frequently the application polls for new inputs, expressed as times per second.<br><br>⚠️ **Changing this value is not recommended.** |
+| `SAVE_PERIOD`                 | Floating point number | >= 0                                                   | 5000          | How frequently the application state is saved at most, expressed as seconds between saves.<br><br>⚠️ **Changing this value is not recommended.** |
+| `RENDER_FREQUENCY`            | Floating point number | >= 1                                                   | 30            | How frequently the display display is updated at most, expressed as times per second.<br><br>⚠️ **Changing this value is not recommended.** |
+| `LONG_PRESS_LENGTH`           | Floating point number | >= 0                                                   | 500           | How many milliseconds a button must be pressed for it to be considered a long press. |
+| `TIMESCALE_MIN`               | Floating point number | any                                                    | 0             | The speed at which the simulation runs when the speed parameter is set to minimum. |
+| `TIMESCALE_MAX`               | Floating point number | any                                                    | 100           | The speed at which the simulation runs when the speed parameter is set to maximum. |
+| `KNOB_CHANGE_THRESHOLD`       | Floating point number | 0.0 - 0.1                                              | 0.01          | How much a knob must change for its value to update. A higher value reduces jitter, but decreases sensitivity. |
+| `DIN_FUNCTION`                | Choice                | One of `impulse`, `reset`                              | `impulse`     | The function to trigger when the digital input is raised. |
+| `AIN_FUNCTION`                | Choice                | One of `speed`, `impulse_speed`, `ball_count`, `width` | `speed`       | The parameter that the analogue input modulates. |
+| `GATE_HOLD_LENGTH_TOP`        | Floating point number | 1 - 10,000                                             | 25            | How long the gate for top edge collisions is held open, expressed in milliseconds. |
+| `GATE_HOLD_LENGTH_LEFT`       | Floating point number | 1 - 10,000                                             | 25            | How long the gate for left edge collisions is held open, expressed in milliseconds. |
+| `GATE_HOLD_LENGTH_RIGHT`      | Floating point number | 1 - 10,000                                             | 25            | How long the gate for right edge collisions is held open, expressed in milliseconds. |
+| `GATE_HOLD_LENGTH_BOTTOM`     | Floating point number | 1 - 10,000                                             | 25            | How long the gate for bottom edge collisions is held open, expressed in milliseconds. |
+| `GATE_HOLD_LENGTH_ANY`        | Floating point number | 1 - 10,000                                             | 10            | How long the gate for any edge collisions is held open, expressed in milliseconds. |
+| `GATE_HOLD_LENGTH_CORNER`     | Floating point number | 1 - 10,000                                             | 100           | How long the gate for corner collisions is held open, expressed in milliseconds. |
+| `ARENA_WIDTH_MIN`             | Floating point number | 30 - 1,920                                             | 30            | The smallest width the playing field can have. Fifteen units equal one pixel. |
+| `ARENA_WIDTH_MAX`             | Floating point number | 30 - 1,920                                             | 1920          | The largest width the playing field can have. Fifteen units equal one pixel. |
+| `BALL_COUNT_MIN`              | Integer number        | 1 - 100                                                | 1             | The number of balls in play when the `ball_count` parameter is set to minimum. |
+| `BALL_COUNT_MAX`              | Integer number        | 1 - 100                                                | 100           | The number of balls in play when the `ball_count` parameter is set to maximum. |
+| `CORNER_COLLISION_MARGIN`     | Floating point number | 0 - 240                                                | 15            | How large a portion of the side edges are considered corners. Fifteen units equal one pixel on the display. |
+| `START_SPEED_MIN`             | Floating point number | >= 0                                                   | 10            | The minimum speed a pixel may get upon reset, expressed as units per second. |
+| `START_SPEED_MAX`             | Floating point number | >= 0                                                   | 100           | The maximum speed a pixel may get upon reset, expressed as units per second. |
+| `ACCEL_MIN`                   | Floating point number | any                                                    | -5.0          | The minimum acceleration a pixel may get upon reset, expressed as units per second squared. |
+| `ACCEL_MAX`                   | Floating point number | any                                                    | 5.0           | The maximum acceleration a pixel may get upon reset, expressed as units per second squared. |
+| `BOUNCINESS_MIN`              | Floating point number | >= 0.0001                                              | 0.8           | The minimum bounce speed multiplier a pixel may get upon reset. |
+| `BOUNCINESS_MAX`              | Floating point number | >= 0.0001                                              | 1.2           | The maximum bounce speed multiplier a pixel may get upon reset. |
+| `BOUNCE_ANGLE_DEVIATION_MAX`  | Floating point number | 0 - 180                                                | 15            | The maximum angle a pixel might deviate from its calculated direction upon bouncing, expressed in degrees. |
+| `UNDER_SPEED_BEHAVIOUR`       | Choice                | One of `impulse`, `reset`, `deactivate`, `noop`        | `reset`       | What a pixel should do when its speed goes below the under speed threshold. |
+| `OVER_SPEED_BEHAVIOUR`        | Choice                | One of `reset`, `deactivate`                           | `reset`       | What a pixel should do when its speed goes above the over speed threshold. |
+| `UNDER_SPEED_THRESHOLD`       | Floating point number | >= 0                                                   | 5.0           | The speed threshold under which a pixel activates its under speed behaviour. |
+| `OVER_SPEED_THRESHOLD`        | Floating point number | >= 0                                                   | 1,000,000     | The speed threshold over which a pixel activates its over speed threshold. |
+| `IMPULSE_SPEED_MIN`           | Floating point number | >= 0                                                   | 0             | The speed added to pixels by an impulse when the `impulse` parameter is at minimum. |
+| `IMPULSE_SPEED_MAX`           | Floating point number | >= 0                                                   | 100,000       | The speed added to pixels by an impulse when the `impulse` parameter is at maximum. |
+| `IMPULSE_SPEED_VARIATION_MIN` | Floating point number | >= 0                                                   | 0.5           | The smallest possible random multiplier for impulse speed. |
+| `IMPULSE_SPEED_VARIATION_MAX` | Floating point number | >= 0                                                   | 2.0           | The largest possible random multiplier for impulse speed. |
+
+## Known issues & limitations
+
+* Gate lengths are not entirely precise. They're guaranteed to be at least the
+  set length, but they may exceed it slightly on account of running on 
+  tick-based timers.
+* Corners aren't actually corners, they're just the top and bottom most
+  parts of the side edges. This means that if a pixel is traveling parallel and
+  close to the top or bottom edge, it's possible that it bounces away from the
+  edge upon hitting the corner collision area and thus triggers a corner gate
+  without hitting both a horizontal and a vertical edge. Another consequence is
+  that a corner gate will always trigger at the same time as a side gate,
+  regardless of whether the pixel collides with a horizontal or vertical edge
+  first.
+* Collision handling cannot handle a pixel going fast enough to bounce with two
+  opposing walls in one tick. If a pixel goes fast enough to do this, it
+  triggers the over speed behaviour (i.e. by default, they will reset).
+* This script consumes a relatively large amount of memory. Max ball counts over
+  100 risk crashing the script. Ball counts over 20 consume enough memory to
+  cause connection issues with Thonny. If you need to debug the script, you will
+  probably want to lower the max ball count.
+
+## Further development
+
+If you fancy developing this script further, here are a few suggestions for what
+you can do:
+
+* Address any of the issues mentioned above.
+* Implement gravity! You can add another set of knob banks (let the buttons
+  trigger different banks) that control gravity magnitude and direction.
+  The tricky part of implementing gravity comes from the fact that a pixel will
+  have zero speed at the top of its arc when bouncing in place, and triggers the
+  under speed behaviour.
+* Allow more in-app configuration instead of relying on the configuration file.
+* Implement visual feedback on the locked knob inputs. This would make the use
+  of the second layer knobs easier.
\ No newline at end of file
diff --git a/software/contrib/bouncing_pixels.py b/software/contrib/bouncing_pixels.py
new file mode 100644
index 000000000..cf5f382ff
--- /dev/null
+++ b/software/contrib/bouncing_pixels.py
@@ -0,0 +1,691 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware.experimental.thread import DigitalInputHelper
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    from experimental.thread import DigitalInputHelper  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread, allocate_lock
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        self.is_running = False
+
+        # This lock is for both the arena and the balls.
+        # Since changing arena size changes ball positions, it is a shared lock.
+        self.state_lock = allocate_lock()
+
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("width")
+            .with_locked_knob("impulse_speed", initial_percentage_value=saved_state.get("impulse_speed", 0.5))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        self.digital_input_helper = DigitalInputHelper(
+            on_b1_rising=self.b1_rising,
+            on_b1_falling=self.b1_falling,
+            on_b2_rising=self.b2_rising,
+            on_b2_falling=self.b2_falling,
+            on_din_rising=getattr(self, self.config.DIN_FUNCTION)
+        )
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("impulse_speed")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("width")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("impulse_speed")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("width")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        with self.state_lock:
+            self.digital_input_helper.update()
+            # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+            # This reduces jitter, but decreases accuracy.
+            if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+                self.speed_input = new_speed_input
+                self.on_speed_input.emit()
+
+            if (
+                abs(new_impulse_speed_input - self.impulse_speed_input)
+                > self.config.KNOB_CHANGE_THRESHOLD
+            ):
+                self.impulse_speed_input = new_impulse_speed_input
+                self.on_impulse_speed_input.emit()
+
+            # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+            # and no threshold check should be needed.
+            if new_ball_count_input != self.ball_count_input:
+                self.ball_count_input = new_ball_count_input
+                self.on_ball_count_input.emit()
+
+            if new_width_input != self.width_input:
+                self.width_input = new_width_input
+                self.on_width_input.emit()
+
+            if new_ain_input != self.ain_input:
+                self.ain_input = new_ain_input
+                self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        with self.state_lock:
+            for i in range(self.ball_count):
+                self.balls[i].tick(delta)
+                any_active = any_active or self.balls[i].active
+            if not any_active:
+                self.reset()
+        for gate in self.gates:
+            gate.tick(delta)
+
+    def render(self):
+        """Render the display.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        with self.state_lock:
+            self.arena.draw_boundary()
+            for i in range(self.ball_count):
+                if not self.balls[i].active:
+                    continue
+                self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        self.is_running = True
+        try:
+            proc_thread = start_new_thread(self.proc_thread, ())
+            self.render_thread()
+        except KeyboardInterrupt:
+            self.is_running = False
+        finally:
+            print("User aborted. Exiting.")
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        usb_connected_at_start = usb_connected.value()
+        while usb_connected.value() == usb_connected_at_start and self.is_running:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+            
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.SAVE_PERIOD:
+                self.save_state()
+
+            self.tick(delta * self.time_factor)
+            prev_cycle = cycle_start
+
+    def render_thread(self):
+        """Render at limited frequency."""
+        render_period = 1000.0 / self.config.RENDER_FREQUENCY
+        usb_connected_at_start = usb_connected.value()
+        while usb_connected.value() == usb_connected_at_start and self.is_running:
+            cycle_start = ticks_ms()
+            self.render()
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
+
+
+if __name__ == "__main__":
+    BouncingPixels().main()
diff --git a/software/contrib/menu.py b/software/contrib/menu.py
index c45a40fb6..74145a46c 100644
--- a/software/contrib/menu.py
+++ b/software/contrib/menu.py
@@ -38,6 +38,7 @@
     ["Bezier Curves",     "contrib.bezier.Bezier"],
     ["Binary Counter",    "contrib.binary_counter.BinaryCounter"],
     ["Bit Garden",        "contrib.bit_garden.BitGarden"],
+    ["Bouncing Pixels",   "contrib.bouncing_pixels.BouncingPixels"],
     ["Clock Modifier",    "contrib.clock_mod.ClockModifier"],
     ["Coin Toss",         "contrib.coin_toss.CoinToss"],
     ["Consequencer",      "contrib.consequencer.Consequencer"],


Reviews:
user: login: chrisib
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-12T18:24:56Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T20:38:42Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T21:36:40Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T21:37:37Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T21:48:53Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T18:46:30Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T18:59:00Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T18:59:17Z
user: login: civerachb-cpr
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-15T22:23:09Z
user: login: civerachb-cpr
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-15T22:24:13Z
user: login: civerachb-cpr
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-15T22:29:35Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:11:11Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T13:20:50Z
user: login: chrisib
body: A few changes noted above, and one larger structural suggestion/question:

Having the Impulse speed as a primary knob function feels a little weird. The previous version where the primary knob functions were speed & width felt more "playable" to me

How difficult would it be to restructure the button/knob interactions so that:
1. Pick two of speed, number of balls, and width are the primary knob functions (these feel the most interesting to play with)
2. B2 + K2 adjusts impulse speed. You could then either always apply an impulse when B2 is released (either on a long or short press) so the impulse gets applied whenever the speed is changed. This could even be a config option to enable/disable (e.g. if din is mapped to impulse you may not want B2 to trigger the impulse at all, but still want speed control).

The above is a suggestion, and if you don't want to make those changes it's fine. But I think the older version was easier to jam with, since the width/speed/number-of-balls parameters all have dramatic, immediately-obvious functions, especially compared with the impulse speed.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T21:03:56Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:46:17Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:51:27Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:55:41Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:57:15Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T11:00:00Z
user: login: chrisib
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T00:43:41Z
user: login: chrisib
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T00:44:37Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:35:36Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:47:01Z
user: login: jorins
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T20:57:01Z
user: login: chrisib
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-31T20:21:07Z

Review comments:
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: Please remeber to add `bouncing_pixels.md` 🙂

Might be worthwhile adding a very brief summary of the controls here too, but that's completely optional.
created_at: 2025-04-12T17:08:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: I found it a little weird that the knobs' initial positions were locked. Generally I find it's more intuitive to use `.with_unlocked_knob` for the first one in any bank. That way when the program starts it can immediately read & apply the physical position of the knobs, instead of having the default state and the user needing to sweep the knob or press buttons to lock & unlock them.
created_at: 2025-04-12T17:12:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: Same applies here as above re: using an unlocked knob in the first position.
created_at: 2025-04-12T17:12:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: This isn't strictly necessary anymore; in a much older version of the firmware the application menu actually loaded all of the classes and used their `display_name` methods to populate the menu. But as the number of programs grew that ran into RAM issues, so the menu got rewritten. No harm in leaving this function, but it's not actually used anywhere anymore and can be omitted.
created_at: 2025-04-12T17:16:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
+        return "Bouncing Pixels"
+    
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint('aspect_ratio_min',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=4.0 / oled.height
+            ),
+            configuration.floatingPoint('aspect_ratio_max',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=oled.width / oled.height
+            ),
+            configuration.floatingPoint('poll_frequency', minimum=5.0, maximum=120.0, default=20.0, danger=True),
+            configuration.floatingPoint('save_period', minimum=0.0, maximum=inf, default=5000.0, danger=True),
+            configuration.floatingPoint('render_frequency', minimum=1.0, maximum=inf, default=30.0, danger=True),
+            configuration.floatingPoint('long_press_length', minimum=0.0, maximum=inf, default=500.0),
+            configuration.floatingPoint('timescale_min', minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint('timescale_max', minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint('knob_change_threshold', minimum=0.0, maximum=0.1, default=0.01),
+            configuration.choice('din_function', choices=['impulse', 'reset'], default='impulse'),
+            configuration.choice('ain_function', choices=['speed', 'aspect_ratio', 'ball_count', 'impulse_speed'], default='speed'),
+            configuration.floatingPoint('gate_hold_length_top', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_left', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_right', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_bottom', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_any', minimum=1.0, maximum=10_000.0, default=10.0),
+            configuration.floatingPoint('gate_hold_length_corner', minimum=1.0, maximum=10_000.0, default=100.0),
+            configuration.integer('ball_count_max', minimum=1, maximum=inf, default=100),
+            configuration.integer('ball_count_min', minimum=1, maximum=inf, default=1),
+            configuration.floatingPoint('corner_collision_margin', minimum=0.0, maximum=ARENA_HEIGHT / 2.0, default=ARENA_HEIGHT / oled.height),
+            configuration.floatingPoint('start_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('start_speed_max', minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint('accel_min', minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint('accel_max', minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint('bounciness_min', minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint('bounciness_max', minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint('bounce_angle_deviation_max', minimum=0.0, maximum=180.0, default=15.0),
+            configuration.choice('under_speed_behaviour', choices=['impulse', 'reset', 'deactivate', 'noop'], default='reset'),
+            configuration.choice('over_speed_behaviour', choices=['reset', 'deactivate'], default='reset'),
+            configuration.floatingPoint('under_speed_threshold', minimum=0.0, maximum=inf, default=5.0),
+            configuration.floatingPoint('over_speed_threshold', minimum=0.0, maximum=inf, default=1.0e6),
+            configuration.floatingPoint('impulse_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('impulse_speed_max', minimum=0.0, maximum=inf, default=1000.0),
+            configuration.floatingPoint('impulse_speed_variation_min', minimum=0.0, maximum=inf, default=0.5),
+            configuration.floatingPoint('impulse_speed_variation_max', minimum=0.0, maximum=inf, default=2.0),
+        ]
+    
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b1_pressed = ticks_ms()
+    
+    def b1_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.long_press_length:
+            self.reset()
+            
+        self.b1_pressed = None
+    
+    def b2_rising(self):
+        # self.k1_bank.set_current('gravity_magnitude')
+        # self.k2_bank.set_current('gravity_direction')
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b2_pressed = ticks_ms()
+    
+    def b2_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.long_press_length:
+            self.impulse()
+            
+        self.b2_pressed = None
+        
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state.
+        """
+        self.state_saved = False
+        
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state.
+        """
+        self.save_state_json({
+            'speed': self.speed_input,
+            'aspect_ratio': self.aspect_ratio_input,
+            'ball_count': self.ball_count_input,
+            'impulse_speed': self.impulse_speed_input
+        })
+        self.state_saved = True
+        
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+    
+    def update_aspect_ratio_ain_term(self):
+        self.aspect_ratio_ain_term = ain.percent()
+        
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+        
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+        
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        # Time factor is calibrated so that an input of 0 stops time, an input of 0.5 runs at 1x speed,
+        # and an input of 1.0 gives the configured max speed.
+        # https://math.stackexchange.com/questions/3311614/find-the-exponential-curve-through-three-data-points
+        input_sum = self.speed_input + self.speed_ain_term
+        # self.time_factor = (
+            # (
+                # e ** (2 * log(self.config.timescale_max - 1) * input_sum
+                      # ) / (self.config.timescale_max - 2)
+            # ) -1 / (self.config.timescale_max - 2)
+        # )
+        self.time_factor = exponential_interpolation(self.config.timescale_min, self.config.timescale_max, input_sum)
+        
+    def apply_aspect_ratio(self):
+        input_sum = self.aspect_ratio_input + self.aspect_ratio_ain_term
+        aspect_ratio = rescale(input_sum, 0.0, 1.0, self.config.aspect_ratio_min, self.config.aspect_ratio_max)
+        self.arena.update_width(aspect_ratio)
+        
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(exponential_interpolation(self.config.ball_count_min, self.config.ball_count_max, input_sum))
+        
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(self.config.impulse_speed_min, self.config.impulse_speed_max, input_sum)
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+    
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate.
+        """
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates.
+        """
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+        
+    def impulse(self):
+        """Apply an impulse to all balls currently in play.
+        """
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected
+        """
+        new_speed_input = self.k1_bank.speed.percent()
+        new_aspect_ratio_input = self.k2_bank.aspect_ratio.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+        
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, which can in particular be visible in the aspect ratio.
+        if abs(new_speed_input - self.speed_input) > self.config.knob_change_threshold:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+            
+        if abs(new_aspect_ratio_input - self.aspect_ratio_input) > self.config.knob_change_threshold:
+            self.aspect_ratio_input = new_aspect_ratio_input
+            self.on_aspect_ratio_input.emit()
+            
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:            
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+            
+        if new_impulse_speed_input != self.impulse_speed_input:
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+            
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+        
+    def render(self):
+        """Render the simulation. This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+        
+    def main(self):
+        """Start the application.
+        """
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary.
+        """
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.poll_frequency
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+            
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+                
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.save_period:
+                self.save_state()
+            
+            self.tick(delta * self.time_factor)            
+            prev_cycle = cycle_start
+            
+    def render_thread(self):
+        """Render at limited frequency.
+        """
+        prev_cycle = None
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: Does this get used anywhere?
created_at: 2025-04-12T18:22:24Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
+        return "Bouncing Pixels"
+    
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint('aspect_ratio_min',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=4.0 / oled.height
+            ),
+            configuration.floatingPoint('aspect_ratio_max',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=oled.width / oled.height
+            ),
+            configuration.floatingPoint('poll_frequency', minimum=5.0, maximum=120.0, default=20.0, danger=True),
+            configuration.floatingPoint('save_period', minimum=0.0, maximum=inf, default=5000.0, danger=True),
+            configuration.floatingPoint('render_frequency', minimum=1.0, maximum=inf, default=30.0, danger=True),
+            configuration.floatingPoint('long_press_length', minimum=0.0, maximum=inf, default=500.0),
+            configuration.floatingPoint('timescale_min', minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint('timescale_max', minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint('knob_change_threshold', minimum=0.0, maximum=0.1, default=0.01),
+            configuration.choice('din_function', choices=['impulse', 'reset'], default='impulse'),
+            configuration.choice('ain_function', choices=['speed', 'aspect_ratio', 'ball_count', 'impulse_speed'], default='speed'),
+            configuration.floatingPoint('gate_hold_length_top', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_left', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_right', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_bottom', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_any', minimum=1.0, maximum=10_000.0, default=10.0),
+            configuration.floatingPoint('gate_hold_length_corner', minimum=1.0, maximum=10_000.0, default=100.0),
+            configuration.integer('ball_count_max', minimum=1, maximum=inf, default=100),
+            configuration.integer('ball_count_min', minimum=1, maximum=inf, default=1),
+            configuration.floatingPoint('corner_collision_margin', minimum=0.0, maximum=ARENA_HEIGHT / 2.0, default=ARENA_HEIGHT / oled.height),
+            configuration.floatingPoint('start_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('start_speed_max', minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint('accel_min', minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint('accel_max', minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint('bounciness_min', minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint('bounciness_max', minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint('bounce_angle_deviation_max', minimum=0.0, maximum=180.0, default=15.0),
+            configuration.choice('under_speed_behaviour', choices=['impulse', 'reset', 'deactivate', 'noop'], default='reset'),
+            configuration.choice('over_speed_behaviour', choices=['reset', 'deactivate'], default='reset'),
+            configuration.floatingPoint('under_speed_threshold', minimum=0.0, maximum=inf, default=5.0),
+            configuration.floatingPoint('over_speed_threshold', minimum=0.0, maximum=inf, default=1.0e6),
+            configuration.floatingPoint('impulse_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('impulse_speed_max', minimum=0.0, maximum=inf, default=1000.0),
+            configuration.floatingPoint('impulse_speed_variation_min', minimum=0.0, maximum=inf, default=0.5),
+            configuration.floatingPoint('impulse_speed_variation_max', minimum=0.0, maximum=inf, default=2.0),
+        ]
+    
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b1_pressed = ticks_ms()
+    
+    def b1_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.long_press_length:
+            self.reset()
+            
+        self.b1_pressed = None
+    
+    def b2_rising(self):
+        # self.k1_bank.set_current('gravity_magnitude')
+        # self.k2_bank.set_current('gravity_direction')
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b2_pressed = ticks_ms()
+    
+    def b2_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.long_press_length:
+            self.impulse()
+            
+        self.b2_pressed = None
+        
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state.
+        """
+        self.state_saved = False
+        
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state.
+        """
+        self.save_state_json({
+            'speed': self.speed_input,
+            'aspect_ratio': self.aspect_ratio_input,
+            'ball_count': self.ball_count_input,
+            'impulse_speed': self.impulse_speed_input
+        })
+        self.state_saved = True
+        
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+    
+    def update_aspect_ratio_ain_term(self):
+        self.aspect_ratio_ain_term = ain.percent()
+        
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+        
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+        
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        # Time factor is calibrated so that an input of 0 stops time, an input of 0.5 runs at 1x speed,
+        # and an input of 1.0 gives the configured max speed.
+        # https://math.stackexchange.com/questions/3311614/find-the-exponential-curve-through-three-data-points
+        input_sum = self.speed_input + self.speed_ain_term
+        # self.time_factor = (
+            # (
+                # e ** (2 * log(self.config.timescale_max - 1) * input_sum
+                      # ) / (self.config.timescale_max - 2)
+            # ) -1 / (self.config.timescale_max - 2)
+        # )
+        self.time_factor = exponential_interpolation(self.config.timescale_min, self.config.timescale_max, input_sum)
+        
+    def apply_aspect_ratio(self):
+        input_sum = self.aspect_ratio_input + self.aspect_ratio_ain_term
+        aspect_ratio = rescale(input_sum, 0.0, 1.0, self.config.aspect_ratio_min, self.config.aspect_ratio_max)
+        self.arena.update_width(aspect_ratio)
+        
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(exponential_interpolation(self.config.ball_count_min, self.config.ball_count_max, input_sum))
+        
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(self.config.impulse_speed_min, self.config.impulse_speed_max, input_sum)
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+    
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate.
+        """
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates.
+        """
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+        
+    def impulse(self):
+        """Apply an impulse to all balls currently in play.
+        """
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected
+        """
+        new_speed_input = self.k1_bank.speed.percent()
+        new_aspect_ratio_input = self.k2_bank.aspect_ratio.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+        
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, which can in particular be visible in the aspect ratio.
+        if abs(new_speed_input - self.speed_input) > self.config.knob_change_threshold:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+            
+        if abs(new_aspect_ratio_input - self.aspect_ratio_input) > self.config.knob_change_threshold:
+            self.aspect_ratio_input = new_aspect_ratio_input
+            self.on_aspect_ratio_input.emit()
+            
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:            
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+            
+        if new_impulse_speed_input != self.impulse_speed_input:
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+            
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+        
+    def render(self):
+        """Render the simulation. This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+        
+    def main(self):
+        """Start the application.
+        """
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary.
+        """
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.poll_frequency
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+            
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+                
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.save_period:
+                self.save_state()
+            
+            self.tick(delta * self.time_factor)            
+            prev_cycle = cycle_start
+            
+    def render_thread(self):
+        """Render at limited frequency.
+        """
+        prev_cycle = None
+        render_period = 1000.0 / self.config.render_frequency
+        while True:
+            cycle_start = ticks_ms()
+            self.render()
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: Not necessary, but would it make any sense to use a `Timer` object with a fixed rate and simply call the render function in the timer tick handler? Again, it's fine like this; I'm just tossing out ideas for other ways things could be done.
created_at: 2025-04-12T18:23:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Added the referenced documentation! I opted to not keep instructions in the docstring to stay in line with other scripts I looked at.
created_at: 2025-04-12T20:38:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
+        return "Bouncing Pixels"
+    
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint('aspect_ratio_min',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=4.0 / oled.height
+            ),
+            configuration.floatingPoint('aspect_ratio_max',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=oled.width / oled.height
+            ),
+            configuration.floatingPoint('poll_frequency', minimum=5.0, maximum=120.0, default=20.0, danger=True),
+            configuration.floatingPoint('save_period', minimum=0.0, maximum=inf, default=5000.0, danger=True),
+            configuration.floatingPoint('render_frequency', minimum=1.0, maximum=inf, default=30.0, danger=True),
+            configuration.floatingPoint('long_press_length', minimum=0.0, maximum=inf, default=500.0),
+            configuration.floatingPoint('timescale_min', minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint('timescale_max', minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint('knob_change_threshold', minimum=0.0, maximum=0.1, default=0.01),
+            configuration.choice('din_function', choices=['impulse', 'reset'], default='impulse'),
+            configuration.choice('ain_function', choices=['speed', 'aspect_ratio', 'ball_count', 'impulse_speed'], default='speed'),
+            configuration.floatingPoint('gate_hold_length_top', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_left', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_right', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_bottom', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_any', minimum=1.0, maximum=10_000.0, default=10.0),
+            configuration.floatingPoint('gate_hold_length_corner', minimum=1.0, maximum=10_000.0, default=100.0),
+            configuration.integer('ball_count_max', minimum=1, maximum=inf, default=100),
+            configuration.integer('ball_count_min', minimum=1, maximum=inf, default=1),
+            configuration.floatingPoint('corner_collision_margin', minimum=0.0, maximum=ARENA_HEIGHT / 2.0, default=ARENA_HEIGHT / oled.height),
+            configuration.floatingPoint('start_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('start_speed_max', minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint('accel_min', minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint('accel_max', minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint('bounciness_min', minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint('bounciness_max', minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint('bounce_angle_deviation_max', minimum=0.0, maximum=180.0, default=15.0),
+            configuration.choice('under_speed_behaviour', choices=['impulse', 'reset', 'deactivate', 'noop'], default='reset'),
+            configuration.choice('over_speed_behaviour', choices=['reset', 'deactivate'], default='reset'),
+            configuration.floatingPoint('under_speed_threshold', minimum=0.0, maximum=inf, default=5.0),
+            configuration.floatingPoint('over_speed_threshold', minimum=0.0, maximum=inf, default=1.0e6),
+            configuration.floatingPoint('impulse_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('impulse_speed_max', minimum=0.0, maximum=inf, default=1000.0),
+            configuration.floatingPoint('impulse_speed_variation_min', minimum=0.0, maximum=inf, default=0.5),
+            configuration.floatingPoint('impulse_speed_variation_max', minimum=0.0, maximum=inf, default=2.0),
+        ]
+    
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b1_pressed = ticks_ms()
+    
+    def b1_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.long_press_length:
+            self.reset()
+            
+        self.b1_pressed = None
+    
+    def b2_rising(self):
+        # self.k1_bank.set_current('gravity_magnitude')
+        # self.k2_bank.set_current('gravity_direction')
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b2_pressed = ticks_ms()
+    
+    def b2_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.long_press_length:
+            self.impulse()
+            
+        self.b2_pressed = None
+        
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state.
+        """
+        self.state_saved = False
+        
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state.
+        """
+        self.save_state_json({
+            'speed': self.speed_input,
+            'aspect_ratio': self.aspect_ratio_input,
+            'ball_count': self.ball_count_input,
+            'impulse_speed': self.impulse_speed_input
+        })
+        self.state_saved = True
+        
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+    
+    def update_aspect_ratio_ain_term(self):
+        self.aspect_ratio_ain_term = ain.percent()
+        
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+        
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+        
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        # Time factor is calibrated so that an input of 0 stops time, an input of 0.5 runs at 1x speed,
+        # and an input of 1.0 gives the configured max speed.
+        # https://math.stackexchange.com/questions/3311614/find-the-exponential-curve-through-three-data-points
+        input_sum = self.speed_input + self.speed_ain_term
+        # self.time_factor = (
+            # (
+                # e ** (2 * log(self.config.timescale_max - 1) * input_sum
+                      # ) / (self.config.timescale_max - 2)
+            # ) -1 / (self.config.timescale_max - 2)
+        # )
+        self.time_factor = exponential_interpolation(self.config.timescale_min, self.config.timescale_max, input_sum)
+        
+    def apply_aspect_ratio(self):
+        input_sum = self.aspect_ratio_input + self.aspect_ratio_ain_term
+        aspect_ratio = rescale(input_sum, 0.0, 1.0, self.config.aspect_ratio_min, self.config.aspect_ratio_max)
+        self.arena.update_width(aspect_ratio)
+        
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(exponential_interpolation(self.config.ball_count_min, self.config.ball_count_max, input_sum))
+        
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(self.config.impulse_speed_min, self.config.impulse_speed_max, input_sum)
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+    
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate.
+        """
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates.
+        """
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+        
+    def impulse(self):
+        """Apply an impulse to all balls currently in play.
+        """
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected
+        """
+        new_speed_input = self.k1_bank.speed.percent()
+        new_aspect_ratio_input = self.k2_bank.aspect_ratio.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+        
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, which can in particular be visible in the aspect ratio.
+        if abs(new_speed_input - self.speed_input) > self.config.knob_change_threshold:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+            
+        if abs(new_aspect_ratio_input - self.aspect_ratio_input) > self.config.knob_change_threshold:
+            self.aspect_ratio_input = new_aspect_ratio_input
+            self.on_aspect_ratio_input.emit()
+            
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:            
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+            
+        if new_impulse_speed_input != self.impulse_speed_input:
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+            
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+        
+    def render(self):
+        """Render the simulation. This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+        
+    def main(self):
+        """Start the application.
+        """
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary.
+        """
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.poll_frequency
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+            
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+                
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.save_period:
+                self.save_state()
+            
+            self.tick(delta * self.time_factor)            
+            prev_cycle = cycle_start
+            
+    def render_thread(self):
+        """Render at limited frequency.
+        """
+        prev_cycle = None
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Nope! Removed along with some other junk.
created_at: 2025-04-12T21:36:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Ahh I figured something like that might be the case. Removed it since it just serves as clutter then.
created_at: 2025-04-12T21:37:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
+        return "Bouncing Pixels"
+    
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint('aspect_ratio_min',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=4.0 / oled.height
+            ),
+            configuration.floatingPoint('aspect_ratio_max',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=oled.width / oled.height
+            ),
+            configuration.floatingPoint('poll_frequency', minimum=5.0, maximum=120.0, default=20.0, danger=True),
+            configuration.floatingPoint('save_period', minimum=0.0, maximum=inf, default=5000.0, danger=True),
+            configuration.floatingPoint('render_frequency', minimum=1.0, maximum=inf, default=30.0, danger=True),
+            configuration.floatingPoint('long_press_length', minimum=0.0, maximum=inf, default=500.0),
+            configuration.floatingPoint('timescale_min', minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint('timescale_max', minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint('knob_change_threshold', minimum=0.0, maximum=0.1, default=0.01),
+            configuration.choice('din_function', choices=['impulse', 'reset'], default='impulse'),
+            configuration.choice('ain_function', choices=['speed', 'aspect_ratio', 'ball_count', 'impulse_speed'], default='speed'),
+            configuration.floatingPoint('gate_hold_length_top', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_left', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_right', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_bottom', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_any', minimum=1.0, maximum=10_000.0, default=10.0),
+            configuration.floatingPoint('gate_hold_length_corner', minimum=1.0, maximum=10_000.0, default=100.0),
+            configuration.integer('ball_count_max', minimum=1, maximum=inf, default=100),
+            configuration.integer('ball_count_min', minimum=1, maximum=inf, default=1),
+            configuration.floatingPoint('corner_collision_margin', minimum=0.0, maximum=ARENA_HEIGHT / 2.0, default=ARENA_HEIGHT / oled.height),
+            configuration.floatingPoint('start_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('start_speed_max', minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint('accel_min', minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint('accel_max', minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint('bounciness_min', minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint('bounciness_max', minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint('bounce_angle_deviation_max', minimum=0.0, maximum=180.0, default=15.0),
+            configuration.choice('under_speed_behaviour', choices=['impulse', 'reset', 'deactivate', 'noop'], default='reset'),
+            configuration.choice('over_speed_behaviour', choices=['reset', 'deactivate'], default='reset'),
+            configuration.floatingPoint('under_speed_threshold', minimum=0.0, maximum=inf, default=5.0),
+            configuration.floatingPoint('over_speed_threshold', minimum=0.0, maximum=inf, default=1.0e6),
+            configuration.floatingPoint('impulse_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('impulse_speed_max', minimum=0.0, maximum=inf, default=1000.0),
+            configuration.floatingPoint('impulse_speed_variation_min', minimum=0.0, maximum=inf, default=0.5),
+            configuration.floatingPoint('impulse_speed_variation_max', minimum=0.0, maximum=inf, default=2.0),
+        ]
+    
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b1_pressed = ticks_ms()
+    
+    def b1_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.long_press_length:
+            self.reset()
+            
+        self.b1_pressed = None
+    
+    def b2_rising(self):
+        # self.k1_bank.set_current('gravity_magnitude')
+        # self.k2_bank.set_current('gravity_direction')
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b2_pressed = ticks_ms()
+    
+    def b2_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.long_press_length:
+            self.impulse()
+            
+        self.b2_pressed = None
+        
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state.
+        """
+        self.state_saved = False
+        
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state.
+        """
+        self.save_state_json({
+            'speed': self.speed_input,
+            'aspect_ratio': self.aspect_ratio_input,
+            'ball_count': self.ball_count_input,
+            'impulse_speed': self.impulse_speed_input
+        })
+        self.state_saved = True
+        
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+    
+    def update_aspect_ratio_ain_term(self):
+        self.aspect_ratio_ain_term = ain.percent()
+        
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+        
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+        
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        # Time factor is calibrated so that an input of 0 stops time, an input of 0.5 runs at 1x speed,
+        # and an input of 1.0 gives the configured max speed.
+        # https://math.stackexchange.com/questions/3311614/find-the-exponential-curve-through-three-data-points
+        input_sum = self.speed_input + self.speed_ain_term
+        # self.time_factor = (
+            # (
+                # e ** (2 * log(self.config.timescale_max - 1) * input_sum
+                      # ) / (self.config.timescale_max - 2)
+            # ) -1 / (self.config.timescale_max - 2)
+        # )
+        self.time_factor = exponential_interpolation(self.config.timescale_min, self.config.timescale_max, input_sum)
+        
+    def apply_aspect_ratio(self):
+        input_sum = self.aspect_ratio_input + self.aspect_ratio_ain_term
+        aspect_ratio = rescale(input_sum, 0.0, 1.0, self.config.aspect_ratio_min, self.config.aspect_ratio_max)
+        self.arena.update_width(aspect_ratio)
+        
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(exponential_interpolation(self.config.ball_count_min, self.config.ball_count_max, input_sum))
+        
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(self.config.impulse_speed_min, self.config.impulse_speed_max, input_sum)
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+    
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate.
+        """
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates.
+        """
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+        
+    def impulse(self):
+        """Apply an impulse to all balls currently in play.
+        """
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected
+        """
+        new_speed_input = self.k1_bank.speed.percent()
+        new_aspect_ratio_input = self.k2_bank.aspect_ratio.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+        
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, which can in particular be visible in the aspect ratio.
+        if abs(new_speed_input - self.speed_input) > self.config.knob_change_threshold:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+            
+        if abs(new_aspect_ratio_input - self.aspect_ratio_input) > self.config.knob_change_threshold:
+            self.aspect_ratio_input = new_aspect_ratio_input
+            self.on_aspect_ratio_input.emit()
+            
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:            
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+            
+        if new_impulse_speed_input != self.impulse_speed_input:
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+            
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+        
+    def render(self):
+        """Render the simulation. This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+        
+    def main(self):
+        """Start the application.
+        """
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary.
+        """
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.poll_frequency
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+            
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+                
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.save_period:
+                self.save_state()
+            
+            self.tick(delta * self.time_factor)            
+            prev_cycle = cycle_start
+            
+    def render_thread(self):
+        """Render at limited frequency.
+        """
+        prev_cycle = None
+        render_period = 1000.0 / self.config.render_frequency
+        while True:
+            cycle_start = ticks_ms()
+            self.render()
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: I'm a bit concerned about what would happen if the render happens to take longer than the timer period. My understanding based on the docs is that since timers are interrupt based, this could start a second render call before the first one finishes.

Timers would definitely aid in gate length accuracy, so I'll add that to the further development ideas.
created_at: 2025-04-12T21:48:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
+            .with_locked_knob('impulse_speed', initial_percentage_value=saved_state.get('impulse_speed', 0.5))
+            # .with_locked_knob('gravity_direction', initial_percentage_value=0.0)
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+        
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.aspect_ratio_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+        
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.aspect_ratio_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+        
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_aspect_ratio_input = Event(self.mark_state_unsaved, self.apply_aspect_ratio)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f'update_{self.config.ain_function}_ain_term'),
+            getattr(self, f'apply_{self.config.ain_function}'),
+        )
+        
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+        
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+        
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+            
+    @classmethod
+    def display_name(cls):
+        return "Bouncing Pixels"
+    
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint('aspect_ratio_min',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=4.0 / oled.height
+            ),
+            configuration.floatingPoint('aspect_ratio_max',
+                minimum=2.0 / oled.height,
+                maximum=oled.width / oled.height,
+                default=oled.width / oled.height
+            ),
+            configuration.floatingPoint('poll_frequency', minimum=5.0, maximum=120.0, default=20.0, danger=True),
+            configuration.floatingPoint('save_period', minimum=0.0, maximum=inf, default=5000.0, danger=True),
+            configuration.floatingPoint('render_frequency', minimum=1.0, maximum=inf, default=30.0, danger=True),
+            configuration.floatingPoint('long_press_length', minimum=0.0, maximum=inf, default=500.0),
+            configuration.floatingPoint('timescale_min', minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint('timescale_max', minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint('knob_change_threshold', minimum=0.0, maximum=0.1, default=0.01),
+            configuration.choice('din_function', choices=['impulse', 'reset'], default='impulse'),
+            configuration.choice('ain_function', choices=['speed', 'aspect_ratio', 'ball_count', 'impulse_speed'], default='speed'),
+            configuration.floatingPoint('gate_hold_length_top', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_left', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_right', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_bottom', minimum=1.0, maximum=10_000.0, default=25.0),
+            configuration.floatingPoint('gate_hold_length_any', minimum=1.0, maximum=10_000.0, default=10.0),
+            configuration.floatingPoint('gate_hold_length_corner', minimum=1.0, maximum=10_000.0, default=100.0),
+            configuration.integer('ball_count_max', minimum=1, maximum=inf, default=100),
+            configuration.integer('ball_count_min', minimum=1, maximum=inf, default=1),
+            configuration.floatingPoint('corner_collision_margin', minimum=0.0, maximum=ARENA_HEIGHT / 2.0, default=ARENA_HEIGHT / oled.height),
+            configuration.floatingPoint('start_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('start_speed_max', minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint('accel_min', minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint('accel_max', minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint('bounciness_min', minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint('bounciness_max', minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint('bounce_angle_deviation_max', minimum=0.0, maximum=180.0, default=15.0),
+            configuration.choice('under_speed_behaviour', choices=['impulse', 'reset', 'deactivate', 'noop'], default='reset'),
+            configuration.choice('over_speed_behaviour', choices=['reset', 'deactivate'], default='reset'),
+            configuration.floatingPoint('under_speed_threshold', minimum=0.0, maximum=inf, default=5.0),
+            configuration.floatingPoint('over_speed_threshold', minimum=0.0, maximum=inf, default=1.0e6),
+            configuration.floatingPoint('impulse_speed_min', minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint('impulse_speed_max', minimum=0.0, maximum=inf, default=1000.0),
+            configuration.floatingPoint('impulse_speed_variation_min', minimum=0.0, maximum=inf, default=0.5),
+            configuration.floatingPoint('impulse_speed_variation_max', minimum=0.0, maximum=inf, default=2.0),
+        ]
+    
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b1_pressed = ticks_ms()
+    
+    def b1_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.long_press_length:
+            self.reset()
+            
+        self.b1_pressed = None
+    
+    def b2_rising(self):
+        # self.k1_bank.set_current('gravity_magnitude')
+        # self.k2_bank.set_current('gravity_direction')
+        self.k1_bank.set_current('ball_count')
+        self.k2_bank.set_current('impulse_speed')
+        self.b2_pressed = ticks_ms()
+    
+    def b2_falling(self):
+        self.k1_bank.set_current('speed')
+        self.k2_bank.set_current('aspect_ratio')
+        
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.long_press_length:
+            self.impulse()
+            
+        self.b2_pressed = None
+        
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state.
+        """
+        self.state_saved = False
+        
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state.
+        """
+        self.save_state_json({
+            'speed': self.speed_input,
+            'aspect_ratio': self.aspect_ratio_input,
+            'ball_count': self.ball_count_input,
+            'impulse_speed': self.impulse_speed_input
+        })
+        self.state_saved = True
+        
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+    
+    def update_aspect_ratio_ain_term(self):
+        self.aspect_ratio_ain_term = ain.percent()
+        
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+        
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+        
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        # Time factor is calibrated so that an input of 0 stops time, an input of 0.5 runs at 1x speed,
+        # and an input of 1.0 gives the configured max speed.
+        # https://math.stackexchange.com/questions/3311614/find-the-exponential-curve-through-three-data-points
+        input_sum = self.speed_input + self.speed_ain_term
+        # self.time_factor = (
+            # (
+                # e ** (2 * log(self.config.timescale_max - 1) * input_sum
+                      # ) / (self.config.timescale_max - 2)
+            # ) -1 / (self.config.timescale_max - 2)
+        # )
+        self.time_factor = exponential_interpolation(self.config.timescale_min, self.config.timescale_max, input_sum)
+        
+    def apply_aspect_ratio(self):
+        input_sum = self.aspect_ratio_input + self.aspect_ratio_ain_term
+        aspect_ratio = rescale(input_sum, 0.0, 1.0, self.config.aspect_ratio_min, self.config.aspect_ratio_max)
+        self.arena.update_width(aspect_ratio)
+        
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(exponential_interpolation(self.config.ball_count_min, self.config.ball_count_max, input_sum))
+        
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(self.config.impulse_speed_min, self.config.impulse_speed_max, input_sum)
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+    
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate.
+        """
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates.
+        """
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+        
+    def impulse(self):
+        """Apply an impulse to all balls currently in play.
+        """
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected
+        """
+        new_speed_input = self.k1_bank.speed.percent()
+        new_aspect_ratio_input = self.k2_bank.aspect_ratio.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+        
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, which can in particular be visible in the aspect ratio.
+        if abs(new_speed_input - self.speed_input) > self.config.knob_change_threshold:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+            
+        if abs(new_aspect_ratio_input - self.aspect_ratio_input) > self.config.knob_change_threshold:
+            self.aspect_ratio_input = new_aspect_ratio_input
+            self.on_aspect_ratio_input.emit()
+            
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:            
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+            
+        if new_impulse_speed_input != self.impulse_speed_input:
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+            
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+        
+    def render(self):
+        """Render the simulation. This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+        
+    def main(self):
+        """Start the application.
+        """
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary.
+        """
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.poll_frequency
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+            
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+                
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.save_period:
+                self.save_state()
+            
+            self.tick(delta * self.time_factor)            
+            prev_cycle = cycle_start
+            
+    def render_thread(self):
+        """Render at limited frequency.
+        """
+        prev_cycle = None
+        render_period = 1000.0 / self.config.render_frequency
+        while True:
+            cycle_start = ticks_ms()
+            self.render()
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: After some testing, I have learned that this is not what happens and implemented it! I tried implementing timers for polling and saving too, but with worse results. Seems like an issue of the tick loop blocking the timers or something along those lines.
created_at: 2025-04-15T18:46:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Yeah that's definitely a good call. I opted to change the base layer to unlocked. I figured it would be nice to be able to set parameters without altering the base ones. However it did feel clunky. Another issue was that the impulse speed parameter had no visual feedback without sending an impulse so it was hard to see if you successfully changed it. Putting impulse speed on the base layer as an unlocked knob solves that issue too. It does mean that width adjustment is a bit more annoying to access (now on the shift layer). I'd imagine that width is going to be a more commonly used feature, but it feels more important to prioritise keeping all features reasonably ergonomic.
created_at: 2025-04-15T18:59:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
+            .with_locked_knob('ball_count', initial_percentage_value=saved_state.get('ball_count', 0.0))
+            # .with_locked_knob('gravity_magnitude', initial_percentage_value=0.0)
+            .build()
+        )
+        
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_locked_knob('aspect_ratio', initial_percentage_value=saved_state.get('aspect_ratio', 1.0))
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Solved; see above.
created_at: 2025-04-15T18:59:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,667 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.arena_width_max
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.arena_width_min, self.config.arena_width_max)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.corner_collision_margin
+                or self.arena.height - self.pos.imag < self.config.corner_collision_margin
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.bounce_angle_deviation_max, self.bounce_angle_deviation_max
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.ain_function}_ain_term"),
+            getattr(self, f"apply_{self.config.ain_function}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)
+        ]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "poll_frequency", minimum=5.0, maximum=120.0, default=20.0, danger=True
path: software/contrib/bouncing_pixels.py
user: login: civerachb-cpr
body: EuroPi's unwritten-but-should-be-written styleguide for config points usually treats them as constants, and assigns them `ALL_CAPS` names accordingly; see `europi_config.MAX_OUTPUT_VOLTAGE`. For a contrib script it's not required, but if you _really_ want to stay consistent you might consider renaming your config points.
created_at: 2025-04-15T22:23:09Z
author_association: NONE
diff_hunk: @@ -0,0 +1,201 @@
+# Bouncing Pixels
+
+author: [Jorin](https://github.com/jorins)
+
+date: 2025-04-12
+
+labels: gates, random, simulation, triggers
+
+Pixels bounce around the display and trigger gates when hitting the edges.
+Inspired by the classic bouncing DVD logo!
+
+## Usage
+
+Just boot up the script and you're already going! You can use the knobs to
+adjust several parameters. The analogue input can be bound to any of them.
+By default, it affects the speed. The analogue input is summed with the knob
+value of its affected parameter, meaning that the knob sets a minimum value
+for the analogue input to add to. Note that since the EuroPi takes analogue
+inputs between 0V and 10V, AC signals must be biased properly or the bottom
+half of the input will be clipped.
+
+### Parameters
+
+* *Speed* affects how fast the simulation runs. It translates to how fast the
+  pixels move.
+* *Impulse speed* controls how much speed is added to each pixel when an impulse
+  occurs.
+* *Width* controls how wide the playing field is.
+* *Ball count* controls how many pixels are in play.
+
+### Impulses
+
+Impulses can be caused globally by pressing B2 or sending a signal to the
+digital input (assuming it has not been reconfigured). When an impulse occurs,
+velocity in a random direction. The speed added is the product of the impulse
+speed parameter and a random number (between 0.5 and 2.0 by default).
+
+### Deactivation
+
+One possible over and under speed behaviour is that pixels are deactivated.
+When deactivated, a pixel will not be processed or rendered until reset. Resets
+can be manually triggered by pressing B1 or using the digital input (must be
+configured). A reset is automatically triggered when all pixels in play are
+deactivated. 
+
+## Outputs
+
+* CV1: collision with top edge (25ms)
+* CV2: collision with left edge (25ms)
+* CV3: collision with right edge (25ms)
+* CV4: collision with bottom edge (25ms)
+* CV5: collision with any edge (10ms)
+* CV6: collision with corner (100ms)
+
+Denoted gate hold lengths are defaults. They can be changed in the
+[configuration file](#Configuration).
+
+## Controls
+
+* K1: simulation speed
+* K2: impulse speed
+* Either button + K1: ball count
+* Either button + K2: width
+* B1 short press: reset
+* B2 short press: send impulse
+* Digital input: send impulse (customisable)
+* Analogue input: speed (customisable)
+
+Second layer knobs are "locking", which is to say you need to physically set the
+knob to a position close to the registered value in order for it to latch and
+start changing. This is to prevent abrupt changes in values when you press the
+buttons. If you lose track of a knob position, try sweeping the full range to
+get it to latch.
+
+The short press threshold is set to 500ms by default. You can adjust this
+length in the [configuration file](#Configuration).
+
+The digital input can be set to trigger a reset instead of an impulse. Likewise,
+the analogue input can be set to control any of the parameters controlled by
+knobs. These behaviours can be defined [configuration file](#Configuration).
+
+## Configuration
+
+The script can be thoroughly customised using a configuration file. The
+configuration file must be located at `/config/BouncingPixels.json` on the micro
+controller.
+
+### Sample configuration file
+
+```json
+{
+    "long_press_length": 500.0,
+    "timescale_min": 0.0,
+    "timescale_max": 100.0,
+    "knob_change_threshold": 0.01,
+    "din_function": "impulse",
+    "ain_function": "speed",
+    "gate_hold_length_top": 25.0,
+    "gate_hold_length_left": 25.0,
+    "gate_hold_length_right": 25.0,
+    "gate_hold_length_bottom": 25.0,
+    "gate_hold_length_any": 10.0,
+    "gate_hold_length_corner": 100.0,
+    "arena_width_min": 30.0,
+    "arena_width_max": 1920.0,
+    "ball_count_max": 100,
+    "ball_count_min": 1,
+    "corner_collision_margin": 15.0,
+    "start_speed_min": 10.0,
+    "start_speed_max": 100.0,
+    "accel_min": -5.0,
+    "accel_max": 5.0,
+    "bounciness_min": 0.8,
+    "bounciness_max": 1.2,
+    "bounce_angle_deviation_max": 15.0,
+    "under_speed_behaviour": "reset",
+    "over_speed_behaviour": "reset",
+    "under_speed_threshold": 5.0,
+    "over_speed_threshold": 1000000,
+    "impulse_speed_min": 0,
+    "impulse_speed_max": 100000,
+    "impulse_speed_variation_min": 0.5,
+    "impulse_speed_variation_max": 2.0,
+}
+```
+
+### Configuration values
+
+| Key                           | Type                  | Possible values                                        | Default value | Description |
+|-------------------------------|-----------------------|--------------------------------------------------------|---------------|-------------|
+| `poll_frequency`              | Floating point number | 5 - 120                                                | 20            | How frequently the application polls for new inputs, expressed as times per second.<br><br>⚠️ **Changing this value is not recommended.** |
path: software/contrib/bouncing_pixels.md
user: login: civerachb-cpr
body: Why limit the polling frequency, instead of just polling the inputs as fast as possible so you can respond immediately? I haven't read through the code exhaustively, so maybe that's explained elsewhere and I just haven't read that yet.
created_at: 2025-04-15T22:24:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,617 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.math_extras import rescale
+from experimental.knobs import KnobBank
+import configuration
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import sleep_ms, ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event():
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+        
+    def __iadd__(self, handler):
+        self.handlers.append(handler)
+        return self
+        
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena():
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+        
+        # Height is constant
+        self.height = ARENA_HEIGHT # Simulated size
+        self.display_height = oled.height # Displayed arena height
+        self.draw_y_min = 0 # First y coordinate for the displayed arena
+        self.draw_y_max = self.draw_y_min + self.display_height # Last y coordinate for the displayed arena
+        
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = oled.width * self.height / oled.height
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+        
+    def update_width(self, aspect_ratio):
+        """Set the arena's width according to given aspect ratio.
+        """
+        prev_width = self.width
+        self.width = int(self.height * aspect_ratio)
+        self.display_width = int(self.display_height * aspect_ratio)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+        
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball():
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.impulse_speed_multiplier = 1.0
+        self.arena = arena
+        
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+        
+        self.reset()
+        
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+    
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(
+            self.pos.real,
+            0, arena.width,
+            arena.draw_x_min, arena.draw_x_max
+        ))
+        
+        y = int(rescale(
+            self.pos.imag,
+            0, arena.height,
+            arena.draw_y_min, arena.draw_y_max
+        ))
+        
+        oled.pixel(x, y, 1)
+                
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the pixel's x so that it's the same relative distance from the edges with new_width as it was with old_width.
+        """
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+        
+    def reset(self):
+        """Reset the pixel, randomising its position, velocity, bounciness, acceleration, and marking it as active.
+        """
+        self.pos = complex(
+            uniform(0, self.arena.width),
+            uniform(0, self.arena.height)
+        )
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max),
+            uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the pixel.
+        """
+        self.velocity += rect(
+            uniform(self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max) * self.impulse_speed,
+            uniform(0, tau)
+        )
+        
+    def deactivate(self):
+        """Set the pixel to inactive, meaning it won't be processed by functions that are wrapped by @if_active.
+        """
+        self.active = False
+    
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour.
+        """
+        pass
+    
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+                
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+        
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+        
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+        
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1: # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1: # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if self.pos.imag < self.config.corner_collision_margin or self.arena.height - self.pos.imag < self.config.corner_collision_margin:
+                self.on_collide.emit(COLLISION_ID_CORNER)
+            
+            speed *= self.bounciness
+            direction = pi - direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        if collide_y != 0:
+            if collide_y != 1: # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1: # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+            
+            speed *= self.bounciness
+            direction = -direction + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+        
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+            
+        # Travelled more than one width or height in one tick.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+            
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+        
+class Gate():
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+        
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+        
+    def off(self):
+        self.cv.off()
+        self.opened = None
+        
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_locked_knob('speed', initial_percentage_value=saved_state.get('speed', 0.5))
path: software/contrib/bouncing_pixels.py
user: login: civerachb-cpr
body: I'm definitely a proponent of keeping the most-used features as the initial/primary knobs, and lesser-used ones as the shift-knobs. Speed & width feel the most useful to me, so I'd keep those as the main ones if possible. I can see an argument for number of balls being more interesting than one of those two, though. Really up to you, but the impulse feels like a secondary feature for sure.

The impulse speed feels like a secondary feature to me, but if you're happy keeping it as a primary knob function I won't tell you you're wrong; it's your program after all.
created_at: 2025-04-15T22:29:35Z
author_association: NONE
diff_hunk: @@ -0,0 +1,667 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.arena_width_max
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.arena_width_min, self.config.arena_width_max)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.bounce_angle_deviation_max = radians(config.bounce_angle_deviation_max)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.over_speed_behaviour))
+        self.on_under_speed = Event(getattr(self, config.under_speed_behaviour))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.start_speed_min, self.config.start_speed_max), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.bounciness_min, self.config.bounciness_max)
+        self.acceleration = uniform(self.config.accel_min, self.config.accel_max)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.impulse_speed_variation_min, self.config.impulse_speed_variation_max
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.corner_collision_margin
+                or self.arena.height - self.pos.imag < self.config.corner_collision_margin
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.bounce_angle_deviation_max, self.bounce_angle_deviation_max)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.bounce_angle_deviation_max, self.bounce_angle_deviation_max
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.over_speed_threshold:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.under_speed_threshold:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.ain_function, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.ain_function}_ain_term"),
+            getattr(self, f"apply_{self.config.ain_function}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.ball_count_max)
+        ]
+        hold_lengths = [
+            self.config.gate_hold_length_top,
+            self.config.gate_hold_length_left,
+            self.config.gate_hold_length_right,
+            self.config.gate_hold_length_bottom,
+            self.config.gate_hold_length_any,
+            self.config.gate_hold_length_corner,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.din_function))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "poll_frequency", minimum=5.0, maximum=120.0, default=20.0, danger=True
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Noted and addressed, thank you :)
created_at: 2025-04-16T13:11:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,201 @@
+# Bouncing Pixels
+
+author: [Jorin](https://github.com/jorins)
+
+date: 2025-04-12
+
+labels: gates, random, simulation, triggers
+
+Pixels bounce around the display and trigger gates when hitting the edges.
+Inspired by the classic bouncing DVD logo!
+
+## Usage
+
+Just boot up the script and you're already going! You can use the knobs to
+adjust several parameters. The analogue input can be bound to any of them.
+By default, it affects the speed. The analogue input is summed with the knob
+value of its affected parameter, meaning that the knob sets a minimum value
+for the analogue input to add to. Note that since the EuroPi takes analogue
+inputs between 0V and 10V, AC signals must be biased properly or the bottom
+half of the input will be clipped.
+
+### Parameters
+
+* *Speed* affects how fast the simulation runs. It translates to how fast the
+  pixels move.
+* *Impulse speed* controls how much speed is added to each pixel when an impulse
+  occurs.
+* *Width* controls how wide the playing field is.
+* *Ball count* controls how many pixels are in play.
+
+### Impulses
+
+Impulses can be caused globally by pressing B2 or sending a signal to the
+digital input (assuming it has not been reconfigured). When an impulse occurs,
+velocity in a random direction. The speed added is the product of the impulse
+speed parameter and a random number (between 0.5 and 2.0 by default).
+
+### Deactivation
+
+One possible over and under speed behaviour is that pixels are deactivated.
+When deactivated, a pixel will not be processed or rendered until reset. Resets
+can be manually triggered by pressing B1 or using the digital input (must be
+configured). A reset is automatically triggered when all pixels in play are
+deactivated. 
+
+## Outputs
+
+* CV1: collision with top edge (25ms)
+* CV2: collision with left edge (25ms)
+* CV3: collision with right edge (25ms)
+* CV4: collision with bottom edge (25ms)
+* CV5: collision with any edge (10ms)
+* CV6: collision with corner (100ms)
+
+Denoted gate hold lengths are defaults. They can be changed in the
+[configuration file](#Configuration).
+
+## Controls
+
+* K1: simulation speed
+* K2: impulse speed
+* Either button + K1: ball count
+* Either button + K2: width
+* B1 short press: reset
+* B2 short press: send impulse
+* Digital input: send impulse (customisable)
+* Analogue input: speed (customisable)
+
+Second layer knobs are "locking", which is to say you need to physically set the
+knob to a position close to the registered value in order for it to latch and
+start changing. This is to prevent abrupt changes in values when you press the
+buttons. If you lose track of a knob position, try sweeping the full range to
+get it to latch.
+
+The short press threshold is set to 500ms by default. You can adjust this
+length in the [configuration file](#Configuration).
+
+The digital input can be set to trigger a reset instead of an impulse. Likewise,
+the analogue input can be set to control any of the parameters controlled by
+knobs. These behaviours can be defined [configuration file](#Configuration).
+
+## Configuration
+
+The script can be thoroughly customised using a configuration file. The
+configuration file must be located at `/config/BouncingPixels.json` on the micro
+controller.
+
+### Sample configuration file
+
+```json
+{
+    "long_press_length": 500.0,
+    "timescale_min": 0.0,
+    "timescale_max": 100.0,
+    "knob_change_threshold": 0.01,
+    "din_function": "impulse",
+    "ain_function": "speed",
+    "gate_hold_length_top": 25.0,
+    "gate_hold_length_left": 25.0,
+    "gate_hold_length_right": 25.0,
+    "gate_hold_length_bottom": 25.0,
+    "gate_hold_length_any": 10.0,
+    "gate_hold_length_corner": 100.0,
+    "arena_width_min": 30.0,
+    "arena_width_max": 1920.0,
+    "ball_count_max": 100,
+    "ball_count_min": 1,
+    "corner_collision_margin": 15.0,
+    "start_speed_min": 10.0,
+    "start_speed_max": 100.0,
+    "accel_min": -5.0,
+    "accel_max": 5.0,
+    "bounciness_min": 0.8,
+    "bounciness_max": 1.2,
+    "bounce_angle_deviation_max": 15.0,
+    "under_speed_behaviour": "reset",
+    "over_speed_behaviour": "reset",
+    "under_speed_threshold": 5.0,
+    "over_speed_threshold": 1000000,
+    "impulse_speed_min": 0,
+    "impulse_speed_max": 100000,
+    "impulse_speed_variation_min": 0.5,
+    "impulse_speed_variation_max": 2.0,
+}
+```
+
+### Configuration values
+
+| Key                           | Type                  | Possible values                                        | Default value | Description |
+|-------------------------------|-----------------------|--------------------------------------------------------|---------------|-------------|
+| `poll_frequency`              | Floating point number | 5 - 120                                                | 20            | How frequently the application polls for new inputs, expressed as times per second.<br><br>⚠️ **Changing this value is not recommended.** |
path: software/contrib/bouncing_pixels.md
user: login: jorins
body: I ran some benchmarking and it comes out to about 1500 µs per call to `poll`. Ticks go as low as 500 µs, although they can go higher with more balls in play. Since input latency isn't hugely important on this script as inputs just affect the simulation and not the outputs, I figure increased accuracy in the simulation is a fair trade for a bit less responsive controls.

That said, I opted to raise the default to 30Hz so that it matches the rendering. It should at least look fully responsive now.
created_at: 2025-04-16T13:20:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: [required]
The Button class has a `.last_pressed()` function that returns the ticks_ms that the button was last pressed. There's no need to track this with `self.b1_pressed` and `self.b2_pressed`
created_at: 2025-04-23T20:45:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.SAVE_PERIOD:
+                self.save_state()
+
+            self.tick(delta * self.time_factor)
+            prev_cycle = cycle_start
+
+    def render_thread(self):
+        """Render at limited frequency."""
+        render_period = 1000.0 / self.config.RENDER_FREQUENCY
+        while True:
+            cycle_start = ticks_ms()
+            self.render(None)
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
+
+
+if __name__ == "__main__":
+    BouncingPixels().main()
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: [required]
Blank newline at the end of the file, please. It wasn't previously enforced, but it makes a lot of editors happier to include it.
created_at: 2025-04-23T20:48:27Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: [required]
Using both threads can cause issues when connecting to Thonny, as the secondary thread can continue to run in the background.

Can you please follow the examples in https://github.com/Allen-Synthesis/EuroPi/blob/main/software/MULTITHREADING.md to force both threads to terminate when the USB connection state changes?
created_at: 2025-04-23T20:50:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: [optional]
`europi.turn_off_all_cvs()` does the same thing, if you don't want to loop through the outputs yourself.
created_at: 2025-04-23T20:54:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,201 @@
+# Bouncing Pixels
+
+author: [Jorin](https://github.com/jorins)
+
+date: 2025-04-12
+
+labels: gates, random, simulation, triggers
+
+Pixels bounce around the display and trigger gates when hitting the edges.
+Inspired by the classic bouncing DVD logo!
+
+## Usage
+
+Just boot up the script and you're already going! You can use the knobs to
+adjust several parameters. The analogue input can be bound to any of them.
+By default, it affects the speed. The analogue input is summed with the knob
+value of its affected parameter, meaning that the knob sets a minimum value
+for the analogue input to add to. Note that since the EuroPi takes analogue
+inputs between 0V and 10V, AC signals must be biased properly or the bottom
+half of the input will be clipped.
+
+### Parameters
+
+* *Speed* affects how fast the simulation runs. It translates to how fast the
+  pixels move.
+* *Impulse speed* controls how much speed is added to each pixel when an impulse
+  occurs.
+* *Width* controls how wide the playing field is.
+* *Ball count* controls how many pixels are in play.
+
+### Impulses
+
+Impulses can be caused globally by pressing B2 or sending a signal to the
+digital input (assuming it has not been reconfigured). When an impulse occurs,
+velocity in a random direction. The speed added is the product of the impulse
+speed parameter and a random number (between 0.5 and 2.0 by default).
+
+### Deactivation
+
+One possible over and under speed behaviour is that pixels are deactivated.
+When deactivated, a pixel will not be processed or rendered until reset. Resets
+can be manually triggered by pressing B1 or using the digital input (must be
+configured). A reset is automatically triggered when all pixels in play are
+deactivated. 
+
+## Outputs
+
+* CV1: collision with top edge (25ms)
+* CV2: collision with left edge (25ms)
+* CV3: collision with right edge (25ms)
+* CV4: collision with bottom edge (25ms)
+* CV5: collision with any edge (10ms)
+* CV6: collision with corner (100ms)
+
+Denoted gate hold lengths are defaults. They can be changed in the
+[configuration file](#Configuration).
+
+## Controls
+
+* K1: simulation speed
+* K2: impulse speed
+* Either button + K1: ball count
+* Either button + K2: width
+* B1 short press: reset
+* B2 short press: send impulse
+* Digital input: send impulse (customisable)
+* Analogue input: speed (customisable)
+
+Second layer knobs are "locking", which is to say you need to physically set the
+knob to a position close to the registered value in order for it to latch and
+start changing. This is to prevent abrupt changes in values when you press the
+buttons. If you lose track of a knob position, try sweeping the full range to
+get it to latch.
+
+The short press threshold is set to 500ms by default. You can adjust this
+length in the [configuration file](#Configuration).
+
+The digital input can be set to trigger a reset instead of an impulse. Likewise,
+the analogue input can be set to control any of the parameters controlled by
+knobs. These behaviours can be defined [configuration file](#Configuration).
+
+## Configuration
+
+The script can be thoroughly customised using a configuration file. The
+configuration file must be located at `/config/BouncingPixels.json` on the micro
+controller.
+
+### Sample configuration file
+
+```json
+{
+    "LONG_PRESS_LENGTH": 500.0,
+    "TIMESCALE_MIN": 0.0,
+    "TIMESCALE_MAX": 100.0,
+    "KNOB_CHANGE_THRESHOLD": 0.01,
+    "DIN_FUNCTION": "impulse",
+    "AIN_FUNCTION": "speed",
+    "GATE_HOLD_LENGTH_TOP": 25.0,
+    "GATE_HOLD_LENGTH_LEFT": 25.0,
+    "GATE_HOLD_LENGTH_RIGHT": 25.0,
+    "GATE_HOLD_LENGTH_BOTTOM": 25.0,
+    "GATE_HOLD_LENGTH_ANY": 10.0,
+    "GATE_HOLD_LENGTH_CORNER": 100.0,
+    "ARENA_WIDTH_MIN": 30.0,
+    "ARENA_WIDTH_MAX": 1920.0,
+    "BALL_COUNT_MAX": 100,
+    "BALL_COUNT_MIN": 1,
+    "CORNER_COLLISION_MARGIN": 15.0,
+    "START_SPEED_MIN": 10.0,
+    "START_SPEED_MAX": 100.0,
+    "ACCEL_MIN": -5.0,
+    "ACCEL_MAX": 5.0,
+    "BOUNCINESS_MIN": 0.8,
+    "BOUNCINESS_MAX": 1.2,
+    "BOUNCE_ANGLE_DEVIATION_MAX": 15.0,
+    "UNDER_SPEED_BEHAVIOUR": "reset",
+    "OVER_SPEED_BEHAVIOUR": "reset",
+    "UNDER_SPEED_THRESHOLD": 5.0,
+    "OVER_SPEED_THRESHOLD": 1000000,
+    "IMPULSE_SPEED_MIN": 0,
+    "IMPULSE_SPEED_MAX": 100000,
+    "IMPULSE_SPEED_VARIATION_MIN": 0.5,
+    "IMPULSE_SPEED_VARIATION_MAX": 2.0,
+}
+```
+
+### Configuration values
+
+| Key                           | Type                  | Possible values                                        | Default value | Description |
+|-------------------------------|-----------------------|--------------------------------------------------------|---------------|-------------|
+| `POLL_FREQUENCY`              | Floating point number | >= 5                                                   | 30            | How frequently the application polls for new inputs, expressed as times per second.<br><br>⚠️ **Changing this value is not recommended.** |
path: software/contrib/bouncing_pixels.md
user: login: chrisib
body: [question]
Why create config points for these properties if they shouldn't be edited? Why not just write them as constants directly in the code?
created_at: 2025-04-23T20:55:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: `self.b1_pressed` and respective b2 value are set to `None` when the button is released. As such the variable runs a double duty of indicating whether the button is pressed, and when it was pressed. `Button.last_pressed()` does not seem to do this. I could rewrite this to track state and time separately, but I found this to be a quiet elegant solution.
created_at: 2025-05-13T10:46:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.SAVE_PERIOD:
+                self.save_state()
+
+            self.tick(delta * self.time_factor)
+            prev_cycle = cycle_start
+
+    def render_thread(self):
+        """Render at limited frequency."""
+        render_period = 1000.0 / self.config.RENDER_FREQUENCY
+        while True:
+            cycle_start = ticks_ms()
+            self.render(None)
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
+
+
+if __name__ == "__main__":
+    BouncingPixels().main()
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: I've got a blank line 671 showing in both Thonny and VS Code. Running black gives no changes either. But I'm not seeing it on GitHub. Could it be a CRLF vs LF issue? VS Code says I'm using CRLF in this file. Which do we prefer in this project?
created_at: 2025-05-13T10:51:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: `Gate.off()` also resets its opened timer to `None`, so that substitution will not quite work. Though looking back at it, I'm not sure that really achieves anything. I'll revisit this in a while.
created_at: 2025-05-13T10:55:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: There's a bit more I should revisit in those examples for sure.
created_at: 2025-05-13T10:57:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,201 @@
+# Bouncing Pixels
+
+author: [Jorin](https://github.com/jorins)
+
+date: 2025-04-12
+
+labels: gates, random, simulation, triggers
+
+Pixels bounce around the display and trigger gates when hitting the edges.
+Inspired by the classic bouncing DVD logo!
+
+## Usage
+
+Just boot up the script and you're already going! You can use the knobs to
+adjust several parameters. The analogue input can be bound to any of them.
+By default, it affects the speed. The analogue input is summed with the knob
+value of its affected parameter, meaning that the knob sets a minimum value
+for the analogue input to add to. Note that since the EuroPi takes analogue
+inputs between 0V and 10V, AC signals must be biased properly or the bottom
+half of the input will be clipped.
+
+### Parameters
+
+* *Speed* affects how fast the simulation runs. It translates to how fast the
+  pixels move.
+* *Impulse speed* controls how much speed is added to each pixel when an impulse
+  occurs.
+* *Width* controls how wide the playing field is.
+* *Ball count* controls how many pixels are in play.
+
+### Impulses
+
+Impulses can be caused globally by pressing B2 or sending a signal to the
+digital input (assuming it has not been reconfigured). When an impulse occurs,
+velocity in a random direction. The speed added is the product of the impulse
+speed parameter and a random number (between 0.5 and 2.0 by default).
+
+### Deactivation
+
+One possible over and under speed behaviour is that pixels are deactivated.
+When deactivated, a pixel will not be processed or rendered until reset. Resets
+can be manually triggered by pressing B1 or using the digital input (must be
+configured). A reset is automatically triggered when all pixels in play are
+deactivated. 
+
+## Outputs
+
+* CV1: collision with top edge (25ms)
+* CV2: collision with left edge (25ms)
+* CV3: collision with right edge (25ms)
+* CV4: collision with bottom edge (25ms)
+* CV5: collision with any edge (10ms)
+* CV6: collision with corner (100ms)
+
+Denoted gate hold lengths are defaults. They can be changed in the
+[configuration file](#Configuration).
+
+## Controls
+
+* K1: simulation speed
+* K2: impulse speed
+* Either button + K1: ball count
+* Either button + K2: width
+* B1 short press: reset
+* B2 short press: send impulse
+* Digital input: send impulse (customisable)
+* Analogue input: speed (customisable)
+
+Second layer knobs are "locking", which is to say you need to physically set the
+knob to a position close to the registered value in order for it to latch and
+start changing. This is to prevent abrupt changes in values when you press the
+buttons. If you lose track of a knob position, try sweeping the full range to
+get it to latch.
+
+The short press threshold is set to 500ms by default. You can adjust this
+length in the [configuration file](#Configuration).
+
+The digital input can be set to trigger a reset instead of an impulse. Likewise,
+the analogue input can be set to control any of the parameters controlled by
+knobs. These behaviours can be defined [configuration file](#Configuration).
+
+## Configuration
+
+The script can be thoroughly customised using a configuration file. The
+configuration file must be located at `/config/BouncingPixels.json` on the micro
+controller.
+
+### Sample configuration file
+
+```json
+{
+    "LONG_PRESS_LENGTH": 500.0,
+    "TIMESCALE_MIN": 0.0,
+    "TIMESCALE_MAX": 100.0,
+    "KNOB_CHANGE_THRESHOLD": 0.01,
+    "DIN_FUNCTION": "impulse",
+    "AIN_FUNCTION": "speed",
+    "GATE_HOLD_LENGTH_TOP": 25.0,
+    "GATE_HOLD_LENGTH_LEFT": 25.0,
+    "GATE_HOLD_LENGTH_RIGHT": 25.0,
+    "GATE_HOLD_LENGTH_BOTTOM": 25.0,
+    "GATE_HOLD_LENGTH_ANY": 10.0,
+    "GATE_HOLD_LENGTH_CORNER": 100.0,
+    "ARENA_WIDTH_MIN": 30.0,
+    "ARENA_WIDTH_MAX": 1920.0,
+    "BALL_COUNT_MAX": 100,
+    "BALL_COUNT_MIN": 1,
+    "CORNER_COLLISION_MARGIN": 15.0,
+    "START_SPEED_MIN": 10.0,
+    "START_SPEED_MAX": 100.0,
+    "ACCEL_MIN": -5.0,
+    "ACCEL_MAX": 5.0,
+    "BOUNCINESS_MIN": 0.8,
+    "BOUNCINESS_MAX": 1.2,
+    "BOUNCE_ANGLE_DEVIATION_MAX": 15.0,
+    "UNDER_SPEED_BEHAVIOUR": "reset",
+    "OVER_SPEED_BEHAVIOUR": "reset",
+    "UNDER_SPEED_THRESHOLD": 5.0,
+    "OVER_SPEED_THRESHOLD": 1000000,
+    "IMPULSE_SPEED_MIN": 0,
+    "IMPULSE_SPEED_MAX": 100000,
+    "IMPULSE_SPEED_VARIATION_MIN": 0.5,
+    "IMPULSE_SPEED_VARIATION_MAX": 2.0,
+}
+```
+
+### Configuration values
+
+| Key                           | Type                  | Possible values                                        | Default value | Description |
+|-------------------------------|-----------------------|--------------------------------------------------------|---------------|-------------|
+| `POLL_FREQUENCY`              | Floating point number | >= 5                                                   | 30            | How frequently the application polls for new inputs, expressed as times per second.<br><br>⚠️ **Changing this value is not recommended.** |
path: software/contrib/bouncing_pixels.md
user: login: jorins
body: I figure some users might prefer better responsiveness in polling at the cost of lower simulation frequency. Or the pico 2 might perform well enough that some of these values can be bumped without any real issue. I've only tested with a pico 1 so far.
created_at: 2025-05-13T11:00:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
+            cycle_start = ticks_ms()
+            delta = ticks_diff(cycle_start, prev_cycle) / 1000
+
+            # Poll inputs at limited frequency
+            time_since_poll = ticks_diff(cycle_start, last_poll)
+            if time_since_poll > poll_period:
+                self.poll()
+                last_poll = cycle_start
+
+            # Save at limited frequency
+            if not self.state_saved and self.last_saved() > self.config.SAVE_PERIOD:
+                self.save_state()
+
+            self.tick(delta * self.time_factor)
+            prev_cycle = cycle_start
+
+    def render_thread(self):
+        """Render at limited frequency."""
+        render_period = 1000.0 / self.config.RENDER_FREQUENCY
+        while True:
+            cycle_start = ticks_ms()
+            self.render(None)
+            cycle_finish = ticks_ms()
+            time_taken = ticks_diff(cycle_finish, cycle_start)
+            wait = int(max(0.0, render_period - time_taken))
+            sleep_ms(wait)
+
+
+if __name__ == "__main__":
+    BouncingPixels().main()
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: Generally everything should be LF with the exception of the docker-building .bat file.
Might just be a github rendering thing though, so don't worry about it.
created_at: 2025-05-14T00:43:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
path: software/contrib/bouncing_pixels.py
user: login: chrisib
body: I've actually been doing some more work on this and have found an even better but still simple solution to handling the background thread exiting. Please refer to https://github.com/Allen-Synthesis/EuroPi/pull/434
created_at: 2025-05-14T00:44:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: I've added the recommended precautions. There's still some issue with stopping the script in Thonny for me though. Even though I can see both threads reach their respective termination message (the `finally` blocks), Thonny finds the device to be non-responsive. Hitting the interrupt again gets you a prompt where you can run `machine.reset()`, but trying to run the script again without resetting nets you a crash it just hangs, in my case after a single message about missing config files.

When exiting, it looks as follows:

```
Render thread finished
User aborted. Exiting.
>Proc thread finished

Device is busy or does not respond. Your options:

  - wait until it completes current work;
  - use Ctrl+C to interrupt current work;
  - reset the device and try again;
  - check connection properties;
  - make sure the device has suitable MicroPython / CircuitPython / firmware;
  - make sure the device is not in bootloader mode.
```

Swapping the threads will swap the printed messages too; it's always the main thread that appears in red, after the "User aborted" message. Not too sure where to proceed from here. The script works fine and you can quit to the menu all the same. `machine.reset()` in the `main` function's `finally` block is an option that works, but it doesn't seem like a clean solution.
created_at: 2025-05-20T14:35:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Looking back, I believe I used `Gate.opened = None` as a safeguard so as to not constantly call `cv.off` when gates are expired. I'm not certain that's a strictly necessary safeguard, but I figure I'd rather not constantly make calls to change the hardware outputs.
created_at: 2025-05-20T14:47:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,670 @@
+# Copyright 2025 Allen Synthesis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A bunch of pixels bouncing around, inspired by the beloved bouncing DVD logo.
+A gate is triggered each time a wall is hit. See `bouncing_pixels.md` for more information.
+
+@author  Jorin (jorin.se)
+@year    2025
+"""
+
+try:
+    # Local development
+    from software.firmware.europi import *
+    from software.firmware.europi_script import EuroPiScript
+    from software.firmware.experimental.math_extras import rescale
+    from software.firmware.experimental.knobs import KnobBank
+    from software.firmware import configuration
+
+except ImportError:
+    # Device import paths
+    from europi import *  # type: ignore
+    from europi_script import EuroPiScript  # type: ignore
+    from experimental.math_extras import rescale  # type: ignore
+    from experimental.knobs import KnobBank  # type: ignore
+    import configuration  # type: ignore
+
+from _thread import start_new_thread
+from cmath import phase, polar, rect
+from machine import Timer
+from math import degrees, e, inf, log, pi, radians
+from random import uniform
+from time import ticks_ms, ticks_diff, sleep_ms
+
+tau = pi * 2
+
+ARENA_HEIGHT = 480
+UNITS_PER_PIXEL = ARENA_HEIGHT / oled.height
+
+COLLISION_ID_UP = 0
+COLLISION_ID_LEFT = 1
+COLLISION_ID_RIGHT = 2
+COLLISION_ID_DOWN = 3
+COLLISION_ID_ANY = 4
+COLLISION_ID_CORNER = 5
+
+
+def exponential_interpolation(a, b, t):
+    """Exponentially interpolate between a and b by t, where t=0 gives a and t=1 gives b.
+    This function uses e as the base which hopefully gives a "natural" feeling curve.
+    """
+    return e ** (t * log(b + 1 - a)) - 1 + a
+
+
+class Event:
+    """A simple, synchronous event implementation. Handlers can be added either when instantiating,
+    or by using the += operator. The event is then emitted using the emit function.
+    """
+
+    def __init__(self, *handlers):
+        # *handlers is a tuple, so we must explicitly create a mutable list to add given handlers to.
+        self.handlers = []
+        self.handlers.extend(handlers)
+
+    def __iadd__(self, handler):
+        """Register an event handling function."""
+        self.handlers.append(handler)
+        return self
+
+    def emit(self, *args, **kwargs):
+        """Emit the event, calling each handler synchronously in the order they were added.
+        Arguments are passed through to each handler, so it is up to the user to ensure that
+        the arguments match up.
+        """
+        for handler in self.handlers:
+            handler(*args, **kwargs)
+
+
+class Arena:
+    def __init__(self, config):
+        self.config = config
+        self.on_width_changed = Event()
+
+        # Height is constant, unlike width.
+        self.height = ARENA_HEIGHT  # Simulated size
+        self.display_height = oled.height  # Displayed arena height
+        self.draw_y_min = 0  # First y coordinate for the displayed arena
+        self.draw_y_max = (  # Last y coordinate for the displayed arena
+            self.draw_y_min + self.display_height
+        )
+
+        # Width is variable, but we initialise its variables here to ensure it can be referenced.
+        self.width = config.ARENA_WIDTH_MAX
+        self.display_width = oled.width
+        self.draw_x_min = 0
+        self.draw_x_max = oled.width
+
+    def set_width(self, width):
+        """Set the arena's width."""
+        prev_width = self.width
+        self.width = clamp(width, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX)
+        self.display_width = int(self.width / UNITS_PER_PIXEL)
+        self.draw_x_min = int(oled.width / 2 - self.display_width / 2)
+        self.draw_x_max = self.draw_x_min + self.display_width
+        self.on_width_changed.emit(prev_width, self.width)
+
+    def draw_boundary(self):
+        """Draw the boundaries of the arena, one pixel outside of draw_x_min and draw_x_max.
+        This will fall outside of the bounds of the display when the arena covers the full screen, which is intentional.
+        """
+        oled.vline(self.draw_x_min - 1, 0, oled.height, 1)
+        oled.vline(self.draw_x_max + 1, 0, oled.height, 1)
+
+
+class Ball:
+    def __init__(self, arena, config):
+        self.config = config
+        self.BOUNCE_ANGLE_DEVIATION_MAX = radians(config.BOUNCE_ANGLE_DEVIATION_MAX)
+        self.arena = arena
+
+        self.on_over_speed = Event(getattr(self, config.OVER_SPEED_BEHAVIOUR))
+        self.on_under_speed = Event(getattr(self, config.UNDER_SPEED_BEHAVIOUR))
+        self.on_collide = Event()
+        arena.on_width_changed += self.translate_x
+
+        self.reset()
+
+    def __repr__(self):
+        speed, direction = polar(self.velocity)
+        return f"Ball at ({self.pos.real}, {self.pos.imag}), velocity {speed}/s {int(degrees(direction))}°"
+
+    def if_active(fn):
+        """Functions wrapped with this function will only run if self.active is true.
+        This is needed to avoid processing when over/under speed behaviour is set to deactivate.
+        """
+
+        def inner(self, *args, **kwargs):
+            if not self.active:
+                return
+            fn(self, *args, **kwargs)
+
+        return inner
+
+    @if_active
+    def draw(self):
+        arena = self.arena
+        x = int(rescale(self.pos.real, 0, arena.width, arena.draw_x_min, arena.draw_x_max))
+        y = int(rescale(self.pos.imag, 0, arena.height, arena.draw_y_min, arena.draw_y_max))
+        oled.pixel(x, y, 1)
+
+    def translate_x(self, old_width: float, new_width: float):
+        """Translate the ball's x so that it's the same relative distance from the edges with new_width as it was with old_width."""
+        new_x = new_width * self.pos.real / old_width
+        self.pos = complex(new_x, self.pos.imag)
+
+    def reset(self):
+        """Reset the ball, randomising its position, velocity, bounciness, acceleration, and marking it as active."""
+        self.pos = complex(uniform(0, self.arena.width), uniform(0, self.arena.height))
+        self.velocity = rect(
+            uniform(self.config.START_SPEED_MIN, self.config.START_SPEED_MAX), uniform(0, tau)
+        )
+        self.bounciness = uniform(self.config.BOUNCINESS_MIN, self.config.BOUNCINESS_MAX)
+        self.acceleration = uniform(self.config.ACCEL_MIN, self.config.ACCEL_MAX)
+        self.active = True
+
+    @if_active
+    def impulse(self):
+        """Apply an impulse of speed in a random direction to the ball."""
+        self.velocity += rect(
+            uniform(
+                self.config.IMPULSE_SPEED_VARIATION_MIN, self.config.IMPULSE_SPEED_VARIATION_MAX
+            )
+            * self.impulse_speed,
+            uniform(0, tau),
+        )
+
+    def deactivate(self):
+        """Set the ball to inactive, meaning it won't be processed by functions that are wrapped by @if_active."""
+        self.active = False
+
+    def noop(self):
+        """Do nothing. This is used as a possible under speed behaviour."""
+        pass
+
+    @if_active
+    def tick(self, delta: float):
+        # Apply acceleration
+        delta_v = rect(self.acceleration * delta, phase(self.velocity))
+        self.velocity += delta_v
+
+        # Grab the speed and direction of the resulting velocity for later use
+        speed, direction = polar(self.velocity)
+
+        # Apply velocity and check whether collisions happened
+        delta_x = self.velocity.real * delta
+        delta_y = self.velocity.imag * delta
+
+        collide_x = (self.pos.real + delta_x) // self.arena.width
+        collide_y = (self.pos.imag + delta_y) // self.arena.height
+
+        self.pos += complex(
+            delta_x - delta_x * abs(collide_x) * 2,
+            delta_y - delta_y * abs(collide_y) * 2,
+        )
+
+        if collide_x != 0:
+            if collide_x != 1:  # Left
+                self.on_collide.emit(COLLISION_ID_LEFT)
+            if collide_x != -1:  # Right
+                self.on_collide.emit(COLLISION_ID_RIGHT)
+            if (
+                self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+                or self.arena.height - self.pos.imag < self.config.CORNER_COLLISION_MARGIN
+            ):
+                self.on_collide.emit(COLLISION_ID_CORNER)
+
+            speed *= self.bounciness
+            direction = (
+                pi
+                - direction
+                + uniform(-self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX)
+            )
+
+        if collide_y != 0:
+            if collide_y != 1:  # Up
+                self.on_collide.emit(COLLISION_ID_UP)
+            if collide_y != -1:  # Down
+                self.on_collide.emit(COLLISION_ID_DOWN)
+
+            speed *= self.bounciness
+            direction = -direction + uniform(
+                -self.BOUNCE_ANGLE_DEVIATION_MAX, self.BOUNCE_ANGLE_DEVIATION_MAX
+            )
+
+        # Update velocity based on changes to speed and direction stemming from collisions
+        self.velocity = rect(speed, direction)
+
+        # Travelled more than one width or height in one tick, or speed is above threshold.
+        # TODO: we should be able to account for multiple collisions in one tick and not need this check
+        if abs(collide_x) > 1 or abs(collide_y) > 1 or speed > self.config.OVER_SPEED_THRESHOLD:
+            self.on_over_speed.emit()
+
+        # Speed too low
+        if speed < self.config.UNDER_SPEED_THRESHOLD:
+            self.on_under_speed.emit()
+
+
+class Gate:
+    def __init__(self, cv, gate_hold_length):
+        self.cv = cv
+        self.gate_hold_length = gate_hold_length
+        self.opened = None
+
+    def on(self):
+        self.cv.on()
+        self.opened = ticks_ms()
+
+    def off(self):
+        self.cv.off()
+        self.opened = None
+
+    def tick(self, delta: float):
+        if self.opened is None:
+            return
+        if ticks_diff(ticks_ms(), self.opened) >= self.gate_hold_length:
+            self.off()
+
+
+class BouncingPixels(EuroPiScript):
+    def __init__(self):
+        super().__init__()
+        saved_state = self.load_state_json()
+        self.state_saved = False
+        self.k1_bank = (
+            KnobBank.builder(k1)
+            .with_unlocked_knob("speed")
+            .with_locked_knob(
+                "ball_count", initial_percentage_value=saved_state.get("ball_count", 0.0)
+            )
+            .build()
+        )
+
+        self.k2_bank = (
+            KnobBank.builder(k2)
+            .with_unlocked_knob("impulse_speed")
+            .with_locked_knob("width", initial_percentage_value=saved_state.get("width", 1.0))
+            .build()
+        )
+
+        # Initialise some internal state variables
+        self.b1_pressed = None
+        self.b2_pressed = None
+        self.ball_count = 1
+        self.time_factor = 1
+
+        # Trackers of inputs
+        # -1 as a starting value means they always get updated on first poll.
+        self.speed_input = -1.0
+        self.width_input = -1.0
+        self.ball_count_input = -1.0
+        self.impulse_speed_input = -1.0
+        self.ain_input = -1.0
+
+        # We store analogue input values in these. Although we only change change one as determined by
+        # config.AIN_FUNCTION, we do need to reference them all when their respective knobs change.
+        self.speed_ain_term = 0.0
+        self.width_ain_term = 0.0
+        self.ball_count_ain_term = 0.0
+        self.impulse_speed_ain_term = 0.0
+
+        # Initialise input events
+        self.on_speed_input = Event(self.mark_state_unsaved, self.apply_speed)
+        self.on_width_input = Event(self.mark_state_unsaved, self.apply_width)
+        self.on_ball_count_input = Event(self.mark_state_unsaved, self.apply_ball_count)
+        self.on_impulse_speed_input = Event(self.mark_state_unsaved, self.apply_impulse_speed)
+        self.on_ain_input = Event(
+            # To avoid excess compute spent on string formatting or dict lookups in the main loop, we keep an assortment of
+            # set and apply functions so that we can assign them once and for all here.
+            getattr(self, f"update_{self.config.AIN_FUNCTION}_ain_term"),
+            getattr(self, f"apply_{self.config.AIN_FUNCTION}"),
+        )
+
+        # Create the playing field and gate abstractions
+        self.arena = Arena(self.config)
+        self.balls = [
+            Ball(arena=self.arena, config=self.config) for _ in range(self.config.BALL_COUNT_MAX)
+        ]
+        hold_lengths = [
+            self.config.GATE_HOLD_LENGTH_TOP,
+            self.config.GATE_HOLD_LENGTH_LEFT,
+            self.config.GATE_HOLD_LENGTH_RIGHT,
+            self.config.GATE_HOLD_LENGTH_BOTTOM,
+            self.config.GATE_HOLD_LENGTH_ANY,
+            self.config.GATE_HOLD_LENGTH_CORNER,
+        ]
+        self.gates = [Gate(cv, hold_length) for cv, hold_length in zip(cvs, hold_lengths)]
+
+        # Register external event handlers.
+        b1.handler(self.b1_rising)
+        b1.handler_falling(self.b1_falling)
+        b2.handler(self.b2_rising)
+        b2.handler_falling(self.b2_falling)
+        din.handler(getattr(self, self.config.DIN_FUNCTION))
+
+        for ball in self.balls:
+            ball.on_collide += self.report_collision
+
+    @classmethod
+    def config_points(cls):
+        return [
+            configuration.floatingPoint(
+                "POLL_FREQUENCY", minimum=5.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "SAVE_PERIOD", minimum=0.0, maximum=inf, default=5000.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "RENDER_FREQUENCY", minimum=1.0, maximum=inf, default=30.0, danger=True
+            ),
+            configuration.floatingPoint(
+                "LONG_PRESS_LENGTH", minimum=0.0, maximum=inf, default=500.0
+            ),
+            configuration.floatingPoint("TIMESCALE_MIN", minimum=-inf, maximum=inf, default=0.0),
+            configuration.floatingPoint("TIMESCALE_MAX", minimum=-inf, maximum=inf, default=100.0),
+            configuration.floatingPoint(
+                "KNOB_CHANGE_THRESHOLD", minimum=0.0, maximum=0.1, default=0.01
+            ),
+            configuration.choice("DIN_FUNCTION", choices=["impulse", "reset"], default="impulse"),
+            configuration.choice(
+                "AIN_FUNCTION",
+                choices=["speed", "impulse_speed", "ball_count", "width"],
+                default="speed",
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_TOP", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_LEFT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_RIGHT", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_BOTTOM", minimum=1.0, maximum=10_000.0, default=25.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_ANY", minimum=1.0, maximum=10_000.0, default=10.0
+            ),
+            configuration.floatingPoint(
+                "GATE_HOLD_LENGTH_CORNER", minimum=1.0, maximum=10_000.0, default=100.0
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MIN",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * 2.0 / oled.height,
+            ),
+            configuration.floatingPoint(
+                "ARENA_WIDTH_MAX",
+                minimum=ARENA_HEIGHT * 2.0 / oled.height,
+                maximum=ARENA_HEIGHT * oled.width / oled.height,
+                default=ARENA_HEIGHT * oled.width / oled.height,
+            ),
+            configuration.integer("BALL_COUNT_MIN", minimum=1, maximum=100, default=1),
+            configuration.integer("BALL_COUNT_MAX", minimum=1, maximum=100, default=100),
+            configuration.floatingPoint(
+                "CORNER_COLLISION_MARGIN",
+                minimum=0.0,
+                maximum=ARENA_HEIGHT / 2.0,
+                default=ARENA_HEIGHT / oled.height,
+            ),
+            configuration.floatingPoint("START_SPEED_MIN", minimum=0.0, maximum=inf, default=10.0),
+            configuration.floatingPoint("START_SPEED_MAX", minimum=0.0, maximum=inf, default=100.0),
+            configuration.floatingPoint("ACCEL_MIN", minimum=-inf, maximum=inf, default=-5.0),
+            configuration.floatingPoint("ACCEL_MAX", minimum=-inf, maximum=inf, default=5.0),
+            configuration.floatingPoint("BOUNCINESS_MIN", minimum=0.0001, maximum=inf, default=0.8),
+            configuration.floatingPoint("BOUNCINESS_MAX", minimum=0.0001, maximum=inf, default=1.2),
+            configuration.floatingPoint(
+                "BOUNCE_ANGLE_DEVIATION_MAX", minimum=0.0, maximum=180.0, default=15.0
+            ),
+            configuration.choice(
+                "UNDER_SPEED_BEHAVIOUR",
+                choices=["impulse", "reset", "deactivate", "noop"],
+                default="reset",
+            ),
+            configuration.choice(
+                "OVER_SPEED_BEHAVIOUR", choices=["reset", "deactivate"], default="reset"
+            ),
+            configuration.floatingPoint(
+                "UNDER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=5.0
+            ),
+            configuration.floatingPoint(
+                "OVER_SPEED_THRESHOLD", minimum=0.0, maximum=inf, default=1.0e6
+            ),
+            configuration.floatingPoint("IMPULSE_SPEED_MIN", minimum=0.0, maximum=inf, default=0.0),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_MAX", minimum=0.0, maximum=inf, default=1.0e5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MIN", minimum=0.0, maximum=inf, default=0.5
+            ),
+            configuration.floatingPoint(
+                "IMPULSE_SPEED_VARIATION_MAX", minimum=0.0, maximum=inf, default=2.0
+            ),
+        ]
+
+    # Button handlers
+    def b1_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b1_pressed = ticks_ms()
+
+    def b1_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b1_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.reset()
+
+        self.b1_pressed = None
+
+    def b2_rising(self):
+        self.k1_bank.set_current("ball_count")
+        self.k2_bank.set_current("width")
+        self.b2_pressed = ticks_ms()
+
+    def b2_falling(self):
+        self.k1_bank.set_current("speed")
+        self.k2_bank.set_current("impulse_speed")
+
+        delta = ticks_diff(ticks_ms(), self.b2_pressed)
+        if delta <= self.config.LONG_PRESS_LENGTH:
+            self.impulse()
+
+        self.b2_pressed = None
+
+    def mark_state_unsaved(self):
+        """Denote that the state saved onto storage does not match current knob state."""
+        self.state_saved = False
+
+    def save_state(self):
+        """Save knob states to storage and denote that storage matches current knob state."""
+        self.save_state_json(
+            {
+                "speed": self.speed_input,
+                "width": self.width_input,
+                "ball_count": self.ball_count_input,
+                "impulse_speed": self.impulse_speed_input,
+            }
+        )
+        self.state_saved = True
+
+    # Event handlers to save analogue input into respective variables
+    def update_speed_ain_term(self):
+        self.speed_ain_term = ain.percent()
+
+    def update_width_ain_term(self):
+        self.width_ain_term = ain.percent()
+
+    def update_ball_count_ain_term(self):
+        self.ball_count_ain_term = ain.percent()
+
+    def update_impulse_speed_ain_term(self):
+        self.impulse_speed_ain_term = ain.percent()
+
+    # Event handlers to apply knob and analogue input values
+    def apply_speed(self):
+        input_sum = self.speed_input + self.speed_ain_term
+        self.time_factor = exponential_interpolation(
+            self.config.TIMESCALE_MIN, self.config.TIMESCALE_MAX, input_sum
+        )
+
+    def apply_width(self):
+        input_sum = self.width_input + self.width_ain_term
+        width = rescale(
+            input_sum, 0.0, 1.0, self.config.ARENA_WIDTH_MIN, self.config.ARENA_WIDTH_MAX
+        )
+        self.arena.set_width(width)
+
+    def apply_ball_count(self):
+        input_sum = self.ball_count_input + self.ball_count_ain_term
+        self.ball_count = int(
+            exponential_interpolation(
+                self.config.BALL_COUNT_MIN, self.config.BALL_COUNT_MAX, input_sum
+            )
+        )
+
+    def apply_impulse_speed(self):
+        # Impulse strength is calibrated so that an input of 0 gives 0.1 and an input of 1 gives 100.
+        input_sum = self.impulse_speed_input + self.impulse_speed_ain_term
+        impulse_speed = exponential_interpolation(
+            self.config.IMPULSE_SPEED_MIN, self.config.IMPULSE_SPEED_MAX, input_sum
+        )
+        for ball in self.balls:
+            ball.impulse_speed = impulse_speed
+
+    def report_collision(self, collision_id: int):
+        """Open the gate corresponding to the given collision ID, as well as the any gate."""
+        self.gates[COLLISION_ID_ANY].on()
+        self.gates[collision_id].on()
+
+    def reset(self):
+        """Reset all balls and gates."""
+        for ball in self.balls:
+            ball.reset()
+        for gate in self.gates:
+            gate.off()
+
+    def impulse(self):
+        """Apply an impulse to all balls currently in play."""
+        for i in range(self.ball_count):
+            self.balls[i].impulse()
+
+    def poll(self):
+        """Poll for input and emit corresponding events when changes are detected"""
+        new_speed_input = self.k1_bank.speed.percent()
+        new_width_input = self.k2_bank.width.percent()
+        new_ball_count_input = self.k1_bank.ball_count.percent()
+        new_impulse_speed_input = self.k2_bank.impulse_speed.percent()
+        new_ain_input = ain.percent()
+
+        # The difference between the new and registered input must exceed the threshold in order to trigger a change.
+        # This reduces jitter, but decreases accuracy.
+        if abs(new_speed_input - self.speed_input) > self.config.KNOB_CHANGE_THRESHOLD:
+            self.speed_input = new_speed_input
+            self.on_speed_input.emit()
+
+        if (
+            abs(new_impulse_speed_input - self.impulse_speed_input)
+            > self.config.KNOB_CHANGE_THRESHOLD
+        ):
+            self.impulse_speed_input = new_impulse_speed_input
+            self.on_impulse_speed_input.emit()
+
+        # Since the rest of the parameters require a button to be held, they will not jitter once the button is released
+        # and no threshold check should be needed.
+        if new_ball_count_input != self.ball_count_input:
+            self.ball_count_input = new_ball_count_input
+            self.on_ball_count_input.emit()
+
+        if new_width_input != self.width_input:
+            self.width_input = new_width_input
+            self.on_width_input.emit()
+
+        if new_ain_input != self.ain_input:
+            self.ain_input = new_ain_input
+            self.on_ain_input.emit()
+
+    def tick(self, delta: float):
+        """Process a tick in the simulation.
+        Delta should already include any time scaling, meaning that this function is
+        naive to any time scale.
+        """
+        any_active = False
+        for i in range(self.ball_count):
+            self.balls[i].tick(delta)
+            any_active = any_active or self.balls[i].active
+        for gate in self.gates:
+            gate.tick(delta)
+        if not any_active:
+            self.reset()
+
+    def render(self, _timer):
+        """Timer callback for rendering.
+        This is the only function that should call any drawing commands.
+        """
+        oled.fill(0)
+        self.arena.draw_boundary()
+        for i in range(self.ball_count):
+            if not self.balls[i].active:
+                continue
+            self.balls[i].draw()
+        oled.show()
+
+    def main(self):
+        """Start the application."""
+        # Per chrisib's recommendation after some display-related errors,
+        # the render thread specifically runs on cpu0 as this might be the
+        # cpu that handles i2c communication with the display.
+        proc_thread = start_new_thread(self.proc_thread, ())
+        self.render_thread()
+
+    def proc_thread(self):
+        """Run the simulation, poll inputs, and save state as necessary."""
+        prev_cycle = None
+        last_poll = None
+        poll_period = 1000.0 / self.config.POLL_FREQUENCY
+        while True:
path: software/contrib/bouncing_pixels.py
user: login: jorins
body: Mentioned `finally` blocks removed as of https://github.com/Allen-Synthesis/EuroPi/pull/432/commits/e3cd657f5093f70ca96b18e29967f5a629c6be42 . You can revert to before that if you need to check it out, just wanted to leave the code clean.
created_at: 2025-05-20T20:57:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: jorins
created_at: 2025-04-12T20:43:13Z
body: Wrote `bouncing_pixels.md` and opted to remove the preliminary docs and known issues from the PR body on account of that information being better written there.
author_association: CONTRIBUTOR
user: login: jorins
created_at: 2025-04-12T20:46:10Z
body: I've had the script running for a few hours without the render thread crash, so I'm marking that as done.
author_association: CONTRIBUTOR
user: login: chrisib
created_at: 2025-04-13T02:50:08Z
body: > Adhere coding style and formatting to project

For `contrib` scripts this isn't a big deal; we don't run any linting on that directory. As long as the code is broadly-legible it's fine. Any changes in the `firmware` or `experimental` directories do get test more though, just in case you plan on changing those in the future.
author_association: COLLABORATOR
user: login: jorins
created_at: 2025-04-15T18:43:30Z
body: > Move Event and exponential_interpolation into libraries

Opting to not do this, it's a bit too large scope for what I have time for currently.



> > Adhere coding style and formatting to project
> 
> For `contrib` scripts this isn't a big deal; we don't run any linting on that directory. As long as the code is broadly-legible it's fine. Any changes in the `firmware` or `experimental` directories do get test more though, just in case you plan on changing those in the future.

I opted to run the formatter at least. Since that's the extent of the style guidelines I'm calling that part done.
author_association: CONTRIBUTOR
user: login: jorins
created_at: 2025-04-15T22:04:07Z
body: Got the code to a point where it all feels nice enough. Some notes on usage:

* Apsect ratio was a needless abstraction; I replaced it with width
* The curve of the impulse speed control didn't feel very nice, so I widened it. It will now launch all balls to almost maximum possible speed even when the time scale is just above zero.
* I ran into some memory issues that I didn't have before. I'm guessing something in the script is a bit hungry for memory. While that could probably be addressed, the script still runs fine up to about 100 balls. 20 if we need to connect Thonny. There's a note about it in the script docs.

I think that's about all I want to do with the script, so I'll give everything a last look later before marking it as ready.
author_association: CONTRIBUTOR
user: login: chrisib
created_at: 2025-04-16T13:13:29Z
body: Whoops; the dangers of working from home. I forgot to switch off of my work Github account and only noticed when I got an update about your latest changes to my work email.
author_association: COLLABORATOR
user: login: jorins
created_at: 2025-04-16T18:46:37Z
body: While doing some "final" QA, I found that while running the script stand-alone (USB disconnected and all), the render thread was prone to crashing upon pressing the buttons. Didn't happen when running the script from my computer via Thonny. Didn't matter which core I ran which thread on. Didn't matter from which thread I assigned the callbacks. The only solution I found was switching back from a timer to a manual loop. I have no idea what internals that caused that issue and I'm not too keen on finding out. Gonna run some last QA later -- I expect it should be good to go now, so hopefully it's final QA for real.
author_association: CONTRIBUTOR
user: login: jorins
created_at: 2025-04-17T12:20:09Z
body: Script's running well now. I feel it's ready to merge unless you wanna review further :)
author_association: CONTRIBUTOR
user: login: chrisib
created_at: 2025-04-18T01:28:14Z
body: I'll give it a final once-over over the long weekend and, assuming it all goes well, it should be merged soon! I haven't actually run it since the first time, so I haven't tried out all your changes yet.
author_association: COLLABORATOR
user: login: jorins
created_at: 2025-05-20T20:55:12Z
body: I've reverted the controls to have width on the first layer. I like your idea of hold and release for impulses, but it's a bit more of a rewrite than I am interested in doing right now and I think it plays well enough as is. All that is left is the [stopping issues](https://github.com/Allen-Synthesis/EuroPi/pull/432#discussion_r2098148373).
author_association: CONTRIBUTOR
user: login: chrisib
created_at: 2025-05-27T01:19:45Z
body: > I've reverted the controls to have width on the first layer. I like your idea of hold and release for impulses, but it's a bit more of a rewrite than I am interested in doing right now and I think it plays well enough as is.

That's reasonable! The proposed rewrite was a suggestion, but as the author of the script you get final say on how you want the user interactions to work!

> All that is left is the [stopping issues](https://github.com/Allen-Synthesis/EuroPi/pull/432#discussion_r2098148373).

I _think_ part of the problem with the `finally` blocks you had is that you're printing in both of them, but the serial I/O that Thonny uses is owned by the primary core; I've never been able to reliably `print` from the background thread, even for debugging. Doing so always seems to cause that core lock-up problem you described.

Realistically, most users are going to be running from the menu, not from Thonny. So if the debugging behaviour requires the odd power-cycle to reset, that's not uncommon in the development world.

I'll try to pull your latest changes and do another quick set of tests this week.
author_association: COLLABORATOR
user: login: chrisib
created_at: 2025-05-29T19:36:24Z
body: I just tried the [latest version](https://raw.githubusercontent.com/Allen-Synthesis/EuroPi/e3cd657f5093f70ca96b18e29967f5a629c6be42/software/contrib/bouncing_pixels.py) and got this output:

```
>>> from contrib.bouncing_pixels import BouncingPixels
>>> bp = BouncingPixels()
[INFO] [file_utils] /config/BouncingPixels.json does not exist. Using default settings
[INFO] [file_utils] /saved_state_BouncingPixels.txt does not exist. Using default settings
>>> bp.main()
Unhandled exception in thread started by <bound_method>
Traceback (most recent call last):
  File "/lib/contrib/bouncing_pixels.py", line 667, in proc_thread
  File "/lib/contrib/bouncing_pixels.py", line 584, in poll
  File "/lib/experimental/thread.py", line 132, in update
  File "/lib/contrib/bouncing_pixels.py", line 573, in impulse
  File "/lib/contrib/bouncing_pixels.py", line 152, in inner
  File "/lib/contrib/bouncing_pixels.py", line 185, in impulse
AttributeError: 'Ball' object has no attribute 'impulse_speed'
```

Did you miss a commit, or accidentally change something? It doesn't seem to work at all for me anymore.
author_association: COLLABORATOR
user: login: jorins
created_at: 2025-05-29T19:42:58Z
body: Oh no, that's not very funky at all! I'll have a look in a few days hopefully.

-------- Original Message --------
On 29/05/2025 21:36, Chris I-B  wrote:

> chrisib left a comment [(Allen-Synthesis/EuroPi#432)](https://github.com/Allen-Synthesis/EuroPi/pull/432#issuecomment-2920390146)
>
> I just tried the [latest version](https://raw.githubusercontent.com/Allen-Synthesis/EuroPi/e3cd657f5093f70ca96b18e29967f5a629c6be42/software/contrib/bouncing_pixels.py) and got this output:
>
>>>> from contrib.bouncing_pixels import BouncingPixels
>>>> bp = BouncingPixels()
> [INFO] [file_utils] /config/BouncingPixels.json does not exist. Using default settings
> [INFO] [file_utils] /saved_state_BouncingPixels.txt does not exist. Using default settings
>>>> bp.main()
> Unhandled exception in thread started by <bound_method>
> Traceback (most recent call last):
>   File "/lib/contrib/bouncing_pixels.py", line 667, in proc_thread
>   File "/lib/contrib/bouncing_pixels.py", line 584, in poll
>   File "/lib/experimental/thread.py", line 132, in update
>   File "/lib/contrib/bouncing_pixels.py", line 573, in impulse
>   File "/lib/contrib/bouncing_pixels.py", line 152, in inner
>   File "/lib/contrib/bouncing_pixels.py", line 185, in impulse
> AttributeError: 'Ball' object has no attribute 'impulse_speed'
>
> Did you miss a commit, or accidentally change something? It doesn't seem to work at all for me anymore.
>
> —
> Reply to this email directly, [view it on GitHub](https://github.com/Allen-Synthesis/EuroPi/pull/432#issuecomment-2920390146), or [unsubscribe](https://github.com/notifications/unsubscribe-auth/AK76SIKZ7T6FJLLFDYNEGK33A5OU5AVCNFSM6AAAAAB3ACIY26VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDSMRQGM4TAMJUGY).
> You are receiving this because you authored the thread.Message ID: ***@***.***>
author_association: CONTRIBUTOR
user: login: civerachb-cpr
created_at: 2025-05-31T20:18:56Z
body: I just tried it again with a couple of very small changes (just removing the `usb_connected` checks from the while loops and changing them to just `while self.is_running` and it's working now. Might have just been a copy & paste bug on my end, or a save failure on my module. It seems to be working though.
author_association: NONE
user: login: chrisib
created_at: 2025-05-31T20:19:56Z
body: I _really_ need to double-check which GitHub account I have activated _before_ I start commenting on things lol. The previous comment was also me. Again.
author_association: COLLABORATOR
user: login: chrisib
created_at: 2025-05-31T20:22:12Z
body: Since the issues all seem to be resolved & it appears to be working on my test module I'm going to merge this in its current state. If you want to make further updates/changes please feel free to do so in a new merge request.

Thanks for all your work! I look forward to seeing what people make with this 😊
author_association: COLLABORATOR
user: login: jorins
created_at: 2025-05-31T20:24:15Z
body: Oh, wonderful! Thanks a bunch for your support and input :heart: 
author_association: CONTRIBUTOR
user: login: chrisib
created_at: 2025-05-31T20:43:37Z
body: > Oh, wonderful! Thanks a bunch for your support and input ❤️

Happy to have new people contributing! Hopefully you'll have more fun ideas and write some more new scripts!
author_association: COLLABORATOR