Skip to content
miblo

Animations on Miblo's screen

The SDK slices, Miblo animates: the art goes to the flash once and the device computes every frame itself. Concepts, engineering and recipes to get the most out of the screen.

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:

  1. A built-in effect on a card item (fx): one word, no art. Start here.

  2. screen.animate('art.gif', { fps }): the SDK does everything else.

  3. 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 redrawnPixelsTime at 40 MHzTime at 80 MHz
One 8x8 tile64~0.04 ms~0.02 ms
One 16x16 sprite256~0.1 ms~0.05 ms
One 32x32 sprite1,024~0.4 ms~0.2 ms
100 tiles (a typical animation)6,400~4 ms~2 ms
A 120x120 region14,400~7 to 10 ms~4 to 6 ms
The whole screen, 240x24057,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:

  1. Decodes the art (the bridge's GIF decoder has no dependencies).

  2. Quantises to 64 colours and picks a 16-colour sub-palette for each tile.

  3. Cuts it into 8x8 tiles and drops the duplicates across all frames, including those that
    mirror another. Pixel art and UI repeat a lot.

  4. Builds the maps of each frame and the deltas between them.

  5. Finds the sprites: small objects on a transparent background that change place become
    sprites, not new tiles.

  6. Picks the path, without you asking:

The artThe pathWhy
Many frames over a small regionOne sprite sheet in one slotMore frames per slot, the highest fps
Pixel art, UI, a repeating backgroundTiles and mapsOnly the tiles that change are redrawn
Objects moving over a still backgroundSprites over the mapThe background is never redrawn
Big changes in part of the screenRectangle framesPays only for the area that changes
More than 256 different tiles (a photo)Whole frames, up to 4 fpsA photo cannot be sliced
fx on an item, or fx with speedA firmware effectNo art per frame
  1. 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.

  2. 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 in
apps/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.

fxOn which itemWhat it doesCost per frame
sweepring, barThe ring or bar fills to the new value~1 ms
countbigThe number counts to the new value~1 ms
slidesparkThe chart slides when a new point arrives~2 ms
pulseanyA short glow when the value changes~1 ms
blinkanyBlinks, to draw attention~0.5 ms
orbitringA dot circles the ring (the ISS)~0.5 ms
rainanyLines falling over the item (the Clima app)~2 ms
tickertextThe 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.

fxParametersWhat it doesWhole screen120x120 region
scrollspeed px/s (-120 to 120; negative scrolls left)Scrolls the layer; several layers at different speeds make parallax~8 fps~15 fps
rotatespeed deg/s (-180 to 180), centreTurns the layer around the centre~4 to 8 fps~15 fps
zoomspeed factor/s (0.25 to 4), centreZooms in or out~4 to 8 fps~15 fps
perspectivespeed (0 to 10, how fast the ground comes at you)Tilts the layer to the horizon: a road~4 to 8 fps~15 fps
waveamplitude 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). speed in 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 and
scroll 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 in
apps/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

WhatLimit
Animation slots in the flash8 (350 KB or more always left for pets and the system)
Size of a whole frameup to 60 KB; animation frames up to ~12 KB compressed
Steps of an animationup 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 framesup to 10 fps
Tiles and spritesup to 30 fps (estimated, measured on the device before release)
Tile setup to 256 tiles of 8x8 (8 KB), 16 colours per tile, 64 on screen
Map30x30 tiles (900 bytes)
Spritesup to 16, from 8x8 to 32x32, with a transparent colour
Card fx effects20 to 30 fps (estimated, measured on the device before release)
Mode 7whole screen ~4 to 8 fps, 120x120 region ~15 fps (estimated, measured on the device before release)
meshup to 64 vertices and 128 faces; flat in a region up to 120x120
Flash writes1 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 saysWhat happenedWhat to do
"20 frames: Miblo keeps 16; kept the 16 most different ones"The art had too many stepsFewer frames, or accept the reduction
"180 colours: kept 64, see the preview"The art was quantisedDraw with fewer colours
"too detailed for tiles: whole frames at 4 fps"More than 256 different tilesMore flat areas and repetition, or a smaller region
"too heavy for 8 fps: 4 fps, or a smaller region"The frame goes over its budgetShrink the region or what changes
"fps 60 above the limit: 30"A parameter was clampedAsk for a value within the table
"the 8 slots are in use"No room for more artReuse pictures or remove the ones you do not use
"the slot's write limit for today"Too many uploads in a dayDo 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 8

The 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:

  • drawMs fits 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.

  • drawMs near 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.

Back to the docs