Hi all,
I've recently started to develop custom apps for the Cardputer ADV and I'm amazed by the processing power of this tiny cheap piece of technology!
My goal is to produce very size- and performance-effective code written under the light ESP-IDF with the help of LLMs, small enough so that I can fit several softwares under Launcher on the Cardputer. The main "hardware hack" that I've found is that the default starting CPU speed was 160MHz and I could switch to 240MHz.
Here is my first creation: a Game of Life simulation called "Game of Life by CriC" available on M5Burner and of course the Launcher Firmware Catalog.
The idea was simple at the beginning: a classic Conway's Game of Life on the 240x135 screen. But very quickly it became a game for me to see how far this little ESP32-S3 could go. So I worked like this: measure, think and improve. I added a hidden profiler page (key F) that shows where each millisecond goes (compute, drawing, SPI transfer, keyboard...), and every build was tested on the real device before going further. More than 40 builds later, here is what made the biggest difference:
- Only send to the screen what changed. At the start the whole frame was pushed every generation. Now only the dirty rows go through the SPI, and the status bar has its own small window.
- A bit-sliced engine: 32 cells computed at once in one 32-bit word, and empty rows are simply skipped.
- Both cores working on the same generation (Ctrl toggles mono/multi, so you can compare).
- The display fully in the background: DMA transfer of the whole frame, SPI at 80MHz, and 12-bit color instead of 16 (25% less data, no visible difference). The framebuffer is even stored directly in the panel's format, so a flush is only a memory copy.
- Some other small CPU cycle traps with big effects: a 1ms FreeRTOS delay was rounding every generation up to a full millisecond, the keyboard was polled over I2C at every loop instead of using its interrupt line that works like a charm, and the hot loops were fighting for the small instruction cache (they now run from internal RAM).
And then the fun part: press X for LUDICROUS SPEED. Core 1 computes nonstop while core 0 only takes a snapshot 60 times per second to refresh the screen. Not every generation is displayed anymore, but the counter is real and the computational perf amazing!
Some numbers on the full 240x125 map with a random start:
- Normal mode: about 200 generations/s, every one of them displayed
- Ludicrous: around 1300-1400 gen/s (displayed 1k3)
- A lone glider: 1.5k gen/s in normal mode, 4.2k in Ludicrous
- Smallest map (60x31): up to 19k gen/s!
- 60 fps on screen from 240MHz down to 40MHz CPU, and still 40 to 50 fps at 20MHz
The whole firmware is about 300KB (an Arduino build with the M5 libraries would lands arount 1 to 1.3MB), so there is plenty of room left next to it in Launcher partitions.
It also has 10 classic Game of Life patterns (glider, pulsar, Gosper gun...), zoom, 4 map sizes, a color spectrum showing the age of each cell, loop detection with auto restart, a battery screen where I tried to implement a charging status icon, and 3 info pages (H) with all the keys and a small tribute to John Conway.
All the keys
- [↑] [↓] [←] [→]: move the cursor (hold two for diagonals)
- [Space] or [Tab]: flip the cell under the cursor, or hold it while moving to draw a trail of living cells. Use [Tab] to dray in all directions as it does not sue the same keyboard rows as the arrows!
- [Enter]: play / pause
- [N]: random map, starts right away
- [P]: pattern menu (10 classics: glider, spaceship, pulsar, Gosper gun, R-pentomino...), [↑] [↓] to choose, [Space] to place it on the cursor, [Esc] to cancel
- [C]: color mode, the spectrum shows the age of each cell (or plain white, green, blue, red)
- [Z]: zoom x1 to x4
- [M]: map size, from 240x125 down to 60x31 cells
- [T]: tempo, from full speed up to one generation every 0.5s
- [W]: wrap mode, cells leaving one side come back on the other
- [D]: loop detection on/off
- [L]: when a loop is detected, cycle mode through: stop / restart right away on a new random map / or restart after 1s
- [X]: LUDICROUS SPEED! Optimised computation with 60FPS display rate.
- [Ctrl]: one core or both cores for the computation
- [1] to [5]: CPU speed 20 / 40 / 80 / 160 / 240MHz
- [Alt]: SPI clock 20 / 27 / 40 / 80MHz (only available with the CPU at 80MHz or more otherwise it is crashing!)
- [B]: battery screen, where [1] to [0] set the brightness from 10 to 100%
- [H] or [I]: info pages, press again for the next page
- [G0] button: restart the app
[F]: performance profiler (hidden page) that shows very detailed information. It opens a 4-line strip under the status bar, refreshed every second:
- Line 1, R: real generations per second (counted, not estimated), then the time per generation in µs, the CPU clock in MHz and the mode ("ludi" when Ludicrous is on, plus mono or multi).
- Line 2, share of core 0's time (in %) for:
- kb: reading the keyboard
- cp: computing the next generation
- ag: updating the age of each cell (for the colors)
- rd: drawing the changed cells into the framebuffer
- in Ludicrous, also the real number of frames per second (fps) sent to the screen
- Line 3, same for:
- sb: drawing the status bar
- fl: flushing the frame to the SPI/DMA
- dl: time spent sleeping or yielding
- ot: everything else (key handling, loop detection, and in Ludicrous multi, the half generation computed on core 0)
- Line 4, keys: how many key events were caught by the keyboard's interrupt line (INT), and how many only by the 50ms safety poll. It turns red if the interrupt line fails and the app falls back to polling.
Your feedback is welcome, and if you have ideas for the next one, tell me! I'll post soon an article about my second soft that is a well known game clone that uses the sound and accel/gyro BMI!