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:
- simulationEngine: Generates render data every frame via
timeStepEvent() - timeStepData: Packages render data for each module
- drawCanvas: Orchestrates the rendering process
- 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 Key | Asset | Used For |
|---|---|---|
DIRT | T2D_Dirt_Placeholder.png | UNPLANTED tiles |
SEED | T2D_Planted_Placeholder.png | SEEDED tiles (all crops) |
WHEAT | wheat.png | MATURE wheat |
CORN | corn.png | MATURE corn |
SOY | soybean.png | MATURE 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) toTILE_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 Type | Sprite | Enum Value |
|---|---|---|
| Harvester | combine-harvester.png | VEHICLES.HARVESTER (0) |
| Seeder | seeder.png | VEHICLES.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:
save(): Preserve current transform statetranslate(): Move origin to vehicle centerrotate(): Apply vehicle’s angledrawImage(): Draw sprite centered on originrestore(): 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:
- simulationEngine.loop(): Updates simulation state
- 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); - Event dispatch:
simulationEngineCreatedevent withtimeStepData - drawCanvas.handleTimeStep(): Receives event
- drawCanvas.renderAllModules(): Iterates over modules
- 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:
| Constant | Value | Purpose |
|---|---|---|
TILE_BASE_SIZE | 64 | Source sprite size (pixels) |
FIELD_SCALE | 8 | Downscale factor for tiles |
TILE_WIDTH | 8 | Rendered tile width (64 ÷ 8) |
TILE_HEIGHT | 8 | Rendered tile height (64 ÷ 8) |
FRAME_WIDTH | 64 | Vehicle sprite width |
FRAME_HEIGHT | 64 | Vehicle 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:
-
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 } } -
Add to RENDER_MODULE_KEYS:
export const RENDER_MODULE_KEYS = { // ... existing keys MY_FEATURE: "myFeature", }; -
Register in drawCanvas:
this.renderModules = { // ... existing modules [RENDER_MODULE_KEYS.MY_FEATURE]: new RenderMyFeature(), }; -
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
- Simulation Engine Architecture: Understand the simulation loop that drives rendering each frame
- Field Tile System: Learn about the field data structure being drawn
- Crop Growth & Weather Mechanics: Explore how crop stages determine which tile sprites are shown