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
- Time progression: Advances
timeAccumulator(0-24 hours) each frame - Day transitions: When time crosses 23:00, advances to the next day
- GDD calculation: Computes daily GDD using the formula
max(0, avgTemp - baseTemp) - Rainfall tracking: Accumulates precipitation from weather data
- 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:
- Resets per-frame values:
gddToApplyThisFrame = 0,rainToApplyThisFrame = 0 - Advances time:
timeAccumulator += deltaTime - Checks for day transition: If time crosses 23:00, calls
advanceDay() - 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:
- Reads GDD from weather:
const gddToAdd = weather.gddToApplyThisFrame - Skips if no GDD: If
gddToAdd <= 0, returns early (no growth) - Iterates all field tiles: Loops through the 300×300 grid
- Updates seeded crops: For tiles with
stage === CROP_STAGES.SEEDED:- Adds GDD:
fieldTile["currentGDD"] += gddToAdd - Checks maturity: If
currentGDD >= requiredGDD, setsstage = CROP_STAGES.MATURE - Writes back:
nextField.setTile(j, i, fieldTile)
- Adds GDD:
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:
- Calculate turn amount:
diff = goalAngle - angle - Check if turning:
isTurning = abs(diff) > 0.1 - Apply turn: Interpolate angle toward goal using
turnSpeed * deltaTime - Calculate movement:
moveDistance = basespeed * deltaTime(if moving or turning) - 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():
- Get tiles under header: Uses
getTilesCurrentlyOver()to sample 10 points across the header width - Check each tile:
- If
isMature(tile): Add yield score, reset tile to UNPLANTED - If
isGrowing(tile): Reset tile (damaged crop, no score)
- If
- Write back:
field.setTile(x, y, tile)
Seeding Logic
When isSeedingOn === true, the manager calls handleSeeding():
- Get tiles under seeder: Uses
getTilesCurrentlyOver()with negative offset (seeder is behind tractor) - Check each tile:
- If
isUnplanted(tile): Callplant(cropBeingPlanted, tile)to set stage to SEEDED
- If
- 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:
- WeatherSimManager writes
gddToApplyThisFrameandrainToApplyThisFrame - CropSimManager reads
gddToApplyThisFrameto grow crops - TractorSimManager reads vehicle states and writes field tiles (harvesting/seeding)
- SoilMoistureSimManager (example) reads
rainToApplyThisFrameand writeswaterLevel
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:
- Blockly Blocks Reference: See how student code triggers manager actions
- Rendering Pipeline: Understand how manager state is visualized on screen
- Worker Command System: See how Blockly-generated commands flow from blocks to state changes