Skip to Content
🌾 Simulation FeaturesWorker Command System

Last Updated: 4/7/2026


Worker Command System

Agricultural Microworlds executes student-written Blockly programs in Web Workers to keep the simulation running smoothly. This architecture prevents long-running or infinite loops in student code from freezing the browser UI. Understanding this system is essential for adding new blocks that interact with the simulation.

Architecture Overview

The worker command system uses a request/response pattern:

  1. Blockly generates JavaScript code that calls simulationMethods functions
  2. Worker sends command messages to the main thread with command name and arguments
  3. simulationEngine processes commands and updates simulation state
  4. Engine sends response back to worker when async operations complete
  5. Worker resumes execution after receiving the response

This allows student code to await simulation actions (like movement or waiting) while the simulation loop continues running on the main thread.

Worker Creation and Execution

When the user clicks “Run”, SimulationControlsContainer creates workers for each vehicle’s code:

const spawnWorker = (userCode, vType) => { if (!userCode.trim()) return; this.expectedWorkers++; const blob = this.createWorkerBlob(userCode); const worker = new Worker(URL.createObjectURL(blob)); worker.onmessage = (e) => { if (e.data.type === "COMMAND") { this.simulationEngine.handleWorkerMessage( { ...e.data, vehicleType: vType }, worker, ); } else if (e.data.type === "DONE") { this.completedWorkers++; if (this.completedWorkers === this.expectedWorkers) { this.stopButtonOnClick(); // Auto-stop when both finish } } }; this.workers.push(worker); }; // 0 = HARVESTER, 1 = SEEDER spawnWorker(harvesterCode, 0); spawnWorker(seederCode, 1);

Key points:

  • Each vehicle (harvester and seeder) gets its own worker
  • Workers run independently and can execute commands simultaneously
  • The simulation auto-stops when all workers complete (type: "DONE")
  • vehicleType is attached to each command to route it to the correct vehicle

Worker Blob Construction

The createWorkerBlob() method wraps user code with the simulationMethods API:

createWorkerBlob(userCode) { const workerScript = ` const simulationMethods = { _send: function(command, args) { return new Promise((resolve) => { const reqId = Math.random().toString(36).substring(7); self.postMessage({ type: 'COMMAND', command: command, args: args, requestId: reqId }); const listener = (e) => { if (e.data.type === 'RESPONSE' && e.data.requestId === reqId) { self.removeEventListener('message', listener); resolve(e.data.result); } }; self.addEventListener('message', listener); }); }, moveForward: function(d) { return this._send('moveForward', [d]); }, turnXDegrees: function(d) { return this._send('turnXDegrees', [d]); }, waitXWeeks: function(d) { return this._send('waitXWeeks', [d]); }, toggleHarvesting: function(b) { return this._send('toggleHarvesting', [b]); }, toggleSeeding: function(b) { return this._send('toggleSeeding', [b]); }, switchCropBeingPlanted: function(c) { return this._send('switchCropBeingPlanted', [c]); } }; async function runUserCode() { ${userCode} } runUserCode().then(() => { self.postMessage({ type: 'DONE' }); }).catch(err => { console.error("Worker Error:", err); }); `; return new Blob([workerScript], { type: "application/javascript" }); }

The _send Helper

The _send method implements the request/response pattern:

  1. Generate unique requestId: Random string to match responses to requests
  2. Post command message: Sends { type: 'COMMAND', command, args, requestId }
  3. Set up listener: Waits for { type: 'RESPONSE', requestId } message
  4. Return Promise: Resolves when matching response arrives

This allows worker code to await commands:

await simulationMethods.moveForward(5); // Waits for movement to complete await simulationMethods.turnXDegrees(90); // Then waits for turn to complete

Available Commands

The simulationMethods object exposes these commands:

CommandParametersAsync?Purpose
moveForward(duration)duration (seconds)YesMove vehicle forward for specified time
turnXDegrees(angle)angle (degrees, ± for direction)YesRotate vehicle by angle
waitXWeeks(weeks)weeks (simulation weeks)YesPause vehicle, advance simulation time
toggleHarvesting(isOn)isOn (boolean)NoEnable/disable harvesting mode
toggleSeeding(isOn)isOn (boolean)NoEnable/disable seeding mode
switchCropBeingPlanted(cropType)cropType (CROP_TYPES enum)NoChange which crop the seeder plants
CheckIfPlantInFront(stage)stage (CROP_STAGES enum)NoCheck if tile ahead matches stage

Async commands return Promises that resolve when the action completes. Sync commands return immediately after updating state.

Engine Command Handler

The simulationEngine.handleWorkerMessage() method routes commands to simulation methods:

async handleWorkerMessage(data, worker) { if (!this.isRunning) return; const { command, args, vehicleType, requestId } = data; // Route the string command to the actual simulation API switch (command) { case "moveForward": await this.moveForward(args[0], vehicleType); break; case "turnXDegrees": await this.turnXDegrees(args[0], vehicleType); break; case "waitXWeeks": await this.waitXWeeks(args[0], vehicleType); break; case "toggleHarvesting": this.toggleHarvesting(args[0], vehicleType); break; case "toggleSeeding": this.toggleSeeding(args[0], vehicleType); break; case "switchCropBeingPlanted": this.switchCropBeingPlanted(args[0], vehicleType); break; default: console.warn("Unknown worker command:", command); break; } // Once the await finishes, send response back to worker if (worker) { worker.postMessage({ type: "RESPONSE", requestId: requestId, result: true, }); } }

Key behavior:

  • Commands are executed with await, so the handler waits for async operations
  • The vehicleType parameter routes commands to the correct vehicle (harvester or seeder)
  • After completion, a RESPONSE message is sent back with the matching requestId
  • Unknown commands log a warning but don’t crash the simulation

Active Task System

Async commands (movement, turning, waiting) use the activeTasks map to track in-progress operations:

async moveForward(durationInSeconds, targetVehicleType) { const mySessionId = this.simulationSessionId; const vehicle = this.getTargetVehicle(targetVehicleType); if (vehicle) vehicle.isMoving = true; return new Promise((resolve) => { if (vehicle) { this.activeTasks.set(vehicle.type, { type: "TIMER", timeLeft: Number(durationInSeconds), resolve: resolve, sessionId: mySessionId, }); } else { resolve(); } }); }

The simulation loop decrements timeLeft each frame:

this.activeTasks.forEach((task, vehicleType) => { if (task.sessionId !== this.simulationSessionId) { this.activeTasks.clear(); } else { if (task.type === "TIMER") { task.timeLeft -= simDeltaTime; if (task.timeLeft <= 0) { if (nextStates.vehicles) { const vehicle = nextStates.vehicles.find( (v) => v.type == vehicleType, ); if (vehicle) vehicle.isMoving = false; } this.resolveActiveTask(vehicleType); } } else if (task.type === "TURN") { const v = nextStates.vehicles?.find((v) => v.type == vehicleType); if (v) { const diff = Math.abs(v.goalAngle - v.angle); if (diff < 0.5) { v.angle = v.goalAngle; this.resolveActiveTask(vehicleType); } } } } });

When the task completes, resolveActiveTask() calls the stored resolve() function, which unblocks the worker’s await.

Task Types

TIMER tasks (moveForward, waitXWeeks):

  • Decrement timeLeft by simDeltaTime each frame
  • Resolve when timeLeft <= 0
  • Used for time-based actions

TURN tasks (turnXDegrees):

  • Check angle difference each frame
  • Resolve when |goalAngle - angle| < 0.5
  • Used for rotation actions

Complete Flow Example

Here’s the full flow for a move_forward block:

  1. Blockly generates code:

    await simulationMethods.moveForward(5);
  2. Worker executes code, calls _send('moveForward', [5])

  3. Worker posts message:

    { type: 'COMMAND', command: 'moveForward', args: [5], requestId: 'abc123' }
  4. Main thread receives message, calls handleWorkerMessage()

  5. Engine calls moveForward(5, vehicleType):

    • Sets vehicle.isMoving = true
    • Creates TIMER task with timeLeft: 5
    • Returns Promise
  6. Simulation loop runs:

    • Each frame, decrements task.timeLeft by deltaTime
    • After ~5 seconds of simulation time, timeLeft <= 0
  7. Engine resolves task:

    • Calls task.resolve()
    • Sets vehicle.isMoving = false
  8. Engine posts response:

    { type: 'RESPONSE', requestId: 'abc123', result: true }
  9. Worker receives response:

    • Matches requestId to pending request
    • Resolves the Promise from step 2
  10. Worker continues execution to the next line of code

Adding a New Command

To add a new worker command:

1. Add to simulationMethods

In createWorkerBlob(), add the method:

const simulationMethods = { // ... existing methods myNewCommand: function(arg1, arg2) { return this._send('myNewCommand', [arg1, arg2]); } };

2. Add Engine Handler

In handleWorkerMessage(), add a case:

switch (command) { // ... existing cases case "myNewCommand": await this.myNewCommand(args[0], args[1], vehicleType); break; }

3. Implement Engine Method

In simulationEngine.js, add the method:

async myNewCommand(arg1, arg2, targetVehicleType) { const vehicle = this.getTargetVehicle(targetVehicleType); // Implement your logic here // For async commands, return a Promise with an active task return new Promise((resolve) => { this.activeTasks.set(vehicle.type, { type: "CUSTOM_TYPE", // ... task data resolve: resolve, sessionId: this.simulationSessionId, }); }); // For sync commands, just update state and return }

4. Update Block Generator

In blocklyJSGenerator.js, generate the call:

javascriptGenerator.forBlock["my_new_block"] = function (block, generator) { const arg1 = generator.valueToCode(block, "ARG1", generator.ORDER_ATOMIC) || "0"; const arg2 = block.getFieldValue("ARG2"); return `await simulationMethods.myNewCommand(${arg1}, ${arg2});\n`; };

Session Management

The simulationSessionId prevents stale tasks from previous runs:

stopMovement() { this.isRunning = false; this.simulationSessionId++; // Increment to invalidate old tasks this.activeTasks.clear(); cancelAnimationFrame(this.animationId); }

When the user clicks “Stop” or “Run” again:

  • simulationSessionId increments
  • All active tasks check task.sessionId !== this.simulationSessionId
  • Mismatched tasks are ignored and cleared

This prevents a worker from a previous run from affecting the new simulation state.

Multi-Vehicle Coordination

Each vehicle has independent code and an independent worker:

// Harvester worker executes: await simulationMethods.moveForward(10); await simulationMethods.toggleHarvesting(true); // Seeder worker executes (simultaneously): await simulationMethods.moveForward(5); await simulationMethods.toggleSeeding(true);

Both workers send commands with their vehicleType (0 for harvester, 1 for seeder). The engine routes commands to the correct vehicle using getTargetVehicle():

getTargetVehicle(targetType) { const vehicleType = targetType !== undefined ? targetType : this.stateManager.getState("activeVehicleType"); const vehicles = this.stateManager.getState("vehicles"); if (!vehicles) return null; return vehicles.find((v) => v.type == vehicleType); }

This allows students to program both vehicles independently, creating complex multi-vehicle behaviors.

Error Handling

Worker errors are caught and logged:

runUserCode().then(() => { self.postMessage({ type: 'DONE' }); }).catch(err => { console.error("Worker Error:", err); });

Unknown commands log a warning:

default: console.warn("Unknown worker command:", command); break;

Missing vehicles are handled gracefully:

const vehicle = this.getTargetVehicle(targetVehicleType); if (!vehicle) { resolve(); // Resolve immediately if vehicle doesn't exist return; }

Performance Considerations

Workers run in parallel: Multiple vehicles can execute commands simultaneously without blocking each other.

Main thread stays responsive: Long-running student code (infinite loops, large iterations) runs in the worker without freezing the UI.

Simulation loop continues: Even when workers are waiting for async commands, the simulation loop keeps running at 60 FPS.

Memory cleanup: Workers are terminated on stop:

stopButtonOnClick() { this.simulationEngine.stopMovement(); this.workers.forEach((w) => w.terminate()); this.workers = []; }

Debugging Worker Code

To debug worker execution:

  1. Check console logs: Worker errors appear in the browser console
  2. Add console.log in worker blob: Modify createWorkerBlob() to add logging
  3. Inspect command messages: Log data in handleWorkerMessage()
  4. Check active tasks: Log this.activeTasks in the simulation loop
  5. Verify requestId matching: Ensure responses match requests

Example debug logging:

async handleWorkerMessage(data, worker) { console.log("Received command:", data.command, "with args:", data.args); // ... rest of handler }

What’s Next