Skip to Content
🏗️ Core ArchitectureSimulation Managers

Last Updated: 4/7/2026


Simulation Managers

Simulation managers are the domain-specific logic engines that power Agricultural Microworlds. Each manager implements the abstract SimManager base class and calculates updates for a specific aspect of the simulation—weather, crops, or tractor movement.

The SimManager Pattern

All simulation managers extend the SimManager base class (located in src/Simulation/SimManager.js):

export default class SimManager { constructor() {} update(deltaTime, oldState, newState) { if (deltaTime < 0 || !oldState || !newState) { console.warn("SimManager: Invalid update arguments provided."); } throw new Error("Method 'update()' must be implemented."); } }

The update() method is called every frame by the simulation engine. It receives:

  • deltaTime: Elapsed simulated time since the last frame (in seconds)
  • oldState: Read-only snapshot of the previous frame’s state
  • newState: Mutable working copy for this frame’s calculations

Managers read from oldState and write to newState, ensuring they don’t interfere with each other.

WeatherSimManager

Manages weather progression, GDD (growing degree days) accumulation, rainfall tracking, and K-State Mesonet data integration.

File: src/Simulation/SimManagers/WeatherSimManager.js

Key Responsibilities

  1. Time progression: Advances timeAccumulator (0-24 hours) each frame
  2. Day transitions: When time crosses 23:00, advances to the next day
  3. GDD calculation: Computes daily GDD using the formula max(0, avgTemp - baseTemp)
  4. Rainfall tracking: Accumulates precipitation from weather data
  5. Data caching: Stores fetched K-State Mesonet data for reuse

Configuration

