Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

Canvas

Canvas(w:, h:[, density:]) · Draw.Pixels(buffer, w, h, dx, dy[, dw, dh]) · Draw.Clear([color]) · Draw.Alpha(a) · Draw.Rect(x, y, w, h, color) · Draw.Line(x1, y1, x2, y2, color[, width]) · Draw.Circle(x, y, radius, color) · Draw.Text(text, x, y, color, size) · Draw.Image(texture|url, [sx, sy, sw, sh,] dx, dy, dw, dh) · Draw.Push() · Draw.Translate(x, y) · Draw.Rotate(radians) · Draw.Scale(sx, sy) · Draw.Pop() · Draw.Reset() · Draw.Surface(w, h) · Draw.Into(surface) · Draw.Screen()

A drawing surface, and the verbs that paint on it. Put a `Canvas` in a render block, call `Draw.*` from an `on frame` body, and the picture is redrawn every frame — a game, a visualisation, a custom chart, anything the box-shaped render vocabulary cannot express. The drawing code is ordinary Osy#: ordinary loops, ordinary helper methods, ordinary state.

preview3 examples compiled by CIuicanvasdrawinggames

Summary#

Everything else the render vocabulary draws is a box. That reaches further than it sounds — a Tetris well, a bar chart and even a textured raycaster are all boxes — but a box cannot vary within itself, so lighting that falls off down a wall, a sprite clipped by the thing in front of it, or a textured floor have no spelling.

Canvas is the surface for those. It is immediate mode: there is no scene, no retained objects and nothing to keep in sync. Each frame you clear it and draw what should be there now.

The drawing happens in Osy#, in your on frame body — not in a callback handed to a JavaScript library. That is the point of the design: a game's inner loop stays in the same language as the rest of the app.

Signature#

Canvas(w: 640, h: 400)              // the surface. `w`/`h` are the drawing BUFFER, in canvas pixels
Canvas(w: 64, h: 64, density: 2)    // …with 2 buffer pixels per unit: sharp on a high-density screen

Draw.Clear(color)                   // fill the whole canvas
Draw.Clear()                        // clear it to TRANSPARENT — the page shows through
Draw.Alpha(a)                       // opacity of everything drawn after it, 0–1; each frame starts at 1
Draw.Rect(x, y, w, h, color)        // a filled rectangle, from its top-left corner
Draw.Line(x1, y1, x2, y2, color)    // a 1px stroked segment
Draw.Line(x1, y1, x2, y2, color, width)
Draw.Circle(x, y, radius, color)    // a filled disc, from its CENTRE
Draw.Text(text, x, y, color, size)  // a string, from its TOP-left; `size` in canvas pixels
Draw.Image(wall, dx, dy, dw, dh)    // a TEXTURE the app ships, into a destination rectangle
Draw.Image(wall, sx, sy, sw, sh, dx, dy, dw, dh)  // a SOURCE rectangle of it, into a destination one
Draw.Image(url, …)                  // the same two forms over a runtime url — see [textures](/reference/ui/textures/)
Draw.Pixels(buffer, w, h, dx, dy)   // a w-by-h buffer of packed 0xRRGGBB colours, one canvas pixel each
Draw.Pixels(buffer, w, h, dx, dy, dw, dh)   // …STRETCHED into a destination rectangle

Draw.Push()                         // save the current transform
Draw.Pop()                          // put back the one the matching Push saved
Draw.Translate(x, y)                // move the origin
Draw.Rotate(radians)                // turn everything drawn after it, about the origin
Draw.Scale(sx, sy)                  // resize it; a NEGATIVE factor mirrors
Draw.Reset()                        // back to plain canvas coordinates, stack and all

Gradient g = Draw.Gradient(x0, y0, x1, y1, colorA, colorB)   // a LINEAR fill, running between two points
Gradient r = Draw.Radial(x, y, radius, colorA, colorB)      // a RADIAL fill, running out from a centre
Draw.Rect(0, 0, 640, 420, g)        // a gradient goes wherever a colour goes

