-
Notifications
You must be signed in to change notification settings - Fork 41
living tree algorithm
Status: Experimental. This document is the normative source of truth for the
wp-living-treecanvas wallpaper. The implementation undersrc/plugins/living-tree-wallpaper/mirrors it method-for-method — the spec wins on any disagreement. The topology-invariance guarantees below are enforced bytests/vitest/living-tree-invariance.test.ts.
The Living Tree is an animated desktop wallpaper (OpenStation) that renders the site as a living plant organism. Its shape is the visual fingerprint of the site's life: a tree that grows from the ground up, with leaves (posts), blossom (comments), meadow wildflowers (categories), butterflies (tags), fireflies (online users), and wind (traffic). An empty WordPress should read as a sprout; a ten-year-old site as a frondose oak standing in a flowering meadow.
It is not interactive: no zoom, no pan. The camera is fixed to the desktop. Once the tree has finished growing, the only permanent animation is the wind swaying branches, leaves, and wildflowers, plus the butterflies, the fireflies, and a slow color easing.
WordPress never emits geometry. It emits hormones.
WordPress reports scalar, biologically-flavoured signals — age, vigour, health, diversity, bloom, foliage… — all normalised. Geometry is decided by the biology (the growth simulator) inside immutable morphological constraints. WordPress never positions a branch, never sets a coordinate, never says "this post goes here."
This is what guarantees that any topology produces tree morphology and never a database diagram. A site with 1 category and 5000 posts, and a site with 2000 categories and 1 post each, at the same age grow the same skeleton. The content difference shows up only in the decoration (density and colour of leaves, LOD), never in the shape of the trunk or the count of branches.
If you ever find yourself threading a post ID, a term ID, or a coordinate from PHP into the growth simulator, stop — you are breaking the invariant.
The system is split into two layers with very different update cadences.
| Layer | What it is | Cadence | Owner |
|---|---|---|---|
| Morphology | The skeleton: trunk + branches. | Slow, structural. Grows once, bottom→top, frame by frame, then settles. | Space Colonization, seeded deterministically per site, bounded by an age-dependent envelope. |
| Content | The decoration: leaves, blossom, wildflowers, butterflies, fireflies, colour. | Fast, volatile. Repositioned / recoloured without touching the skeleton. | The hormones feed this layer directly. |
WordPress only feeds the CONTENT layer and modulates MORPHOLOGY parameters via hormones. It never positions a branch.
A data refresh (re-poll) re-points the hormones and re-eases the decoration. The skeleton only re-grows if age crosses a level threshold (see the age table in A.4).
seed = hash32( siteUrl + '|' + siteName + '|' + installEpoch )
PRNG = mulberry32( seed )
- Same site → same skeleton on every page load. The skeleton is a pure function of the seed plus the age/vigour hormones.
- Different sites → different trees, even with identical metrics. Two installations with the same number of posts must never look identical. The blog NAME is part of the seed on purpose: two blogs can share a URL shape (two localhost installs, staging clones) and still must grow distinct individuals. The same identity also nudges each site's base canopy green (±12°), and the envelope jitter is wide (height ±12%, crown ±18%) so sites differ in stature, not just in branch layout.
Every stochastic choice in the morphology layer draws from this single
seeded PRNG so the whole skeleton is reproducible. The rng.ts module
(hash32 + mulberry32) is the only randomness source the growth layer is
allowed to touch. The decoration layer may use per-index variation seeded
from the same PRNG so a reload doesn't reshuffle leaf colours.
Hormones are the only crossing point from WordPress into the simulator.
buildHormones( snapshot ) maps a TreeSnapshot (raw WP metrics) to a
Hormones object, all normalised to 0..1 (except spark, an integer
count).
| Hormone | Source | Drives |
|---|---|---|
age01 |
ageCurve( siteAgeDays ) — saturating logarithmic. |
Master clock. Height, trunk girth, maxDepth (levels), envelope size. |
vigor01 |
Energy: f( posts, comments, traffic, users − errors − cpu ). |
Growth speed, branching density. |
foliage01 |
Post count, saturating and LOD-capped. | Canopy fill. |
health01 |
SEO / site health. | Leaf colour temperature and vitality. |
bloom01 |
Comment density (comments / post). | Fraction of leaves that flower. |
wind01 |
Traffic. | Wind amplitude / frequency. |
structure01 |
Pages (evergreen content). | Trunk ivy — an evergreen cloak climbing the trunk + heavy boughs (coverage height + density). |
vitality01 |
Performance. | Canopy turgor — leaf fullness + brightness. |
spark |
Active (online) users. | Number of fireflies (visible at night). |
Two decoration budgets sit beside the hormones, each a pure saturating
function of one aggregate count: computeFlowerCount( totalCategories )
(meadow wildflowers) and computeButterflyCount( totalTags )
(butterflies). Like the hormones they only ever touch decoration, never
the skeleton.
The min() of two monotone regimes:
-
Sapling regime (linear) —
days / 250, which wins for roughly the first five months. The raw log curve alone rockets early (a day-old site sat at ~8% of the master clock, a month-old one at ~42% — sprout to full tree overnight), and a smoothstep damp tried first had the opposite failure — its quadratically-flat start pinned days 0–10 at the 2-node sprout. The linear clock advances the same small step every day: each early day visibly adds a node or two, day 30 reads as a small sapling (~12% of the clock, with the A.4 depth cap bounding its structure on top). - Log regime — the saturating logarithmic curve, which wins from ~5 months on: fast through the first years, flattening as the site ages so a 10-year-old site and a 12-year-old site look similar in scale (extra age past ~10y buys texture, not height — see A.4).
Monotone across the whole domain — the depth-unlock table in A.4 compares through the same curve, so level thresholds stay pinned to their configured day counts regardless of the curve's shape.
A normalised composite of positive energy (posts, comments, traffic, users)
minus drains (error rate, CPU/perf pressure). Clamped to 0..1. Controls how
fast the tree grows and how densely it branches — a busy, healthy site
fills its envelope quickly and thickly; a dormant one grows slowly and
sparsely within the same envelope.
buildEnvelope( age01, vigor01, rng ) returns an Envelope — the silhouette
the crown is allowed to fill (an ellipse / egg sitting on a trunk gap), plus:
-
heightMax(Hmax) — overall tree height. -
crownRadius— half-width of the canopy. -
trunkBaseGirth— base thickness of the trunk. -
maxDepth— maximum branching levels. The envelope always carries the ancient tier's cap of 12 (the canonical skeleton is built to full depth); the age table below is applied at reveal time bymaxDepthForAge( age01 ):
| Age | Levels |
|---|---|
| 0–30 days | 2 |
| 1–6 months | 4 |
| 6–24 months | 6 |
| 2–5 years | 8 |
| 5–10 years | 10 |
| 10+ years | 12 — the hard cap (finer bifurcations / texture, not more height) |
The canonical-skeleton principle (the crux): the envelope — and therefore the attractor cloud and the fully-grown skeleton — is a function of the seed alone. Age never touches geometry; it gates three things:
-
revealCountForAge( total, age01 )— how many nodes of the canonical skeleton are revealed, in growth order (monotone in age); -
maxDepthForAge( age01 )— the branching levels unlocked (the table above); -
trunkGirthForAge( age01 )— how thick the revealed wood is.
Two invariants fall out of this, both pinned by tests:
- Topology invariance — 5 posts and 50 000 posts at equal age reveal the same skeleton; content appears only in decoration (leaf density / colour, LOD). Categories and tags never enter the geometry.
-
Gradual growth — the tree at day N+1 contains the tree at day N,
node for node, plus a few more. Growth is monotone accretion, never a
reshuffle. (An earlier design derived the envelope from
age01; every daily tick ofsiteAgeDaysshifted the sampling boundaries and re-rolled the whole tree.)
sampleAttractors( env, count, rng ) scatters the auxin sources inside the
envelope volume using the seeded PRNG. The cloud is dense on purpose —
attractors surviving near a passing branch (tight kill radius) pull out the
fine interior twigs that let foliage clothe the whole canopy.
Engine: Space Colonization (Runions et al., 2007, "Modeling Trees with a Space Colonization Algorithm").
State:
-
nodes: BranchNode[]— starts with a single root node at the base. -
attractors: Vec2[]— auxin sources sampled inside the envelope (A.4).
Per growth step step( budget ):
- Each attractor finds the closest node within its influence radius
di. - Each node that has ≥1 associated attractor averages the direction toward
them, normalises it, and spawns a child at
pos + dir · segLen, plus:- PRNG jitter (organic wobble),
- an upward tropism term (this is what makes growth go bottom→top),
- a slight gravity droop at the tips.
- Attractors within the kill radius
dkof any node are removed. - Termination: attractors exhausted or the envelope-derived node cap
reached (
maxNodes = max( 6, round( attractorBudget × 2 ) )).
Why bottom→top emerges: root at the base + attractors above + upward tropism. No coordinate is dictated; the direction is an emergent average.
Incremental growth: the number of new nodes added per frame is capped
at growthRate = f( vigor01 ), so the tree visibly grows sprout→canopy in
~3–6 s and then settles. It never appears fully-formed — it grows in.
A post-pass accumulates thickness from child→parent by Murray's / da Vinci's law:
parentR^n = Σ childR^n (n ≈ 2.2)
The trunk-base girth is scaled by age01. computeGirth( nodes, trunkBase, exponent = 2.2 ) walks the node tree from tips to root filling
BranchNode.radius.
buildBranchMesh( nodes, pixi ) turns the node graph into renderable ribbon
geometry — no branch sprites, so no two branches are ever identical:
For each parent→child chain:
- Build the centerline straight through the node positions — one sample per node, no smoothing or subdivision. (The organic wobble is already in the node positions themselves, from the growth jitter; an optional per-node displacement hook is how the wind bends the chain.)
- Derive per-point radii from the accumulated girth, with a root flare on the ground run and a pinched first vertex on child runs so forks don't web into melted wedges.
- Offset the edges along per-point normals and emit a watertight
tapered ribbon (triangle strip) with width =
girthalong the chain.
Per-vertex compliance is stored (0 at the root → 1 at the tips) and
drives the wind: tips sway, the trunk stays still.
LeafGenerator.populate( nodes, hormones, palette ):
-
Never "one leaf per post." A hard cap
maxLeaves = f( foliage01 )(LOD bounds 6–3200: a sprout wears a handful, an oak up to 3200).postsPerLeaf = ceil( totalPosts / maxLeaves ). Because there is no zoom, this is a fixed LOD cap — no split-on-zoom. - Leaves seek terminal nodes (highest
compliance/ most "light"); distributed along branch length with jitter. -
Hue is the site's own canopy green (
canopyHue, ±12° per identity) with small random per-tuft variation — natural foliage, never a data legend. (An earlier design partitioned the crown into per-category hue wedges; real trees don't grow in colour sectors, so categories moved to the meadow wildflowers — A.8.) -
Colour value =
health01/ SEO: green (high) → yellow → red → grey (dead). Size =log( visits ). A very old post → desaturated / curled (dry).
LeafGenerator.update( dt, wind ) eases each leaf's colour and size toward
its target and applies the wind displacement (× per-leaf compliance).
-
BloomEngine— fraction of leaves that flower =bloom01; a leaf with high comment density is promoted to a flower (petals drawn withGraphics).apply( bloom01 )selects the set;update( dt )animates the bloom. -
FlowerField— categories bloom as meadow wildflowers: each category reads as a patch of one species + colour (four hand-drawn species — daisy, poppy, bellflower, cosmos — rasterized once per species+colour combo) growing in the grass around the trunk. One category is already a small cluster;computeFlowerCount()saturates hard (cap 80) so 2000 categories is a flowerbed, never a sprite storm. Every flower is ONE sprite anchored at its stem base, bending with the wind like a single stalk. Layout draws from its own seeded stream (<seed>|flowers).build( opts )thenupdate( dt, t, displace );targets()exposes the flower heads as butterfly waypoints. -
ButterflyLayer— tags do not create branches; they cross-pollinate. Tags render as butterflies working the category wildflowers: flying flower to flower, perching with slow wing-pumps, banking with their own flight.computeButterflyCount( totalTags )saturates at 8; wing-colour variety grows with the population. One sprite each — the flap is ascale.xfold along the body axis. They live inside the tree body, so night dims them exactly as the fireflies wake: butterflies by day, fireflies by night. Colours and first perches draw from the seeded PRNG; the wandering itself isMath.random(), like the fireflies — flight is live behaviour, not DNA.populate( totalTags, targets, roam, rng )thenupdate( dt, t ). -
GroundLayer— the meadow: soil mounds (soft gradient sprites), a contact shadow, a full-coverage turf of thousands of individually- drawn grass blades (STATIC — tessellated once into a single Graphics; see the performance note), and a few fallen leaves near the trunk. Grass colour runs through the sameleafColor()health ramp as the canopy — poor SEO dries the lawn too. Layout draws from its own seeded stream (<seed>|ground), stable per site and isolated from the skeleton's PRNG. -
IvyLayer— pages render as trunk ivy: small dark evergreen leaves on exactly the thick wood the canopy's leaf placer disqualifies, climbing bottom→up withstructure01. Pure decoration; replaced an earlier pages→girth channel that read as arbitrary trunk fatness. -
WindField.sample( x, y, t )— sum of 2–3 sines (gust + breeze) scaled bywind01, applied per vertex / per leaf multiplied bycompliance(tips sway, trunk still). Non-interactive. Reduced-motion →wind01 = 0, static frame.setStrength( w01 )retunes live. -
FireflyLayer— sparkSprites in an ordinaryContainerwith additive glow, drifting through the canopy; the count follows live presence.setCount( n )thenupdate( dt ). -
FallingLeaves— every few seconds one leaf detaches from a real canopy position (same tint/size as the tuft it left), tumbles down through the wind, and fades into the grass. Sparse by design (≤5 airborne). Ambient motion like the fireflies —Math.random()timing, no DNA involvement, nothing falls under reduced motion.
With age01 ≈ 0, vigor01 ≈ 0, foliage ≈ 0: the envelope is minimal, SCA
emits 2–3 nodes, a handful of leaves. Literally a sprout. This is guaranteed
because the floor of the envelope IS a sprout, and Space Colonization over
a tiny envelope still yields a little-tree shape — never an empty canvas,
never a stray diagram.
if growing:
sim.step( min( budget, growthRate ) ) // adds branches bottom→top until the envelope fills
girth.update()
leaves.update( dt, wind ) // colour / size ease toward target
bloom.update( dt )
flowerField.update( dt ) // wildflower sway (30 Hz with the canopy)
butterflies.update( dt ) // full rate — the wing-flap needs it
wind.apply( t ) // displace FOLIAGE by compliance · windField (wood + turf stay static — see notes)
fireflies.update( dt )
renderer.draw()
Growth runs once (sprout → settled). After that, only wind +
butterflies + fireflies + slow colour easing animate. On a data refresh (trackedFetch re-poll) the
hormones re-point and the decoration re-eases; the skeleton only re-grows if
age crosses a level threshold (A.4).
ground · wildflowers · branches (ribbon mesh) · leaves (Container of Sprites) · blossom · butterflies · fireflies (additive)
No camera zoom / pan. Fixed fit to the desktop. A ResizeObserver re-fits the
envelope on resize (the envelope re-fits; the seed and therefore the
skeleton topology are unchanged).
The wallpaper fetches a TreeSnapshot from
GET desktop-mode/v1/living-tree/snapshot (see
includes/living-tree/snapshot.php). The snapshot is compact DNA, not the
database: aggregate counts and a branches hint — never the full post
list. The client turns the snapshot into hormones and never sees
individual rows.
Permission defaults to current_user_can( 'read' ), filterable via
openstation_living_tree_user_can_use. The response is cached in a transient
under one fixed key, desktop_mode_living_tree_snapshot (TTL 6h) —
the value keeps its pre-rebrand spelling deliberately, so live caches
stay addressable. Freshness comes from delete_transient() on
save_post / deleted_post / comment_post, not from a
content-signature key.
-
Canonical skeleton + reveal. The mature skeleton is grown to
completion at mount (a pure function of the seed, ~tens of ms);
revealSkeleton()then exposes the age-gated prefix. The growth animation is a staged reveal in original growth order (bottom → top), paced bygrowthRate = f( vigor01 )— pacing never changes what the tree looks like, only how fast it appears. -
Envelope purity.
buildEnvelope()acceptsage01/vigor01for signature stability but deliberately uses NEITHER for shape. Both the invariance suite and the gradual-growth suite fail if this regresses. - Anti-dive. Late in a run only low leftover attractors remain and branches would chase them downward. Two guards: a direction clamp (steeper than a gentle sag gets damped hard) and stall-pruning (unreachable low attractors are dropped rather than chain-chased).
-
Branch rendering. Tapered rounded strokes into one
Graphics, redrawn only while the skeleton changes (growth / tuner regrow) — NEVER per frame. The wood and the turf are static at steady state AND baked withcacheAsTexture(one textured quad each per frame instead of thousands of vertices); the bake is released during regrowth and re-applied when the tree settles. Re-tessellating the ribbon skeleton every frame (and rotating hundreds of grass-clump containers) was the wallpaper's whole CPU bill. The foliage carries the wind — leaves, blossom, wildflowers, butterflies, fireflies are sprite-transform updates the GPU batches cheaply, with the big loops (canopy, blossom, wildflowers) ticking at 30 Hz. Measured on the software rasterizer (worst case): 133 ms → 33 ms per frame. Colour lightens with compliance (dark trunk → warm extremities). -
Decoration randomness. The skeleton and leaf/bloom/wildflower
placement draw from the seeded PRNG (same site → same canopy and
meadow). Firefly wander and butterfly flight use
Math.random()— live behaviour, not DNA. -
Health sources.
performancereads core's own Site Health tallies (the weekly-cronhealth-check-site-status-resulttransient: 1.0 minus 0.15 per critical and 0.04 per recommendation, clamped to [0.2, 1]), falling back to 0.8 until the first tally exists.seoHealthis the remaining gap: WordPress ships nothing SEO-shaped to read, so PHP serves a healthy 0.7 default — the planned future source is aggregating the per-post scores SEO plugins keep in post-meta. Both pass through theiropenstation_living_tree_seo_health/openstation_living_tree_performancefilters so monitoring / SEO plugins can feed real telemetry in. -
Time-of-day sky. The backdrop tracks the viewer's LOCAL clock
through a 24-hour cycle (
src/plugins/living-tree-wallpaper/sky.ts): starry night → pre-dawn → sunrise → midday → golden afternoon → dusk. A sun disc arcs the day sky, a moon the night, and a star field fades in after dark. The same cycle yields an ambientlight01that dims the whole tree at night and brightens it by day; fireflies run the opposite way (bright at night, invisible in daylight).skyForTime()is pure;new Date()is read only at the scene boundary. Debug override:window.openStationLivingTreeHourOverride = <0..24>forces a specific hour. -
Hidden DNA tuner (developer mode only). With OpenStation Preferences →
Features → Enable developer mode ON, clicking the trunk 20 times
(gaps under 2.5 s) opens a slider panel over every snapshot metric —
age, posts, pages, terms, comments, presence, traffic, health,
performance — regrowing the tree instantly on each drag, plus a
time-of-day slider (0:00–24:00) that scrubs the sky/luminosity
cycle live; its
livebutton (and closing the panel) hands the clock back to local time. Client-side preview only: nothing is persisted, the server snapshot is untouched, and the panel never appears while developer mode is off. Source:src/plugins/living-tree-wallpaper/debug-panel.ts.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config