Skip to Content
🏗️ Core ArchitectureSimulation Engine Architecture

Last Updated: 4/7/2026


Simulation Engine Architecture

The simulation engine is the heart of Agricultural Microworlds, orchestrating the game loop, executing student-written Blockly code, and coordinating all simulation managers. Understanding this architecture is essential for contributing to the project.

System Overview

The simulation engine follows a classic game loop pattern with a twist: student code runs in Web Workers to prevent blocking the main thread. The engine maintains an immutable state system where each frame clones the previous state, runs all simulation managers to calculate updates, then commits the new state atomically.

umldiagram.png

This diagram shows the separation between simulation logic and rendering. The simulationEngine class serves as the central coordinator, managing the game loop, state transitions, and communication with Web Workers.

Core Components

SimulationEngine Class

The simulationEngine class (located in src/SimulationEngine/simulationEngine.js) extends EventTarget to dispatch events when the simulation updates. It maintains:

  • StateManager: Holds all simulation state (field, weather, vehicles, tractor)
  • Managers array: Contains WeatherSimManager, CropSimManager, and TractorSimManager
  • Active tasks map: Tracks ongoing asynchronous commands (movement, turns, waits)
  • Loop variables: Frame timing, animation ID, session ID for cancellation

Key constants define the simulation world:

this.TILE_SIZE = 8; this.ROWS = 300; this.COLS = 300; this.worldPixelWidth = 2400; // 300 * 8 this.worldPixelHeight = 2400;

The Game Loop

The loop(timestamp) method runs every frame via requestAnimationFrame. Here’s the flow:

  1. Calculate simulated time: Real delta time is multiplied by the weather speed multiplier to allow time acceleration
const realDeltaTime = (timestamp - this.lastFrameTime) / 1000; const speedMult = weather.getSpeedMultiplier(); const simDeltaTime = Math.min(realDeltaTime, 0.1) * speedMult;
  1. Clone all states: Each state object is cloned to create a working copy
for (const key in oldStates) { if (oldStates[key] && typeof oldStates[key].clone === 'function') { nextStates[key] = oldStates[key].clone(); } }
  1. Run all managers: Each SimManager’s update() method receives simDeltaTime, oldStates, and nextStates
for (const sm of this.managers) { sm.update(simDeltaTime, oldStates, nextStates); }
  1. Process active tasks: Check if movement timers have expired or turns have completed
  2. Commit new states: Replace old states with the calculated new states
  3. Dispatch render event: Fire simulationEngineCreated with render data
  4. Check game over: Stop the loop if isGameOver is true

The loop runs at the browser’s refresh rate (typically 60 FPS), but simulation time can be faster or slower depending on the speed multiplier.

Web Worker Pattern

Why Web Workers? Student-written Blockly code can contain infinite loops or long-running operations. Running this code on the main thread would freeze the UI. Web Workers execute code in a separate thread, keeping the simulation responsive.

How It Works

  1. Student clicks “Run”: The Blockly workspace generates JavaScript code
  2. Code is sent to a Web Worker: The worker executes the generated code
  3. Worker sends commands: When the code calls moveForward(5), the worker posts a message to the main thread:
{ command: "moveForward", args: [5], vehicleType: VEHICLES.HARVESTER, requestId: 123 }
  1. Engine handles the command: handleWorkerMessage() routes the command to the appropriate method
  2. Async command executes: The engine creates an active task and returns a Promise
  3. Worker waits: The worker blocks until it receives a RESPONSE message
  4. Engine resolves: When the timer expires, the engine sends { type: "RESPONSE", requestId: 123 }
  5. Worker continues: The next line of student code executes

This pattern allows student code to appear synchronous (moveForward(5); turnLeft();) while actually being asynchronous under the hood.

Event System

The engine dispatches two main events:

simulationEngineCreated

Fired every frame with render data. Components listen for this event to update the canvas:

this.dispatchEvent( new CustomEvent("simulationEngineCreated", { bubbles: true, detail: timeStepData }) );

The detail object contains:

  • stats: Yield score, current date, cumulative GDD, rainfall
  • field: Field dimensions, camera position, tile data
  • vehicles: Vehicle positions, angles, states
  • dayCycle: Current time for day/night rendering

simulationCrashed

Fired when isGameOver becomes true, signaling the simulation has ended (either successfully or due to an error).

State Coordination

