Hazumi
A typed 2D graphics library for sketches, generative work, and games.
Draw with ordinary functions. The same scene can run on WebGL2, export as SVG, or record commands in a unit test. Colour is OKLCH. Input, a camera, sprites, paths, text, physics, and audio are all there.
0.1.0, pre-alpha.
Start a project
Site: hazumi-eta.vercel.app
bun create hazumi
The wizard asks for a name and whether you want a sketch (draw) or a game
(update + draw). vite build writes a static dist/ you can zip for
itch.io or GitHub Pages.
A scene
import { start } from "hazumi/app";
import { webgl2 } from "hazumi/backends/webgl2";
import { background, circle, fill, oklch } from "hazumi/draw";
import { screen, time } from "hazumi/scene";
start({ backend: webgl2(), width: 600, height: 600 }, () => {
return {
draw() {
background(oklch(0.15, 0.02, 260));
fill(oklch(0.7, 0.18, 250));
circle(screen.width / 2, screen.height / 2, 200 + Math.sin(time.elapsed) * 80);
},
};
});
Import by capability: hazumi/draw, hazumi/input, hazumi/scene,
hazumi/assets, hazumi/audio, hazumi/math, hazumi/color. Functions do
the work; live values sit on objects (screen.width, time.elapsed,
input.mouseX). They resolve to the application that is currently running.
The scene factory still receives the context, which is where plugin APIs such
as audio live. After an await in the factory, capability imports are
inactive until the returned callbacks run — finish async setup from the
context argument.
Pass --auto-import to create-hazumi for *.scene.ts files that skip the
capability imports. The Vite plugin inserts them at build time.
Colours are values: oklch(l, c, h) or rgb(r, g, b). A CSS string still
parses if you paste one.
Style can be scoped instead of pushed and popped. It restores even if the body throws:
scoped({ fill: oklch(0.65, 0.22, 20), stroke: null }, () => {
drawPetals();
});
A game
Split simulation from drawing. update runs at a fixed step; draw follows
the display and receives an interpolation alpha:
import { background, circle, fill, oklch } from "hazumi/draw";
import { keyIsDown, keyJustPressed } from "hazumi/input";
start({ backend: webgl2(), clock: { fixedStep: 1 / 60 } }, () => {
let previousX = 100;
let x = 100;
return {
update(dt) {
previousX = x;
if (keyIsDown("ArrowRight")) x += 120 * dt;
if (keyJustPressed(" ")) jump();
},
draw(alpha) {
background(oklch(0.12, 0.02, 260));
fill(oklch(0.7, 0.12, 250));
circle(previousX + (x - previousX) * alpha, 300, 32);
},
};
});
Catch-up is capped, so a backgrounded tab cannot pile up unbounded simulation
steps. Override clock.maxDelta and clock.maxFixedSteps if you need
different limits.
Input
keyJustPressed, keyJustReleased, pointerJustPressed, and
pointerJustReleased last for one fixed update. A tap that finishes between
updates still reports both edges; key repeat does not. pointers is mouse,
pen, and every current touch, in logical canvas coordinates. A released
contact stays in the list for that update so its last position is available.
wheelX / wheelY accumulate once per update, in CSS pixels.
mouseX, mouseY, mouseIsPressed, mouseJustPressed, and
mouseJustReleased alias the primary pointer.
Gamepads are polled at the start of each fixed update: gamepads for axes and
analog buttons, plus gamepadButtonIsDown / JustPressed / JustReleased.
Disconnecting a held pad reports release edges before it leaves the list.
Camera
Every scene has a camera. Its position is the world point shown at the centre of the canvas:
import { background, circle, oklch, text } from "hazumi/draw";
import { camera } from "hazumi/scene";
return {
update(dt) {
movePlayer(dt);
camera.follow(player.x, player.y, 0.12);
camera.setZoom(2);
},
draw(alpha) {
background(oklch(0.12, 0.02, 260));
circle(player.x, player.y, 32);
camera.screen(() => {
text("HP 100", 16, 24);
});
},
};
background always covers the screen. camera.screen() draws HUD in canvas
coordinates. screenToWorld() and worldToScreen() convert pointer and world
points; both take an optional output object.
Sprites
const sheet = spritesheet(await loadImage("tiles.png"), { frame: [16, 16] });
image(sheet.at(3, 1), x, y);
image() takes a frame wherever it takes an image. Frames are reused by
reference, so asking for the same cell every frame allocates nothing. Indices
wrap, so frame(t) loops.
Clips live on the sheet:
const hero = spritesheet(await loadImage("hero.png"), {
frame: [16, 24],
clips: {
idle: { frames: [0, 1, 2, 3], fps: 6 },
run: { frames: [8, 9, 10, 11, 12, 13], fps: 14 },
jump: { frames: [16], end: ClipEnd.Hold },
},
});
image(hero.clip("run").at(time.elapsed), x, y);
at(seconds) is a pure function of time. Several entities can share a clip.
Clips loop, hold, or ping-pong; repeat a frame index to hold it longer.
Batching follows draw order. Sprites from one sheet drawn together are one
draw call; mixing sheets costs one call per switch. app.stats.drawCalls
reports the last frame.
Tilemaps
const world = tilemap({
columns: 64,
rows: 32,
tileWidth: 16,
tileHeight: 16,
layers: [
{ name: "ground", sheet, tiles: groundTiles },
{ name: "detail", sheet, tiles: detailTiles },
],
});
return {
update() {
camera.follow(player.x, player.y, 0.12);
},
draw() {
world.draw();
},
};
Use EMPTY_TILE for gaps. world.layer("detail").set(x, y, frame) edits a
cell in place. Invalid frame indices throw.
Paths
beginShape();
vertex(0, 0);
bezierVertex(70, -40, 140, -60, 200, 0);
bezierVertex(140, 60, 70, 40, 0, 0);
endShape(true);
SVG export keeps the curves as curve commands.
Text
textFont("Georgia");
textSize(24);
text("hello", 16, 32);
Uses fonts already on the system. Size and font persist across frames until you change them.
Shaders
A pass is a main(). The runtime provides v_uv, fragColor, u_texture,
u_resolution, u_time, and texelSize():
setPasses([
{
fragment: `void main() {
vec4 c = texture(u_texture, v_uv);
fragColor = vec4(1.0 - c.rgb, c.a);
}`,
},
]);
Passes run in order. A scene that never sets any allocates nothing for them.
Canvas size and pixels
resize() changes the logical size and the backing store together. width,
height, and pixelRatio are on the context. If you do not pin a ratio in
start(), moving between displays updates the backing store.
const app = start({ backend: webgl2(), width: 320, height: 180 }, scene);
await app.ready;
app.resize(640, 360);
const pixels = app.loadPixels();
pixels.set(0, 0, [255, 0, 255, 255]);
app.updatePixels(pixels);
const png = await app.capturePng();
Pixels.get() and set() address physical pixels. SVG and headless throw
PixelAccessUnavailableError.
Collision
import { collision, vec2 } from "hazumi/math";
const hit = collision.sweepAabb(collision.aabb(x, y, 24, 24), vec2.vec2(vx * dt, vy * dt), wall);
if (hit) {
x += vx * dt * hit.time;
y += vy * dt * hit.time;
}
Point containment, AABB/circle overlap, raycasts, and circle sweeps use the
same names. createRayHit() and createSweepHit() give reusable results.
slideAabb moves against a list of solid AABBs one axis at a time, and skips
null holes:
const out = { x: 0, y: 0 };
collision.slideAabb(player, vx * dt, vy * dt, walls, out);
player.x += out.x;
player.y += out.y;
Pathfinding
A cost grid, separate from a tilemap. 0 is blocked, 1 is a normal step:
import { pathfind } from "hazumi/math";
const map = pathfind.grid(32, 18);
map.set(4, 5, 0);
const path = pathfind.createPath();
pathfind.astar(map, 1, 1, 30, 16, { out: path });
Returns cell coordinates from start through goal, or null. { diagonal: true }
allows 8-direction movement without cutting a blocked corner. Reuse the grid
and the path.
Physics
Circles and oriented boxes, with restitution and friction. The usual way to run it is the plugin, which steps after each fixed update:
import { createPluginHost, start } from "hazumi/app";
import { overlay } from "hazumi/debug";
import { physics } from "hazumi/physics";
start(
{
backend: webgl2(),
plugins: createPluginHost()
.use(physics({ gravityY: 1400 }))
.use(overlay()),
},
({ physics }) => {
physics.world.addBox({ x: 300, y: 580, width: 600, height: 24, isStatic: true });
const ball = physics.world.addCircle({ x: 300, y: 80, radius: 18, restitution: 0.7 });
return {
draw() {
circle(ball.x, ball.y, ball.radius * 2);
},
};
},
);
Do not also call world.step — the host already did. overlay() draws stats
and body outlines after the scene; toggleKey: "F1" makes it dismissible.
import { physics } from "hazumi/math" is the solver on its own, if you want
to step it yourself. Platformers that slide on tiles still use slideAabb.
Backends
| Import | Role |
|---|---|
hazumi/backends/webgl2 |
Default renderer |
hazumi/backends/canvas2d |
2D canvas |
hazumi/backends/svg |
Vector export |
hazumi/backends/headless |
Recorded command stream for tests |
Try it
bun install
bun run build
bun run dev
| Page | |
|---|---|
/ |
Landing page |
/playground |
Live editor |
/reference |
API reference |
/examples |
Example scenes |
Requires Bun 1.3+.
Site
apps/web, deployed on Vercel from main.
License
MIT