sprout-garden-architecture
Sprout Garden: Technical Architecture
A modular, auto-wiring game engine built on middleware patterns.
Design Goals
- Not a monolith - No 10,000-line files
- Drop-in plots - Add a file, it's available
- Auto-wiring - Minimal manual registration
- Same shape everywhere - Everything is
seed → grow → fruit - Maintainable - Small files, clear separation
- Testable - Pure functions, no DOM in logic
- Works with Vite - Already in the project
Core Architecture
The Three Layers
┌─────────────────────────────────────────────────────────┐
│ PRESENTATION │
│ (Renderer, UI, Animation, Sound) │
├─────────────────────────────────────────────────────────┤
│ SIMULATION │
│ (Garden State, Tick Loop, Path Builder) │
├─────────────────────────────────────────────────────────┤
│ PLOTS (Middleware) │
│ (Auto-loaded transformers, all same shape) │
└─────────────────────────────────────────────────────────┘Data flows down. Events flow up. Logic stays in the middle.
File Structure
sprout-garden/
├── index.html
├── vite.config.js
│
├── src/
│ ├── main.js # Bootstrap only
│ │
│ ├── core/ # The engine (reusable)
│ │ ├── registry.js # Auto-loads plots
│ │ ├── garden.js # Garden state management
│ │ ├── sprout.js # Sprout state & helpers
│ │ ├── simulation.js # Tick loop, path builder
│ │ └── constants.js # Directions, cycles
│ │
│ ├── plots/ # DROP-IN PLOT TYPES
│ │ ├── index.js # Auto-loader (import.meta.glob)
│ │ └── types/
│ │ ├── vine.js # → ↓ ← ↑
│ │ ├── form-grower.js # ⚙️
│ │ ├── color-bloomer.js # 🎨
│ │ ├── spring.js # 🌱
│ │ ├── harvest.js # 🎯
│ │ └── ... # Just add files here
│ │
│ ├── levels/ # Level definitions
│ │ ├── index.js # Auto-loader
│ │ └── phase-1/
│ │ ├── level-01.js
│ │ ├── level-02.js
│ │ └── ...
│ │
│ ├── renderer/ # Presentation layer
│ │ ├── garden-renderer.js # Grid drawing
│ │ ├── sprout-renderer.js # Sprout animation
│ │ ├── ui.js # Palette, buttons, Professor Hoot
│ │ └── sound.js # Audio (optional)
│ │
│ └── styles/
│ └── garden.css
│
└── tests/
├── plots/
│ └── form-grower.test.js
└── simulation.test.jsAuto-Wiring: Plot Registry
The magic that makes "drop a file, it works":
// src/plots/index.js
const plotModules = import.meta.glob('./types/*.js', { eager: true });
export const plots = {};
for (const [path, module] of Object.entries(plotModules)) {
const plot = module.default;
if (plot && plot.id) {
plots[plot.id] = plot;
console.log(`Registered plot: ${plot.id}`);
}
}
export function getPlot(id) {
return plots[id];
}
export function getAllPlots() {
return Object.values(plots);
}That's it. Vite's import.meta.glob finds all .js files in /types/, imports them, and we register by id. No manifest. No switch statement.
Plot Definition Shape
Every plot is the same shape. No exceptions.
// src/plots/types/form-grower.js
import { SHAPE_CYCLE, nextInCycle } from '../../core/constants.js';
export default {
// Identity
id: 'form-grower',
name: 'Form Grower',
symbol: '⚙️',
category: 'grower',
phase: 1, // When it unlocks
// Flow control
seeds: ['left', 'right', 'up', 'down'], // Accepts from
fruits: ['pass-through'], // Emits to (same direction it came)
// The transform (PURE FUNCTION)
grow(sprout) {
return {
...sprout,
shape: nextInCycle(sprout.shape, SHAPE_CYCLE)
};
},
// Optional: validation
canGrow(sprout) {
return sprout !== null;
},
// Optional: for grafters, gates, etc.
seedSlots: 1, // How many inputs needed before grow() fires
};The Contract
Every plot MUST have:
id- unique stringsymbol- emoji for displayseeds- array of directions it accepts fromfruits- array of directions it emits to (or'pass-through')grow(sprout)- pure function, returns new sprout (or null to block)
Everything else is optional metadata.
Data Structures
Garden State
// Flat object, coordinate keys for sparse grids
const garden = {
id: 'level-03',
size: { width: 6, height: 5 },
// Plots by position
plots: {
'A1': { type: 'spring', config: { emits: { shape: 'circle', color: 'red' } } },
'B1': { type: 'vine-right' },
'C1': { type: 'form-grower' },
'D1': { type: 'vine-right' },
'E1': { type: 'harvest', config: { expects: { shape: 'square', color: 'red' } } }
}
};
// Helper functions
function getPlotAt(garden, col, row) {
const key = `${String.fromCharCode(65 + row)}${col + 1}`; // A1, B2, etc.
return garden.plots[key];
}
function setPlotAt(garden, col, row, plot) {
const key = `${String.fromCharCode(65 + row)}${col + 1}`;
return {
...garden,
plots: { ...garden.plots, [key]: plot }
};
}Why flat object?
- Easy to serialize (JSON, localStorage, URL)
- Sparse grids are efficient
- Simple key lookup
- Immutable-friendly
Sprout State
const sprout = {
id: 'sprout-001',
// Current state
shape: 'circle', // circle | square | triangle | star
color: 'red', // red | green | blue | yellow
// Position
position: 'C1',
direction: 'right', // Which way it came from (for pass-through)
// Execution state
status: 'transit', // transit | arrived | lost | looping
// History (for debugger/replay)
lineage: [
{ tick: 0, plot: 'A1', action: 'emit', shape: 'circle', color: 'red' },
{ tick: 1, plot: 'B1', action: 'pass' },
{ tick: 2, plot: 'C1', action: 'grow', before: 'circle', after: 'square' }
]
};Simulation Engine
The Tick Loop (No Sprout Left Behind + Hot Potato)
// src/core/simulation.js
import { plots } from '../plots/index.js';
import { getNeighbor, oppositeDirection } from './constants.js';
export class Simulation {
constructor(garden) {
this.garden = garden;
this.sprouts = []; // Active sprouts
this.tick = 0;
this.history = []; // Full execution history
}
// Initialize from springs
emitFromSprings() {
for (const [pos, plotData] of Object.entries(this.garden.plots)) {
if (plotData.type === 'spring') {
const sprout = this.createSprout(pos, plotData.config.emits);
this.sprouts.push(sprout);
}
}
}
// One tick of execution
executeTick() {
this.tick++;
const results = [];
// HOT POTATO: Everyone with a seed processes
for (const sprout of this.sprouts) {
if (sprout.status !== 'transit') continue;
const plotData = this.garden.plots[sprout.position];
const plotDef = plots[plotData.type];
// Grow
const grown = plotDef.grow(sprout);
if (grown === null) {
// Blocked (gate, etc.)
sprout.status = 'blocked';
sprout.lineage.push({ tick: this.tick, plot: sprout.position, action: 'block' });
continue;
}
// Update sprout
Object.assign(sprout, grown);
sprout.lineage.push({
tick: this.tick,
plot: sprout.position,
action: 'grow',
shape: sprout.shape,
color: sprout.color
});
// Find next position
const fruitDirs = this.getFruitDirections(plotDef, sprout.direction);
for (const dir of fruitDirs) {
const nextPos = getNeighbor(sprout.position, dir);
const nextPlot = this.garden.plots[nextPos];
if (!nextPlot) {
// Fell off edge
sprout.status = 'lost';
sprout.lineage.push({ tick: this.tick, plot: sprout.position, action: 'fall' });
} else if (nextPlot.type === 'harvest') {
// Arrived!
sprout.status = 'arrived';
sprout.position = nextPos;
sprout.lineage.push({ tick: this.tick, plot: nextPos, action: 'arrive' });
} else {
// Move to next plot
sprout.position = nextPos;
sprout.direction = oppositeDirection(dir);
}
}
}
// NO SPROUT LEFT BEHIND: Tick complete when all processed
this.history.push({
tick: this.tick,
sprouts: this.sprouts.map(s => ({ ...s }))
});
return this.sprouts.every(s => s.status !== 'transit');
}
// Run until complete
runToCompletion(maxTicks = 100) {
this.emitFromSprings();
while (this.tick < maxTicks) {
const complete = this.executeTick();
if (complete) break;
}
return {
success: this.sprouts.every(s => s.status === 'arrived'),
sprouts: this.sprouts,
history: this.history,
ticks: this.tick
};
}
// Helpers
getFruitDirections(plotDef, incomingDir) {
if (plotDef.fruits.includes('pass-through')) {
return [oppositeDirection(incomingDir)];
}
return plotDef.fruits;
}
createSprout(position, state) {
return {
id: `sprout-${Date.now()}-${Math.random().toString(36).slice(2)}`,
...state,
position,
direction: null,
status: 'transit',
lineage: [{ tick: 0, plot: position, action: 'emit', ...state }]
};
}
}Renderer (Separate from Logic)
// src/renderer/garden-renderer.js
import { plots } from '../plots/index.js';
export class GardenRenderer {
constructor(container, cellSize = 60) {
this.container = container;
this.cellSize = cellSize;
this.grid = null;
this.sproutElements = new Map();
}
// Render static garden (plots, grid)
renderGarden(garden) {
this.container.innerHTML = '';
this.grid = document.createElement('div');
this.grid.className = 'garden-grid';
this.grid.style.gridTemplateColumns = `repeat(${garden.size.width}, ${this.cellSize}px)`;
// Create cells
for (let row = 0; row < garden.size.height; row++) {
for (let col = 0; col < garden.size.width; col++) {
const key = `${String.fromCharCode(65 + row)}${col + 1}`;
const cell = this.createCell(key, garden.plots[key]);
this.grid.appendChild(cell);
}
}
this.container.appendChild(this.grid);
}
createCell(key, plotData) {
const cell = document.createElement('div');
cell.className = 'garden-cell';
cell.dataset.position = key;
if (plotData) {
const plotDef = plots[plotData.type];
cell.innerHTML = `<span class="plot-symbol">${plotDef.symbol}</span>`;
cell.classList.add(`plot-${plotData.type}`);
}
return cell;
}
// Render/animate sprout
renderSprout(sprout) {
let el = this.sproutElements.get(sprout.id);
if (!el) {
el = document.createElement('div');
el.className = 'sprout';
this.container.appendChild(el);
this.sproutElements.set(sprout.id, el);
}
// Update appearance
el.innerHTML = this.getSproutSymbol(sprout);
el.style.setProperty('--color', sprout.color);
// Position (CSS will animate)
const cell = this.grid.querySelector(`[data-position="${sprout.position}"]`);
if (cell) {
const rect = cell.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
el.style.left = `${rect.left - containerRect.left + this.cellSize/2}px`;
el.style.top = `${rect.top - containerRect.top + this.cellSize/2}px`;
}
}
getSproutSymbol(sprout) {
const shapes = { circle: '●', square: '■', triangle: '▲', star: '★' };
return shapes[sprout.shape] || '●';
}
// Animate through history
async playHistory(history, stepDelay = 500) {
for (const frame of history) {
for (const sprout of frame.sprouts) {
this.renderSprout(sprout);
}
await this.delay(stepDelay);
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}Level Definition
// src/levels/phase-1/level-03.js
export default {
id: 'p1-03',
name: 'First Growth',
phase: 1,
grid: { width: 6, height: 5 },
goal: {
description: 'Turn the circle into a square',
input: { shape: 'circle', color: 'red', count: 1 },
output: { shape: 'square', color: 'red', count: 1 }
},
// What plots are available
palette: ['spring', 'harvest', 'vine-right', 'vine-down', 'vine-left', 'vine-up', 'form-grower', 'weeder'],
// Pre-placed plots (optional)
prePlaced: {},
// Constraints
constraints: {
maxPlots: null
},
// Professor Hoot's hints
hints: [
"Hoo! This garden needs something to change shapes...",
"Hoo hoo! The gear machine (⚙️) ripens forms...",
"Try: spring → vine → gear → vine → harvest"
],
// Par scores
par: {
plots: 5,
ticks: 5
}
};Bootstrap
// src/main.js
import './plots/index.js'; // Auto-registers all plots
import { Simulation } from './core/simulation.js';
import { GardenRenderer } from './renderer/garden-renderer.js';
import { loadLevel } from './levels/index.js';
// DOM
const container = document.getElementById('garden');
const tendButton = document.getElementById('tend');
const resetButton = document.getElementById('reset');
// State
let currentLevel = null;
let currentGarden = null;
let renderer = null;
// Load level
async function startLevel(levelId) {
currentLevel = await loadLevel(levelId);
currentGarden = createGardenFromLevel(currentLevel);
renderer = new GardenRenderer(container);
renderer.renderGarden(currentGarden);
}
// Run simulation
async function tend() {
const sim = new Simulation(currentGarden);
const result = sim.runToCompletion();
// Animate
await renderer.playHistory(result.history);
// Check win
if (result.success) {
showVictory(result);
}
}
// Wire up
tendButton.addEventListener('click', tend);
resetButton.addEventListener('click', () => startLevel(currentLevel.id));
// Start
startLevel('p1-01');Adding a New Plot Type
Just create a file:
// src/plots/types/color-bloomer.js
import { COLOR_CYCLE, nextInCycle } from '../../core/constants.js';
export default {
id: 'color-bloomer',
name: 'Color Bloomer',
symbol: '🎨',
category: 'bloomer',
phase: 1,
seeds: ['left', 'right', 'up', 'down'],
fruits: ['pass-through'],
grow(sprout) {
return {
...sprout,
color: nextInCycle(sprout.color, COLOR_CYCLE)
};
}
};That's it. No registration code. No imports to update. No switch statements. The import.meta.glob in /plots/index.js picks it up automatically.
Testing
// tests/plots/form-grower.test.js
import { describe, test, expect } from 'vitest';
import formGrower from '../../src/plots/types/form-grower.js';
describe('Form Grower', () => {
test('ripens circle to square', () => {
const input = { shape: 'circle', color: 'red' };
const output = formGrower.grow(input);
expect(output.shape).toBe('square');
});
test('ripens star back to circle (cycle)', () => {
const input = { shape: 'star', color: 'red' };
const output = formGrower.grow(input);
expect(output.shape).toBe('circle');
});
test('preserves color', () => {
const input = { shape: 'circle', color: 'blue' };
const output = formGrower.grow(input);
expect(output.color).toBe('blue');
});
test('is pure (no mutation)', () => {
const input = { shape: 'circle', color: 'red' };
formGrower.grow(input);
expect(input.shape).toBe('circle');
});
});Why NOT Use
Entity-Component-System (ECS)
- Overkill for ~1-10 sprouts
- Adds complexity without benefit
- Consider later if we have 50+ concurrent sprouts
Full Game Engine (Phaser, PixiJS)
- We don't need physics
- We don't need complex sprites
- DOM/CSS is fine for tile puzzles
- Keeps bundle small
React/Vue/Svelte
- Could use, but adds build complexity
- Vanilla JS is simpler for this scale
- CSS handles the animation we need
Evolution Path
Phase 1 (Now): Single sprout, basic plots, step-by-step animation
Phase 2: Multiple sprouts, forks/grafters, tick-based animation
Phase 3: Debugger overlay, replay, production dashboard mode
Phase 4: Sound, music, Professor Hoot animations
Phase 5: Level editor, save/load, sharing
Tags
sprout-garden, architecture, technical-design, auto-wiring, middleware, vite
Slots
North
slots:
- sprout-gardenSouth
slots: []East
slots: []West
slots: []