var sky = Draw.Surface(640, 420)    // an OFFSCREEN surface — hold it in a field, build it once
Draw.Into(sky)                      // …the verbs above now paint on it
Draw.Screen()                       // …and back to the component's Canvas
Draw.Image(sky, dx, dy, dw, dh)     // blit it, with the same two forms a texture takes

Description#

What goes in a frame body — clear, then draw#

A canvas keeps what you drew last time, so a frame normally starts by clearing it and then draws everything that should be visible now. State lives in the component, exactly as it does without a canvas.

[Route("/bounce")]
[AllowAnonymous]
component Bounce() {
  double x = 60;
  double y = 60;
  double vx = 210;
  double vy = 160;

  on frame (double dt) {
    x = x + vx * dt;
    y = y + vy * dt;
    if (x < 24) { x = 24; vx = 0 - vx; }
    if (x > 296) { x = 296; vx = 0 - vx; }
    if (y < 24) { y = 24; vy = 0 - vy; }
    if (y > 176) { y = 176; vy = 0 - vy; }

    Draw.Clear("#0B0616");
    Draw.Circle(x, y, 22, "#22E5FF");
  }

  render { Canvas(w: 320, h: 200); }
}

Every position is scaled by dt, so the disc crosses the surface in the same wall-clock time whatever frame rate the display runs at. See on mount / on unmount — that is a property of on frame, not of the canvas.

Coordinates are canvas pixels#

x grows right, y grows down, and the origin is the top-left corner — the convention every 2D drawing API uses. The unit is the buffer declared by Canvas(w:, h:), not CSS pixels and not a normalised space, so a loop index is an x directly:

[Route("/columns")]
[AllowAnonymous]
component Columns() {
  double t = 0;

  on frame (double dt) {
    t = t + dt;
    Draw.Clear("#0B0616");
    for (var i = 0; i < 160; i = i + 1) {
      var h = 20 + i;
      Draw.Rect(i * 2, 200 - h, 2, h, i % 2 == 0 ? "#2A1B3D" : "#3A2551");
    }
  }

  render { Canvas(w: 320, h: 200); }
}

Rotating, moving and scaling what you draw#

Every shape above is drawn axis-aligned: Draw.Rect takes a rectangle and Draw.Image takes a destination rectangle, so neither can be turned. The transform verbs are how anything points somewhere.

The idea is that you draw a thing once, in its own coordinates — at the origin, facing along +x — and then say where it is and which way it faces:

Draw.Push();                 // remember where we were
Draw.Translate(ship.X, ship.Y);
Draw.Rotate(ship.Heading);   // RADIANS, like Math.Cos / Math.Sin
Draw.Line(14, 0, -10, -9, "#22E5FF", 2);     // the ship, at the origin, pointing along +x
Draw.Line(14, 0, -10, 9, "#22E5FF", 2);
Draw.Line(-10, -9, -10, 9, "#22E5FF", 2);
Draw.Pop();                  // and back

Draw.Scale(sx, sy) resizes the same way, which lets one unit-sized shape serve every size of a thing; a negative factor mirrors, so Draw.Scale(-1, 1) is how a sprite faces left.

The verbs compose: a translate inside a translate is relative to the outer one, which is what lets a turret sit on a tank and turn independently, with each drawn in its own frame.

⚠️ Angles are radians. Same as Draw.Mesh's rotations, and same as Math.Sin / Math.Cos — which is what you are usually holding anyway. From degrees: deg * Math.Pi / 180.0.

Every frame starts clean

The transform is reset before each frame body runs. That is worth knowing for two reasons:

  • A Draw.Push() you forget to Draw.Pop() is wrong for that frame only. Without the reset the world would drift a little further every frame, and the symptom — a slow rotation over half a minute — looks like a bug in your own maths rather than a missing line. (You are also told, once, when a frame body leaves one unpopped.)
  • A camera can be a single line. Draw.Translate(-camX, -camY) at the top of the body, with no push and no pop, is the whole of it — because the next frame does not inherit it.

Draw.Reset() does the same thing mid-frame: it drops every transform and every push, which is how a HUD gets nailed to the screen over a world that is moving.

