Design document — a browser-native flower grooming game
Iris Trimming is a portrait-oriented browser game where you groom an iris flower by plucking dying leaves, removing rotting petals, and slicing the stem at a proper 45° angle. Every tear and cut squirts chlorophyll—green particle physics that spray, drip, and fall with gravity.
The game runs as a single HTML file with zero external dependencies. No build step, no bundler, no framework. Open the file, play the game.
| Layer | Technology | Why |
|---|---|---|
| Rendering | Canvas 2D |
Lightweight, universal support, no WebGL overhead for 2D shapes |
| Language | Vanilla JS |
Zero dependencies = zero bundle size. Single file deployment. |
| Input | Pointer Events |
Unified mouse + touch handling. One API for both. |
| Animation | requestAnimationFrame |
Vsync-aligned, battery-friendly, auto-pauses when tab hidden |
| Layout | CSS (inline) |
UI overlay only. All game rendering is canvas. |
Plucking uses a drag-to-tear mechanic. When you grab a leaf or petal, the game tracks pull distance from the attachment point. As you drag further, a tearProgress value ramps from 0 to 1:
// Pull distance determines tear progress const dx = pointerX - part.x; const dy = pointerY - part.y; const dist = Math.sqrt(dx * dx + dy * dy); part.tearProgress = Math.min(1, dist / (part.len * 1.8));
While pulling (tearProgress > 0.3), chlorophyll particles ooze from the base. A visible stretch line connects the attachment point to your pointer, thinning as it nears breaking. At tearProgress = 1, the part detaches:
Slicing uses line-segment intersection. The shears tool records a trail of pointer positions. Each frame, the last two trail points form a line segment that's tested against the stem segments:
// Line-line intersection test const denom = (x1-x2)*(y3-y4) - (y1-y2)*(x3-x4); const t = ((x1-x3)*(y3-y4) - (y1-y3)*(x3-x4)) / denom; const u = -((x1-x2)*(y1-y3) - (y1-y2)*(x1-x3)) / denom; // Hit if both t and u are in [0, 1]
When the stem is cut, the game measures the cut angle relative to the stem. In real floristry, a 45° angle maximizes water uptake surface area. The game scores this:
| Angle Range | Rating | Health Effect |
|---|---|---|
| 40–50° | Perfect | +10 |
| 30–60° | Good | +5 |
| 15–75° | OK | 0 |
| 0–15° or 75–90° | Bad | -5 |
After cutting, the top of the stem (bloom + upper leaves) is kept—the bottom stump falls away. This is the part you harvest into your bouquet.
Each flower can be saved to a persistent bouquet collection. The game deep-clones the entire flower object (not a screenshot)—all leaf positions, petal shapes, stem state, dying/rotting flags. When viewing the bouquet, each saved flower is re-rendered from scratch using the real drawing functions, then arranged in a fan from a wrapped vase.
Each frame draws in this order (painter's algorithm, back to front):
An iris is procedurally generated each time with randomized parameters:
Iris petals have characteristically ruffled edges. This is achieved by displacing outline points along the perpendicular using a sine wave:
const widthAtT = Math.sin(t * 3.1416) * w; const ruffle = Math.sin(t * 6 + rufflePhase) * w * 0.15; const px = bx + perpX * (widthAtT + ruffle);
The base width follows a sin(t * π) envelope (zero at base and tip, max at middle). The ruffle adds a higher-frequency wave on top, with a random phase per petal so they don't all match.
Three particle types create the "sap squirt" effect:
| Type | Behavior | Visual |
|---|---|---|
| scatter | Random direction, medium speed, slow decay | Green circles, 3 color shades |
| stream | Directional spray (opposite to pull/cut), fast, spread | Larger green blobs, 4 color shades |
| drip | Downward bias, high gravity, slow decay | Small circles with RGBA color |
All particles share the same physics: position integration, velocity damping (0.99x per frame on X), gravity accumulation on Y, and life decay. Dead particles are removed via swap-and-pop from a pre-allocated pool.
A global wind value drifts toward random targets on a 2–6 second timer. Stem segments displace horizontally by wind * t² where t=1 at the top (most sway) and t=0 at the base (anchored). Leaves and petals add their own sinusoidal sway on top, each with a unique phase and fixed speed.
The game targets 60fps on mobile browsers. Key optimizations:
The sky/grass gradient and noise texture are rendered to an offscreen <canvas> once, then drawImage'd each frame. Rebuilds only on window resize.
Colors, vein colors, dying spot positions, and rotting spot positions are all computed at flower creation time and stored on each leaf/petal object. No lerpColor parsing, no Math.random() in draw calls. This also eliminates the spot-flickering that random-per-frame caused.
Particles and falling parts use fixed-size arrays with a count index. Removal uses swap-and-pop (O(1)) instead of Array.splice (O(n)). Pool caps at 500 particles to prevent runaway allocation during heavy splatter.
The slice trail uses a ring buffer instead of Array.push + Array.shift. Fixed 30-element array with head pointer—no array resizing or copying.
Petal side veins are batched into a single beginPath/stroke call. Particle drawing skips save/restore per particle—just sets globalAlpha directly. Falls and standards are pre-sorted into separate arrays at creation, eliminating per-frame .filter() allocations.
Constants like Math.PI * 2 are replaced with 6.2832 literals. 180/Math.PI becomes 57.2958. Division replaced with multiplication where possible. Square-root avoided in hot paths by comparing squared distances.