Iris Trimming

Design document — a browser-native flower grooming game

Overview Tech Stack Mechanics Rendering Physics Optimization

Overview

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.

Design goal: Feel like you're handling a real plant. The stretch before a leaf tears, the spray of green sap, the weight of a falling petal—all of it sells the physicality.

Tech Stack

LayerTechnologyWhy
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.
Libraries used: None. The entire game is vanilla HTML + CSS + JavaScript. No Three.js, no Pixi, no Matter.js, no physics engine. All physics, particle systems, and rendering are hand-rolled.

Game Mechanics

Plucking (Leaves & Petals)

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:

Health feedback: Removing sick parts (brown/dying) heals the plant (+3). Removing healthy parts hurts it (-5 leaves, -8 petals). This teaches players to identify what needs trimming.

Slicing (Stems & Leaves)

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 RangeRatingHealth Effect
40–50°Perfect+10
30–60°Good+5
15–75°OK0
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.

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

Rendering Pipeline

Each frame draws in this order (painter's algorithm, back to front):

Background (cached offscreen canvas) ↓ Stem (quadratic Bézier curves + gradient stroke) ↓ Leaves (cubic Bézier outlines, pre-baked colors) ↓ Petals: Falls (drooping, with beard detail) ↓ Petals: Standards (upright, with ruffle edges) ↓ Falling Parts (simplified ellipses with rotation) ↓ Particles (circles, no save/restore per particle) ↓ Slice Trail (gradient-alpha polyline) ↓ Tool Cursor (circle or X depending on mode)

Flower Structure

An iris is procedurally generated each time with randomized parameters:

Petal Edge Ruffling

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.

Physics Systems

Chlorophyll Particles

Three particle types create the "sap squirt" effect:

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

Wind & Sway

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.

Optimization

The game targets 60fps on mobile browsers. Key optimizations:

Cached Background

The sky/grass gradient and noise texture are rendered to an offscreen <canvas> once, then drawImage'd each frame. Rebuilds only on window resize.

Pre-baked Visual Data

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.

Object Pooling

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.

Ring Buffer Trail

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.

Batched Draw Calls

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.

Reduced Math

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.