constructor() { super(); this.WHEAT_BASE_TEMP = 10; // Base temperature for GDD calculation (°C) this.weatherDataCache = null; }

The base temperature of 10°C is standard for wheat. Different crops use different base temps (corn: 10°C, soybeans: 10°C).

K-State Mesonet Integration

The loadWeatherData(stateManager, stationId, startDateString) method fetches real weather data:

const url = `https://mesonet.k-state.edu/rest/stationdata?stn=${stationId}&int=day&t_start=${startFmt}000000&t_end=${endFmt}000000&vars=TEMP2MAVG,PRECIP`;

Parameters:

  • stationId: Weather station name (e.g., “Flickner”)
  • startDateString: Start date in YYYY-MM-DD format
  • Fetches 365 days of data (TEMP2MAVG = average temperature, PRECIP = precipitation)

The data is cached in weatherDataCache and applied to the weather state:

this.weatherDataCache = { csvLines: parsedCsv, // Array of [timestamp, station, temp, precip] startDate: startDate };

Update Logic

Each frame, the manager:

  1. Resets per-frame values: gddToApplyThisFrame = 0, rainToApplyThisFrame = 0
  2. Advances time: timeAccumulator += deltaTime
  3. Checks for day transition: If time crosses 23:00, calls advanceDay()
  4. Wraps time: If time >= 24:00, subtracts 24 to start a new day

Day Advancement

When a new day begins, advanceDay() processes the next row of weather data:

advanceDay(oldWeather, newWeather) { const dayData = oldWeather.csvLines[oldWeather.currentDayIndex]; const temp = parseFloat(dayData[2]); // Average temperature const rain = parseFloat(dayData[3]); // Precipitation const dailyGDD = Math.max(0, temp - this.WHEAT_BASE_TEMP); newWeather.cumulativeRain = oldWeather.cumulativeRain + rain; newWeather.cumulativeGDD = oldWeather.cumulativeGDD + dailyGDD; newWeather.currentDayIndex = oldWeather.currentDayIndex + 1; newWeather.gddToApplyThisFrame = dailyGDD; newWeather.rainToApplyThisFrame = rain; }

The gddToApplyThisFrame value is read by CropSimManager to grow crops.

Speed Multiplier

The weather state’s getSpeedMultiplier() method controls simulation speed:

getSpeedMultiplier() { if (this.isWaiting) return this.speedMultiplier * 6.0; return this.speedMultiplier; }

When waitXWeeks() is active, the simulation runs 6× faster to quickly advance time.

CropSimManager

Handles crop growth based on accumulated GDD. Reads weather data and updates field tiles.

File: src/Simulation/SimManagers/CropSimManager.js

Update Logic

Each frame, the manager:

  1. Reads GDD from weather: const gddToAdd = weather.gddToApplyThisFrame
  2. Skips if no GDD: If gddToAdd <= 0, returns early (no growth)
  3. Iterates all field tiles: Loops through the 300×300 grid
  4. Updates seeded crops: For tiles with stage === CROP_STAGES.SEEDED:
    • Adds GDD: fieldTile["currentGDD"] += gddToAdd
    • Checks maturity: If currentGDD >= requiredGDD, sets stage = CROP_STAGES.MATURE
    • Writes back: nextField.setTile(j, i, fieldTile)

Example Flow

// Day 1: Plant corn (requiredGDD = 1300) tile = { stage: SEEDED, type: CORN, currentGDD: 0, requiredGDD: 1300 } // Day 2: Weather adds 15 GDD tile.currentGDD = 0 + 15 = 15 // Day 50: Weather adds 20 GDD tile.currentGDD = 1280 + 20 = 1300 tile.stage = MATURE // Crop is ready to harvest!

Performance Optimization

The manager only processes tiles when gddToApplyThisFrame > 0, avoiding unnecessary loops on frames without day transitions. This reduces CPU usage from ~10% to <1% on non-day-change frames.

TractorSimManager

Manages vehicle movement, turning, collision detection, and farm operations (harvesting, seeding).

File: src/Simulation/SimManagers/TractorSimManager.js

Configuration

constructor() { super(); this.TILE_WIDTH = 8; this.TILE_HEIGHT = 8; this.HEADER_OFFSET = 20; // Distance from tractor center to header this.HEADER_WIDTH = 64; // Width of harvester/seeder header this.FIELD_COLS = 300; this.SPRITE_SIZE = 64; this.COLLISION_RADIUS = 28; // Collision detection radius this.cameraX = 0; this.cameraY = 0; this.activeVehicleCamera = VEHICLES.HARVESTER; }

Movement Logic

Each frame, for each vehicle:

  1. Calculate turn amount: diff = goalAngle - angle
  2. Check if turning: isTurning = abs(diff) > 0.1
  3. Apply turn: Interpolate angle toward goal using turnSpeed * deltaTime
  4. Calculate movement: moveDistance = basespeed * deltaTime (if moving or turning)
  5. Update position:
    const rad = (angle * Math.PI) / 180; x += Math.cos(rad) * moveDistance; y += Math.sin(rad) * moveDistance;

Harvesting Logic

When isHarvestingOn === true, the manager calls handleHarvesting():

  1. Get tiles under header: Uses getTilesCurrentlyOver() to sample 10 points across the header width
  2. Check each tile:
    • If isMature(tile): Add yield score, reset tile to UNPLANTED
    • If isGrowing(tile): Reset tile (damaged crop, no score)
  3. Write back: field.setTile(x, y, tile)

Seeding Logic

When isSeedingOn === true, the manager calls handleSeeding():

  1. Get tiles under seeder: Uses getTilesCurrentlyOver() with negative offset (seeder is behind tractor)
  2. Check each tile:
    • If isUnplanted(tile): Call plant(cropBeingPlanted, tile) to set stage to SEEDED
  3. Write back: field.setTile(x, y, tile)

Tile Sampling

The getTilesCurrentlyOver() generator function samples points across the header:

*getTilesCurrentlyOver(tractor, field, offset) { const centerX = tractor.x + 32; const centerY = tractor.y + 32; const rad = (tractor.angle * Math.PI) / 180; const frontX = centerX + Math.cos(rad) * offset; const frontY = centerY + Math.sin(rad) * offset; const pSin = Math.sin(rad); const pCos = Math.cos(rad); const pointsToCheck = 10; for (let i = 0; i < pointsToCheck; i++) { const t = i / (pointsToCheck - 1) - 0.5; const offset = t * this.HEADER_WIDTH; const checkX = frontX - pSin * offset; const checkY = frontY + pCos * offset; const targetCrop = this.getTileAtLocation(checkX, checkY, field); if (targetCrop) yield targetCrop; } }

This samples 10 evenly-spaced points perpendicular to the tractor’s heading, covering the full 64-pixel header width.

Collision Detection

The manager checks for vehicle-to-vehicle collisions:

checkVehicleCollisions(newState) { const vehicles = newState.vehicles; if (!vehicles || vehicles.length < 2) return false; for (let i = 0; i < vehicles.length; i++) { for (let j = i + 1; j < vehicles.length; j++) { const result = this.areVehiclesColliding(vehicles[i], vehicles[j]); if (result.collided) { newState.isGameOver = true; newState.crash = { x: result.x, y: result.y }; return true; } } } return false; }

Collision uses circle-circle detection with a 28-pixel radius. If vehicles collide, isGameOver is set to true, stopping the simulation.

Camera Tracking

The updateCameraCoordinates() method centers the camera on the active vehicle:

updateCameraCoordinates(implement, fieldWidth, canvasWidth, canvasHeight) { const tractorX = implement.x + 32; const tractorY = implement.y + 32; let targetX = tractorX - canvasWidth / 2; let targetY = tractorY - canvasHeight / 2; const maxCameraX = fieldWidth * this.TILE_WIDTH - canvasWidth; const maxCameraY = fieldWidth * this.TILE_WIDTH - canvasHeight; targetX = Math.min(targetX, maxCameraX); targetY = Math.min(targetY, maxCameraY); this.cameraX = Math.max(-150, targetX); this.cameraY = Math.max(0, targetY); }

The camera is clamped to keep the field visible (no scrolling past edges).

Manager Registration

Managers are registered in the simulationEngine constructor:

this.managers = [ new WeatherManager(), new CropManager(), new TractorManager() ];

The engine calls update() on each manager in order every frame.

Implementing a New SimManager

Here’s a complete example of adding a soil moisture manager:

import SimManager from '../SimManager'; import { CROP_STAGES } from '../../States/StateClasses/CropState'; export default class SoilMoistureSimManager extends SimManager { constructor() { super(); this.EVAPORATION_RATE = 0.5; // Water lost per second this.RAIN_ABSORPTION = 10; // Water gained per mm of rain } update(deltaTime, oldState, newState) { const weather = oldState.weather; const field = newState.field; if (!weather || !field) return; // Get rainfall for this frame const rainfall = weather.rainToApplyThisFrame || 0; // Update all tiles for (let y = 0; y < field.rows; y++) { for (let x = 0; x < field.columns; x++) { const tile = field.getTileAt(x, y); // Add rainfall tile.waterLevel += rainfall * this.RAIN_ABSORPTION; // Evaporation (faster for bare soil) if (tile.stage === CROP_STAGES.UNPLANTED) { tile.waterLevel -= this.EVAPORATION_RATE * deltaTime * 2; } else { tile.waterLevel -= this.EVAPORATION_RATE * deltaTime; } // Growing crops consume water if (tile.stage === CROP_STAGES.SEEDED) { tile.waterLevel -= deltaTime * 0.3; } // Clamp to valid range tile.waterLevel = Math.max(0, Math.min(1000, tile.waterLevel)); // Write back field.setTile(x, y, tile); } } } }

To activate it, add to the managers array:

this.managers = [ new WeatherManager(), new CropManager(), new TractorManager(), new SoilMoistureSimManager() // Your new manager ];

Manager Coordination

Managers coordinate through shared state:

  1. WeatherSimManager writes gddToApplyThisFrame and rainToApplyThisFrame
  2. CropSimManager reads gddToApplyThisFrame to grow crops
  3. TractorSimManager reads vehicle states and writes field tiles (harvesting/seeding)
  4. SoilMoistureSimManager (example) reads rainToApplyThisFrame and writes waterLevel

The immutable commit pattern ensures all managers see the same old state, preventing race conditions.

Performance Considerations

Early returns: Managers should return early when there’s no work to do (e.g., if (gddToAdd <= 0) return)

Tile iteration: Looping through 90,000 tiles (300×300) is expensive. Only iterate when necessary (e.g., on day transitions, not every frame).

Generator functions: getTilesCurrentlyOver() uses a generator to avoid allocating arrays for tile samples.

Collision checks: O(n²) collision detection is acceptable for 2 vehicles but would need spatial partitioning for 10+.

What’s Next

Now that you understand how simulation managers work: