Skip to Content
🏗️ Core ArchitectureState Management System

Last Updated: 4/7/2026


State Management System

The state management system is the foundation of Agricultural Microworlds’ simulation architecture. It uses an immutable commit pattern to ensure SimManagers can read and write state without interfering with each other, preventing race conditions and order-of-execution bugs.

The StateManager

The StateManager class (located in src/States/StateManager.js) is a simple container that holds all simulation state in a single states object. It provides three core methods:

export class StateManager { constructor() { this.states = {}; } // Register a new state type initState(key, initialState) { this.states[key] = initialState; } // Get the current state of an object getState(key) { return this.states[key]; } // Save the new calculation as the official state for the next frame commitState(key, newState) { this.states[key] = newState; } }

This API enforces a clear pattern: initialize once, read many times, commit once per frame.

The Immutable Commit Pattern

Every frame, the simulation engine follows this sequence:

  1. Read old states: All managers read from this.stateManager.states
  2. Clone states: Each state object is cloned to create a working copy
  3. Managers update: Each manager reads from oldStates and writes to nextStates
  4. Commit new states: The engine calls commitState(key, nextStates[key]) for each state

This pattern ensures:

  • No race conditions: Managers can’t overwrite each other’s changes mid-frame
  • Order independence: Managers can run in any order without affecting results
  • Predictable state: Each frame starts with a consistent snapshot of the previous frame

Example: Weather and Crop Coordination

// CropSimManager reads weather state (for GDD) and writes field state (crop growth) update(deltaTime, oldStates, nextStates) { const weather = oldStates.weather; // Read-only const field = nextStates.field; // Mutable working copy const deltaGDD = weather.gddToApplyThisFrame; // Update crop growth based on GDD... } // WeatherSimManager reads weather state and writes weather state update(deltaTime, oldStates, nextStates) { const weather = nextStates.weather; // Mutable working copy weather.cumulativeGDD += deltaGDD; weather.gddToApplyThisFrame = deltaGDD; }

Both managers see the same oldStates.weather, preventing inconsistencies.

State Keys

The StateManager tracks these keys:

KeyTypePurpose
weatherWeatherStateCurrent date, GDD, rainfall, speed multiplier
fieldBitmapFieldState300×300 grid of crop tiles (stage, type, GDD, water, minerals)
vehiclesImplementState[]Array of vehicles (harvester, seeder) with position, angle, flags
tractorImplementStateLegacy single tractor state (appears unused but breaks if removed)
activeVehicleTypenumberWhich vehicle the student is currently controlling (VEHICLES.HARVESTER or VEHICLES.SEEDER)
isGameOverbooleanWhether the simulation has ended

State Classes

Each state class implements a clone() method to support the immutable commit pattern.

WeatherState

Tracks weather data, time progression, and GDD accumulation.

File: src/States/StateClasses/WeatherState.js

Core Properties (defined in constructor):

{ csvLines: [], // Parsed K-State Mesonet CSV data startDate: null, // Simulation start date (Date object) currentDayIndex: 0, // Days elapsed since start timeAccumulator: 5, // Current hour (0-24, starts at 5 AM) speedMultiplier: 1, // Base simulation speed (1x = real-time) isWaiting: false, // True when waitXWeeks() is active cumulativeGDD: 0, // Total growing degree days accumulated gddToApplyThisFrame: 0 // GDD to add this frame (calculated by WeatherSimManager) }

Dynamic Properties (added by WeatherSimManager):

  • cumulativeRain: Total precipitation accumulated (added in applyCacheToState() and advanceDay())
  • rainToApplyThisFrame: Precipitation for current frame (added in advanceDay())

Key method:

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 without moving the tractor.

ImplementState

Represents a single vehicle (harvester or seeder) with position, orientation, and operational flags.

File: src/States/StateClasses/ImplementState.js

Properties:

{ basespeed: 20, // Movement speed (pixels/second) turnSpeed: 90, // Rotation speed (degrees/second) x: 0, // World X position (pixels) y: 0, // World Y position (pixels) angle: 0, // Current orientation (degrees) goalAngle: 0, // Target orientation for turns isMoving: false, // True when moveForward() is active isHarvestingOn: false, // True when harvester header is on isSeedingOn: false, // True when seeder is on cropBeingPlanted: CROP_TYPES.WHEAT, // Crop type for seeder yieldScore: 0, // Accumulated harvest score type: VEHICLES.HARVESTER // Vehicle type (0=harvester, 1=seeder) }

Vehicle types:

export const VEHICLES = { HARVESTER: 0, SEEDER: 1 };

The vehicles state is an array: [harvesterState, seederState]. Students can switch between them using the change_vehicle block.

BitmapFieldState

A memory-efficient representation of the 300×300 tile field using typed arrays (ArrayBuffers).

File: src/BinaryArrayAbstractionMethods/BitmapFieldState.js

Why binary packing? A naive approach storing each tile as a JavaScript object would require ~10 MB of memory. BitmapFieldState uses typed arrays to pack tile data into ~2 MB, improving cache locality and performance.

Tile properties:

{ stage: 1, // CROP_STAGES: UNPLANTED=0, SEEDED=1, MATURE=2 type: 1, // CROP_TYPES: EMPTY=0, WHEAT=1, CORN=2, SOY=3 currentGDD: 0, // GDD accumulated so far requiredGDD: 1000, // GDD needed to reach maturity waterLevel: 1000, // Soil water (currently unused) minerals: 1000 // Soil nutrients (currently unused) }

Key methods:

MethodPurpose
InitializeField(initialTileState)Set all tiles to the same starting values
clone()Create a deep copy of the field for the next frame
getTileAt(x, y)Get all properties of a tile as an object
setTile(x, y, tileState)Set all properties of a tile
GetVariableAt(x, y, name)Get a single property (e.g., “stage”)
setVariable(name, value, x, y)Set a single property

Example usage:

const field = new BitmapFieldState(300, 300, tileStateDefinition); field.InitializeField({ stage: CROP_STAGES.MATURE, type: CROP_TYPES.CORN, currentGDD: 0, requiredGDD: 1300, waterLevel: 1000, minerals: 1000 }); // Later, in a SimManager: const tile = field.getTileAt(150, 150); if (tile.stage === CROP_STAGES.MATURE) { // Harvest the crop field.setVariable("stage", CROP_STAGES.UNPLANTED, 150, 150); }

Crop State Utilities

File: src/States/StateClasses/CropState.js

This file defines enums and utility functions for working with crop tiles:

Crop stages:

export const CROP_STAGES = { UNPLANTED: 0, // Bare dirt SEEDED: 1, // Planted, growing MATURE: 2 // Ready to harvest };

Crop types:

export const CROP_TYPES = { EMPTY: 0, // No crop WHEAT: 1, // Requires 1000 GDD CORN: 2, // Requires 1300 GDD SOY: 3 // Requires 900 GDD };

GDD requirements:

export const CROP_GDDS = { [CROP_TYPES.WHEAT]: 1000.0, [CROP_TYPES.CORN]: 1300.0, [CROP_TYPES.SOY]: 900.0 };

Utility functions:

FunctionPurpose
updateGrowth(deltaGDD, tile)Add GDD to a seeded tile, transition to MATURE when threshold reached
changeCropType(cropType, tile)Change tile’s crop type and update requiredGDD
getYieldScore(tile)Get harvest value (WHEAT=1, SOY=2, CORN=3)
isGrowing(tile)Check if stage === SEEDED
isMature(tile)Check if stage === MATURE
isUnplanted(tile)Check if stage === UNPLANTED
reset(tile)Clear tile to UNPLANTED, EMPTY, 0 GDD
plant(cropType, tile)Set stage to SEEDED, reset currentGDD, set crop type
clone(tile)Create a shallow copy of a tile object

Example: Crop growth:

import { updateGrowth, isMature, CROP_STAGES } from './CropState'; // In CropSimManager: const tile = field.getTileAt(x, y); if (tile.stage === CROP_STAGES.SEEDED) { updateGrowth(deltaGDD, tile); field.setTile(x, y, tile); if (isMature(tile)) { console.log('Crop is ready to harvest!'); } }

How SimManagers Use State

SimManagers follow this pattern:

import SimManager from './SimManager'; export default class ExampleSimManager extends SimManager { update(deltaTime, oldStates, newStates) { // 1. Read from oldStates (immutable) const weather = oldStates.weather; const field = oldStates.field; // 2. Get mutable working copies from newStates const nextWeather = newStates.weather; const nextField = newStates.field; // 3. Calculate updates const deltaGDD = calculateGDD(weather, deltaTime); nextWeather.cumulativeGDD += deltaGDD; // 4. Update field tiles for (let y = 0; y < field.rows; y++) { for (let x = 0; x < field.columns; x++) { const tile = nextField.getTileAt(x, y); updateGrowth(deltaGDD, tile); nextField.setTile(x, y, tile); } } // 5. No explicit commit—engine handles it } }

The engine automatically commits newStates at the end of each frame.

Code Example: Reading and Committing State

Here’s a complete example showing how a custom SimManager would interact with the state system:

import SimManager from '../Simulation/SimManager'; import { CROP_STAGES } from '../States/StateClasses/CropState'; export default class IrrigationSimManager extends SimManager { update(deltaTime, oldStates, newStates) { // Read weather to check if it rained const weather = oldStates.weather; const rainfall = weather.gddToApplyThisFrame > 0 ? 10 : 0; // Get mutable field state const field = newStates.field; // Update water levels for 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; // Growing crops consume water if (tile.stage === CROP_STAGES.SEEDED) { tile.waterLevel -= deltaTime * 0.5; } // Clamp to valid range tile.waterLevel = Math.max(0, Math.min(1000, tile.waterLevel)); // Write back to field field.setTile(x, y, tile); } } // No explicit commit needed—engine handles it } }

To register this manager, add it to the managers array in simulationEngine.js:

this.managers = [ new WeatherManager(), new CropManager(), new TractorManager(), new IrrigationSimManager() // Add your custom manager ];

Why This Design?

The immutable commit pattern solves several problems:

Prevents race conditions: If WeatherSimManager and CropSimManager both modified weather.cumulativeGDD directly, the final value would depend on execution order. With separate read/write copies, both managers see the same old value and write to independent new values.

Simplifies debugging: Each frame’s state is a clean snapshot. You can log oldStates and newStates to see exactly what changed.

Enables time travel: The pattern makes it easy to implement rewind/replay features—just store old state snapshots and restore them.

Improves testability: SimManagers are pure functions: given oldStates and deltaTime, they produce predictable newStates. No hidden dependencies or side effects.

Performance Considerations

BitmapFieldState optimization: The field uses typed arrays (Uint8Array, Float32Array) instead of objects, reducing memory from ~10 MB to ~2 MB and improving cache locality.

Cloning cost: Every frame clones all state objects. For small states (WeatherState, ImplementState), this is negligible. For BitmapFieldState, cloning 90,000 tiles takes ~1-2ms on modern hardware—acceptable for a 60 FPS game loop.

Lazy cloning: The engine only clones states that have clone() methods. Primitive values (numbers, booleans) are copied by reference.

What’s Next

Now that you understand the state management system: