Simulator¶
scrollkit.simulator is a desktop emulation of the CircuitPython display stack.
It is what lets you develop, test, and demo ScrollKit apps with no hardware.
What it emulates¶
displayio—Bitmap,Palette,TileGrid,Group,Display,FourWire,OnDiskBitmap.adafruit_display_text—Label.adafruit_bitmap_font— BDF font loading (the same.bdffonts the hardware uses).terminalio— the built-in terminal font.- Devices —
MatrixPortalS3. - A virtual accelerometer — see Tilt below.
A pygame window renders the virtual LED matrix pixel-for-pixel, so what you see on screen matches what the panel shows.
Two display classes
UnifiedDisplay is the display an app uses — it
auto-selects the real displayio backend on CircuitPython and the simulator
on desktop, so your app code never branches on platform. SimulatorDisplay
is a thin subclass of UnifiedDisplay (~80 lines) that adds only desktop
interactive-window ergonomics: it auto-opens the pygame window on the first
show(), plus scale/pitch constructor knobs. screenshot() /
start_recording() / save_gif() / save_video() and the
hardware_timing/throttle/strict flags all live on UnifiedDisplay
itself and are safe no-ops (return None) on real hardware — you get them
whether you ship against UnifiedDisplay or use SimulatorDisplay directly.
Reach for SimulatorDisplay specifically when you want the auto-opened
interactive window (tests, demos, the dev harness).
from scrollkit.display.simulator import SimulatorDisplay
display = SimulatorDisplay(width=64, height=32)
await display.create_window("My ScrollKit App")
Why it matters¶
Because the simulator emulates the CircuitPython APIs, your application code is
identical on desktop and device. UnifiedDisplay picks the simulator on
desktop and the real displayio backend on CircuitPython — you never write
platform branches.
Keep them in sync
If the simulator ever diverges from real hardware behaviour, fix the simulator, not the shared display code — the device is the source of truth.
The verification workflow¶
The simulator can model the real board's speed and RAM (opt in with
hardware_timing, or feel it in real time with throttle), so the classic
trap — looks great at desktop speed, crawls on the ~100×-slower device — surfaces
before you flash. The scrollkit.dev toolkit (desktop-only)
turns that into a tight build-and-prove loop:
flowchart TB
write["Write app<br/>(subclass ScrollKitApp, fill the queue)"] --> discover["Discover the API<br/>capabilities() · performance_guide()"]
discover --> run["run_headless(app, strict=True)"]
run --> model["PerformanceManager accumulates modeled µs/frame<br/>from the calibrated matrixportal_s3_baseline.json"]
model --> gate{"strict gate:<br/>frame > ~50 ms budget<br/>or peak RAM > usable?"}
gate -->|over budget| fail["raises FeasibilityError<br/>result.ok == False"]
gate -->|within budget| validate["validate(app)<br/>static + dynamic checks"]
fail --> write
validate -->|issues found| write
validate -->|clean| green["make test-unit<br/>+ make lint-errors"]
green --> flash["Flash to device"]
The feasibility numbers are measured, not guessed: the shipped MatrixPortal S3 baseline was captured from a real board. See Performance for the cost model and Adding New Hardware for recalibration.
How fast does the simulator run?¶
Three different things control "speed" here, and conflating them causes real confusion — so keep them separate:
| Mode | What sets the pace | Tracks your PC's speed? | Represents the device? |
|---|---|---|---|
| Default live window | The app loop's fixed await sleep(0.05) → a ~20 FPS wall-clock target; motion is measured in pixels-per-frame |
No — capped at ~20 FPS by the sleep (only a too-slow PC drops below it) | No — it's the authoring rate, not a device estimate |
hardware_timing=True |
Nothing real-time: it accumulates modeled µs/frame (operation counts × device-measured costs) and reports an estimated hardware FPS | No — pure arithmetic, identical on any machine | Yes — a calibrated estimate |
throttle=True (implies hardware timing) |
Each frame time.sleeps its modeled duration |
No — it sleeps the device time regardless of host | Yes — the window plays at the modeled device FPS |
virtual_clock=True |
Nothing real-time: content's own clock reads accumulated modeled µs instead of wall time | No — arithmetic again | Yes — duration-driven content advances at device speed |
The non-obvious point: the default window's playback speed represents neither
your PC nor the real device. A standard ScrollKitApp self-paces to a fixed
~20 FPS — the loop sleeps 50 ms per frame and motion is defined per frame — so it
runs at roughly the same real-time speed on a slow laptop and a fast workstation. A
quicker CPU just idles more of each 50 ms window rather than animating faster.
(Drive the display from your own tight loop with no sleep and it will free-run as
fast as the CPU allows; the pacing lives in the app loop, not the display.)
That fixed 20 FPS is an authoring convenience, not a device measurement. To learn what the hardware would actually do, reach for one of the model-driven modes:
hardware_timing=Trueanswers "what FPS would the device get?" as a number, without spending real time — it counts each frame's operations and multiplies by costs measured on a real board (see Performance). Because it's arithmetic on counts, the estimate is the same whether you run it on a decade-old netbook or a brand-new workstation.throttle=Trueanswers the same question viscerally: it sleeps each frame's modeled time so the window physically crawls at the predicted device rate, with console nags when a frame would stutter.
Why isn't exact-FPS playback the default?
Every per-operation timing needed to replay at the device's true rate is always
present — throttle just spends those timings as real time.sleeps. It's
opt-in because the common case (and the entire headless test suite) wants to run
faster than the device and read the verdict off a gauge, not sit through a
6-FPS crawl on every preview. So the default computes the estimate and reports
it; throttle makes you feel it.
What time does the content think it is?¶
A fourth thing, and the one that surprises people: duration-driven content asks
the clock how long it has been on screen, and by default the clock is wall time.
That is right on the device and wrong in a headless run, which steps frames with
no sleep at all — 60 frames pass in about 0.2 s, a duration=2.0 item never
expires, the queue never advances, and a perfectly correct animated app reports
that it never animates.
virtual_clock=True replaces wall time with modeled device time: the same
accumulated µs/frame the feasibility estimate is built from.
run_headless(app, frames=60, virtual_clock=True) # content time == device time
Content then advances by what the frames would have cost the panel, so a sign whose items last two seconds gets two seconds of device time regardless of how fast the host renders. Because that number comes from operation counts rather than from a stopwatch, it is identical on any machine and in any runtime — a browser running ScrollKit under Pyodide and a desktop running it under CPython read the same clock, frame for frame.
Driving your own loop instead of the harness? scrollkit.dev.clock has the
primitives:
from scrollkit.dev.clock import install_virtual_clock, uninstall_virtual_clock
install_virtual_clock() # after the display exists — it reads the model
try:
...
finally:
uninstall_virtual_clock()
It reads the active performance manager on every tick, so it needs hardware
timing on (run_headless(..., hardware=True) or SCROLLKIT_HW_SIM=1) and it
raises rather than quietly returning zero if there is nothing to read.
Tilting a laptop¶
You can't — but the MatrixPortal S3 has an accelerometer, so apps that respond to
being physically turned need some way to be developed. The display carries a
virtual gravity vector that TiltSensor reads whenever there's no
real chip, driven from the simulator window:
| Key | Effect |
|---|---|
| ++left++ / ++right++ | Swing gravity 15° |
| ++down++ | Snap back upright |
| ++up++ | Lay the panel flat on its back |
Arrow keys are for humans; tests and headless runs set the angle exactly:
display.set_virtual_tilt(angle=90) # right edge down
display.set_virtual_tilt(flat=True) # lying face up
Both are no-ops on hardware, where the real accelerometer is the source. Try it
with PYTHONPATH=src python demos/medium/tilt_drip.py.
Screenshots¶
SimulatorDisplay.screenshot(path) saves the current frame to an image file —
handy for documentation, bug reports, and visual tests:
await display.show()
display.screenshot("frame.png") # returns the path, or None on hardware
It captures whatever is currently on the simulated matrix. On real hardware
(no pygame) it returns None, so the same call is safe to leave in cross-platform
code.
Requires pygame on desktop: pip install pygame.
Recording animated GIFs¶
screenshot()'s sibling captures many frames and encodes an animated GIF —
ideal for a README, a docs preview, or a bug report. Turn recording on, run the
animation, then save:
display.start_recording()
for _ in range(80):
await content.render(display)
await display.show() # each shown frame is captured
display.save_gif("demo.gif") # encodes + returns the path (None on hardware)
save_gif(path, *, fps=20, target_width=360, max_colors=48, frame_step=1, ...)
downscales to target_width, shares one adaptive palette across frames, and only
stores each frame's changed region, so files stay small. Raise frame_step (keep
only every Nth frame) for an even smaller file; raise the display's pitch (e.g.
SimulatorDisplay(pitch=4)) for crisper recordings.
To capture a whole ScrollKitApp headlessly there's a one-call helper:
from scrollkit.dev import record_gif
record_gif(MyApp(), "demo.gif", seconds=4) # target_width=, max_colors=, ... forwarded
This is exactly how the Demo Gallery previews are generated
(demos/render_gifs.py). Like screenshot(), recording is desktop-only and a
no-op on hardware.
Recording MP4 video¶
For full-colour animation an MP4 is far smaller and smoother than a GIF (no
256-colour palette, real inter-frame compression), so it's the right format for a
site hero or a long clip. The recording flow is identical — just save with
save_video instead of save_gif:
display.start_recording()
for _ in range(120):
await content.render(display)
await display.show()
display.save_video("hero.mp4") # H.264 MP4; returns the path, or None on hardware
save_video(path, *, fps=24, target_width=None, crf=20, preset="medium", border=0,
border_color=(10, 10, 13)) pipes the recorded frames straight to ffmpeg.
target_width optionally downscales (None keeps native size); crf trades size
for quality (≈18 best … 24 smaller; 20 is a good default); border adds a dark
bezel of that many pixels on every side, like a real sign's frame.
The whole-app one-call helper mirrors record_gif:
from scrollkit.dev import record_video
record_video(MyApp(), "hero.mp4", seconds=6, border=22) # crf=, target_width=, ... forwarded
This is how the landing-page hero is generated (demos/render_hero.py, run via
make hero). MP4 recording needs the ffmpeg binary on your PATH
(brew install ffmpeg); without it save_video returns None. Like every
recording call, it's desktop-only and a no-op on hardware.
Fonts: BDF vs PCF¶
ScrollKit ships and uses BDF fonts (under scrollkit/simulator/fonts/), and
the simulator + hardware both load them with the same
bitmap_font.load_font(path) API. BDF is plain-text and easy to work with, which
is why it's the default.
On a memory-constrained device, PCF is the more efficient choice for larger fonts:
| BDF | PCF | |
|---|---|---|
| Format | text | binary |
| Load cost | parses the whole font into RAM | glyphs read from flash on demand |
| Best for | small fonts, the simulator, development | large fonts on the MatrixPortal S3 |
On real CircuitPython hardware, both formats load through the identical
adafruit_bitmap_font.bitmap_font.load_font(path) API, so switching is a
one-line change there:
font = bitmap_font.load_font("/fonts/MyFont.pcf") # instead of .bdf
The desktop simulator does not implement PCF —
scrollkit.simulator.adafruit_bitmap_font only parses BDF text; pointing it at
a .pcf file loads silently with zero glyphs (blank text, no error). Keep
testing with the .bdf version in the simulator, and swap to the converted
.pcf only for the on-device build.
Convert a BDF to PCF on a desktop with bdftopcf (part of the X11 font utils):
bdftopcf MyFont.bdf -o MyFont.pcf
Recommendation: keep BDF as the default (it works everywhere and the simulator
is not RAM-constrained); convert to PCF only the specific large fonts you load on
hardware, where the RAM saving matters. BDF parity is preserved either way — the
same fonts remain available as .bdf.