Skip to Content
🌾 Simulation FeaturesRendering Pipeline

Last Updated: 4/7/2026


Rendering Pipeline

The Agricultural Microworlds rendering system transforms simulation state into visual output through a modular pipeline. Each frame, the simulation engine dispatches a simulationEngineCreated event containing render data, which the drawCanvas class processes through specialized render modules.

Architecture Overview

The rendering pipeline consists of:

  1. simulationEngine: Generates render data every frame via timeStepEvent()
  2. timeStepData: Packages render data for each module
  3. drawCanvas: Orchestrates the rendering process
  4. RenderState modules: Specialized renderers for field, vehicles, stats, etc.

This separation allows each visual concern (field tiles, tractor sprites, UI stats) to be developed and tested independently.

Render Modules

The system defines six render modules in RENDER_MODULE_KEYS:

export const RENDER_MODULE_KEYS = { FIELD: "field", IMPLEMENTS: "implements", STATS: "stats", WEATHER: "weather", DAY_CYCLE: "dayCycle", DEBUG: "debug", };

Each module receives specific data from the simulation engine and renders to the canvas context.

drawCanvas Orchestrator

The drawCanvas class (in agricultural-microworlds.client/src/Rendering/drawCanvas.js) manages the render loop.

Initialization

constructor(canvasRef, canvasWidth, canvasHeight) { this.canvas = canvasRef; this.ctx = this.canvas.getContext("2d"); this.canvas.width = canvasWidth; this.canvas.height = canvasHeight; this.renderModules = { [RENDER_MODULE_KEYS.FIELD]: new RenderFieldState(), [RENDER_MODULE_KEYS.IMPLEMENTS]: new RenderImplementState(), [RENDER_MODULE_KEYS.STATS]: new RenderStatState(), [RENDER_MODULE_KEYS.WEATHER]: new RenderWeatherState(), [RENDER_MODULE_KEYS.DAY_CYCLE]: new RenderDayCycleState(), [RENDER_MODULE_KEYS.DEBUG]: new RenderDebugState(), }; }

Each render module extends the RenderState base class, which handles image loading and provides a common render(context, data) interface.

Render Loop

handleTimeStep(simulationData) { this.simulationState = simulationData.detail; this.renderAllModules(); } renderAllModules() { Object.entries(this.simulationState.renderModuleData).forEach( ([key, data]) => { const module = this.renderModules[key]; if (module && module.imageCount && module.imageLoadCount < module.imageCount) { console.log(`Skipping render of ${key} until images load`); return; } module.render(this.ctx, data); } ); }

The loop waits for all images to load before rendering each module. This prevents rendering errors from missing sprite sheets or tile images.

RenderFieldState: Drawing Crop Tiles

The RenderFieldState module (in renderFieldState.js) draws the field using a viewport culling optimization.

Image Assets

Five tile images are loaded:

Image KeyAssetUsed For
DIRTT2D_Dirt_Placeholder.pngUNPLANTED tiles
SEEDT2D_Planted_Placeholder.pngSEEDED tiles (all crops)
WHEATwheat.pngMATURE wheat
CORNcorn.pngMATURE corn
SOYsoybean.pngMATURE soybeans

Viewport Culling

Only visible tiles are rendered:

const startCol = Math.floor(cameraX / TILE_WIDTH); const startRow = Math.floor(cameraY / TILE_HEIGHT); const SCREEN_ROWS = Math.floor(data.canvasWidth / TILE_HEIGHT) + 2; const SCREEN_COLUMNS = Math.floor(data.canvasHeight / TILE_WIDTH) + 2; const endRow = Math.min(fieldHeight, startRow + SCREEN_ROWS); const endCol = Math.min(fieldWidth, startCol + SCREEN_COLUMNS);

For a 300×300 field, this reduces rendering from 90,000 tiles to ~200-400 visible tiles per frame, dramatically improving performance.

Tile Rendering Logic

for (let i = startRow; i < endRow; i++) { for (let j = startCol; j < endCol; j++) { const crop = data.field.getTileAt(j, i); let tileImage = this.images[IMAGE_KEYS.DIRT]; switch (crop["stage"]) { case CROP_STAGES.UNPLANTED: tileImage = this.images[IMAGE_KEYS.DIRT]; break; case CROP_STAGES.SEEDED: tileImage = this.images[IMAGE_KEYS.SEED]; break; case CROP_STAGES.MATURE: switch (crop["type"]) { case CROP_TYPES.WHEAT: tileImage = this.images[IMAGE_KEYS.WHEAT]; break; case CROP_TYPES.CORN: tileImage = this.images[IMAGE_KEYS.CORN]; break; case CROP_TYPES.SOY: tileImage = this.images[IMAGE_KEYS.SOY]; break; } break; } const tileWorldX = j * TILE_WIDTH; const tileWorldY = i * TILE_HEIGHT; const tileScreenX = tileWorldX - cameraX; const tileScreenY = tileWorldY - cameraY; context.drawImage( tileImage, 0, 0, TILE_BASE_SIZE, TILE_BASE_SIZE, Math.floor(tileScreenX), Math.floor(tileScreenY), TILE_WIDTH, TILE_HEIGHT ); } }

