npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

Fruta 🍒

npm version TypeScript types included ESM only size NPM License

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 }), not ctx.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 dev for 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 fruta

ESM-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, Bash

dt 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 allows

Drawing

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 group

Dynamic 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 · dilate

Animation

// 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) % count

Easings: 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 source

Collision & 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 pair

Response — 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 timersfruta.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 worldfruta.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}, …] | null

Scale: 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 cells

Curves & 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 hexes

Sprites & 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 s

Tilemaps & 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 · depth

For 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 selection

Undo / 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.current

Scenes, 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 widget

button 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.gl is its own object. Both are full engines — pick Canvas2D for the richest 2D, WebGL for raw throughput. WebGLBatch/Shader/PostFX are exported.

Going lower-level

fruta.context     // raw CanvasRenderingContext2D
fruta.shapes      // chainable Canvas wrappers
fruta.fonts       // chainable text wrappers
fruta.state       // context save/restore

What 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.

License

MIT