fruta
v0.2.0
Published
Tiny, semantic 2D graphics & game engine for the web (Canvas2D + WebGL) — plus `fruta/web`: a dependency-free animation module for websites (springs, scroll-scrub, FLIP, ~25 shader backdrops, animated components). TypeScript-first, framework-friendly.
Maintainers
Readme
Fruta 🍒
A tiny, semantic 2D graphics & game engine for the web. One friendly object, intent-named calls, sensible defaults. The goal: easier to learn than Phaser or Pixi, while doing real games — sprites, tilemaps, physics, pathfinding, collision, pooling, camera, scenes, state machines, game-feel timers (coyote / jump-buffer / dash), audio, particles, entities, charts, and WebGL shaders.
Written in TypeScript, types ship with the package — every call, option and return value is typed, so your editor autocompletes the whole API and catches mistakes before you run.
import Fruta from 'fruta'
const fruta = Fruta({ width: 500, height: 500 }).mount()
fruta.loop((dt, t) => {
fruta.background('#111')
fruta.circle({ x: fruta.mouse.x, y: fruta.mouse.y, r: 24, fill: 'tomato' })
})- One object.
const fruta = Fruta(...)— everything hangs off it, fully typed. - Semantic.
fruta.circle({ x, y, r, fill }), notctx.beginPath()…. - Immediate by default, retained when you want it (
fruta.add(...)). - Two backends, one API. Canvas2D for the richest features;
Fruta.gl(...)(WebGL) for tens of thousands of sprites — same sugar. - No build lock-in. Bring your own
<canvas>— works in React/Vue/Svelte or plain HTML.
See it all running: clone the repo and
bun run devfor the demo playground — a Celeste-feel platformer (dash / coyote / wall-jump / climb), a mini RPG, Sokoban (undo/redo), tower defense, a rhythm game, isometric worlds, a bullet-hell scale test, charts/dashboards, and dozens more. Recipes: EXAMPLES.MD · Changes: CHANGELOG.md.
Install
npm i fruta # or: bun add frutaESM-only, zero dependencies, tree-shakeable (sideEffects: false), types bundled.
Setup
const fruta = Fruta({ width, height, background?, clear?, mount?, canvas? })
fruta.mount(parent?) // append the canvas (defaults to <body>)
fruta.canvas // the HTMLCanvasElement
fruta.context // the raw CanvasRenderingContext2D (escape hatch)
fruta.destroy() // stop the loop + remove listeners (call on unmount)Pass { canvas: el } (element or CSS selector) to use your own canvas — the clean path for
frameworks. Omit it and Fruta makes one you .mount() yourself, or pass { mount: el } to
attach it on creation.
If you set background, Fruta repaints it at the start of every frame, so your loop
body is just the scene. For trails or manual fades, opt out with { clear: false }.
The loop
loop(fn) runs every frame. dt is seconds since the last frame (multiply movement by it
and speed is identical on any screen); t is seconds since start.
let x = 0
fruta.loop((dt, t) => {
fruta.background('#111')
x += 200 * dt // 200 px/second
fruta.rect({ x, y: 100, w: 40, h: 40, fill: 'deepskyblue' })
})
fruta.stop()
fruta.timeScale = 0.2 // global slow-mo / freeze (0 = frozen) — hit-stop, bullet-time, Bashdt is clamped to maxDt (default 0.1s, set via Fruta({ maxDt })) so a tab-out or GC pause can't blow
up your physics — no need for the Math.min(dt, 1/30) dance. Cap the whole loop's rate with Fruta({ fps }).
Fixed timestep (opt-in) — for physics that must stay stable and deterministic no matter the frame rate.
fixedUpdate(fn, hz) runs fn(step) at a constant hz (default 60) decoupled from render; keep drawing in
loop and interpolate with fruta.fixedAlpha (0..1):
fruta.fixedUpdate((s) => world.step(s), 60) // sim at a rock-steady 60 Hz, backlog-capped
fruta.loop(() => { fruta.clear(); draw(fruta.fixedAlpha) }) // render as often as the display allowsDrawing
Every shape takes a typed options object with sensible defaults (no fill/stroke → black fill).
fruta.rect({ x, y, w, h, fill, stroke, strokeWidth, radius, rotation }) // rotation in degrees
fruta.circle({ x, y, r, fill, stroke, strokeWidth }) // x,y = center
fruta.ellipse({ x, y, rx, ry, rotation, fill, stroke, strokeWidth })
fruta.line({ x1, y1, x2, y2, stroke, strokeWidth })
fruta.polygon({ points: [{ x, y }, …], fill, stroke, strokeWidth, close })
fruta.text('hello', { x, y, fill, size, font, align, baseline, stroke, strokeWidth })
fruta.image('/pic.png', { x, y, w, h }) // cached + async; call inside loop()
fruta.background(color) · fruta.clear()Drawing thousands? Batch them. For many same-size, same-colour shapes (particles, entities), pass parallel
coordinate arrays — the whole batch becomes ONE fillStyle set + ONE fill(), with no per-shape option object
to allocate and GC. The Canvas2D twin of gl.circles/gl.rects:
fruta.circles(xs, ys, r, fill, count?) // xs/ys: number[] or Float32Array of centres
fruta.rects(xs, ys, w, h, fill, count?) // group your objects by colour, one call per groupDynamic 2D lighting. Darken the scene to an ambient colour and carve soft glows — torches, day/night,
spooky dungeons. Draw your scene, add a light() per source, then drawLights() once (a multiply-blended
lightmap under the hood):
fruta.loop(() => {
fruta.background('#14161f')
drawWorld()
fruta.light(torch.x, torch.y, 160, { color: '#ffb347' }) // warm torch
fruta.light(fruta.mouse.x, fruta.mouse.y, 230) // the light you carry (white)
fruta.drawLights({ ambient: '#0a0a14' }) // near-dark unlit
})Gradients (usable anywhere a fill/stroke goes), and transforms (push/pop,
nestable — translate, rotate in degrees, scale, fade):
fruta.radialGradient(x, y, r, [[0, '#fff'], [1, 'navy']])
fruta.linearGradient(x1, y1, x2, y2, [[0, 'red'], [1, 'gold']])
fruta.push({ x: 250, y: 250, rotate: angle, scale: 1.5, alpha: 0.8 })
fruta.circle({ x: 120, y: 0, r: 12, fill: 'deepskyblue' })
fruta.pop()Coming from p5? The primitives you reach for are here, in fruta's options style — so p5 muscle memory (and an AI's p5 prior) transfers:
fruta.arc({ x, y, r, start, stop, mode: 'pie' | 'chord' | 'open' }) // degrees
fruta.beginShape(); fruta.vertex(x, y); fruta.curveVertex(x, y); fruta.bezierVertex(cx1,cy1,cx2,cy2,x,y); fruta.endShape()
fruta.noise(x, y?, z?) · fruta.fbm(x, y, octaves) · fruta.vec(x, y) · fruta.hsl(h, s, l, a)
fruta.textWidth('Score', { size: 24 }) // measure without drawing
fruta.paragraph(story, { x, y, w: 300, size: 16, leading: 22, align: 'left' }) // word-wrapped, returns height
fruta.loadPixels(); fruta.set(x, y, [r,g,b,a]); fruta.updatePixels() // direct pixel access
fruta.filter('sepia') // invert · grayscale · threshold · brightness · posterize · blur · sepia · erode · dilateAnimation
// tween any numeric props (and colours!) over time → handle: pause()/resume()/cancel()
fruta.tween(obj, { to: { x: 300, fill: 'gold' }, duration: 0.8, ease: 'bounce',
delay, repeat, yoyo, onUpdate, onComplete })
fruta.stagger(items, { to, duration, ease, each }) // animate a group, offset by `each`s
fruta.timeline()
.to(a, { to: { x: 100 }, duration: 1 })
.to(b, { to: { y: 50 }, duration: 0.5 }, { at: 0 }) // { at } = absolute time (parallel)
.call(fn)
// named sprite-animation states
const anim = fruta.anim({ idle: { frames: [0], fps: 1 }, walk: { frames: [1, 2], fps: 9 } })
anim.play('walk', t)
fruta.frameAt(t, 8, 4) // quick looping frame: floor(t*fps) % countEasings: linear, plus easeIn/Out/InOut and the families quad · cubic · quart · sine ·
expo · circ · back · bounce · elastic each as …In / …Out / …InOut. Or pass your own (t) => number.
Web animations (fruta/web)
A separate opt-in module for modern DOM/website animations — vanilla, works in any framework, native-first
(IntersectionObserver · Web Animations API · View Transitions), ~11 KB. Reuses the easings above on the DOM via
CSS linear(). Pass a selector or element(s). Live demo: playground/motion.html.
The core — one animate(), simpler than anime.js, for DOM and canvas. The same call tweens a DOM element
or a plain/canvas JS object with the same friendly props (x, y, rotate, scale, opacity, any color or CSS prop).
Chain effects with the fx() facade (select once, chain); standalone functions stay as the power-user layer.
import { animate, stagger, timeline, spring, setReducedMotion, fx } from 'fruta/web'
animate('.box', { x: 240, rotate: 360, scale: 1.4, backgroundColor: '#ff5a74', duration: 800, ease: 'backOut' })
animate(sprite, { y: [220, 90, 220], r: 46, loop: true }) // ← keyframes on a CANVAS object; you draw it
animate('.item', { y: [40, 0], opacity: [0, 1], delay: stagger(80) }) // stagger + keyframes + [from,to]
animate('.card', { y: [40, 0], scale: [0.9, 1], ...spring({ stiffness: 220, damping: 14 }) }) // physics spring
timeline().add('.a', { x: 100 }).add('.b', { y: 50 }, '-=200') // sequence / overlap on one clock
// → handle: .play() .pause() .restart() .reverse() .seek(0..1) .cancel(), and `await handle.finished`
// playback: duration · delay · ease · loop · loopDelay · alternate · reversed · onBegin/onUpdate/onLoop/onComplete
setReducedMotion('auto') // accessibility: animate()/reveal()/parallax() jump to rest when the OS asks for less motion
fx('.card').tilt().ripple() // chainable sugar
fx('h1').splitText().reveal({ effect: 'blur-rise' })Live showcases (serve the repo root, bunx serve):
- playground/gallery.html — a modern scrolling gallery: grid ripples, SVG line-drawing & motion paths, canvas fireworks + 3D dot-sphere, 3D card flip, gradient text, gradient morph, and image scroll-reveal + parallax.
- playground/docs.html — a docs-style page with 100+ examples, each with its source
(easings, properties, keyframes, stagger incl.
grid, playback, timeline, text, reveal effects, canvas, interactions). - playground/motion.html — the compact landing showcase.
Layered & extensible: a small core (els/cssEase/passProgress/inView/onScroll) → the animate()
engine → an effects registry (registerEffect) → the effect APIs. Add an effect once, every reveal uses it.
import { reveal, revealText, scrub, parallax, flip, viewTransition,
magnetic, tilt, ripple, cursor, countUp, registerEffect } from 'fruta/web'
// 1 · reveal on scroll — named, extensible effects (fade · rise · slide-{up,down,left,right} · zoom · blur · flip · rotate)
reveal('.card', { effect: 'blur-rise', stagger: 100, ease: 'cubicOut' })
registerEffect('spin', (p) => ({ opacity: 0, transform: `rotate(-90deg) scale(${p.scale})` })) // extend it
// 2 · scroll scrub — drive any value 0→1 from an element's scroll position; parallax is a preset
scrub('.story', { onUpdate: (p) => bar.style.width = p * 100 + '%' })
parallax('.bg', { speed: 0.35 })
// 3 · FLIP — Figma-style smart-animate between two layout states
flip('.tile', () => tiles.classList.toggle('list')) // measure → mutate → glide each to its new box
// 4 · View Transitions — crossfade / shared-element morph (graceful fallback); tag shared nodes with viewName()
viewTransition(() => swapContent())
// text — split/type/count on scroll · interactions — pointer micro-motion
revealText('h1', { by: 'word', effect: 'blur-rise' }) magnetic('.btn'); tilt('.card'); ripple('.btn')
typeText('#code', "reveal('.x', { effect: 'zoom' })") cursor({ hideNative: true }) // custom animated cursor
countUp('.stat', { to: 42000 })Scroll/interaction APIs return { destroy() }; flip returns a Promise. Easing accepts any Fruta name, your
own (t)=>number, or a raw CSS string.
Also in the box: background('#hero', 'aurora') — drop-in animated backdrops. Canvas 2D: mesh · stars ·
particles · waves · gradient · dotfield. WebGL shaders (modern gradients, mouse-reactive): aurora · blinds ·
beams · grid · silk · lightrays · siderays · orb · lightning · prism · prismatic · liquidether · dither ·
ripplegrid · threads · terminal · pixelsnow · distort — each takes props
(background('#hero', 'blinds', { gradientColors: ['#FF9FFC', '#5227FF'], blindCount: 16 })). Extend with
registerBackground(name, opts => ({ frag, uniforms })). Plus draggable('.slider', { snapBack: true }),
morph(pathEl, toD) (SVG path morphing), shimmer('.skeleton'). All honor reduced-motion.
Animated components (they build their own DOM; no gsap / motion / ogl — they run on fruta's own engine):
counter('#score', { value: 42 }) → { set(v) } (spring odometer), flowingMenu('#menu', { items }) (marquee
slides in from the edge you entered), flyingPosters('#wall', { items }) (WebGL poster carousel that twists as
you scroll), masonry('#wall', { items }) (tiles tween to their slots, so a reflow is an animation),
animatedList, borderGlow, lineSidebar, glassSurface (SVG-filter refraction), lanyard, plus the text set
(circularText, textType, shinyText, curvedLoop, trueFocus, scrollFloat, rotatingText).
Scope: this is an animation module — stateful UI widgets (accordion, tabs, dialog, tooltip…) are a component-library concern, out of scope here.
Input
fruta.keyDown('ArrowRight') // true while held (poll in loop)
fruta.keyPressed('Space') // true only the frame it goes down — one-shot, no `wasX` bookkeeping
fruta.keyPressedAt('d') // exact event time (s, same clock as loop's t) of this frame's press, or -1 — for frame-independent timing (rhythm/parry)
fruta.onKey('Space', () => { … }) // on each press
fruta.axis() // { x, y, len } normalised WASD + arrow movement (diagonals normalised)Letter keys are case-insensitive: keyDown('w') stays true while you hold Shift (or CapsLock) — the
browser fires those as 'W', but fruta canonicalises them, so a dash/laser bound to Shift never breaks WASD or
strands a held key. Read axis() at the dash moment for its direction.
fruta.mouse // { x, y } in canvas coords (correct under CSS scaling)
fruta.mouseDown // held? · fruta.pressed — true only the frame it goes down
fruta.onClick(p => …) · fruta.onPress(p => …) · fruta.onRelease(p => …) · fruta.onMove(p => …)
const pad = fruta.gamepad({ buttons: { jump: 'A', shoot: 'X' }, deadzone: 0.12 })
pad.down('jump') · pad.pressed('jump') · pad.value('LT') · pad.stick('left')Game keys (Space, arrows, Page/Home/End) no longer scroll the page — Fruta
preventDefaults them (unless you're typing in a form field).
Audio
Web Audio under the hood — low latency, overlapping playback, and a synth beep so you can make
sound with zero asset files. Auto-unlocks on first input.
await fruta.load({ jump: 'jump.mp3' })
fruta.play('jump', { volume: 0.5, loop, rate }) // → handle: stop()/volume()
fruta.beep({ freq: 440, duration: 0.15, type: 'square', volume: 0.3 })
fruta.volume(0.8) · fruta.mute()Synthesis, notes & audio-reactive visuals — oscillators, musical notes, and a live analyser (the p5.sound staples, in fruta sugar):
fruta.tone({ note: 'C4', duration: 0.4 }) // a note with a soft envelope (or { freq })
const drone = fruta.osc({ freq: 110, type: 'sawtooth' }) // continuous — drone.freq(..)/amp(..)/stop()
const a = fruta.analyser() // taps the mix (or `await fruta.mic()` for the microphone)
fruta.loop(() => { const bins = a.freqs(); const loud = a.level() // 0..1 — drive visuals off the sound
bins.forEach((v, i) => fruta.rect({ x: i * 3, y: 400, w: 2, h: -v, fill: 'aqua' })) })Load & export
const cfg = await fruta.loadJSON('/level.json') // + loadText, loadCSV (→ row objects; parseCSV is exported too)
fruta.screenshot('art.png') // download the canvas as a PNG
const buf = fruta.createImage(64, 64) // offscreen canvas to draw onto + reuse as a source
const cam = await fruta.webcam() // live webcam feed (p5's createCapture) — draw each frame
// const clip = await fruta.video('/loop.mp4') // a <video> as a drawable sourceCollision & physics
Detection — overlap tests for rects ({x,y,w,h} top-left) and circles ({x,y,r} center), auto-detected:
fruta.hits(a, b) // overlap?
fruta.inside(point, shape)
fruta.overlap(a, b, (a, b) => { … }) // a/b each one thing or a list; callback per overlapping pairResponse — push a moving body out of a solid (the arcade collision behind platformers):
// fruta.solve(body, solid) → 'x' | 'y' | null; zeroes velocity into the surface, sets body.onGround
player.vy += GRAVITY * dt
player.x += player.vx * dt; player.y += player.vy * dt
player.onGround = false
for (const wall of solids) fruta.solve(player, wall)Tile collision — sweep an AABB through a solid-tile grid, resolving X then Y. Returns the stopped
position + which sides hit, so it covers top-down (ignore the flags), normal gravity (down = grounded)
and gravity-flip (up = grounded) — no per-axis boilerplate:
const r = fruta.moveAABB(player, vx * dt, vy * dt, TILE, (tx, ty) => grid[ty]?.[tx] === SOLID)
player.x = r.x; player.y = r.y
if (r.left || r.right) vx = 0
if (r.up || r.down) vy = 0
const grounded = player.gravityUp ? r.up : r.down
// fruta.moveRects(box, dx, dy, solids[]) — same, against a list of rects (non-grid levels)Follow a route — move along a polyline of waypoints by arc-length (tower-defense creeps, patrols, a
platform on a track). Keep a traveled distance; pointAlong gives the position, done past the end:
creep.traveled += creep.speed * dt
const p = fruta.pointAlong(PATH, creep.traveled) // PATH = [{x,y}, …] · pathLength(PATH) for the total
creep.x = p.x; creep.y = p.y
if (p.done) leak(creep)Game-feel timers — fruta.cooldown() is a poll-able countdown: the coyote-time, jump-buffer, dash-cooldown
and i-frame logic that makes a platformer feel fair. set(secs) to arm, tick(dt) per frame, read active /
ready, and consume() to spend a buffered input once:
const coyote = fruta.cooldown(), jumpBuf = fruta.cooldown()
if (grounded) coyote.set(0.08) // refresh while on the ground
if (jumpPressed) jumpBuf.set(0.1) // buffer the press
;[coyote, jumpBuf].forEach(c => c.tick(dt))
if (jumpBuf.active && (grounded || coyote.active)) { vy = JUMP; jumpBuf.consume(); coyote.reset() }A real physics world — fruta.physics() gives a World: AABB + circle bodies, gravity, and
body-vs-body collision (impulse solver with friction + restitution + Baumgarte stabilisation,
so stacks settle). The sugar: world.add(obj) attaches physics to any object in place.
const world = fruta.physics({ gravity: 1400 })
world.static(0, H - 20, W, 20) // walls/floors (mass 0)
const box = world.box(100, 0, 30, 30, { friction: 0.5, restitution: 0.2 })
const ball = world.circle(200, 0, 16, { restitution: 0.6 })
world.add(myPlayer, { mass: 1 }) // give physics to YOUR object
fruta.loop((dt) => { world.step(dt); for (const b of world.bodies) draw(b) })Pathfinding (A*)
import { findPath } from 'fruta'
const path = findPath({ x: 0, y: 0 }, { x: 9, y: 6 }, cols, rows, (x, y) => isWall(x, y))
// or on a tilemap, around solid tiles:
const route = map.path({ x: 2, y: 2 }, { x: 18, y: 9 }) // → [{x,y}, …] | nullScale: pooling & spatial hashing
The patterns behind thousands of bullets/enemies at 60 fps — no per-frame allocation, near-O(n) collision.
import { Pool, Registry, SpatialHash, SpatialGrid } from 'fruta'
const bullets = new Pool(2000, () => ({ x: 0, y: 0, vx: 0, vy: 0, dead: false }))
const b = bullets.spawn() // next free slot (or null when full); reset its fields
bullets.kill(i) // swap-remove the active body at index i
// Registry — like Pool, but hands back a STABLE generational handle, so you can remove "this exact
// object" from anywhere (a collision result, a spatial query) without tracking indices:
const enemies = new Registry(3000, () => ({ x: 0, y: 0, hp: 0 }))
const h = enemies.spawn(); const e = enemies.get(h)!; e.hp = 10 // handle survives swap-removes
enemies.remove(h) // kill it from anywhere; a stale handle reads back null
for (let i = 0; i < enemies.count; i++) { /* enemies.items[i] — dense, cache-friendly */ }
const hash = new SpatialHash(32) // cell size — unbounded world
// SpatialGrid: same broadphase for a KNOWN world rect — a flat grid, no Map on the hot path (faster at scale):
const grid = new SpatialGrid(0, 0, 4000, 4000, 32) // x, y, w, h, cellSize
grid.clear(); for (const e of enemies.items) grid.insert(e.x, e.y, e)
const near: Enemy[] = []
grid.query(b.x, b.y, 12, near) // only the entities in nearby cellsCurves & hex grids
import { cubicBezier, bezierPath, hexToPixel, pixelToHex, hexNeighbors } from 'fruta'
cubicBezier(p0, p1, p2, p3, t) // a point on a cubic Bézier
const pts = bezierPath(p0, p1, p2, p3, 32) // sample it to a polyline (path / motion curve)
hexToPixel(q, r, size) // pointy-top axial → pixel
pixelToHex(x, y, size) // pixel → { q, r } (rounded)
hexNeighbors(q, r) // the 6 surrounding hexesSprites & assets
await fruta.load({ hero: '/hero.png' }) // preload → loading screen
fruta.addImage('hero', imageOrCanvas) // register an image/canvas (e.g. a generated sheet)
fruta.sprite('hero', { x, y, w, h, frame, frameW, frameH, cols, rotation, anchor: 'center', flipX })Camera
Draw the world between begin()/end(); draw HUD after (screen space).
fruta.camera.follow(player, 0.14) // smooth follow (1 = instant)
fruta.camera.clamp(0, 0, world.w, world.h)
fruta.camera.zoom = 1.5
fruta.camera.begin(); /* world drawing */ fruta.camera.end()
fruta.camera.screenToWorld(fruta.mouse.x, fruta.mouse.y)
fruta.camera.shake(12, 0.3) // strength px, duration sTilemaps & platformer physics
A grid level that draws itself and resolves arcade collisions against solid tiles.
const map = fruta.tilemap({
size: 32,
data: [' o ', '#####'], // rows of chars
tiles: { '#': { solid: true, color: '#7d4a26' } }, // or { sprite, frame }
bevel: true, // edge light/shadow for depth (autotiling-lite)
})
map.draw()
map.move(player, dt) // moves by vx/vy, resolves vs tiles, sets player.onGround
map.path({ x: 1, y: 1 }, { x: 9, y: 3 }) // A* around solid tiles
map.isSolid(tx, ty) · map.at/set(tx, ty)Isometric (2.5D)
The orthographic-camera perspective Unity gives you, as small primitives + one-call sugar.
fruta.isoToScreen(gx, gy, z, opts) // grid cell (+ height) → screen pixel
fruta.screenToIso(sx, sy, opts) // ground-plane pixel → grid cell
fruta.isoPick(sx, sy, cols, rows, heightAt, opts) // tile-top under the cursor (height-aware — exact)
const map = fruta.isoMap({ // draws + picks itself
cols: 12, rows: 12, tileW: 46, tileH: 23, tileZ: 22,
height: (x, y) => terrain[x][y],
color: (x, y, h) => ({ top: '#6cbf5a', left: '#5a3f24', right: '#7a5430' }),
})
map.draw(fruta, (g, gx, gy, top) => { /* decorate in depth order — sprites occlude correctly */ })
const tile = map.pick(fruta.mouse.x, fruta.mouse.y)Helpers
Pure utilities, also standalone imports: hsl(h, s, l, a?) colour strings · polar(angle, r, cx?, cy?) ·
ngon(cx, cy, sides, r, rotation?) regular-polygon points · gridIndex(x, y, w, h) (toroidal wrap) ·
clamp · lerp · map · rand · dist · angle. Draw a regular polygon directly with fruta.ngon({ x, y, r, sides, rotation }).
Game states (FSM)
A pushdown state machine for game phases — a stack of states, because real games aren't flat: a pause
menu, dialog or sub-screen must overlay gameplay and return to it preserved. enter/leave/resume
hooks + per-state time (frozen while overlaid). Lighter than scene() (no engine ownership / fade) and
nestable (one machine per concern). You poll it in your own loop; states close over your vars.
const game = fruta.fsm('menu', {
menu: { update: () => { drawMenu(); if (fruta.pressed) game.go('play') } },
play: { enter: () => reset(), // runs once on entry — reset score/spawn
update: (dt, m) => {
drawScene(); step(dt)
if (fruta.keyPressed('Escape')) m.push('pause') // OVERLAY — play is frozen, not left
if (m.time > 60 || dead) m.go('over') // m.time = seconds in state
} },
pause: { update: (_dt, m) => { drawScene(); drawPauseMenu() // draw the frozen scene through, then the menu
if (fruta.keyPressed('Escape')) m.pop() } }, // resume play exactly where it was
over: { update: () => { drawOver(); if (fruta.pressed) game.go('menu') } },
})
fruta.loop((dt) => game.update(dt))
// go(name, data?) replace · push(name, data?) overlay · pop(data?) resume below · is('play','over') · state · prev · depthFor nested logic (e.g. a battle with its own intro/menu/animating phases), keep a second fsm for the
sub-phase and update it from the parent state — machines compose, nothing special required.
Keyboard / gamepad menus — a cursor over a list (title screen, pause, shop, battle menu), driven by the
d-pad, not the mouse (that's button()). poll() moves the cursor and reports the action:
const m = fruta.menu(4, { columns: 1 }) // 4 items, vertical (default)
fruta.loop(() => {
const act = m.poll() // arrows/WASD move; returns 'confirm' | 'cancel' | null
OPTIONS.forEach((o, i) => fruta.text(o, { x: 40, y: 60 + i * 24, fill: i === m.index ? '#ffd24a' : '#999' }))
if (act === 'confirm') choose(m.index)
})
// m.move(dx, dy) to drive it from gamepad edges yourself · m.index is the selectionUndo / redo — a snapshot timeline for puzzles, editors, drawing apps. Snapshot your state, record() it
after each change, undo() / redo() hand back the state to restore (a new record after undo drops the
redo branch):
const past = fruta.history(snapshot(), { limit: 200 }) // snapshot() = a plain copy of your state
function onChange() { applyMove(); past.record(snapshot()) }
function onUndo() { const s = past.undo(); if (s) restore(s) } // past.redo() · past.canUndo · past.currentScenes, particles, timers, save
fruta.scene('game', { enter() { reset() }, update(dt, t) { … }, leave() { … } })
fruta.start('game', 0.4) // switch with a 0.4s fade
fruta.burst({ x, y, count: 30, color: ['#ff5', '#5cf'], speed: [40, 200], spread: 360, life: 1, gravity: 250 })
const trail = fruta.emit({ x, y, rate: 60, color: '#6cf', life: 0.6 }); trail.x = fruta.mouse.x
fruta.drawParticles()
fruta.floatText('+18', { x, y, color: '#ffd24a', rise: 60, life: 0.6 }) // score/combo/damage popup
fruta.drawFloatText(dt) // rises + fades, auto-pruned
fruta.after(2, () => { … }) // once → handle.cancel()
fruta.every(0.5, () => { … }) // repeating
fruta.store('highscore', 9000); fruta.stored('highscore', 0) // persist (localStorage, JSON)Entities (opt-in retained mode)
Register an entity and the engine moves it (velocity + gravity + its own update()).
const ball = fruta.add({
shape: 'circle', x, y, r: 10, vx: 80, gravity: 520, color: 'tomato',
bounds: 'bounce', // 'bounce' off walls or 'wrap' across them
update(self, dt, t) { if (self.y > 400) self.vy *= -0.8 },
})
fruta.drawEntities() · fruta.all · ball.remove()Live debug & charts
fruta.debug(true) // overlay: FPS, entity/particle/tween counts, mouse
fruta.watch('player.x', player.x)
fruta.barChart({ data: [{ label: 'Mon', value: 40 }, …], max: 100 })
fruta.lineChart({ series: [{ name: 'Revenue', data: [...] }], points: true, legend: true })
fruta.pieChart({ data: [...], donut: 0.55 }) · fruta.radar({ axes, series }) · fruta.legend({ … })Charts handle scales/axes/legend; pass progress (0..1) for an animated reveal.
Dialogue & GUI
Dialogue — a typewriter dialogue engine with branching choices. Lines carry a label;
choices goto a label (or index) — linear and tree dialogue are the same data shape.
const story = fruta.dialog([
{ speaker: 'Aria', text: 'The path splits. Which way?', choices: [
{ text: 'The cave', goto: 'cave' },
{ text: 'The river', goto: 'river' },
] },
{ label: 'cave', speaker: 'Aria', text: 'Brave. It hides treasure… and teeth.', goto: 'end' },
{ label: 'river', speaker: 'Aria', text: 'Wise. Long, but safe.', goto: 'end' },
{ label: 'end', speaker: 'Hero', text: 'My path is chosen.' },
])
fruta.onPress(() => story.advance()) // advance / pick
fruta.onKey('ArrowDown', () => story.move(1)) // move the choice cursor
fruta.loop((dt) => { story.update(dt); fruta.drawDialog(story, { position: 'bottom' }) })Fully stylable — restyle every box once with fruta.dialogTheme({ accent, bg, border, text, radius, font, size }),
or pass the same keys per drawDialog call. Or render the same dialogue as a 2D speech bubble that
follows a character (auto-sizing, with a tail):
fruta.bubble(story.visible, { x: npc.x, y: npc.y - 50, tail: 'down', border: '#5b9bd1' })GUI — immediate-mode: call a widget each frame, it draws itself and returns its interaction. You own the state, so there's nothing to wire up or tear down.
fruta.panel({ x: 24, y: 24, w: 300, h: 220 })
if (fruta.button('Play', { x: 40, y: 44 })) start()
volume = fruta.slider(volume, { x: 40, y: 110, w: 240, min: 0, max: 1 })
sound = fruta.checkbox('Sound', sound, { x: 40, y: 150 })
fruta.progressBar(hp, { x: 40, y: 190, w: 240, max: 100, color: '#e35' })
fruta.guiTheme({ accent: '#e0795a' }) // restyle every widgetbutton fires a real click (the facade tracks a one-frame release edge). Dialogue, bubbles and the
GUI work on both backends — the same calls run on Fruta.gl (WebGL), which emulates the borders,
text baseline and corner radius that the GL primitives lack.
WebGL: same sugar, GPU speed, shaders
For thousands of objects, switch to the WebGL renderer — identical API, GPU-batched:
const gl = Fruta.gl({ width: 800, height: 600, background: '#0a0a14' }).mount()
gl.loop((dt) => { gl.background(); for (const b of balls) gl.circle({ x: b.x, y: b.y, r: b.r, fill: b.color }) })Canvas2D vs WebGL: which, and why
Same picture, different rasterizer. Canvas2D fills every shape on the CPU, one at a time — great into
the low thousands, but 50k circles is its ceiling (~10–15 fps) because each arc() is tessellated + filled
sequentially. WebGL packs the whole frame into flat typed-array buffers and lets the GPU rasterize them all
in parallel, in a few draw calls. Feed it with the batched calls (the twin of the Canvas2D ones):
gl.circles(xs, ys, r, fill, count?) // xs/ys: Float32Array — one batch, a few GPU draw calls
gl.rects(xs, ys, w, h, fill, count?)Measured: ~100,000 particles at 60–70 fps (Ryzen 7 4000-series + RTX 2060) with gl.circles — vs ~10–15 fps
for the same count on Canvas2D. Rule of thumb: hundreds–thousands → Canvas2D (simplest); tens of thousands
→ renderer: 'webgl'. Same sugar either way, so you only switch the backend, not your code. (See bench.html
and bench-gl.html.)
Fruta.gl mirrors the full Canvas2D engine — draw, camera, input, entities, tilemap + physics,
collision, particles, scenes, timers, save, audio, animation, timeScale, solve, physics, and
the dialogue + GUI systems — all GPU-batched. Plus shaders, Fruta-style:
const bg = gl.shader(`void main(){ gl_FragColor = vec4(0.5+0.5*cos(uTime+gl_FragCoord.xyx), 1.0); }`)
gl.loop(() => bg.draw())
// post-processing: ~25 presets, one call
gl.effect('crt') · gl.effect('vignette', { intensity: 0.7 }) · gl.effect('chromatic') · gl.effect(null)
// or your own fragment (samples uScene at vUV):
gl.effect('uniform vec2 uCenter;void main(){ /* a custom shockwave */ }', { uCenter: [0.5, 0.5] })~25 post presets: color (grayscale invert sepia tint brightness posterize threshold),
retro (scanlines crt vhs pixelate noise glitch), distortion (chromatic wave fisheye mirror
shockwave), blur/glow (blur sharpen bloom dream edge), special (vignette nightvision).
Canvas2D and WebGL can't share a canvas, so
Fruta.glis its own object. Both are full engines — pick Canvas2D for the richest 2D, WebGL for raw throughput.WebGLBatch/Shader/PostFXare exported.
Going lower-level
fruta.context // raw CanvasRenderingContext2D
fruta.shapes // chainable Canvas wrappers
fruta.fonts // chainable text wrappers
fruta.state // context save/restoreWhat you can build
Fruta is small, but it's been stress-tested against a suite of real game/app slices (in the playground) — these are the genres it comfortably handles today:
| You want… | Fruta gives you |
|---|---|
| Precision platformers (Celeste-style) | moveAABB tile collision, cooldown for coyote-time / jump-buffer / dash, hitstop + camera.shake + particles + squash-stretch for game-feel |
| RPGs / puzzles / rhythm | pushdown fsm (pause & dialog overlays), menu (keyboard/gamepad nav), history (undo/redo), floatText popups, keyPressedAt (frame-exact timing) |
| Bullet-hell / survivors (thousands of entities) | Pool + SpatialHash + the GL batch renderer — 60 fps at thousands |
| Top-down / colony sims | findPath (A*) NPC AI, state machines, day/night, save/load |
| Terraria-likes (huge worlds) | chunked tile streaming, dynamic lighting, simple liquids, mining, procedural gen |
| Noita-likes (pixel physics) | cellular automata over a grid blitted as one texture (context + drawImage) |
| Factorio-likes (sim at scale) | a fixed-timestep loop + data-oriented machines (belts, inserters, recipes) |
| Editor apps (level / animation / node) | onPress/onMove/onRelease/onKey + context — zoom/pan, gizmos, undo/redo, timelines |
| Data viz & dashboards | one-call barChart/lineChart/pieChart/radar |
The playground (bun run dev) ships runnable versions of all of these.
TypeScript
Fruta is written in TypeScript and ships its own .d.ts — no @types/fruta needed. Every
option object, return value and exported helper (World, Pool, SpatialHash, findPath,
cubicBezier, hexToPixel, Vec, Hex, …) is typed, so you get full autocomplete and
compile-time checks. It works just as well from plain JavaScript.
Status
0.1.3 — alpha, the API is settling but solid (~111 KB minified, ~35 KB gzipped, zero deps). Canvas2D
is draw-call bound (fine into the low thousands); Fruta.gl (WebGL, batched) is the throughput path for
tens of thousands. See CHANGELOG.md and ROADMAP.md.