Key points:

  • SEEDED tiles show the same generic “seed” sprite regardless of crop type
  • MATURE tiles show crop-specific sprites (wheat, corn, or soy)
  • World coordinates are converted to screen coordinates by subtracting camera position
  • Tiles are scaled from TILE_BASE_SIZE (64px) to TILE_WIDTH × TILE_HEIGHT (8px × 8px)

RenderImplementState: Drawing Vehicles

The RenderImplementState module (in renderImplementState.js) renders tractors and the crash overlay.

Vehicle Sprites

Two vehicle sprites are loaded:

Vehicle TypeSpriteEnum Value
Harvestercombine-harvester.pngVEHICLES.HARVESTER (0)
Seederseeder.pngVEHICLES.SEEDER (1)

Rotation Rendering

Vehicles are rendered with rotation using canvas transforms:

data.vehicles.forEach((vehicle) => { const screenX = vehicle.x - data.cameraX; const screenY = vehicle.y - data.cameraY; const normalizedAngle = ((vehicle.angle % 360) + 360) % 360; var angleInRadians = (normalizedAngle * Math.PI) / 180; const sprite = vehicle.type === VEHICLES.SEEDER ? this.images[IMAGE_KEYS.SEEDER] : this.images[IMAGE_KEYS.HARVESTER]; context.save(); context.translate(screenX + FRAME_WIDTH / 2, screenY + FRAME_HEIGHT / 2); context.rotate(angleInRadians); context.drawImage(sprite, -FRAME_WIDTH / 2, -FRAME_HEIGHT / 2); context.restore(); });

Transform sequence:

  1. save(): Preserve current transform state
  2. translate(): Move origin to vehicle center
  3. rotate(): Apply vehicle’s angle
  4. drawImage(): Draw sprite centered on origin
  5. restore(): Restore original transform

This allows smooth rotation without distorting the sprite.

Crash Overlay

When vehicles collide (isGameOver is true), a crash sprite appears at the collision point:

if (data?.isGameOver && data?.crashed) { const crashX = data.crashed.x; const crashY = data.crashed.y; const screenX = crashX - data.cameraX; const screenY = crashY - data.cameraY; const size = 40; context.drawImage( this.images[IMAGE_KEYS.CRASH], screenX - size / 2, screenY - size / 2, size, size ); }

The crash sprite (crash_sprite_overlay.png) is drawn at the midpoint between the two colliding vehicles.

RenderStatState: UI Updates

The RenderStatState module (in renderStatState.js) updates DOM elements rather than drawing to canvas:

render(context, data) { const yieldEl = document.getElementById("scoreText"); if (yieldEl) yieldEl.innerText = "Yield: " + data.yieldScore; const dateEl = document.getElementById("dateText"); if (dateEl) dateEl.innerText = "Date: " + data.currentDate; const timeEl = document.getElementById("timeText"); if (timeEl) { const totalHours = 1 + Math.floor(data.currentTime % 12.0); const totalMinutes = Math.floor(60 * (data.currentTime % 1.0)); const formattedHours = totalHours.toString().padStart(2, "0"); const formattedMinutes = totalMinutes.toString().padStart(2, "0"); const formattedMeridiem = data.currentTime % 23.0 >= 11.0 ? "P.M." : "A.M."; timeEl.innerText = `Time: ${formattedHours}:${formattedMinutes} ${formattedMeridiem}`; } const gddEl = document.getElementById("gddText"); if (gddEl) gddEl.innerText = "GDD: " + data.cumulativeGDD; const rainEl = document.getElementById("rainText"); const r = data.cumulativeRain ?? 0; if (rainEl) rainEl.innerText = "Precipitation: " + Number(r).toFixed(2) + " mm"; const activeVehicleEl = document.getElementById("activeVehicleText"); if (activeVehicleEl) { const typeName = data.activeVehicleType === 1 ? "Seeder" : "Harvester"; activeVehicleEl.innerText = "Active Vehicle: " + typeName; } }

Time Formatting