The engine uses a read-old, write-new pattern to prevent race conditions:

  • Old states are read-only during a frame
  • New states are mutable working copies
  • Managers read from oldStates and write to nextStates
  • At frame end, nextStates becomes the new oldStates

This ensures SimManagers can’t interfere with each other. For example:

  • WeatherSimManager reads oldStates.weather and writes nextStates.weather
  • CropSimManager reads oldStates.weather (for GDD) and writes nextStates.field (crop growth)
  • Both managers see the same consistent old state, preventing order-of-execution bugs

Active Task System

Asynchronous commands (movement, turns, waits) are tracked in the activeTasks Map:

this.activeTasks.set(vehicleType, { type: "TIMER", timeLeft: 5.0, resolve: promiseResolveFunction, sessionId: 42 });

Each frame, the loop decrements timeLeft by simDeltaTime. When it reaches zero, the task’s resolve() function is called, unblocking the Web Worker.

The sessionId ensures tasks from previous runs are ignored when the simulation is stopped and restarted.

Initialization Flow

When the engine starts:

  1. StateManager is created: Empty state container
  2. Managers are instantiated: Weather, Crop, Tractor managers
  3. initializeStates() runs:
  • Creates WeatherState with default values (or cached K-State Mesonet data)
  • Creates two ImplementState objects (harvester at (x: -150, y: 1150), seeder at (x: -150, y: 1250), both off-screen)
  • Creates BitmapFieldState (300×300 tiles, initialized to mature corn)
  • Sets activeVehicleType to VEHICLES.HARVESTER
  • Sets isGameOver to false
  1. timeStepEvent() fires: Initial render data is dispatched
  2. Engine waits: Loop doesn’t start until startMoving() is called

Key Methods

MethodPurpose
startMoving()Begins the game loop
stopMovement()Halts the loop, increments session ID, clears tasks
loop(timestamp)Main game loop (called every frame)
moveForward(duration, vehicleType)Async: Move vehicle for N seconds
turnXDegrees(angle, vehicleType)Async: Turn vehicle by N degrees
waitXWeeks(weeks, vehicleType)Async: Pause vehicle for N weeks (simulated time)
toggleHarvesting(isOn, vehicleType)Toggle harvester header on/off
toggleSeeding(isOn, vehicleType)Toggle seeder on/off
switchCropBeingPlanted(crop, vehicleType)Change seeder’s crop type
handleWorkerMessage(data, worker)Route worker commands to engine methods
resetEverything()Stop loop, clear tasks, reinitialize states
getManager(type)Retrieve a specific SimManager instance
getActiveVehicle()Get the vehicle object for the user-selected vehicle
getTargetVehicle(targetType)Get a specific vehicle by type

From Student Click to Simulation Update

Here’s the complete flow when a student clicks “Run”:

  1. Blockly generates code: The workspace converts blocks to JavaScript
  2. Code is sent to Web Worker: Worker starts executing
  3. **Worker calls moveForward(5)**: Posts message to main thread
  4. Engine receives message: handleWorkerMessage() is called
  5. Engine creates active task: activeTasks.set(vehicleType, { type: "TIMER", timeLeft: 5.0, ... })
  6. Engine returns Promise: Worker blocks waiting for response
  7. Game loop runs: Each frame decrements timeLeft by simDeltaTime
  8. TractorSimManager updates: Reads vehicle.isMoving = true, calculates new position
  9. States are committed: New vehicle position becomes official
  10. Render event fires: Canvas updates, tractor moves on screen
  11. Timer expires: timeLeft <= 0, engine calls resolve()
  12. Worker receives response: Unblocks and continues to next line
  13. Next command executes: Process repeats

This architecture keeps the UI responsive even when student code contains long-running operations, while maintaining the illusion of synchronous execution.

Why This Design?

The simulation engine’s architecture solves several key problems:

Non-blocking execution: Web Workers prevent student code from freezing the browser.

State consistency: The immutable commit pattern prevents SimManagers from interfering with each other, eliminating race conditions.

Time acceleration: The speed multiplier allows students to see crop growth in seconds rather than waiting for real-time weeks to pass.

Modularity: Each SimManager is independent, making it easy to add new simulation systems (soil, pests, irrigation) without modifying the core engine.

Session management: The session ID system ensures old tasks are ignored when the simulation restarts, preventing stale commands from affecting new runs.

Understanding this architecture is the foundation for working with the simulation managers, state system, and Blockly integration.

What’s Next

Now that you understand the simulation engine’s game loop and Web Worker pattern: