Skip to content

Canvas Ops Reference

Alex Van de Putte edited this page Jul 28, 2026 · 10 revisions

Canvas Ops Reference

Every drawing op the Matrix Gateway's POST /api/canvas/ops accepts, in detail. This is the companion page to Canvas API (the endpoint-level view) and Writing Matrix Apps (the app author's view — the canvas.* helpers there emit exactly these ops).

An ops request is a JSON array of op objects, applied in order, then presented:

[ {"op":"clear","color":[0,0,40]},
  {"op":"arc","x":64,"y":16,"r":14,"t":3,"start":0,"end":270,"color":[0,200,120]},
  {"op":"text","x":64,"y":11,"s":"75%","align":"center"} ]

The same array, byte for byte, rides the stream channel's ops record (PUT /api/canvas/stream, type 0x03) — everything below applies there too.

Conventions (read once)

  • Coordinates: pixels, origin top-left, x right / y down. Everything is clipped to the panel (and to the clip window when one is set) — off-panel drawing is dropped, never an error.
  • Colours: "color": [r, g, b] (0–255 each). Omitted, it defaults to white for drawing ops and black for clear/scroll vacancy. The panel quantises to its running bit depth.
  • Batching: ops draw into the back buffer; nothing appears until a present. The batch auto-presents when it ends unless it contained an explicit show — include {"op":"show"} mid-batch only when you want an intermediate present. (On the stream channel there is no auto-present: send show ops or a 0x05 record.)
  • Batch-scoped state: origin and clip affect only later ops in the same batch and reset between batches. atlas binding is sticky across batches by design.
  • Unknown ops and params are ignored (the op is skipped / the param unread, never a 400) — old and new clients interoperate with old and new firmware in any combination. GET /api/capabilitiescanvas.ops is the authoritative list of what the wall you're talking to understands.
  • The reply is {"ok":true,"applied":N}applied counts ops that did something (unknown ops and out-of-range sprites don't count). It answers before the present, so the ~14 ms present overlaps your next frame's build.

Fills & pixels

clear — fill the panel

{"op":"clear","color":[0,0,0]}
param type default
color [r,g,b] black fill colour

Fills the whole panel (a fast whole-buffer fill, ~5 ms on 256×64). Almost every frame starts here. Ignores origin; respects clip.

pixel — one pixel

{"op":"pixel","x":10,"y":4,"color":[255,0,0]}
param type default
x, y int 0
color [r,g,b] white

hline / vline — axis-aligned runs

{"op":"hline","x":0,"y":31,"w":128,"color":[80,80,80]}
{"op":"vline","x":64,"y":0,"h":32}
param type default
x, y int 0 start
w (hline) / h (vline) int 0 run length
color [r,g,b] white

For a thick rule use rect with fill:true — it's one fast fill.


Lines & outlines

line — any-angle line (thickness fw 3.5)

{"op":"line","x":0,"y":0,"x1":127,"y1":31,"t":3,"color":[255,200,0]}
param type default
x, y int 0 one end
x1, y1 int 0 other end
t int 1 stroke thickness (fw 3.5)
color [r,g,b] white

Bresenham at t:1; thicker strokes sweep a t×t square brush along the line, which reads as a solid stroke at panel resolutions.

polyline — connected open line (thickness fw 3.5)

{"op":"polyline","points":[[0,30],[20,10],[40,25],[60,5]],"t":2,"color":[0,255,255]}
param type default
points [[x,y],…] vertices, drawn in order, not closed
t int 1 stroke thickness (fw 3.5)
color [r,g,b] white

The graphing primitive. For a closed shape use poly.


Shapes

All shape ops share: color (default white), fill (default false — outline). Outlines take t thickness since fw 3.5 where noted.

rect — rectangle (outline thickness fw 3.5)

{"op":"rect","x":4,"y":4,"w":40,"h":24,"t":2,"color":[255,255,255]}
{"op":"rect","x":4,"y":4,"w":40,"h":24,"fill":true}
param type default
x, y, w, h int 0
fill bool false
t int 1 (outline only)

A t-thick outline is four filled bars, drawn inside the w×h box.

roundrect — rounded rectangle

{"op":"roundrect","x":2,"y":2,"w":60,"h":28,"r":6,"fill":true,"color":[30,30,60]}
param type default
x, y, w, h int 0 box
r int 0 corner radius
fill bool false

The card/badge primitive. (Outline is 1 px; for a thick rounded frame draw two filled roundrects, inner in the background colour.)

circle — circle (outline thickness fw 3.5)

{"op":"circle","x":16,"y":16,"r":10,"t":3,"color":[255,0,128]}
param type default
x, y int 0 centre
r int 0 radius
fill bool false disc
t int 1 ring thickness (outline; fw 3.5 renders a true annulus)

ellipse — ellipse (outline thickness fw 3.5)

{"op":"ellipse","x":64,"y":16,"rx":30,"ry":12,"fill":true,"color":[0,60,120]}
param type default
x, y int 0 centre
rx, ry int 0 semi-axes
fill bool false
t int 1 outline thickness (nested rings)

triangle — triangle

{"op":"triangle","x":10,"y":28,"x1":30,"y1":4,"x2":50,"y2":28,"fill":true}
param type default
x,y, x1,y1, x2,y2 int 0
fill bool false
color [r,g,b] white

poly — closed polygon (fw 3.5)

{"op":"poly","points":[[10,2],[20,12],[16,26],[4,26],[0,12]],"color":[255,180,0]}
{"op":"poly","points":[[10,2],[20,12],[4,26]],"fill":false,"t":2}
param type default
points [[x,y],…] up to 16 vertices, closed automatically
fill bool true even-odd scanline fill
t int 1 outline thickness when fill:false
color [r,g,b] white

Even-odd fill means self-intersecting outlines (stars) render with holes, HTML-canvas style. Beyond 16 vertices the extras are ignored.

arc — arc, ring segment, pie slice (fw 3.5)

{"op":"arc","x":64,"y":32,"r":20,"t":4,"start":0,"end":270,"color":[0,220,100]}
{"op":"arc","x":64,"y":32,"r":20,"start":120,"end":200,"fill":true}
param type default
x, y int 0 centre
r int 0 outer radius
t int 2 ring thickness (ignored when fill)
start, end int deg 0, 360 sweep, 0° = 12 o'clock, clockwise
fill bool false pie slice instead of ring segment
color [r,g,b] white

The gauge primitive. start:0, end: value*360/max is a progress ring; t:r makes the ring a full disc segment. Sweeps may cross 0° (start:300, end:420 or equivalently end:60, which the firmware unwraps). A full 0→360 with t is exactly a thick circle outline.


Compositing (fw 3.8)

Every draw can composite over what's already in the back buffer instead of overwriting. Two independent controls: per-colour alpha and a batch blend mode.

  • Alpha — a colour may carry a 4th element: "color":[r,g,b,a], a = 0 (transparent) … 255 (opaque). Applies to any op that takes a color (shapes, text, gradient, clear).
  • blend{"op":"blend","mode":"over"|"add"|"multiply"|"screen"|"max"} sets the mode for subsequent ops (batch-scoped, resets between batches). over is normal alpha; add is additive (the LED-glow mode — overlapping lights sum); multiply darkens; screen lightens; max keeps the brighter of source/destination.
[ {"op":"clear","color":[0,0,40]},
  {"op":"rect","x":0,"y":0,"w":64,"h":32,"fill":true,"color":[0,0,200,128]},
  {"op":"blend","mode":"add"},
  {"op":"circle","x":40,"y":16,"r":14,"fill":true,"color":[80,0,0]},
  {"op":"circle","x":56,"y":16,"r":14,"fill":true,"color":[80,0,0]} ]

Compositing reads the current back buffer, so it layers over what the batch has already drawn (start with a clear or a background). Advertised in capabilities as canvas.compositing ({alpha, blendModes[], aa}).

Anti-aliased strokes (fw 3.8)

"aa":true on line, polyline, poly (outline) and circle (outline) draws smooth edges (Xiaolin-Wu lines; coverage-blended circle ring) instead of hard jaggies — coverage becomes alpha through the same compositing path, so it honours the colour alpha and blend mode. AA is 1 px; thick strokes stay hard-edged. Especially worth it on diagonals and curves.

Stroke styling (fw 3.8.1)

line, polyline and poly (outline) take stroke-style params:

param type
cap string butt (default) / round / square end shape of a thick line
join string miter (default) / round corner fill at thick-polyline/poly vertices
dash [on, off] e.g. [8,4] dashed stroke; the pattern flows continuously along a polyline/poly
{"op":"line","x":4,"y":16,"x1":124,"y1":16,"t":4,"cap":"round","color":[0,255,0]}
{"op":"line","x":4,"y":24,"x1":124,"y1":24,"t":2,"dash":[8,4],"color":[255,255,0]}
{"op":"polyline","points":[[4,8],[40,28],[80,8],[120,28]],"t":3,"join":"round","color":[0,200,255]}

Caps/joins apply to thick strokes (t>1). Dash and AA don't combine (AA wins).

bezier — smooth curves (fw 3.8)

{"op":"bezier","points":[[4,28],[64,2],[124,28]],"aa":true,"color":[255,200,0]}
{"op":"bezier","points":[[0,30],[20,0],[80,60],[120,10]],"t":2,"color":[0,200,255]}
param type default
points [[x,y],…] 3 points = quadratic, 4 = cubic
aa bool false anti-aliased
t int 1 stroke thickness (non-AA)
color [r,g,b(,a)] white

Flattened to line segments on-device (segment count scales with the curve's size). The charting/gauge-needle primitive.

Batch state (fw 3.5)

clip — clip window

{"op":"clip","x":10,"y":0,"w":50,"h":32}
{"op":"clip"}
param type default
x, y, w, h int 0 window; w/h ≤ 0 (or the bare form) clears

Every later op in the batch — including full clear fills, images, sprites and text — draws only inside the window. Enforced in the firmware's pixel/fill/blit fast paths, so there's no op that escapes it. Reset automatically when the batch ends.

origin — translate

{"op":"origin","x":40,"y":8}
{"op":"origin"}
param type default
x, y int 0 added to every later coordinate; bare form resets

Applies to all coordinate params, including x1/y1/x2/y2 and points arrays. Absolute (not cumulative): a second origin replaces the first. The component pattern: draw a widget's ops with coordinates relative to (0,0), prefix them with origin (and usually clip) to place as many copies as you like:

[ {"op":"origin","x":0,"y":0},   {"op":"clip","x":0,"y":0,"w":64,"h":32},  …widget ops…,
  {"op":"origin","x":64,"y":0},  {"op":"clip","x":64,"y":0,"w":64,"h":32}, …same ops… ]

(Note clip coordinates are themselves origin-translated — set origin first with the window in mind, or set clip before origin as above.)


Text

text — one line

{"op":"text","x":64,"y":2,"s":"21° café","align":"center","size":13,"color":[255,220,0]}
{"op":"text","x":64,"y":2,"s":"BIG","aa":true,"size":34,"align":"center"}
{"op":"text","x":2,"y":2,"s":"Score","outline":[0,0,0]}
param type default
x, y int 0 anchor; y is the glyph top
s string "" UTF-8; transcoded to CP1252 on device (°, é, ß … all fine)
size int 10 bitmap faces: 8, 9, 10, 13, 18, 20 px (nearest wins)
font string an uploaded font by name (PUT /api/canvas/font); unknown names keep the built-in face
align string left left | center | right, about x
aa bool false fw 3.5: anti-aliased Orbitron instead of the bitmap face — size ≥ 30 → 34 px, ≥ 18 → 24 px, else 13 px; charset A–Z 0–9 :.-+%/ (input folded to uppercase, other glyphs skipped)
outline [r,g,b] fw 3.5: 1 px ring under the text (bitmap path)
shadow [r,g,b] fw 3.5: +1,+1 drop shadow (bitmap path; outline wins if both)
color [r,g,b] white

Bitmap glyphs draw transparent (background shows between strokes) — text over gradients/images works directly. The AA path is the one for big numerals: clocks, temperatures, scores.

textbox — wrapped, aligned text in a box (fw 3.5)

{"op":"textbox","x":4,"y":4,"w":120,"h":24,"s":"Multi-line text wraps by word and stays inside the box","size":8,"align":"center","valign":"middle"}
param type default
x, y, w, h int 0 the box
s string "" UTF-8; explicit \n forces a line break
size / font 10 / — as text (bitmap faces only)
align string left per-line: left | center | right
valign string top block: top | middle | bottom
color [r,g,b] white

Greedy word-wrap; a word longer than the box hard-breaks; everything is clipped to the box (up to 10 lines). The message-card primitive.


Images & sprites

image — inline bitmap

{"op":"image","x":8,"y":8,"w":16,"h":16,"fmt":"rgb565","data":"<base64>"}
param type default
x, y int 0 top-left
w, h int 0 pixel dimensions of the data
fmt string rgb888 rgb888 | rgb565 (big-endian)
data string base64 of w*h*bpp bytes

One-off small graphics. For anything reused, upload a sprite sheet once (Atlas) and sprite it — base64-in-JSON is ~33 % bigger and re-sent every time.

atlas — bind a sprite sheet (fw 3.1)

{"op":"atlas","name":"weather"}

Binds the named sheet for subsequent sprite ops. Sticky across batches (rebind per batch if you use content-fingerprint names). Lazy-loads a persisted sheet from flash on first bind; an unknown name binds nothing and later sprites no-op.

sprite — blit an atlas tile (fw 2.1; transforms fw 3.5)

{"op":"sprite","i":4,"x":10,"y":6}
{"op":"sprite","i":4,"x":10,"y":6,"flip":"h","rot":90,"scale":2}
param type default
i int tile index in the bound sheet
x, y int 0 destination top-left
flip string h | v | hv — applied first, in tile space (fw 3.5)
rot int 0 90 | 180 | 270, clockwise, after the flip (fw 3.5)
scale int 1 1–4, integer pixel scaling (fw 3.5)

Magenta (255,0,255 / 0xF81F) is transparent. With transforms, one sheet serves every orientation — a game ships one spaceship, not eight. Rotation pivots the tile about its own top-left destination box (a 90°-rotated w×h tile occupies h×w).


Motion & present

gradient — linear gradient fill

{"op":"gradient","x":0,"y":0,"w":128,"h":32,"from":[10,16,44],"to":[3,5,16],"dir":"v"}
param type default
x, y, w, h int 0 box
from, to [r,g,b] end colours
dir string v v | h | r radial, centre→corners (fw 3.6)
angle int deg (fw 3.6) any-angle linear gradient (0 = left→right, 90 = top→down); implies dir:"a"
dither bool true (fw 3.6) ordered (Bayer 4×4) dithering — banding at panel bit depths breaks into a fine blend; false restores hard bands

The backdrop primitive (skies, cards). Binary encoding: the dir byte accepts 2 = radial since fw 3.6 (always dithered).

scroll — shift the frame

{"op":"scroll","dx":-1,"dy":0,"color":[0,0,0]}
param type default
dx, dy int 0 shift, pixels (negative = left/up)
color [r,g,b] black fill for the vacated strip

Shift the whole back buffer and repaint only the fresh edge — the cheap way to pan or tick a chart along.

show — present

{"op":"show"}

Presents the back buffer (double-buffered; the firmware's tear-guard paces the swap). A batch without any show auto-presents at the end — reach for explicit show only for multi-present batches or on the stream channel (which never auto-presents).


Where ops fit among the other canvas paths

you want use
shapes, gauges, text, sprites ops (this page)
photographic / Pillow-rendered frames PUT /api/canvas/frame (or QOI)
small changed regions of a frame PUT /api/canvas/rects deltas
animation-rate updates, no per-frame HTTP the stream channel (ops record 0x03 carries this page's ops)
panel-side animation with zero network effects (POST /api/canvas/effect)
scrolling text the ticker

Binary encoding (fw 3.5)

For game-rate clients every op above also exists in a fixed-layout binary encoding (canvas.opsBin capability, format 1): ~6× smaller and 1.5–1.9× the frame rate on op-heavy scenes, because the wall skips JSON parsing entirely. Carried by POST /api/canvas/opsb or — the intended path — stream record 0x06. All integers big-endian, coordinates signed int16 (draw off-panel freely). One op = u8 opcode + fixed fields:

opcode op fields after the opcode
0x01 clear rgb(3)
0x02 pixel x y rgb
0x03/0x04 hline / vline x y len(2) rgb
0x05 line x y x1 y1 t(1) rgb
0x06 rect x y w h flags(1: bit0 fill) t(1) rgb
0x07 circle x y r(2) flags t rgb
0x08 ellipse x y rx ry flags t rgb
0x09 triangle x y x1 y1 x2 y2 flags rgb
0x0A roundrect x y w h r(2) flags rgb
0x0B gradient x y w h from(3) to(3) dir(1: 0 v, 1 h)
0x0C arc x y r(2) t(1) start(2) end(2) flags(bit0 pie) rgb
0x0D poly n(1) flags(bit0 fill, bit1 closed) t(1) rgb then n × (x y)
0x0E clip x y w h (w ≤ 0 clears)
0x0F origin x y
0x10 text x y size(1) flags(1) rgb [outline rgb] [shadow rgb] len(1) utf8 — flags: bits 0-1 align (0 L / 1 C / 2 R), bit 2 aa, bit 3 has-outline, bit 4 has-shadow
0x11 sprite i(2) x y flags(1) — bit 0 flipH, bit 1 flipV, bits 2-3 rot/90°, bits 4-5 scale−1
0x12 scroll dx dy rgb
0x13 show
0x14 blend mode(1): 0 over, 1 add, 2 multiply, 3 screen, 4 max (batch-scoped, fw 3.8)
0x15 alpha alpha(1): 0–255, applied to following ops (batch-scoped, fw 3.8)

Strict decode: an unknown opcode or truncated op fails the whole batch (binary cannot skip what it cannot size) — feature-detect via canvas.opsBin, don't probe. Semantics are identical to the JSON ops above, verified pixel-for-pixel on device.

Full endpoint semantics: Canvas API. Machine-readable: the device's own GET /openapi.yaml.

Clone this wiki locally