The time display converts the 24-hour currentTime (0-24) into 12-hour format with AM/PM:

  • currentTime = 5.5 → “06:30 A.M.”
  • currentTime = 14.75 → “03:45 P.M.”
  • currentTime = 23.0 → “12:00 P.M.”

RenderDayCycleState: Day/Night Overlay

The RenderDayCycleState module (in renderDayCycleState.js) creates a visual day/night cycle by overlaying a semi-transparent blue rectangle:

render(context, data) { let nightAlpha = 0.0; if (data.currentTime < 12) nightAlpha = 1.0 - Math.min(Math.max(data.currentTime - 5.0, 0.0) / 4.0, 1.0); else nightAlpha = 1.0 - Math.min(Math.max(22.0 - data.currentTime, 0.0) / 4.0, 1.0); context.fillStyle = `rgba(0, 0, 150, ${0.45 * nightAlpha})`; context.fillRect(0, 0, data.canvasWidth, data.canvasHeight); }

Alpha calculation:

  • Dawn (5-9 AM): Alpha fades from 1.0 to 0.0 over 4 hours
  • Day (9 AM - 6 PM): Alpha = 0.0 (no overlay)
  • Dusk (6-10 PM): Alpha fades from 0.0 to 1.0 over 4 hours
  • Night (10 PM - 5 AM): Alpha = 1.0 (maximum darkness)

The final alpha is multiplied by 0.45, so maximum darkness is rgba(0, 0, 150, 0.45) — a semi-transparent dark blue.

Camera System

The camera position is managed by TractorSimManager.updateCameraCoordinates():

updateCameraCoordinates(implement, fieldWidth, canvasWidth, canvasHeight) { if (!implement) return; 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); }

Camera behavior:

  • Centers on the active vehicle (harvester or seeder)
  • Clamps to field boundaries (prevents showing empty space)
  • Allows -150px on the left (where vehicles start off-screen)
  • Ensures smooth scrolling as the vehicle moves

The camera position is passed to all render modules that need world-to-screen coordinate conversion (field and implements).

Render Data Flow

Here’s the complete flow from simulation to screen:

  1. simulationEngine.loop(): Updates simulation state
  2. simulationEngine.timeStepEvent(): Packages render data
    const renderModules = { [RENDER_MODULE_KEYS.STATS]: statData, [RENDER_MODULE_KEYS.FIELD]: fieldData, [RENDER_MODULE_KEYS.IMPLEMENTS]: vehicleData, [RENDER_MODULE_KEYS.DAY_CYCLE]: dayCycleData, }; const ts = new timeStepData(rainString, renderModules);
  3. Event dispatch: simulationEngineCreated event with timeStepData
  4. drawCanvas.handleTimeStep(): Receives event
  5. drawCanvas.renderAllModules(): Iterates over modules
  6. Each module.render(): Draws to canvas or updates DOM

This event-driven architecture decouples simulation logic from rendering, making it easy to add new visual features without modifying the core engine.

Rendering Constants

Key constants defined in renderingConstants.js:

ConstantValuePurpose
TILE_BASE_SIZE64Source sprite size (pixels)
FIELD_SCALE8Downscale factor for tiles
TILE_WIDTH8Rendered tile width (64 ÷ 8)
TILE_HEIGHT8Rendered tile height (64 ÷ 8)
FRAME_WIDTH64Vehicle sprite width
FRAME_HEIGHT64Vehicle sprite height

The 8:1 scale ratio allows high-resolution tile sprites to be rendered at small sizes for the zoomed-out field view.

Adding a New Render Module

To add a new visual feature:

  1. Create a render class extending RenderState:

    export default class RenderMyFeature extends RenderState { constructor() { const paths = { myImage: "/path/to/image.png" }; super(paths, Object.keys(paths).length); } render(context, data) { // Draw using context and data } }
  2. Add to RENDER_MODULE_KEYS:

    export const RENDER_MODULE_KEYS = { // ... existing keys MY_FEATURE: "myFeature", };
  3. Register in drawCanvas:

    this.renderModules = { // ... existing modules [RENDER_MODULE_KEYS.MY_FEATURE]: new RenderMyFeature(), };
  4. Provide data in timeStepEvent():

    const myFeatureData = { /* data for your module */ }; const renderModules = { // ... existing modules [RENDER_MODULE_KEYS.MY_FEATURE]: myFeatureData, };

Performance Considerations

Viewport culling: Only visible tiles are rendered (200-400 instead of 90,000)

Image preloading: All sprites load before rendering begins (prevents flashing)

Canvas transforms: Used sparingly (only for vehicle rotation) to avoid performance overhead

DOM updates: Stats are updated via direct DOM manipulation rather than React re-renders for better performance

What’s Next