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:
- Read old states: All managers read from
this.stateManager.states - Clone states: Each state object is cloned to create a working copy
- Managers update: Each manager reads from
oldStatesand writes tonextStates - 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:
| Key | Type | Purpose |
|---|---|---|
weather | WeatherState | Current date, GDD, rainfall, speed multiplier |
field | BitmapFieldState | 300×300 grid of crop tiles (stage, type, GDD, water, minerals) |
vehicles | ImplementState[] | Array of vehicles (harvester, seeder) with position, angle, flags |
tractor | ImplementState | Legacy single tractor state (appears unused but breaks if removed) |
activeVehicleType | number | Which vehicle the student is currently controlling (VEHICLES.HARVESTER or VEHICLES.SEEDER) |
isGameOver | boolean | Whether 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 inapplyCacheToState()andadvanceDay())rainToApplyThisFrame: Precipitation for current frame (added inadvanceDay())
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:
| Method | Purpose |
|---|---|
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:
| Function | Purpose |
|---|---|
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:
- Simulation Managers: See how managers read and write state each frame
- Blockly Blocks Reference: Understand how student code affects state
- Rendering Pipeline: See how state is visualized on the canvas