This guide explains how Miblo animates its screen and how to get the most out of it in your app.
The rule behind everything: the SDK slices, Miblo animates. Your program sends the art once and a
few numbers; the device computes every frame itself. Nothing streams while an animation plays.
There are three levels, and only three:
A built-in effect on a card item (
fx): one word, no art. Start here.screen.animate('art.gif', { fps }): the SDK does everything else.The same call with options (
fps,loop,region,fx,speed,amplitude,centre): Mode 7, parallax and 3D.
Slots, formats, steps and tiles never appear in your program. To stop everything your app
animates: screen.stop().
Numbers marked (estimated, measured on the device before release) come from the design and the
arithmetic; they will be replaced by the values measured on a real Miblo before the release ships.
1. How Miblo draws
No frame buffer
The screen has 240x240 pixels of 16 bits: one full picture takes 115 KB. Miblo's ESP8266 has
about 50 KB of free RAM. There is no copy of the screen in memory: Miblo draws straight to the
panel, in strips, and whatever is on the glass stays there until something covers it. Everything
below follows from that.
The cost is the bus
Every pixel travels over the SPI bus to the panel. The cost of drawing is, almost entirely, how
many pixels go through it:
| Area redrawn | Pixels | Time at 40 MHz | Time at 80 MHz |
|---|---|---|---|
| One 8x8 tile | 64 | ~0.04 ms | ~0.02 ms |
| One 16x16 sprite | 256 | ~0.1 ms | ~0.05 ms |
| One 32x32 sprite | 1,024 | ~0.4 ms | ~0.2 ms |
| 100 tiles (a typical animation) | 6,400 | ~4 ms | ~2 ms |
| A 120x120 region | 14,400 | ~7 to 10 ms | ~4 to 6 ms |
| The whole screen, 240x240 | 57,600 | ~25 to 40 ms | ~13 to 20 ms |
(estimated, measured on the device before release)
The takeaway: animating the whole screen is expensive; animating what changes is cheap. An effect
that touches a few tiles runs at 30 frames per second; the same effect over the whole screen, at 4
to 8.
One loop, shared with Wi-Fi and the pet
Miblo has a single main loop. Wi-Fi, the pet, your session alerts, the clock and your animation all
run in it. That is why drawing happens in strips and hands control back between one strip and the
next: Wi-Fi never waits for a whole screen.
The frame governor
Every animation has a time budget per frame. When a frame goes over it, the governor lowers
that animation's rate (say, from 30 to 15 fps) until it fits again. It never lets an animation
delay Wi-Fi, the pet or an alert. If your animation looks slower than the fps you asked for, it
was the governor: see Debugging with drawMs (#debugging-with-drawms).
2. The NES model
The NES had no frame buffer and 2 KB of RAM. It drew with three pieces, and Miblo does the same.
Tiles
A tile is an 8x8 drawing stored once. Each pixel is a 4-bit index into a 16-colour palette
(taken from the card's 64): 32 bytes per tile. A tile set holds up to 256 tiles (up to 8 KB),
lives in a flash slot and is copied to RAM while the animation plays.
Maps
The screen is a 30x30 grid of tiles (240 / 8). A map says which tile goes in each cell: 900
bytes for the whole screen, against 115 KB for a picture. An animation is a sequence of maps.
Between one map and the next, Miblo redraws only the cells whose index changed.
Sprites
A sprite is an object that moves over the map: up to 16 of them, 8x8 or 16x16 (up to 32x32),
with a transparent colour. At every step Miblo restores the tiles under the old position and draws
the sprite at the new one: about 0.1 ms per sprite
(estimated, measured on the device before release). The background is never redrawn whole.
Palettes
The card uses 64 colours. Each tile picks a sub-palette of 16 of them: 4 bits per pixel, yet
the screen as a whole can show all 64.
What came from the Super Nintendo
Flips: a tile can be drawn mirrored horizontally or vertically. A symmetric drawing needs half
the tiles.Two priorities: behind or in front of the card items.
Sprites up to 32x32, blended against the tile under them (the ISS's see-through shadow in the
Demo).Map scrolling by whole tiles: a repeating background scrolls almost for free. Scrolling by
the pixel redraws everything, so it stays at up to 8 fps.
Why this gives 8 to 30 fps here
A typical animation changes 50 to 150 tiles per frame. At ~40 µs per tile, that is 2 to 6 ms per
frame: room for 30 fps. Even redrawing all 900 tiles takes 25 to 40 ms, so at least 8 fps
(estimated, measured on the device before release). And nothing comes from the flash on the
critical path: tiles and maps are already in RAM.
3. What the SDK does with the art
You send an animated GIF, an APNG, a list of PNGs or a sprite sheet ({ sheet, cell, count }).
The SDK, on your computer, does the work an NES artist did by hand:
Decodes the art (the bridge's GIF decoder has no dependencies).
Quantises to 64 colours and picks a 16-colour sub-palette for each tile.
Cuts it into 8x8 tiles and drops the duplicates across all frames, including those that
mirror another. Pixel art and UI repeat a lot.Builds the maps of each frame and the deltas between them.
Finds the sprites: small objects on a transparent background that change place become
sprites, not new tiles.Picks the path, without you asking:
| The art | The path | Why |
|---|---|---|
| Many frames over a small region | One sprite sheet in one slot | More frames per slot, the highest fps |
| Pixel art, UI, a repeating background | Tiles and maps | Only the tiles that change are redrawn |
| Objects moving over a still background | Sprites over the map | The background is never redrawn |
| Big changes in part of the screen | Rectangle frames | Pays only for the area that changes |
| More than 256 different tiles (a photo) | Whole frames, up to 4 fps | A photo cannot be sliced |
fx on an item, or fx with speed | A firmware effect | No art per frame |
Converts, picks the slots and sends each part. Next time, it re-sends only what changed (by
hash): when the art is the same, nothing is written to the flash again.Checks the limits (8 slots, the fps, the day's writes) before anything reaches Miblo,
and explains any refusal in plain words (see
Reading the SDK's messages (#reading-the-sdks-messages)).
4. Drawing art that animates well
The right art is the difference between 30 fps and 4 fps. The rules:
Draw on the 8-px grid. Edges, bands and objects aligned to multiples of 8 become whole,
repeated tiles. A line crossing a tile in the middle creates new tiles.Flat areas. A plain colour is a single tile, repeated as often as needed.
Repeat. Grass, water, stars, tarmac: a pattern of 8, 16 or 32 px that repeats costs few tiles
and scrolls for free.Few gradients. A smooth (or dithered) gradient makes a different tile in every cell. Use
bands of 2 or 3 shades.A still background, a few moving objects. A fixed scene with a car, a fish and a pet walking
is the perfect case: the background goes once, the objects become sprites.Up to 16 colours per tile. The screen can show 64, but each 8x8 tile uses 16 at most.
Photos are not animations. A photo has a different tile in every cell: it ends up as whole
frames at 4 fps. Art is animation; a photo is a background (bg: 'frame:N').
The Demo follows all of this: the road (128x128) is 224 bytes, the starry sky 349 bytes, the water
285 bytes. Its eight pictures add up to 12 KB and are drawn by code inapps/scripts/demo-assets.mjs.
5. Built-in effects and Mode 7
Effects on a card item (fx)
A card item may carry fx. The firmware computes the effect in small regions at 20 to 30 fps: no
frame, no upload, no flash wear. It is the safest and smoothest way to animate.
fx | On which item | What it does | Cost per frame |
|---|---|---|---|
sweep | ring, bar | The ring or bar fills to the new value | ~1 ms |
count | big | The number counts to the new value | ~1 ms |
slide | spark | The chart slides when a new point arrives | ~2 ms |
pulse | any | A short glow when the value changes | ~1 ms |
blink | any | Blinks, to draw attention | ~0.5 ms |
orbit | ring | A dot circles the ring (the ISS) | ~0.5 ms |
rain | any | Lines falling over the item (the Clima app) | ~2 ms |
ticker | text | The text scrolls once, when it does not fit | ~1 ms |
(estimated, measured on the device before release)
Effects run on every card update: send the new value, Miblo animates the change. Nothing changes
in the API.
Layer effects: Mode 7
With fx on screen.animate, the art becomes a tile layer in RAM (up to 8 KB of tiles and a
900-byte map) and Miblo computes every row with integer math. Only the art (once) and three
numbers leave the computer: it is the safest animation there is.
fx | Parameters | What it does | Whole screen | 120x120 region |
|---|---|---|---|---|
scroll | speed px/s (-120 to 120; negative scrolls left) | Scrolls the layer; several layers at different speeds make parallax | ~8 fps | ~15 fps |
rotate | speed deg/s (-180 to 180), centre | Turns the layer around the centre | ~4 to 8 fps | ~15 fps |
zoom | speed factor/s (0.25 to 4), centre | Zooms in or out | ~4 to 8 fps | ~15 fps |
perspective | speed (0 to 10, how fast the ground comes at you) | Tilts the layer to the horizon: a road | ~4 to 8 fps | ~15 fps |
wave | amplitude px (0 to 16), speed waves/s (0 to 4) | Shifts each row sideways: water, heat haze | ~8 fps | ~15 fps |
(estimated, measured on the device before release) The ranges are what the SDK accepts; a
value outside them is clamped to the limit.
Every layer has a region ({ x, y, w, h }, on screen). The smaller the region, the higher the
fps. The governor applies here too.
3D: mesh
screen.animate({ mesh }, { fx, speed, shading, region }) draws a small 3D model, turned by the
firmware with integer math.
mesh:'cube','icosphere'or{ vertices, faces }(up to 64 vertices and 128 faces;
vertices[x, y, z], faces as vertex indexes).fx:spin(turns around an axis),orbit,tumble(rolls on two axes).speedin deg/s
(-360 to 360).shading: 'wire'(lines; only the tiles under the old and new lines are redrawn: 20 to 30 fps)
or'flat'(filled triangles with a simple light, in a region up to 120x120: 10 to 15 fps)
(estimated, measured on the device before release).
Examples in motion
Every effect above shows in the Demo app (store, Delights category): perspective andscroll on the road, rotate on the planet, wave on the water, rain over the water, the card
effects on the dashboard and mesh with spin on the logo. Each scene's code is inapps/lib/demo-scenes.js, and miblo screen preview shows any effect on your computer before you
send it.
6. Recipes
Each recipe is 15 lines at most and runs as is (Node 20+, @miblo/status). The pictures are the
Demo's (apps/assets/demo).
Rain
// recipe: rain
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Weather' });
// Rain is a firmware effect over the item: no art, no upload.
await screen.card({
v: 1, title: 'London', icon: 'cloud',
items: [
{ t: 'big', value: '12°', label: 'heavy rain', color: 'blue' },
{ t: 'text', value: 'rain until 4 pm', fx: 'rain' },
],
});Orbit
// recipe: orbit
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'ISS' });
// A dot circles the ring: the station going round the Earth.
await screen.card({
v: 1, title: 'ISS', icon: 'star',
items: [
{ t: 'ring', value: 0.62, label: 'this orbit', color: 'blue', fx: 'orbit' },
{ t: 'text', value: 'over the Atlantic Ocean' },
],
});Pulse
// recipe: pulse
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Sales' });
// Glows once on every new value: send the card again when the number changes.
await screen.card({
v: 1, title: 'Sales', icon: 'chart',
items: [{ t: 'big', value: '128', label: 'today', color: 'green', fx: 'pulse' }],
});Road
// recipe: road
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Road' });
// Three layers: the sky far and slow, the sea in the middle, the road coming at you.
await screen.animate('stars.png', { fx: 'scroll', speed: -4, region: { x: 0, y: 0, w: 240, h: 72 } });
await screen.animate('water.png', { fx: 'scroll', speed: -20, region: { x: 0, y: 72, w: 240, h: 40 } });
await screen.animate('road.png', { fx: 'perspective', speed: 6, region: { x: 0, y: 112, w: 240, h: 128 } });
// The car is a GIF with a transparent background: the SDK makes it a sprite.
await screen.animate('car.gif', { fps: 8, region: { x: 40, y: 192, w: 160, h: 40 } });Water
// recipe: water
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Aquarium' });
// The wave shifts each row of the water; the fish swims over it.
await screen.animate('water.png', { fx: 'wave', amplitude: 4, speed: 2, region: { x: 0, y: 120, w: 240, h: 120 } });
await screen.animate('fish.gif', { fps: 9, region: { x: 0, y: 152, w: 240, h: 48 } });Counter
// recipe: counter
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Downloads' });
// Send only the new value: Miblo counts from the previous one to it.
const total = 48213;
await screen.card({
v: 1, title: 'Downloads', icon: 'chart',
items: [{ t: 'big', value: total.toLocaleString('en-US'), label: 'this week', fx: 'count' }],
});Ticker
// recipe: ticker
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Headlines' });
// A text that does not fit scrolls once, from start to end.
await screen.card({
v: 1, title: 'Headlines', icon: 'mail',
items: [{ t: 'text', value: 'Central bank holds rates, hints at cuts', fx: 'ticker' }],
});Cube
// recipe: cube
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Cube' });
// A spinning wireframe cube: Miblo computes every frame, only the lines are redrawn.
await screen.animate({ mesh: 'cube' }, { fx: 'spin', speed: 90, shading: 'wire', region: { x: 60, y: 60, w: 120, h: 120 } });Any GIF
// recipe: sprite
import { MibloStatus } from '@miblo/status';
const screen = new MibloStatus().screen({ tool: 'Pet' });
// Level 2: a GIF and the fps. With loop: false it plays once.
await screen.animate('pet.gif', { fps: 8, loop: false, region: { x: 0, y: 204, w: 240, h: 32 } });
// Later, to take off everything your app animates:
await screen.stop();7. Limits, messages and debugging
Limits
| What | Limit |
|---|---|
| Animation slots in the flash | 8 (350 KB or more always left for pets and the system) |
| Size of a whole frame | up to 60 KB; animation frames up to ~12 KB compressed |
| Steps of an animation | up to 16, 50 to 2000 ms each |
| Whole frames (full screen) | up to 4 fps; target 8 fps or more (estimated, measured on the device before release) |
| Rectangle frames | up to 10 fps |
| Tiles and sprites | up to 30 fps (estimated, measured on the device before release) |
| Tile set | up to 256 tiles of 8x8 (8 KB), 16 colours per tile, 64 on screen |
| Map | 30x30 tiles (900 bytes) |
| Sprites | up to 16, from 8x8 to 32x32, with a transparent colour |
Card fx effects | 20 to 30 fps (estimated, measured on the device before release) |
| Mode 7 | whole screen ~4 to 8 fps, 120x120 region ~15 fps (estimated, measured on the device before release) |
mesh | up to 64 vertices and 128 faces; flat in a region up to 120x120 |
| Flash writes | 1 per slot every 10 s, 100 per slot per day |
Writes count for uploading the art; playing an animation only reads the flash. Reuse the same
pictures across scenes (as the Demo does with the stars, the water and the road) and the SDK writes
nothing new.
Reading the SDK's messages
The SDK checks everything before sending and says what it did in plain words. The messages read
like this:
| The message says | What happened | What to do |
|---|---|---|
| "20 frames: Miblo keeps 16; kept the 16 most different ones" | The art had too many steps | Fewer frames, or accept the reduction |
| "180 colours: kept 64, see the preview" | The art was quantised | Draw with fewer colours |
| "too detailed for tiles: whole frames at 4 fps" | More than 256 different tiles | More flat areas and repetition, or a smaller region |
| "too heavy for 8 fps: 4 fps, or a smaller region" | The frame goes over its budget | Shrink the region or what changes |
| "fps 60 above the limit: 30" | A parameter was clamped | Ask for a value within the table |
| "the 8 slots are in use" | No room for more art | Reuse pictures or remove the ones you do not use |
| "the slot's write limit for today" | Too many uploads in a day | Do not re-send the same art; it is already there |
miblo screen preview
Before sending anything, see exactly what Miblo will show, pixel for pixel:
miblo screen preview car.gif --fps 8The preview runs the same core (WebAssembly) the desktop app and the phone use: the same colours,
the same tiles, the same effects. It shows the SDK's messages and the path it picked (tiles,
sprites, frames or effect). The same preview is in the Miblo app's Apps tab.
Debugging with drawMs
Miblo measures how long it takes to draw and reports it in the screen status
(GET /sdk/v1/screen, or await screen.status()): drawMs, the time of the last draw, and the
animation's real fps. The Demo shows both numbers in its corner.
const st = await screen.status();
console.log(st.anim?.fps ?? st.fps, 'fps', st.anim?.drawMs ?? st.drawMs, 'ms');How to read them:
drawMsfits the budget (below 1000 / fps): all good.The real fps is lower than the one asked for: the governor stepped in. Shrink the
region,
trade frames for tiles (flatter, more repeated art) or ask for a lower fps.drawMsnear 25 to 40 ms: the whole screen is being redrawn every frame. Look for what
changes needlessly: a gradient, noise, a photo.Occasional spikes: Wi-Fi or the pet taking their turn. That is expected; the governor handles
it.