2025-11-14 12:45:04 +01:00
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MicroPythonOS is an embedded operating system that runs on ESP32 hardware (particularly the Waveshare ESP32-S3-Touch-LCD-2) and desktop Linux/macOS. It provides an LVGL-based UI framework with an Android-inspired app architecture featuring Activities, Intents, and a PackageManager.
The OS supports:
- Touch and non-touch input devices (keyboard/joystick navigation)
- Camera with QR decoding (using quirc)
- WiFi connectivity
- Over-the-air (OTA) firmware updates
- App installation via MPK packages
- Bitcoin Lightning and Nostr protocols
## Repository Structure
### Core Directories
- `internal_filesystem/` : The runtime filesystem containing the OS and apps
- `boot.py` : Hardware initialization for ESP32-S3-Touch-LCD-2
- `boot_unix.py` : Desktop-specific boot initialization
- `main.py` : UI initialization, theme setup, and launcher start
- `lib/mpos/` : Core OS library (apps, config, UI, content management)
- `apps/` : User-installed apps (symlinks to external app repos)
- `builtin/` : System apps frozen into the firmware (launcher, appstore, settings, etc.)
- `data/` : Static data files
- `sdcard/` : SD card mount point
- `lvgl_micropython/` : Submodule containing LVGL bindings for MicroPython
- `micropython-camera-API/` : Submodule for camera support
- `micropython-nostr/` : Submodule for Nostr protocol
- `c_mpos/` : C extension modules (includes quirc for QR decoding)
- `secp256k1-embedded-ecdh/` : Submodule for cryptographic operations
- `manifests/` : Build manifests defining what gets frozen into firmware
- `freezeFS/` : Files to be frozen into the built-in filesystem
- `scripts/` : Build and deployment scripts
- `tests/` : Test suite (both unit tests and manual tests)
### Key Architecture Components
**App System ** : Similar to Android
- Apps are identified by reverse-domain names (e.g., `com.micropythonos.camera` )
- Each app has a `META-INF/MANIFEST.JSON` with metadata and activity definitions
- Activities extend `mpos.app.activity.Activity` class (import: `from mpos.app.activity import Activity` )
- Apps implement `onCreate()` to set up their UI and `onDestroy()` for cleanup
- Activity lifecycle: `onCreate()` → `onStart()` → `onResume()` → `onPause()` → `onStop()` → `onDestroy()`
- Apps are packaged as `.mpk` files (zip archives)
- Built-in system apps (frozen into firmware): launcher, appstore, settings, wifi, osupdate, about
**UI Framework ** : Built on LVGL 9.3.0
- `mpos.ui.topmenu` : Notification bar and drawer (top menu)
- `mpos.ui.display` : Root screen initialization
- Gesture support: left-edge swipe for back, top-edge swipe for menu
- Theme system with configurable colors and light/dark modes
- Focus groups for keyboard/joystick navigation
**Content Management ** :
- `PackageManager` : Install/uninstall/query apps
- `Intent` : Launch activities with action/category filters
- `SharedPreferences` : Per-app key-value storage (similar to Android)
**Hardware Abstraction ** :
- `boot.py` configures SPI, I2C, display (ST7789), touchscreen (CST816S), and battery ADC
- Platform detection via `sys.platform` ("esp32" vs others)
- Different boot files per hardware variant (boot_fri3d-2024.py, etc.)
## Build System
### Building Firmware
The main build script is `scripts/build_mpos.sh` :
``` bash
# Development build (no frozen filesystem, requires ./scripts/install.sh after flashing)
./scripts/build_mpos.sh unix dev
# Production build (with frozen filesystem)
./scripts/build_mpos.sh unix prod
# ESP32 builds (specify hardware variant)
./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2
./scripts/build_mpos.sh esp32 prod fri3d-2024
```
**Build types ** :
- `dev` : No preinstalled files or builtin filesystem. Boots to black screen until you run `./scripts/install.sh`
- `prod` : Files from `manifest*.py` are frozen into firmware. Run `./scripts/freezefs_mount_builtin.sh` before building
**Targets ** :
- `esp32` : ESP32-S3 hardware (requires subtarget: `waveshare-esp32-s3-touch-lcd-2` or `fri3d-2024` )
- `unix` : Linux desktop
- `macOS` : macOS desktop
The build system uses `lvgl_micropython/make.py` which wraps MicroPython's build system. It:
1. Fetches SDL tags for desktop builds
2. Patches manifests to include camera and asyncio support
3. Creates symlinks for C modules (secp256k1, c_mpos)
4. Runs the lvgl_micropython build with appropriate flags
**ESP32 build configuration ** :
- Board: `ESP32_GENERIC_S3` with `SPIRAM_OCT` variant
- Display driver: `st7789`
- Input device: `cst816s`
- OTA enabled with 4MB partition size (16MB total flash)
- Dual-core threading enabled (no GIL)
- User C modules: camera, secp256k1, c_mpos/quirc
**Desktop build configuration ** :
- Display: `sdl_display`
- Input: `sdl_pointer` , `sdl_keyboard`
- Compiler flags: `-g -O0 -ggdb -ljpeg` (debug symbols enabled)
- STRIP is disabled to keep debug symbols
### Building and Bundling Apps
Apps can be bundled into `.mpk` files:
``` bash
./scripts/bundle_apps.sh
```
### Running on Desktop
``` bash
# Run normally (starts launcher)
./scripts/run_desktop.sh
# Run a specific Python script directly
./scripts/run_desktop.sh path/to/script.py
# Run a specific app by name
./scripts/run_desktop.sh com.micropythonos.camera
```
**Important environment variables ** :
- `HEAPSIZE` : Set heap size (default 8M, matches ESP32-S3 PSRAM). Increase for memory-intensive apps
- `SDL_WINDOW_FULLSCREEN` : Set to `true` for fullscreen mode
The script automatically selects the correct binary (`lvgl_micropy_unix` or `lvgl_micropy_macOS` ) and runs from the `internal_filesystem/` directory.
## Deploying to Hardware
### Flashing Firmware
``` bash
# Flash firmware over USB
./scripts/flash_over_usb.sh
```
### Installing Files to Device
``` bash
# Install all files to device (boot.py, main.py, lib/, apps/, builtin/)
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2
# Install a single app to device
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 camera
```
Uses `mpremote` from MicroPython tools to copy files over serial connection.
## Testing
### Running Tests
Tests are in the `tests/` directory. There are two types: unit tests and manual tests.
**Unit tests ** (automated, run on desktop or device):
``` bash
# Run all unit tests on desktop
./tests/unittest.sh
# Run a specific test file on desktop
./tests/unittest.sh tests/test_shared_preferences.py
./tests/unittest.sh tests/test_intent.py
./tests/unittest.sh tests/test_package_manager.py
2025-11-16 01:13:53 +01:00
./tests/unittest.sh tests/test_graphical_start_app.py
2025-11-14 12:45:04 +01:00
# Run a specific test on connected device (via mpremote)
2025-11-16 00:29:22 +01:00
./tests/unittest.sh tests/test_shared_preferences.py --ondevice
# Run all tests on connected device
./tests/unittest.sh --ondevice
2025-11-14 12:45:04 +01:00
```
The `unittest.sh` script:
- Automatically detects the platform (Linux/macOS) and uses the correct binary
- Sets up the proper paths and heapsize
2025-11-16 00:29:22 +01:00
- Can run tests on device using `mpremote` with the `--ondevice` flag
2025-11-14 12:45:04 +01:00
- Runs all `test_*.py` files when no argument is provided
2025-11-14 14:32:59 +01:00
- On device, assumes the OS is already running (boot.py and main.py already executed), so tests run against the live system
- Test infrastructure (graphical_test_helper.py) is automatically installed by `scripts/install.sh`
2025-11-14 12:45:04 +01:00
**Available unit test modules ** :
- `test_shared_preferences.py` : Tests for `mpos.config.SharedPreferences` (configuration storage)
- `test_intent.py` : Tests for `mpos.content.intent.Intent` (intent creation, extras, flags)
- `test_package_manager.py` : Tests for `PackageManager` (version comparison, app discovery)
2025-11-16 01:13:53 +01:00
- `test_graphical_start_app.py` : Tests for app launching (graphical test with proper boot/main initialization)
2025-11-14 14:32:59 +01:00
- `test_graphical_about_app.py` : Graphical test that verifies About app UI and captures screenshots
**Graphical tests ** (UI verification with screenshots):
``` bash
# Run graphical tests on desktop
./tests/unittest.sh tests/test_graphical_about_app.py
# Run graphical tests on device
2025-11-16 00:29:22 +01:00
./tests/unittest.sh tests/test_graphical_about_app.py --ondevice
2025-11-14 14:32:59 +01:00
# Convert screenshots from raw RGB565 to PNG
cd tests/screenshots
./convert_to_png.sh # Converts all .raw files in the directory
```
Graphical tests use `tests/graphical_test_helper.py` which provides utilities like:
- `wait_for_render()` : Wait for LVGL to process UI events
- `capture_screenshot()` : Take screenshot as RGB565 raw data
- `find_label_with_text()` : Find labels containing specific text
- `verify_text_present()` : Verify expected text is on screen
Screenshots are saved as `.raw` files (RGB565 format) and can be converted to PNG using `tests/screenshots/convert_to_png.sh`
2025-11-14 12:45:04 +01:00
**Manual tests ** (interactive, for hardware-specific features):
- `manual_test_camera.py` : Camera and QR scanning
- `manual_test_nostr_asyncio.py` : Nostr protocol
- `manual_test_nwcwallet*.py` : Lightning wallet connectivity (Alby, Cashu)
- `manual_test_lnbitswallet.py` : LNbits wallet integration
- `test_websocket.py` : WebSocket functionality
- `test_multi_connect.py` : Multiple concurrent connections
Run manual tests with:
``` bash
./scripts/run_desktop.sh tests/manual_test_camera.py
```
### Writing New Tests
**Unit test guidelines ** :
- Use Python's `unittest` module (compatible with MicroPython)
- Place tests in `tests/` directory with `test_*.py` naming
- Use `setUp()` and `tearDown()` for test fixtures
- Clean up any created files/directories in `tearDown()`
- Tests should be runnable on desktop (unix build) without hardware dependencies
- Use descriptive test names: `test_<what_is_being_tested>`
- Group related tests in test classes
**Example test structure ** :
``` python
import unittest
from mpos . some_module import SomeClass
class TestSomeClass ( unittest . TestCase ) :
def setUp ( self ) :
# Initialize test fixtures
pass
def tearDown ( self ) :
# Clean up after test
pass
def test_some_functionality ( self ) :
# Arrange
obj = SomeClass ( )
# Act
result = obj . some_method ( )
# Assert
self . assertEqual ( result , expected_value )
```
## Development Workflow
### Creating a New App
1. Create app directory: `internal_filesystem/apps/com.example.myapp/`
2. Create `META-INF/MANIFEST.JSON` with app metadata and activities
3. Create `assets/` directory for Python code
4. Create main activity file extending `Activity` class
5. Implement `onCreate()` method to build UI
6. Optional: Create `res/` directory for resources (icons, images)
**Minimal app structure ** :
```
com.example.myapp/
├── META-INF/
│ └── MANIFEST.JSON
├── assets/
│ └── main_activity.py
└── res/
└── mipmap-mdpi/
└── icon_64x64.png
```
**Minimal Activity code ** :
``` python
from mpos . app . activity import Activity
import lvgl as lv
class MainActivity ( Activity ) :
def onCreate ( self ) :
screen = lv . obj ( )
label = lv . label ( screen )
label . set_text ( ' Hello World! ' )
label . center ( )
self . setContentView ( screen )
```
See `internal_filesystem/apps/com.micropythonos.helloworld/` for a minimal example and built-in apps in `internal_filesystem/builtin/apps/` for more complex examples.
### Testing App Changes
For rapid iteration on desktop:
``` bash
# Build desktop version (only needed once)
./scripts/build_mpos.sh unix dev
# Install filesystem to device (run after code changes)
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2
# Or run directly on desktop
./scripts/run_desktop.sh com.example.myapp
```
### Debugging
Desktop builds include debug symbols by default. Use GDB:
``` bash
gdb --args ./lvgl_micropython/build/lvgl_micropy_unix -X heapsize = 8M -v -i -c " $( cat boot_unix.py main.py) "
```
For ESP32 debugging, enable core dumps:
``` bash
./scripts/core_dump_activate.sh
```
## Important Constraints
### Memory Management
ESP32-S3 has 8MB PSRAM. Memory-intensive operations:
- Camera images consume ~2.5MB per frame
- LVGL image cache must be managed with `lv.image.cache_drop(None)`
- Large UI components should be created/destroyed rather than hidden
- Use `gc.collect()` strategically after deallocating large objects
### Threading
- Main UI/LVGL operations must run on main thread
- Background tasks use `_thread.start_new_thread()`
- Stack size: 16KB for ESP32, 24KB for desktop (see `mpos.apps.good_stack_size()` )
- Use `mpos.ui.async_call()` to safely invoke UI operations from background threads
### Async Operations
- OS uses `uasyncio` for networking (WebSockets, HTTP, Nostr)
- WebSocket library is custom `websocket.py` using uasyncio
- HTTP uses `aiohttp` package (in `lib/aiohttp/` )
- Async tasks are throttled per frame to prevent memory overflow
### File Paths
- Use `M:/path/to/file` prefix for LVGL file operations (registered in main.py)
- Absolute paths for Python imports
- Apps run with their directory added to `sys.path`
## Build Dependencies
The build requires all git submodules checked out recursively:
``` bash
git submodule update --init --recursive
```
**Desktop dependencies ** : See `.github/workflows/build.yml` for full list including:
- SDL2 development libraries
- Mesa/EGL libraries
- libjpeg
- Python 3.8+
- cmake, ninja-build
## Manifest System
Manifests define what gets frozen into firmware:
- `manifests/manifest.py` : ESP32 production builds
- `manifests/manifest_fri3d-2024.py` : Fri3d Camp 2024 Badge variant
- `manifests/manifest_unix.py` : Desktop builds
Manifests use `freeze()` directives to include files in the frozen filesystem. Frozen files are baked into the firmware and cannot be modified at runtime.
## Version Management
Versions are tracked in:
- `CHANGELOG.md` : User-facing changelog with release history
- App versions in `META-INF/MANIFEST.JSON` files
- OS update system checks `hardware_id` from `mpos.info.get_hardware_id()`
Current stable version: 0.3.3 (as of latest CHANGELOG entry)
## Critical Code Locations
- App lifecycle: `internal_filesystem/lib/mpos/apps.py:execute_script()`
- Activity base class: `internal_filesystem/lib/mpos/app/activity.py`
- Package management: `internal_filesystem/lib/mpos/content/package_manager.py`
- Intent system: `internal_filesystem/lib/mpos/content/intent.py`
- UI initialization: `internal_filesystem/main.py`
- Hardware init: `internal_filesystem/boot.py`
- Config/preferences: `internal_filesystem/lib/mpos/config.py`
- Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py`
- Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py`
## Common Utilities and Helpers
**SharedPreferences ** : Persistent key-value storage per app
``` python
from mpos . config import SharedPreferences
# Load preferences
prefs = SharedPreferences ( " com.example.myapp " )
value = prefs . get_string ( " key " , " default_value " )
number = prefs . get_int ( " count " , 0 )
data = prefs . get_dict ( " data " , { } )
# Save preferences
editor = prefs . edit ( )
editor . put_string ( " key " , " value " )
editor . put_int ( " count " , 42 )
editor . put_dict ( " data " , { " key " : " value " } )
editor . commit ( )
```
**Intent system ** : Launch activities and pass data
``` python
from mpos . content . intent import Intent
# Launch activity by name
intent = Intent ( )
intent . setClassName ( " com.micropythonos.camera " , " Camera " )
self . startActivity ( intent )
# Launch with extras
intent . putExtra ( " key " , " value " )
self . startActivityForResult ( intent , self . handle_result )
def handle_result ( self , result ) :
if result [ " result_code " ] == Activity . RESULT_OK :
data = result [ " data " ]
```
**UI utilities ** :
- `mpos.ui.async_call(func, *args, **kwargs)` : Safely call UI operations from background threads
- `mpos.ui.back_screen()` : Navigate back to previous screen
- `mpos.ui.focus_direction` : Keyboard/joystick navigation helpers
- `mpos.ui.anim` : Animation utilities
2025-11-14 16:58:12 +01:00
### Keyboard and Focus Navigation
MicroPythonOS supports keyboard/joystick navigation through LVGL's focus group system. This allows users to navigate apps using arrow keys and select items with Enter.
**Basic focus handling pattern ** :
``` python
def onCreate ( self ) :
# Get the default focus group
focusgroup = lv . group_get_default ( )
if not focusgroup :
print ( " WARNING: could not get default focusgroup " )
# Create a clickable object
button = lv . button ( screen )
# Add focus/defocus event handlers
button . add_event_cb ( lambda e , b = button : self . focus_handler ( b ) , lv . EVENT . FOCUSED , None )
button . add_event_cb ( lambda e , b = button : self . defocus_handler ( b ) , lv . EVENT . DEFOCUSED , None )
# Add to focus group (enables keyboard navigation)
if focusgroup :
focusgroup . add_obj ( button )
def focus_handler ( self , obj ) :
""" Called when object receives focus """
obj . set_style_border_color ( lv . theme_get_color_primary ( None ) , lv . PART . MAIN )
obj . set_style_border_width ( 2 , lv . PART . MAIN )
obj . scroll_to_view ( True ) # Scroll into view if needed
def defocus_handler ( self , obj ) :
""" Called when object loses focus """
obj . set_style_border_width ( 0 , lv . PART . MAIN )
```
**Key principles ** :
- Get the default focus group with `lv.group_get_default()`
- Add objects to the focus group to make them keyboard-navigable
- Use `lv.EVENT.FOCUSED` to highlight focused elements (usually with a border)
- Use `lv.EVENT.DEFOCUSED` to remove highlighting
- Use theme color for consistency: `lv.theme_get_color_primary(None)`
- Call `scroll_to_view(True)` to auto-scroll focused items into view
- The focus group automatically handles arrow key navigation between objects
**Example apps with focus handling ** :
- **Launcher** (`builtin/apps/com.micropythonos.launcher/assets/launcher.py` ): App icons are focusable
- **Settings** (`builtin/apps/com.micropythonos.settings/assets/settings_app.py` ): Settings items are focusable
- **Connect 4** (`apps/com.micropythonos.connect4/assets/connect4.py` ): Game columns are focusable
2025-11-14 12:45:04 +01:00
**Other utilities ** :
- `mpos.apps.good_stack_size()` : Returns appropriate thread stack size for platform (16KB ESP32, 24KB desktop)
- `mpos.wifi` : WiFi management utilities
- `mpos.sdcard.SDCardManager` : SD card mounting and management
- `mpos.clipboard` : System clipboard access
- `mpos.battery_voltage` : Battery level reading (ESP32 only)
2025-11-14 15:13:10 +01:00
## Animations and Game Loops
MicroPythonOS supports frame-based animations and game loops using the TaskHandler event system. This pattern is used for games, particle effects, and smooth animations.
### The update_frame() Pattern
The core pattern involves:
1. Registering a callback that fires every frame
2. Calculating delta time for framerate-independent physics
3. Updating object positions and properties
4. Rendering to LVGL objects
5. Unregistering when animation completes
**Basic structure ** :
``` python
from mpos . apps import Activity
import mpos . ui
import time
import lvgl as lv
class MyAnimatedApp ( Activity ) :
last_time = 0
def onCreate ( self ) :
# Set up your UI
self . screen = lv . obj ( )
# ... create objects ...
self . setContentView ( self . screen )
def onResume ( self , screen ) :
# Register the frame callback
self . last_time = time . ticks_ms ( )
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . add_event_cb ( self . update_frame , 1 )
2025-11-14 15:13:10 +01:00
def onPause ( self , screen ) :
# Unregister when app goes to background
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . remove_event_cb ( self . update_frame )
2025-11-14 15:13:10 +01:00
def update_frame ( self , a , b ) :
# Calculate delta time for framerate independence
current_time = time . ticks_ms ( )
delta_ms = time . ticks_diff ( current_time , self . last_time )
delta_time = delta_ms / 1000.0 # Convert to seconds
self . last_time = current_time
# Update your animation/game logic here
# Use delta_time to make physics framerate-independent
```
### Framerate-Independent Physics
All movement and physics should be multiplied by `delta_time` to ensure consistent behavior regardless of framerate:
``` python
# Example from QuasiBird game
GRAVITY = 200 # pixels per second²
PIPE_SPEED = 100 # pixels per second
def update_frame ( self , a , b ) :
current_time = time . ticks_ms ( )
delta_time = time . ticks_diff ( current_time , self . last_time ) / 1000.0
self . last_time = current_time
# Update velocity with gravity
self . bird_velocity + = self . GRAVITY * delta_time
# Update position with velocity
self . bird_y + = self . bird_velocity * delta_time
# Update bird sprite position
self . bird_img . set_y ( int ( self . bird_y ) )
# Move pipes
for pipe in self . pipes :
pipe . x - = self . PIPE_SPEED * delta_time
```
**Key principles ** :
- Constants define rates in "per second" units (pixels/second, degrees/second)
- Multiply all rates by `delta_time` when applying them
- This ensures objects move at the same speed regardless of framerate
- Use `time.ticks_ms()` and `time.ticks_diff()` for timing (handles rollover correctly)
### Object Pooling for Performance
Pre-create LVGL objects and reuse them instead of creating/destroying during animation:
``` python
# Example from LightningPiggy confetti animation
MAX_CONFETTI = 21
confetti_images = [ ]
confetti_pieces = [ ]
used_img_indices = set ( )
def onStart ( self , screen ) :
# Pre-create all image objects (hidden initially)
for i in range ( self . MAX_CONFETTI ) :
img = lv . image ( lv . layer_top ( ) )
img . set_src ( f " { self . ASSET_PATH } confetti { i % 5 } .png " )
img . add_flag ( lv . obj . FLAG . HIDDEN )
self . confetti_images . append ( img )
def _spawn_one ( self ) :
# Find a free image slot
for idx , img in enumerate ( self . confetti_images ) :
if img . has_flag ( lv . obj . FLAG . HIDDEN ) and idx not in self . used_img_indices :
break
else :
return # No free slot
# Create particle data (not LVGL object)
piece = {
' img_idx ' : idx ,
' x ' : random . uniform ( 0 , self . SCREEN_WIDTH ) ,
' y ' : 0 ,
' vx ' : random . uniform ( - 80 , 80 ) ,
' vy ' : random . uniform ( - 150 , 0 ) ,
' rotation ' : 0 ,
' scale ' : 1.0 ,
' age ' : 0.0
}
self . confetti_pieces . append ( piece )
self . used_img_indices . add ( idx )
def update_frame ( self , a , b ) :
delta_time = time . ticks_diff ( time . ticks_ms ( ) , self . last_time ) / 1000.0
self . last_time = time . ticks_ms ( )
new_pieces = [ ]
for piece in self . confetti_pieces :
# Update physics
piece [ ' x ' ] + = piece [ ' vx ' ] * delta_time
piece [ ' y ' ] + = piece [ ' vy ' ] * delta_time
piece [ ' vy ' ] + = self . GRAVITY * delta_time
piece [ ' rotation ' ] + = piece [ ' spin ' ] * delta_time
piece [ ' age ' ] + = delta_time
# Update LVGL object
img = self . confetti_images [ piece [ ' img_idx ' ] ]
img . remove_flag ( lv . obj . FLAG . HIDDEN )
img . set_pos ( int ( piece [ ' x ' ] ) , int ( piece [ ' y ' ] ) )
img . set_rotation ( int ( piece [ ' rotation ' ] * 10 ) )
img . set_scale ( int ( 256 * piece [ ' scale ' ] ) )
# Check if particle should die
if piece [ ' y ' ] > self . SCREEN_HEIGHT or piece [ ' age ' ] > piece [ ' lifetime ' ] :
img . add_flag ( lv . obj . FLAG . HIDDEN )
self . used_img_indices . discard ( piece [ ' img_idx ' ] )
else :
new_pieces . append ( piece )
self . confetti_pieces = new_pieces
```
**Object pooling benefits ** :
- Avoid memory allocation/deallocation during animation
- Reuse LVGL image objects (expensive to create)
- Hide/show objects instead of create/delete
- Track which slots are in use with a set
- Separate particle data (Python dict) from rendering (LVGL object)
### Particle Systems and Effects
**Staggered spawning ** (spawn particles over time instead of all at once):
``` python
def start_animation ( self ) :
self . spawn_timer = 0
self . spawn_interval = 0.15 # seconds between spawns
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . add_event_cb ( self . update_frame , 1 )
2025-11-14 15:13:10 +01:00
def update_frame ( self , a , b ) :
delta_time = time . ticks_diff ( time . ticks_ms ( ) , self . last_time ) / 1000.0
# Staggered spawning
self . spawn_timer + = delta_time
if self . spawn_timer > = self . spawn_interval :
self . spawn_timer = 0
for _ in range ( random . randint ( 1 , 2 ) ) :
if len ( self . particles ) < self . MAX_PARTICLES :
self . _spawn_one ( )
```
**Particle lifecycle ** (age, scale, death):
``` python
piece = {
' x ' : x , ' y ' : y ,
' vx ' : random . uniform ( - 80 , 80 ) ,
' vy ' : random . uniform ( - 150 , 0 ) ,
' spin ' : random . uniform ( - 500 , 500 ) , # degrees/sec
' age ' : 0.0 ,
' lifetime ' : random . uniform ( 5.0 , 10.0 ) ,
' rotation ' : random . uniform ( 0 , 360 ) ,
' scale ' : 1.0
}
# In update_frame
piece [ ' age ' ] + = delta_time
piece [ ' scale ' ] = max ( 0.3 , 1.0 - ( piece [ ' age ' ] / piece [ ' lifetime ' ] ) * 0.7 )
# Death check
dead = (
piece [ ' x ' ] < - 60 or piece [ ' x ' ] > SCREEN_WIDTH + 60 or
piece [ ' y ' ] > SCREEN_HEIGHT + 60 or
piece [ ' age ' ] > piece [ ' lifetime ' ]
)
```
### Game Loop Patterns
**Scrolling backgrounds ** (parallax and tiling):
``` python
# Parallax clouds (multiple layers at different speeds)
CLOUD_SPEED = 30 # pixels/sec (slower than foreground)
cloud_positions = [ 50 , 180 , 320 ]
for i , cloud_img in enumerate ( self . cloud_images ) :
self . cloud_positions [ i ] - = self . CLOUD_SPEED * delta_time
# Wrap around when off-screen
if self . cloud_positions [ i ] < - 60 :
self . cloud_positions [ i ] = SCREEN_WIDTH + 20
cloud_img . set_x ( int ( self . cloud_positions [ i ] ) )
# Tiled ground (infinite scrolling)
self . ground_x - = self . PIPE_SPEED * delta_time
self . ground_img . set_offset_x ( int ( self . ground_x ) ) # LVGL handles wrapping
```
**Object pooling for game entities ** :
``` python
# Pre-create pipe images
MAX_PIPES = 4
pipe_images = [ ]
for i in range ( MAX_PIPES ) :
top_pipe = lv . image ( screen )
top_pipe . set_src ( " M:path/to/pipe.png " )
top_pipe . set_rotation ( 1800 ) # 180 degrees * 10
top_pipe . add_flag ( lv . obj . FLAG . HIDDEN )
bottom_pipe = lv . image ( screen )
bottom_pipe . set_src ( " M:path/to/pipe.png " )
bottom_pipe . add_flag ( lv . obj . FLAG . HIDDEN )
pipe_images . append ( { " top " : top_pipe , " bottom " : bottom_pipe , " in_use " : False } )
# Update visible pipes
def update_pipe_images ( self ) :
for pipe_img in self . pipe_images :
pipe_img [ " in_use " ] = False
for i , pipe in enumerate ( self . pipes ) :
if i < self . MAX_PIPES :
pipe_imgs = self . pipe_images [ i ]
pipe_imgs [ " in_use " ] = True
pipe_imgs [ " top " ] . remove_flag ( lv . obj . FLAG . HIDDEN )
pipe_imgs [ " top " ] . set_pos ( int ( pipe . x ) , int ( pipe . gap_y - 200 ) )
pipe_imgs [ " bottom " ] . remove_flag ( lv . obj . FLAG . HIDDEN )
pipe_imgs [ " bottom " ] . set_pos ( int ( pipe . x ) , int ( pipe . gap_y + pipe . gap_size ) )
# Hide unused slots
for pipe_img in self . pipe_images :
if not pipe_img [ " in_use " ] :
pipe_img [ " top " ] . add_flag ( lv . obj . FLAG . HIDDEN )
pipe_img [ " bottom " ] . add_flag ( lv . obj . FLAG . HIDDEN )
```
**Collision detection ** :
``` python
def check_collision ( self ) :
# Boundaries
if self . bird_y < = 0 or self . bird_y > = SCREEN_HEIGHT - 40 - self . bird_size :
return True
# AABB (Axis-Aligned Bounding Box) collision
bird_left = self . BIRD_X
bird_right = self . BIRD_X + self . bird_size
bird_top = self . bird_y
bird_bottom = self . bird_y + self . bird_size
for pipe in self . pipes :
pipe_left = pipe . x
pipe_right = pipe . x + pipe . width
# Check horizontal overlap
if bird_right > pipe_left and bird_left < pipe_right :
# Check if bird is outside the gap
if bird_top < pipe . gap_y or bird_bottom > pipe . gap_y + pipe . gap_size :
return True
return False
```
### Animation Control and Cleanup
**Starting/stopping animations ** :
``` python
def start_animation ( self ) :
self . animation_running = True
self . last_time = time . ticks_ms ( )
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . add_event_cb ( self . update_frame , 1 )
2025-11-14 15:13:10 +01:00
# Optional: auto-stop after duration
lv . timer_create ( self . stop_animation , 15000 , None ) . set_repeat_count ( 1 )
def stop_animation ( self , timer = None ) :
self . animation_running = False
# Don't remove callback yet - let it clean up and remove itself
def update_frame ( self , a , b ) :
# ... update logic ...
# Stop when animation completes
if not self . animation_running and len ( self . particles ) == 0 :
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . remove_event_cb ( self . update_frame )
2025-11-14 15:13:10 +01:00
print ( " Animation finished " )
```
**Lifecycle integration ** :
``` python
def onResume ( self , screen ) :
# Only start if needed (e.g., game in progress)
if self . game_started and not self . game_over :
self . last_time = time . ticks_ms ( )
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . add_event_cb ( self . update_frame , 1 )
2025-11-14 15:13:10 +01:00
def onPause ( self , screen ) :
# Always stop when app goes to background
2025-11-15 16:25:10 +01:00
mpos . ui . task_handler . remove_event_cb ( self . update_frame )
2025-11-14 15:13:10 +01:00
```
### Performance Tips
1. **Pre-create LVGL objects ** : Creating objects during animation causes lag
2. **Use object pools ** : Reuse objects instead of create/destroy
3. **Limit particle counts ** : Use `MAX_PARTICLES` constant (21 is a good default)
4. **Integer positions ** : Convert float positions to int before setting: `img.set_pos(int(x), int(y))`
5. **Delta time ** : Always use delta time for framerate independence
6. **Layer management ** : Use `lv.layer_top()` for overlays (confetti, popups)
7. **Rotation units ** : LVGL rotation is in 1/10 degrees: `set_rotation(int(degrees * 10))`
8. **Scale units ** : LVGL scale is 256 = 100%: `set_scale(int(256 * scale_factor))`
9. **Hide vs destroy ** : Hide objects with `add_flag(lv.obj.FLAG.HIDDEN)` instead of deleting
10. **Cleanup ** : Always unregister callbacks in `onPause()` to prevent memory leaks
### Example Apps
- **QuasiBird** (`MPOS-QuasiBird/assets/quasibird.py` ): Full game with physics, scrolling, object pooling
- **LightningPiggy** (`LightningPiggyApp/.../displaywallet.py` ): Confetti particle system with staggered spawning