lantern

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

Auto-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 string
  • symbol - emoji for display
  • seeds - array of directions it accepts from
  • fruits - 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-garden

South

slots: []

East

slots: []

West

slots: []
↑ northsprout-garden