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
Draw.Clear(color) // fill the whole canvas
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 takesDescription#
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.
[Page("/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:
[Page("/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 backDraw.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 toDraw.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.Clearalways 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.
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.
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#
- Canvas 3D — the lit, shadowed 3D scene the same canvas can carry:
Draw.Camera,Draw.Light,Draw.Mesh - on mount / on unmount —
on frame (double dt), the clock that drives a canvas - component — component state, which is where a drawing's state lives
- animation — looping motion with no destination state — declarative motion for ordinary elements, which needs no canvas