Two verbs that do not follow the transform

  • Draw.Clear always clears the whole canvas, whatever the transform is. Clearing "the visible rectangle, moved" is never what anyone means, and the leftovers would read as smearing.
  • Draw.Pixels, in its unscaled form, lands in plain canvas coordinates — the browser's pixel blit ignores transforms by specification. The stretched form (dw, dh) goes through the image path and does follow them. You are told once if you blit under a transform.
  void PaintShip() {
    Draw.Push();
    Draw.Translate(sx, sy);
    Draw.Rotate(heading);
    Draw.Line(14, 0, -10, -9, "#22E5FF", 2);
    Draw.Line(14, 0, -10, 9, "#22E5FF", 2);
    Draw.Line(-10, -9, -10, 9, "#22E5FF", 2);
    // The exhaust flickers, and it is drawn in the SAME frame as the hull — so it stays glued to the tail however
    // the ship is turning, with no second angle to keep in step.
    if (thrusting) {
      var flame = 8.0 + Math.Sin(spin * 40.0) * 4.0;
      Draw.Line(-10, -4, -10 - flame, 0, "#FF2D95", 2);
      Draw.Line(-10, 4, -10 - flame, 0, "#FF2D95", 2);
    }
    Draw.Pop();
  }

Gradients are values, not verbs#

Draw.Gradient and Draw.Radial do not draw anything. They answer a Gradient — a fill you then pass wherever a colour string goes:

Gradient sky;

on frame (double dt) {
  if (sky == null) {
    sky = Draw.Gradient(0, 0, 0, 430, "#4d8fd6", "#a9d2f2", "#ffeccd");
  }
  Draw.Rect(0, 0, 1280, 430, sky);
}

That is why there is no Draw.GradientRect. A gradient is accepted by Draw.Clear, Draw.Rect, Draw.Circle, Draw.Line and Draw.Text alike, and by anything added later — because the fill is a value rather than a second version of every verb.

Two or more colours, spaced evenly along the run. Draw.Gradient runs between the two points you give, so (0, 0, 0, 430) is vertical and (0, 0, 640, 0) is horizontal; Draw.Radial runs from a centre outward, which is what a sun, a glow or a vignette wants. A colour with alpha ("rgba(255,238,190,0)") is how a glow fades into what is behind it.

Build it once. A gradient is fixed in canvas pixels, so hold it in a Gradient field — as above, or in on mount — rather than rebuilding it every frame.

Painting something once — offscreen surfaces#

A canvas keeps nothing between frames, so everything on screen is redrawn every frame — including the parts that never change. A starfield of 160 stars is 160 draw commands, which is 9,600 a second for a picture that is identical each time.

A surface is an offscreen canvas you paint once and then blit:

var sky = Draw.Surface(640, 420);     // a field: built once, used every frame
bool painted = false;

on frame (double dt) {
  if (!painted) {
    Draw.Into(sky);                   // every verb from here paints on `sky`…
    Draw.Clear("#0B0616");            // …including Clear, which clears the SURFACE
    PaintStars();
    Draw.Screen();                    // …and back to the canvas
    painted = true;
  }
  Draw.Image(sky, 0, 0, 640, 420);    // one command instead of a hundred and sixty
  …the moving things…
}

Draw.Image takes a surface exactly as it takes a texture, in both the whole-image and source-rectangle forms — so switching a background from shipped art to something you rendered is a change of value, not of call site.

The other thing surfaces buy is a pre-rendered sprite: rotating a shape costs the same every frame, and rotating it once into a surface costs one blit thereafter.

⚠️ Build a surface once. It is a real canvas — allocating one per frame is the same mistake as loading an image per frame. A field, or the first frame, is where one belongs. The largest edge is 4096 pixels.

Every frame starts on the screen

As with the transform, the target is reset before each frame body runs. A Draw.Into you forget to close would otherwise send every following frame into a buffer, and the screen would simply stop changing — no error, no blank canvas, a frozen picture that reads as a hung game. Instead it costs you the rest of one frame, and you are told once.

What a surface cannot do

