fruta
v0.3.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), fully tree-shakeable — import just animate and
the shader backdrops / fluid / components never ship. 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}, …] | nullAI: enemies with minds (and a way to watch them think)
An enemy that always knows where you are is not intelligent, it is omniscient — and it feels cheap. f.brain()
gives a body senses (a sight cone with real line of sight, hearing that goes round corners, and memory), a
decision model, and steering. The engine thinks for it every frame.
import { seek, orbit, wander, avoid, separate, blend, squad } from 'fruta'
const brain = f.brain(enemy, {
speed: 90,
senses: { sight: 380, fov: 120, hearing: 200, memory: 5, blocked }, // `blocked(x,y)` = your walls
target: () => player,
loudness: () => (player.moving ? 1 : 0.35), // stand still and it stops hearing you
group: () => enemies, // for separation / flocking
squad: pack, // shared: only N of them get to charge at once
states: { // a story → a state machine
patrol: {
run: (m) => blend([[wander(enemy, wob, 50, m.dt), 1], [avoid(enemy, blocked, 90, 70), 2.4]], 900),
to: { hunt: (m) => m.knows.aware },
},
hunt: {
run: (m) => {
const close = m.squad.claim('attacker', enemy, 3) // three attack; the rest circle
m.say(close ? 'charging' : 'circling') // ← printed above its head in the brain view
return close ? seek(enemy, m.knows.lastSeen!, 90) : orbit(enemy, m.knows.lastSeen!, 190, 90)
},
to: { patrol: (m) => !m.knows.lastSeen }, // memory ran out → it gives up
},
},
})m.knows is what it believes, not the truth: sees, hears, dist, lastSeen (the last place it believed
you were — null once memory expires) and since. Break the line of sight behind a wall and stand still, and it walks
to where you were. Being wrong is the whole difference between a mind and a seek().
Utility AI instead of states, when the behaviour is a set of urges rather than a story — every action scores
itself, the best one runs, and commit stops it dithering between two near-equal ideas:
f.brain(enemy, {
senses: { sight: 460, memory: 3, blocked }, target: () => player,
actions: {
rush: { score: (m) => (m.knows.sees && enemy.hp > 40 ? 0.8 : 0), run: (m) => seek(enemy, m.target!, 160) },
retreat: { score: () => (enemy.hp < 35 ? 0.95 : 0), run: (m) => flee(enemy, m.target!, 180) },
prowl: { score: () => 0.12, run: (m) => wander(enemy, wob, 80, m.dt) },
},
})See it think — AI is the one system you cannot debug by reading; you have to watch it be wrong:
f.drawBrains() // inside the camera…draws, per enemy: the sight cone and hearing radius, a solid line to what it sees, a fading ghost of
where it only remembers you, its A* path, the steering force pulling it this frame, its state, the
note it left with m.say(...), and — in utility mode — every action's score as a live bar, so you watch the
decision get made.
Steering (all pure, all composable — forces sum and clamp): seek flee arrive pursue evade orbit
wander separate cohere align avoid, combined with blend([[force, weight], …], maxForce). "Chase him +
keep off my friends + don't hit the wall" is three forces added together, not three special cases.
Squad (squad()): claim(role, id, max) / release — the token that stops all six enemies lunging at once —
plus a blackboard (set/get) they all share. One enemy sees you, writes your position to the board, and the whole
map converges: that is "if they find you, they kill you", in one line.
brain.enabled = false stops a mind (stunned, cutscene — or your game is paused: the engine ticks brains in its
own frame, so an overlay that only stops your update loop leaves the enemies happily walking into you behind it).
Being spotted is a meter, not a switch
if (canSee(player)) chase() gives you an enemy that snaps from oblivious to omniscient in one frame: no tension, no
counter-play, and no way for the player to understand what just happened. So detection is a meter — it fills
faster when you are close and centred in the cone, slower when you are far, at its rim, still, crouched or in cover,
and it drains when you break the line.
senses: { sight: 380, fov: 120, hearing: 200, memory: 5, notice: 0.9, forget: 4, suspicion: 0.35, blocked }
m.knows.awareness // 0..1 — the bar the player watches fill
m.knows.suspicious // it does not KNOW, but it is coming to look
m.knows.alerted // it is certaindrawBrains() puts that bar over its head, with a ? and then a !. A noise never fully alerts anyone — it
makes them come and look; it takes eyes to be certain.
Hiding is a system, not an if
import { stealth, concealed } from 'fruta'
const prof = stealth({
moving: player.moving,
crouching: player.crouch, // slower, quieter, smaller
concealed: concealed(player, shadows), // the strong one — but it should cost you (no shooting from the bush)
})
f.brain(enemy, { …, loudness: () => prof.loudness, visibility: () => prof.visibility })Two numbers, and every enemy on the map reacts to them, consistently, for free: visibility scales how fast their
meter fills, loudness scales their hearing range. Cover hides you from eyes; it does not make you silent.
An alarm is a shout, not a GPS ping
Broadcasting your exact position to every enemy the instant one of them blinks at you is the classic cheat, and it reads as one. A shout has a range, an accuracy, and it escalates:
const alarm = new Alarm({ radius: 660, blur: 90, memory: 8, calm: 14 })
alarm.raise(player, enemy) // it is CERTAIN, so it shouts — from where it is standing
alarm.hear(other) // → a BLURRED position, or null if that one was out of earshot
alarm.level // 0..1 — the whole map's tension, draining on its own. Wire it to anything.
alarm.update(dt)
f.drawAlarm(alarm) // the earshot circle, the blurred guess, the line from the mouth that shoutedsenses.alertTo(pos, 0.6) brings an enemy in suspicious rather than omniscient: it has seen nothing, it is just
on its way to what it was told.
They learn your habits
const model = f.learn(player) // the engine feeds it every frame
model.predict(player, 0.4) // where he will be in 0.4s → lead the shot
model.hotspot() // the corner he keeps going back to → go wait there
model.confidence // 0..1 — how predictable he is. Only act on it when it is high.
f.drawLearner(model, player) // and SEE the model: heatmap, direction rose, the shot it is leadingTwo small online models, a few hundred bytes, no black box: a decayed Markov chain over eight movement
directions ("from north-east, where does this player usually go next?") and a decayed heatmap of where they
spend their time. Both decay, which is the part that matters — change your habits and the model follows you, so the
counter-play is real. confidence is normalised entropy, scaled down while the model is still thin: an enemy that
acts on a prediction it is not confident about is not smart, it is broken.
And it is drawn, because adaptive AI has an honesty problem: when an enemy leads a shot perfectly, the player
cannot tell whether it read them or the game cheated. f.drawLearner() puts the model on screen — if it is wrong,
you see it be wrong.
Works on both backends (Fruta(…) and Fruta.gl(…)), and the whole module is pure and unit-tested.
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 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 sHUD (immediate-mode, data-driven)
A whole HUD from a list: you say where (an anchor), not at what pixel, and elements sharing an anchor stack
themselves. Values are paths read live out of your data — 'score', 'player.hp' — and 'Score {score}'
interpolates. It survives a resize and needs no coordinates.
fruta.hud([ // anchor, not pixels; same anchor → stacks in order
{ type: 'pips', value: 'hp', max: 'maxHp', at: 'top-left' }, // hearts / lives / charges
{ type: 'bar', value: 'boss', max: 100, w: 300, at: 'bottom', label: '{name}' },
{ type: 'text', text: 'Score {score}', at: 'top-right' },
{ type: 'text', text: '{time}', format: 'time', at: 'top' }, // 92.4 → "1:32"
], { hp: 3, maxHp: 5, boss: 60, name: 'GOLEM', score: 1200, time: 92.4 })Renderer-agnostic — it draws through the minimal Drawer, so Canvas2D and WebGL. Lay out once, draw through
any facade:
const read = (p: string) => readPath(data, p) // reads 'player.hp' off a plain object, never crashes
drawHud(fruta, layoutHud(spec, width, height, read), read)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)Procedural caves
Cellular-automata caves that build themselves — pure, seeded, DOM-free. Same seed → the same cave forever, and it flood-fills the regions and tunnels the islands together, so the level is always one connected, finishable cave.
const r = rng(1337) // seeded stream: r() → the same sequence, forever
const cave = caveMap({ w: 80, h: 45, seed: 1337, fill: 0.45, steps: 4 }) // fill = rock; steps = smoothing passes
const map = fruta.tilemap({ size: 16, data: cave.data, tiles: { '#': { solid: true, color: '#3a2f28' } } })
const spawn = cave.floor[0] // standable cells: air with rock underfoot
const exit = farthestFrom(cave, spawn) // the floor cell furthest away → the cave is a journey
const mobs = scatter(cave, 8, 1337, 5) // 8 floor cells, spread ≥ 5 apart, never overlapping
cave.regions // how many caverns existed before they were tunnelled into oneHand-edits survive regeneration: pass dig: [[x, y]] / wall: [[x, y]] and they apply on top of the noise (a
wall can never seal the map — the flood fill tunnels around it). findRegions exposes the raw pockets before
they're joined.
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)Voxels (FEZ-style 2.5D)
A genuinely 3D grid of cubes drawn through the plain 2D Drawer (orthographic = affine, so a cube is just three
quads — no z-buffer, no shaders). It looks flat at rest and only turns 3D while it rotates. Rotating flattens to
a 2D collision level per facing, so what you walk on is what you see.
const world: VoxelWorld = { // w × h × d integer grid; y is UP
w: 8, h: 4, d: 8,
voxels: [{ x: 2, y: 0, z: 3 }, { x: 3, y: 0, z: 3, tile: 'gold' }],
}
let turn = 0 // 0 · 1 · 2 · 3 quarter-turns
drawVoxels(fruta, world, { size: 32, angle: turn * Math.PI / 2, color: '#3F8A84', edge: '#0b1a1a', fog: 0.4 })
const view = flatten(world, turn, 32) // squash to a 2D level for this facing
view.solids // Rect[] — the platforms you can stand on
view.ambiguous // cells where different depths collapse together — the puzzle
solidAt(view, cx, cy) // is this cell solid? (cell coords = pixels ÷ 32)
nearestVoxel(view, cx, cy) // the frontmost cube at that cell
const maps = voxelViews(world).map((v) => viewToTilemap(v, 32)) // four Tilemap specs, one per facingAlso: rotateWorld / rotateVoxel (exact integer permutation, no float drift) and visibleFaces(world) to
sanity-check a big world's face count.
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()Combat
The action-game boilerplate every hack & slash hand-rolls: a swing with a damage window, input-buffered combos,
per-swing hit dedup, and directional hitboxes / i-frame timers. Pure — compose with fruta.hits for the actual
collision.
const sword = fruta.strike({ duration: 0.3, active: [0.08, 0.2], combo: 2 }) // 0 = single, 2 = a 3-hit combo
const iframes = fruta.cooldown() // countdown for i-frames / dash / parry
if (fruta.keyPressed('Space')) sword.press() // pressed mid-swing → buffers the next combo step
sword.tick(dt); iframes.tick(dt) // advance each frame
if (sword.live) { // inside the damage-dealing window
const box = fruta.hitbox(player, player.face, { reach: 44 }) // Rect in front of the body (face: -1 / +1)
for (const e of enemies) if (fruta.hits(box, e) && sword.first(e)) hurt(e) // first() → one hit per swing
}
if (struck && iframes.ready) { damage(player); iframes.set(0.8) } // .active while counting · .ready when doneCooldown also does buffered inputs: iframes.consume() spends a valid buffer (jump buffer / coyote time)
exactly once.
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/restoreStarter behaviors (fruta/starter)
A tiny, tree-shakeable subpath: the behavior-verb starter kit as ejectable source (28 scripts —
walk, patrol, chase, …). It is not part of the engine or the runtime — the engine knows nothing about
patrol; this is the shelf an editor or codegen tool copies verbs off, as real files it then owns.
import { STARTER, starterNames, behaviorParams, STARTER_SOURCE, STARTER_DOC } from 'fruta/starter'
starterNames() // () => string[] — every verb in the kit
STARTER_SOURCE['walk'] // string — the verb's real source, comments and all → write it to scripts/walk.js
STARTER_DOC['walk'] // string — its one-line description
behaviorParams('walk') // (type) => VerbParam[] — its tunable @param knobs (name, default, range)
STARTER['walk'] // the factory itself, if you'd rather run it than eject itMarked sideEffects: false, so if you never import it the bundler ships nothing.
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.3.0 — alpha, the API is settling but solid (zero deps; fruta/web and fruta/starter are separate
opt-in subpaths that add nothing unless imported). 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.