The 3D verbs (Draw.Camera / Light / Fog / Mesh) always render on the component's own canvas and refuse to paint into a surface — call Draw.Screen() first. Each surface keeps its own transform and its own Draw.Push stack, so a Draw.Pop inside one can never restore the screen's; entering a surface gives you a clean coordinate system.

    if (!starsPainted) {
      Draw.Into(stars);
      Draw.Clear("#0B0616");
      // ⚠ A JITTERED LATTICE, NOT `Roll()` FOR THE POSITIONS. `Roll()` is the small LCG `/blockfall` uses
      //    (`* 75 % 65537`, chosen because Osy# integer arithmetic is checked and a textbook multiplier overflows),
      //    and its period is short enough that 160 draws landed on 17 distinct pixels — measured. Coprime strides
      //    give 160 distinct positions by construction; `Roll()` still picks the brightness, where a short cycle is
      //    invisible.
      for (var i = 0; i < 160; i = i + 1) {
        var sxp = (i * 271) % 640;
        var syp = (i * 157) % 420;
        var mag = Roll();
        Draw.Rect(sxp, syp, mag > 0.86 ? 2 : 1, mag > 0.86 ? 2 : 1,
                  mag > 0.86 ? "#FFFFFF" : (mag > 0.5 ? "#8B79A8" : "#3A2A55"));
      }
      Draw.Screen();
      starsPainted = true;
    }

    Draw.Image(stars, 0, 0, W, H);

w: and h: are the resolution, and they are required#

They set the drawing buffer — how many pixels there are to draw on — as well as the element's CSS size, so one canvas pixel is one CSS pixel by default. To show a fixed resolution larger or smaller, style the element: Canvas(w: 320, h: 200, maxW: "100%").

Both are required, and both must be written as whole-number literals. A canvas with no declared size silently gets 300×150 for its buffer while the element stretches to whatever CSS says, which renders everything blurred and in the wrong place with nothing to indicate why — so it is refused instead. And because assigning a canvas's size clears it, a size that varied with state would blank the surface at a moment nothing in the code names; a fixed resolution scaled by CSS is what you want anyway.

Sharp on a high-density screen — density:#

On a 2x display a Canvas(w: 64, h: 64) has 64×64 buffer pixels spread over 128×128 device pixels, so everything on it is upscaled and soft — a small drawing reads as a smudge. density: is how many buffer pixels each canvas unit gets: Canvas(w: 64, h: 64, density: 2) is still a 64×64 box that you draw on in 64×64 coordinates, but it holds a 128×128 buffer and every frame starts scaled by 2, so the drawing is sharp. A whole number from 1 to 4, written literally, for the reason w/h are. (Unscaled Draw.Pixels still lands one buffer pixel each — see [[ui-canvas#transform-exceptions]].)

Drawing on a themed page — colours, opacity, a clear ground#

A drawing that sits on a page must follow the page's theme, or it is invisible the moment a reader switches to dark mode. Four things make that the ordinary way to write one:

  • A theme colour is a fill. Draw.Circle(x, y, r, Colors.OnSurface) draws in the theme's ink, resolved against the canvas's own style — so it follows light and dark, a look and a variant, on the next frame after a switch. It works wherever a colour goes, gradient stops included.
  • Draw.Alpha(a) sets how opaque everything drawn after it is, 0 to 1 — the way to fade a colour that comes from a token, which cannot carry an alpha of its own. Each frame starts back at 1.
  • Draw.Clear() with no colour clears to transparent, so the page shows through. Draw.Clear("transparent") is not the same thing: it fills with nothing, and last frame stays where it was.
  • Theme.Mode() answers the mode in force ("light", "dark", or a custom one) and Theme.ReducedMotion() whether the reader asked their system for less motion. Styled elements follow both through CSS with nothing written; a canvas body has to ask. Draw one still frame when motion is reduced, and redraw it only when the mode changes.
theme Signal {
  Colors { Ink = Modes.Of(light: "#0D1716", dark: "#E3ECEB"); Lamp = Modes.Of(light: "#0E9AA9", dark: "#33CAD8"); }
}

[Route("/pulse")]
[AllowAnonymous]
component Pulse() {
  double t = 0;
  string drawnFor = "";

  on frame (double dt) {
    if (Theme.ReducedMotion()) {
      if (drawnFor == Theme.Mode()) { return; }   // one still frame, redrawn only after a mode switch
      drawnFor = Theme.Mode();
      Paint(0.5);
      return;
    }
    t = t + dt;
    Paint(t);
  }

  void Paint(double at) {
    Draw.Clear();                                         // transparent: the page shows through
    var ring = 0.5 + 0.5 * Math.Sin(at * 3);
    Draw.Alpha(0.35 * (1 - ring));
    Draw.Circle(20, 20, 8 + 10 * ring, Colors.Lamp);      // the ring breathing out
    Draw.Alpha(1);
    Draw.Circle(20, 20, 6, Colors.Lamp);                  // the lit lamp itself
  }

  render { Canvas(w: 40, h: 40, density: 2, role: UiRole.Graphic, label: "Live"); }
}

Text draws in the canvas's own font#

Draw.Text uses the font-family the Canvas element itself has, so Canvas(w: 640, h: 400, fontFamily: Font.Display) makes drawn text match the Text(…) beside it. y is the text's top, like every other verb's y — not its baseline.

Images load on first use#

Draw.Image starts loading a url the first time it sees one and draws nothing until it has arrived, so the first frame that names a new image skips it and every later frame has it. Cache-warm it by drawing it off-screen if the first frame matters.

The nine-argument form takes a source rectangle and a destination rectangle, which is how a sprite sheet is cut up — and how a textured wall is drawn, by stretching a one-pixel-wide slice of a texture to the wall's height.

Drawing needs a canvas in the same component#

Draw.* paints the Canvas declared by the component whose body is running. A canvas inside a nested component belongs to that component, and drawing on it from the outside is refused with a message saying so — it would otherwise appear to work until the child re-rendered.

Can a screen reader read a canvas? No#

Nothing drawn on a canvas is text, an element, or reachable by a screen reader or a keyboard. Use it for what is genuinely a picture, and put anything a reader must be able to read or press in ordinary atoms around it — a score, a legend, the controls. A canvas that is decorative needs no description; one that carries information needs the same information available another way.

When the picture IS the information — a waiting indicator, a status lamp — name it as one: Canvas(w: 40, h: 40, role: UiRole.Graphic, label: "Searching…") is announced as an image called "Searching…", and a test reads it the same way: Assert.Visible("Searching…").

A canvas out of view does no work#

The frame loop stops while its tab is hidden, and also while its canvas is scrolled out of view: a page of twenty waiting indicators, or a chart deep in a report, costs nothing until it is seen. Coming back resumes it with a fresh clock, so the first frame's dt is an ordinary one. (Only a component that has a Canvas is paused this way; an on frame body with no canvas keeps running.)

How much can a frame draw?#

Re-measured in a real browser, 2026-09-05: roughly 75,000 draw commands per frame while holding 60fps (90,000 drops to 30), and more than 300,000 arithmetic steps — the top of the rig, so that ceiling was not found. That is far past a per-column raycaster and well into per-tile and per-sprite work. If a frame body gets slower than the display, the frame rate drops rather than frames queueing up.

⚠️ The draw budget is the browser's, not the language's. The same measurement with the JS compilation switched off is identical — 75,000 either way — because what a Draw.* call costs is the canvas, and Osy#'s own overhead around it is already small beside that. The arithmetic budget is where compilation shows, and there the ceiling is past anything a 2D game needs.

⚑ These are one machine on one day, as the previous figures were; treat them as an order of magnitude rather than a specification, and measure your own frame if you are close to the edge.

See also#

Related

Canvas 3D

A lit, shadowed 3D scene on the same `Canvas` the 2D verbs paint. Build meshes once into fields, then each frame place…

textures

Drop `.png`, `.jpg` or `.webp` files into `model/textures/` and blit them onto a canvas with `Draw.Image(wall, …)`. The…

on mount / on unmount

`on mount { … }` runs a block ONCE, the first time a component appears, beside its first reads; `on unmount { … }` runs…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view

An `animation` block declares reusable keyframe motion — an entrance, a shimmer, a pulse, a flash that decays. Its…