SimulatorEngine API Documentation

SimulatorEngine API Documentation

STEAM StartUp (steam-startup.org): Create interactive educational simulators with a config-driven architecture

1. Architecture Overview

The SimulatorEngine is a JavaScript class (_core/simulator-engine.js) that follows the “uma classe, varios objetos” pattern: a single reusable engine that generates the full HTML UI from a per-simulator config object. Each simulator is defined entirely by its config.js file, which describes controls, translations, physics logic, canvas rendering, data displays, and graphs.

Engine Responsibilities
  • Generate the full HTML UI from config (language bar, header, controls, canvas, data panel, graphs, footer)
  • Manage 7-language i18n system with auto-translation via data-i18n attributes
  • Provide DPR-aware (devicePixelRatio) Canvas with crisp rendering on retina displays
  • Run the animation loop (requestAnimationFrame) and call hook functions each frame
  • Bind events for sliders, toggles, buttons, and preset controls
  • Handle Start/Pause/Reset lifecycle

Config Responsibilities
  • Define simulator identity (id, title, subtitle)
  • Provide full i18n translations for all 7 supported languages
  • Describe controls, data fields, and graph definitions
  • Set initial simulation state
  • Implement physics, rendering, and interaction via hook functions
  • Provide rendering helpers (drawBackground, drawBall, etc.)

2. Directory Structure

Each simulator lives in its own folder under simuladores/. The shared engine and styles live in _core/ at the same level:

simuladores/
  _core/
    simulator-engine.js      // Base class (873 lines) : generates UI from config
    simulator.css             // Shared styles: dark theme, glass panels, sliders, buttons, graphs
  projectile-motion/
    index.html                // Minimal HTML : loads Tailwind CDN + engine + config
    config.js                 // The only file that varies per simulator
  buoyancy/
    index.html
    config.js
  doppler-effect/
    index.html
    config.js
  ...

Every index.html references ../_core/simulator-engine.js and ../_core/simulator.css via relative paths. This structure must be preserved.

index.html Template

Keep this file nearly identical across all simulators. Only change the <title> and the config path if needed:

<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Simulator Name</title>
  <script src="https://cdn.tailwindcss.com/3.4.17"></script>
  <link rel="stylesheet" href="../_core/simulator.css">
  <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
  <style>
    html, body { height: 100%; margin: 0; padding: 0; }
    #app { min-height: 100%; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="../_core/simulator-engine.js"></script>
  <script src="config.js"></script>
  <script>
    document.addEventListener('DOMContentLoaded', function() {
      const engine = new SimulatorEngine(config, '#app');
      window.engine = engine;
    });
  </script>
</body>
</html>

Note: The engine uses regular <script> tags, not ES modules. This allows testing by double-clicking the HTML file directly (file:// protocol).

3. Config Object

The config object is the heart of every simulator. It is a global JavaScript object defined in config.js. Below is the complete top-level structure:

var config = {
  id: 'my-simulator',             // Unique slug, used as folder name
  languages: ['en', 'fr', 'de', 'es', 'it', 'pt', 'ro'],  // Must include all 7
  i18n: { ... },                  // Nested translations by language key
  controls: [ ... ],              // Array of control definitions
  dataFields: [ ... ],            // Array of data display field definitions
  graphs: [ ... ],                // Array of graph definitions (optional)
  state: { ... },                 // Initial simulation state object

  // Hook functions (all optional except onDraw for visible output)
  onInit: function(sim) { },
  onResize: function(state, sim) { },
  onStart: function(state, controls, sim) { },
  onUpdate: function(dt, state, controls, sim) { },
  onDraw: function(ctx, canvas, state, sim) { },
  onDrawGraphs: function(graphCtxs, state, sim) { },
  onUpdateData: function(state, sim) { },
  onControlChange: function(id, value, state, sim) { },
  onReset: function(sim) { },
};

config.id
string required : Unique slug identifier. Used as the folder name and referenced in CPT meta. Example: 'projectile-motion'

config.languages
Array<string> required : Must be exactly ['en', 'fr', 'de', 'es', 'it', 'pt', 'ro']. The engine generates the language bar from this array.

config.i18n
Object required : Nested object keyed by language code. Each language contains string keys used throughout the UI. See section 4.

config.controls
Array<Object> required : Array of control definitions (slider, toggle, button-group, presets). See section 5.

config.dataFields
Array<Object> required : Array of read-only data display field definitions. See section 6.

config.graphs
Array<Object> optional : Array of graph definitions. Each graph gets its own canvas element. See section 7.

config.state
Object required : The initial simulation state. All state properties that will change during simulation must be initialized here. See section 8.

4. Languages & i18n

The engine supports exactly 7 languages: English, French, German, Spanish, Italian, Portuguese (PT-PT), and Romanian. The i18n config property is a nested object:

i18n: {
  en: {
    title: 'Projectile Motion',
    subtitle: 'Explore the physics of projectiles',
    footer: 'STEAM StartUp © 2025',
    velocityLabel: 'Initial Velocity',
    angleLabel: 'Launch Angle',
    massLabel: 'Mass',
    startBtn: 'Launch',
    pauseBtn: 'Pause',
    resetBtn: 'Reset',
    timeLabel: 'Time',
    heightLabel: 'Height',
    distanceLabel: 'Distance',
    velocityDisplay: 'Velocity',
    graphTitle: 'Trajectory',
    presetLow: 'Low Arc',
    presetHigh: 'High Arc',
    presetOptimal: 'Optimal (45°)',
  },
  fr: {
    title: 'Mouvement des Projectiles',
    subtitle: 'Explorez la physique des projectiles',
    // ... same keys translated
  },
  de: { /* ... */ },
  es: { /* ... */ },
  it: { /* ... */ },
  pt: {
    title: 'Lançamento de Projéteis',
    subtitle: 'Explore a física dos projéteis',
    // ... proper PT-PT accents required
  },
  ro: { /* ... */ },
}

String Keys You Must Define

Key Used In Description
title Header Simulator display title
subtitle Header Short description below title
footer Footer bar Copyright or credits text
startBtn Start/Launch button Text for the primary action button
pauseBtn Pause button Text for pause button
resetBtn Reset button Text for reset button
[controlId]Label Control labels Each control’s labelKey maps to a key in i18n
[fieldId]Label Data field labels Each data field’s labelKey maps to a key in i18n
[graphId]Title Graph headers Each graph’s title key maps to i18n
[presetId] Preset buttons Each preset’s labelKey maps to a key in i18n
[buttonId] Button-group buttons Each button’s label string maps to i18n
PT-PT Requirement: European Portuguese translations must include all proper accents (acutes, tildes, circumflexes, cedillas). Common corrections: “fisica” to “fisica” (acute i), “trajetorias” to “trajetorias” (acute o), “Lancamento” to “Lancamento” (cedilla c), “Canhao” to “Canhao” (tilde n). See the project’s PT-PT reference for the full list.

5. Controls

The controls array defines all interactive input elements. The engine supports four control types:

Slider
type: 'slider' : Range input with label, value display, and unit.

{
  type: 'slider',
  id: 'velocity',
  labelKey: 'velocityLabel',
  min: 5,
  max: 30,
  step: 0.5,
  default: 15,
  unit: 'm/s',
  displayFormat: function(v) {
    return v.toFixed(1);
  }
}

Toggle
type: 'toggle' : Checkbox switch for boolean options.

{
  type: 'toggle',
  id: 'showTrajectory',
  labelKey: 'showTrajectoryLabel',
  default: true
}

Button Group
type: 'button-group' : Grid of action buttons. Only start, pause, reset are handled natively.

{
  type: 'button-group',
  id: 'mainControls',
  buttons: [
    { id: 'startBtn', label: 'startBtn',
      action: 'start' },
    { id: 'resetBtn', label: 'resetBtn',
      action: 'reset' },
  ]
}

Presets
type: 'presets' : Quick-value buttons that set multiple sliders at once.

{
  type: 'presets',
  presets: [
    {
      id: 'presetLow',
      labelKey: 'presetLow',
      values: { velocity: 10, angle: 30 }
    },
    {
      id: 'presetHigh',
      labelKey: 'presetHigh',
      values: { velocity: 25, angle: 60 }
    },
  ]
}

Custom Button Actions

The engine only handles start, pause, and reset actions natively. For any other action (e.g., lens type selection, shape toggles), attach event listeners inside onInit():

onInit: function(sim) {
  sim.state.lensType = 'convex';

  var btns = document.querySelectorAll('[data-action^="setLens"]');
  btns.forEach(function(btn) {
    btn.addEventListener('click', function() {
      sim.state.lensType = btn.getAttribute('data-action').replace('setLens', '').toLowerCase();
      document.querySelectorAll('[data-action^="setLens"]').forEach(function(b) {
        b.classList.remove('active-lens');
      });
      btn.classList.add('active-lens');
    });
  });
}

Then define the button-group in controls:

{
  type: 'button-group',
  id: 'lensSelector',
  buttons: [
    { id: 'lensConvex', label: 'convexLabel', action: 'setLensConvex' },
    { id: 'lensConcave', label: 'concaveLabel', action: 'setLensConcave' },
  ]
}

Add CSS for .active-lens in the index.html style block:

.active-lens {
  background: linear-gradient(135deg, #6366f1, #8b5cf6) !important;
  box-shadow: 0 0 15px rgba(99,102,241,0.4);
}

6. Data Fields

The dataFields array defines a grid of read-only data display elements. Each field shows a label and a dynamically updated value.

dataFields: [
  {
    id: 'time',
    labelKey: 'timeLabel',
    unit: 's',
    color: '#22d3ee',
    format: function(v) { return v.toFixed(2); }
  },
  {
    id: 'height',
    labelKey: 'heightLabel',
    unit: 'm',
    color: '#34d399',
    format: function(v) { return v.toFixed(1); }
  },
  {
    id: 'distance',
    labelKey: 'distanceLabel',
    unit: 'm',
    color: '#a78bfa',
    format: function(v) { return v.toFixed(1); }
  },
]

id
string required : Unique identifier. Used as data-field-id in the DOM for onUpdateData to target.

labelKey
string required : Key into i18n[lang] for the display label.

unit
string optional : Unit string appended to the value (e.g., 'm/s', 's', 'N').

color
string optional : CSS color for the value text. Example: '#22d3ee'

format
function optional : Format function. Receives the raw value, returns a display string. Example: function(v) { return v.toFixed(2); }

Update data values each frame inside onUpdateData():

onUpdateData: function(state, sim) {
  sim.setData('time', state.time);
  sim.setData('height', state.height);
  sim.setData('distance', state.distance);
}

7. Graphs

The graphs array defines additional canvas elements below the main canvas for plotting data. Each graph gets its own 2D context.

graphs: [
  {
    id: 'trajectoryGraph',
    labelKey: 'graphTitle',
    height: 200,
    bgColor: '#0a1029',
    lineColor: '#22d3ee',
    gridColor: '#1e293b',
  }
]

id
string required : Unique graph identifier.

labelKey
string required : Key into i18n[lang] for the graph title.

height
number optional : Canvas height in pixels. Default: 180

bgColor
string optional : Background color. Default: '#0a1029'

lineColor
string optional : Primary line color. Default: '#22d3ee'

gridColor
string optional : Grid line color. Default: '#1e293b'

Render graphs inside onDrawGraphs():

onDrawGraphs: function(graphCtxs, state, sim) {
  var ctx = graphCtxs.trajectoryGraph;
  var canvas = ctx.canvas;
  var w = canvas.width;
  var h = canvas.height;

  // Clear
  ctx.fillStyle = '#0a1029';
  ctx.fillRect(0, 0, w, h);

  // Draw grid
  ctx.strokeStyle = '#1e293b';
  ctx.lineWidth = 1;
  for (var x = 0; x < w; x += 40) {
    ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
  }
  for (var y = 0; y < h; y += 40) {
    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
  }

  // Plot data
  if (state.xHistory && state.xHistory.length > 1) {
    ctx.strokeStyle = '#22d3ee';
    ctx.lineWidth = 2;
    ctx.beginPath();
    for (var i = 0; i < state.xHistory.length; i++) {
      var px = (state.xHistory[i] / state.maxX) * w;
      var py = h - (state.yHistory[i] / state.maxY) * h;
      if (i === 0) ctx.moveTo(px, py);
      else ctx.lineTo(px, py);
    }
    ctx.stroke();
  }
}

8. State

The state object stores all mutable simulation variables. It is initialized in the config and cloned by the engine so that onReset() can restore the original values.

state: {
  // Physics variables
  time: 0,
  vx: 0,
  vy: 0,
  x: 0,
  y: 0,

  // History arrays for graphing
  xHistory: [],
  yHistory: [],

  // Flags
  hasStarted: false,
  hasDropped: false,
  settled: false,
}

Important: The engine uses JSON.parse(JSON.stringify(config.state)) to create a fresh copy on reset. Make sure all initial values are serializable (no functions, no Date objects, no DOM references). Store custom UI state (like shape, lensType) directly on sim.state inside onInit().

9. Hook Functions

All hook functions are optional. The engine calls them at specific lifecycle points. Each receives the sim object (the engine instance) and relevant context.

onInit(sim)
Called once after the DOM is fully built and all controls are rendered. Use this to:

  • Attach custom event listeners to buttons with non-standard actions
  • Initialize custom state properties (e.g., sim.state.lensType = 'convex')
  • Set up additional DOM elements
  • Draw initial canvas state

onResize(state, sim)
Called on window resize and once during initialization. The engine handles DPR-aware canvas resizing automatically. Use this to recalculate layout-dependent values (e.g., scale factors, grid spacing). Do not manually resize canvases here.

onStart(state, controls, sim)
Called when the user presses the Start/Launch button. Use this to:

  • Calculate initial velocities from slider values
  • Set initial positions
  • Reset history arrays if needed
  • Set state.hasStarted = true

onUpdate(dt, state, controls, sim)
Called every animation frame while the simulation is running (Start has been pressed and not Paused). The dt parameter is the delta time in seconds since the last frame (capped at 0.05s to prevent physics tunneling). Use this for:

  • Physics integration (Euler, Verlet, etc.)
  • Updating positions, velocities, accelerations
  • Appending to history arrays for graphing
  • Collision detection

For static/interactive simulators (no time-stepping), this function can be minimal or omitted entirely.

onDraw(ctx, canvas, state, sim)
Called every animation frame (always, even when paused) to render the main canvas. This is where you draw the simulation visuals. The ctx is the 2D context of the main canvas, already DPR-scaled. Tasks:

  • Clear the canvas with ctx.clearRect()
  • Draw background elements (grid, ground, sky, axes)
  • Draw simulation objects (balls, vectors, paths)
  • Draw labels, annotations, or overlays

onDrawGraphs(graphCtxs, state, sim)
Called every frame when graphs are defined. The graphCtxs parameter is an object keyed by graph ID, each value being a 2D canvas context. Tasks:

  • Clear each graph canvas
  • Draw grid lines
  • Plot data from history arrays
  • Draw axis labels

onUpdateData(state, sim)
Called every frame to update the data display panel. Use sim.setData(id, value) to update each field:

onUpdateData: function(state, sim) {
  sim.setData('time', state.time);
  sim.setData('height', state.y);
  sim.setData('distance', state.x);
  sim.setData('velocity', Math.sqrt(
    state.vx * state.vx + state.vy * state.vy
  ));
}

onControlChange(id, value, state, sim)
Called when any slider or toggle value changes. Use this for real-time updates (e.g., recalculating derived values, redrawing without restarting). For many simulators this can be left empty if the engine's automatic control binding is sufficient.

onReset(sim)
Called when the Reset button is pressed. By default, the engine restores state to its initial values. Use this hook for additional cleanup:

  • Resetting custom state properties
  • Clearing history arrays beyond what the engine handles
  • Resetting button active states
  • Clearing canvas decorations
onReset: function(sim) {
  sim.state.lensType = 'convex';
  document.querySelectorAll('.active-lens').forEach(function(b) {
    b.classList.remove('active-lens');
  });
}

10. Complete Example: Projectile Motion

Below is a full, working config.js file for a Projectile Motion simulator. This demonstrates every config section and hook function in context.

// ============================================================
// Projectile Motion : Simulator Config
// STEAM StartUp (steam-startup.org)
// ============================================================

var config = {
  id: 'projectile-motion',
  languages: ['en', 'fr', 'de', 'es', 'it', 'pt', 'ro'],

  i18n: {
    en: {
      title: 'Projectile Motion',
      subtitle: 'Explore the physics of launched projectiles',
      footer: 'STEAM StartUp © 2025',
      velocityLabel: 'Initial Velocity',
      angleLabel: 'Launch Angle',
      massLabel: 'Mass',
      gravityLabel: 'Gravity',
      startBtn: 'Launch',
      pauseBtn: 'Pause',
      resumeBtn: 'Resume',
      resetBtn: 'Reset',
      timeLabel: 'Time',
      heightLabel: 'Height',
      distanceLabel: 'Distance',
      velocityDisplay: 'Velocity',
      graphTitle: 'Trajectory',
      presetLow: 'Low Arc (10m/s, 30deg)',
      presetHigh: 'High Arc (25m/s, 60deg)',
      presetOptimal: 'Optimal (20m/s, 45deg)',
    },
    fr: {
      title: 'Mouvement des Projectiles',
      subtitle: 'Explorez la physique des projectiles',
      footer: 'STEAM StartUp © 2025',
      velocityLabel: 'Vitesse initiale',
      angleLabel: 'Angle de lancement',
      massLabel: 'Masse',
      gravityLabel: 'Gravite',
      startBtn: 'Lancer',
      pauseBtn: 'Pause',
      resumeBtn: 'Reprendre',
      resetBtn: 'Reinitialiser',
      timeLabel: 'Temps',
      heightLabel: 'Hauteur',
      distanceLabel: 'Distance',
      velocityDisplay: 'Vitesse',
      graphTitle: 'Trajectoire',
      presetLow: 'Arc bas (10m/s, 30deg)',
      presetHigh: 'Arc haut (25m/s, 60deg)',
      presetOptimal: 'Optimal (20m/s, 45deg)',
    },
    // de, es, it, pt, ro : same keys translated ...
    pt: {
      title: 'Lancamento de Projeteis',
      subtitle: 'Explore a fisica dos projeteis',
      footer: 'STEAM StartUp © 2025',
      velocityLabel: 'Velocidade inicial',
      angleLabel: 'Angulo de lancamento',
      massLabel: 'Massa',
      gravityLabel: 'Gravidade',
      startBtn: 'Lancar',
      pauseBtn: 'Pausar',
      resumeBtn: 'Continuar',
      resetBtn: 'Reiniciar',
      timeLabel: 'Tempo',
      heightLabel: 'Altura',
      distanceLabel: 'Distancia',
      velocityDisplay: 'Velocidade',
      graphTitle: 'Trajetoria',
      presetLow: 'Arco baixo (10m/s, 30deg)',
      presetHigh: 'Arco alto (25m/s, 60deg)',
      presetOptimal: 'Otimo (20m/s, 45deg)',
    },
  },

  // --- Controls ---
  controls: [
    {
      type: 'slider',
      id: 'velocity',
      labelKey: 'velocityLabel',
      min: 5,
      max: 30,
      step: 0.5,
      default: 15,
      unit: 'm/s',
      displayFormat: function(v) { return v.toFixed(1); },
    },
    {
      type: 'slider',
      id: 'angle',
      labelKey: 'angleLabel',
      min: 0,
      max: 90,
      step: 1,
      default: 45,
      unit: 'deg',
      displayFormat: function(v) { return v + 'deg'; },
    },
    {
      type: 'presets',
      presets: [
        { id: 'presetLow', labelKey: 'presetLow', values: { velocity: 10, angle: 30 } },
        { id: 'presetHigh', labelKey: 'presetHigh', values: { velocity: 25, angle: 60 } },
        { id: 'presetOptimal', labelKey: 'presetOptimal', values: { velocity: 20, angle: 45 } },
      ],
    },
    {
      type: 'toggle',
      id: 'showTrajectory',
      labelKey: 'showTrajectoryLabel',
      default: true,
    },
    {
      type: 'button-group',
      id: 'mainControls',
      buttons: [
        { id: 'startBtn', label: 'startBtn', action: 'start' },
        { id: 'resetBtn', label: 'resetBtn', action: 'reset' },
      ],
    },
  ],

  // --- Data Fields ---
  dataFields: [
    { id: 'time', labelKey: 'timeLabel', unit: 's', color: '#22d3ee', format: function(v) { return v.toFixed(2); } },
    { id: 'height', labelKey: 'heightLabel', unit: 'm', color: '#34d399', format: function(v) { return v.toFixed(1); } },
    { id: 'distance', labelKey: 'distanceLabel', unit: 'm', color: '#a78bfa', format: function(v) { return v.toFixed(1); } },
    { id: 'velocity', labelKey: 'velocityDisplay', unit: 'm/s', color: '#fbbf24', format: function(v) { return v.toFixed(1); } },
  ],

  // --- Graphs ---
  graphs: [
    {
      id: 'trajectoryGraph',
      labelKey: 'graphTitle',
      height: 200,
      bgColor: '#0a1029',
      lineColor: '#22d3ee',
      gridColor: '#1e293b',
    },
  ],

  // --- Initial State ---
  state: {
    time: 0,
    vx: 0,
    vy: 0,
    x: 0,
    y: 0,
    xHistory: [],
    yHistory: [],
    maxX: 100,
    maxY: 50,
    hasStarted: false,
    hasLanded: false,
    G: 9.81,
  },

  // --- Hook Functions ---

  onInit: function(sim) {
    // Draw initial ground line
    sim.needsRedraw = true;
  },

  onStart: function(state, controls, sim) {
    // Convert angle to radians and decompose velocity
    var angleRad = controls.angle * (Math.PI / 180);
    state.vx = controls.velocity * Math.cos(angleRad);
    state.vy = controls.velocity * Math.sin(angleRad);
    state.x = 0;
    state.y = 0;
    state.time = 0;
    state.xHistory = [];
    state.yHistory = [];
    state.hasStarted = true;
    state.hasLanded = false;

    // Estimate max range for graph scaling
    var totalTime = (2 * controls.velocity * Math.sin(angleRad)) / state.G;
    state.maxX = controls.velocity * Math.cos(angleRad) * totalTime * 1.1;
    state.maxY = (controls.velocity * controls.velocity *
      Math.sin(angleRad) * Math.sin(angleRad)) / (2 * state.G) * 1.2;
  },

  onUpdate: function(dt, state, controls, sim) {
    if (!state.hasStarted || state.hasLanded) return;

    // Euler integration
    state.vy -= state.G * dt;
    state.x += state.vx * dt;
    state.y += state.vy * dt;
    state.time += dt;

    // Record history
    state.xHistory.push(state.x);
    state.yHistory.push(state.y);

    // Ground collision
    if (state.y < 0) {
      state.y = 0;
      state.hasLanded = true;
    }
  },

  onDraw: function(ctx, canvas, state, sim) {
    var w = canvas.width;
    var h = canvas.height;

    // Clear
    ctx.clearRect(0, 0, w, h);

    // Background
    ctx.fillStyle = '#0a1029';
    ctx.fillRect(0, 0, w, h);

    // Ground
    var groundY = h - 60;
    ctx.strokeStyle = '#334155';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0, groundY);
    ctx.lineTo(w, groundY);
    ctx.stroke();

    // Scale: map physics coords to canvas pixels
    var maxDisplayX = state.maxX || 100;
    var maxDisplayY = state.maxY || 50;
    var scaleX = (w - 80) / maxDisplayX;
    var scaleY = (groundY - 20) / maxDisplayY;

    // Draw trajectory trace
    if (state.showTrajectory !== false && state.xHistory.length > 1) {
      ctx.strokeStyle = 'rgba(34, 211, 238, 0.4)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      for (var i = 0; i < state.xHistory.length; i++) {
        var px = 40 + state.xHistory[i] * scaleX;
        var py = groundY - state.yHistory[i] * scaleY;
        if (i === 0) ctx.moveTo(px, py);
        else ctx.lineTo(px, py);
      }
      ctx.stroke();
    }

    // Draw projectile (ball)
    if (state.hasStarted) {
      var bx = 40 + state.x * scaleX;
      var by = groundY - state.y * scaleY;
      var radius = 8;

      // Glow
      var gradient = ctx.createRadialGradient(bx, by, 0, bx, by, radius * 3);
      gradient.addColorStop(0, 'rgba(52, 211, 153, 0.4)');
      gradient.addColorStop(1, 'rgba(52, 211, 153, 0)');
      ctx.fillStyle = gradient;
      ctx.beginPath();
      ctx.arc(bx, by, radius * 3, 0, Math.PI * 2);
      ctx.fill();

      // Ball
      ctx.fillStyle = '#34d399';
      ctx.beginPath();
      ctx.arc(bx, by, radius, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = '#22d3ee';
      ctx.lineWidth = 1.5;
      ctx.stroke();
    }
  },

  onDrawGraphs: function(graphCtxs, state, sim) {
    var ctx = graphCtxs.trajectoryGraph;
    if (!ctx) return;
    var cvs = ctx.canvas;
    var w = cvs.width;
    var h = cvs.height;

    // Clear
    ctx.fillStyle = '#0a1029';
    ctx.fillRect(0, 0, w, h);

    // Grid
    ctx.strokeStyle = '#1e293b';
    ctx.lineWidth = 1;
    for (var x = 0; x < w; x += 40) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
    }
    for (var y = 0; y < h; y += 40) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
    }

    // Plot
    if (state.xHistory && state.xHistory.length > 1) {
      var maxX = state.maxX || 100;
      var maxY = state.maxY || 50;
      ctx.strokeStyle = '#22d3ee';
      ctx.lineWidth = 2;
      ctx.beginPath();
      for (var i = 0; i < state.xHistory.length; i++) {
        var px = (state.xHistory[i] / maxX) * w;
        var py = h - (state.yHistory[i] / maxY) * h;
        if (i === 0) ctx.moveTo(px, py);
        else ctx.lineTo(px, py);
      }
      ctx.stroke();
    }
  },

  onUpdateData: function(state, sim) {
    sim.setData('time', state.time);
    sim.setData('height', Math.max(0, state.y));
    sim.setData('distance', state.x);
    var speed = Math.sqrt(state.vx * state.vx + state.vy * state.vy);
    sim.setData('velocity', speed);
  },

  onControlChange: function(id, value, state, sim) {
    // Update graph scaling when sliders change
    if (id === 'velocity' || id === 'angle') {
      var angleRad = sim.controlValues.angle * (Math.PI / 180);
      var v = sim.controlValues.velocity;
      var totalTime = (2 * v * Math.sin(angleRad)) / state.G;
      state.maxX = v * Math.cos(angleRad) * totalTime * 1.1;
      state.maxY = (v * v * Math.sin(angleRad) * Math.sin(angleRad)) / (2 * state.G) * 1.2;
    }
  },

  onReset: function(sim) {
    sim.state.hasStarted = false;
    sim.state.hasLanded = false;
    sim.state.xHistory = [];
    sim.state.yHistory = [];
  },
};

11. Common Pitfalls

Never use em dash character in any text output. Replace with colons, commas, periods, or rephrase. This applies to all i18n strings, documentation, and code comments.
Canvas DPR: The engine handles devicePixelRatio scaling automatically via _resizeCanvas(). Do not manually resize canvases or apply ctx.scale() yourself, or rendering will appear blurry on retina displays.
Tailwind CDN loads asynchronously. The engine uses inline style.minHeight for critical dimensions so the canvas has size even before Tailwind applies its styles.
file:// protocol requires regular <script> tags. Do not use ES modules (import/export) in config.js or index.html. The engine uses global variables and prototype-based inheritance.
Custom action buttons: The engine only handles start, pause, and reset actions natively. For any other data-action value (e.g., convex, sphere), handle it in onInit() via DOM event listeners. Do not add new action types to the engine's switch statement.
State serialization: The engine clones state with JSON.parse(JSON.stringify()). Do not store functions, DOM references, or non-serializable objects in the initial config.state. Add mutable custom state in onInit() via sim.state.prop = value.
PT-PT accents: Subagents frequently strip Portuguese accents from i18n strings. Verify all PT-PT text against the project's accent correction list: "fisica" (fisica with acute i), "trajetorias" (trajetorias with acute o), "Lancamento" (Lancamento with cedilla), "Canhao" (Canhao with tilde), "Posicao" (Posicao with tilde), "Graficos" (Graficos with acute a), etc.
Animation loop dt capping: The engine caps dt at 0.05 seconds to prevent physics tunneling when the browser tab is backgrounded. Always use dt (not a fixed timestep) in your physics integration.

12. Deployment

Validation Checklist

Before deploying, verify your simulator works:

# 1. Syntax check config.js
node --check config.js

# 2. Open index.html directly in browser (file:// protocol)
# - All 7 language buttons switch translations
# - Sliders, toggles, presets work
# - Start/Pause/Reset cycle works
# - Canvas renders correctly at different viewport sizes
# - Data fields update in real time
# - Graphs render data

Upload via FTP

# Upload a single simulator
lftp -u user,password ftp.steam-startup.org -e "
  set ssl:verify-certificate no
  mkdir -p wp-content/uploads/simulators/my-simulator
  put /local/my-simulator/index.html -o wp-content/uploads/simulators/my-simulator/index.html
  put /local/my-simulator/config.js -o wp-content/uploads/simulators/my-simulator/config.js
  bye
"

# Upload all simulators at once (mirror entire simuladores/ folder)
lftp -u user,password ftp.steam-startup.org -e "
  set ssl:verify-certificate no
  mirror -R /local/simuladores/ wp-content/uploads/simulators/
  bye
"

Create WordPress CPT Post

Each simulator needs a Custom Post Type post with metadata. Use a PHP script uploaded to the WordPress root:

<?php
require_once(dirname(__FILE__) . '/wp-load.php');
wp_set_current_user(5); // Athena admin user ID

$slug = 'my-simulator';
$title = 'My Simulator';
$url = 'https://steam-startup.org/wp-content/uploads/simulators/' . $slug . '/index.html';

$existing = get_page_by_path($slug, OBJECT, 'simulator');
if ($existing) {
  update_post_meta($existing->ID, '_simulator_url', $url);
  echo "UPDATED: {$title} (ID {$existing->ID})n";
} else {
  $post_id = wp_insert_post([
    'post_title'   => $title,
    'post_name'    => $slug,
    'post_status'  => 'publish',
    'post_type'    => 'simulator',
    'post_author'  => 5,
    'post_content' => 'Interactive educational simulator for ' . $title . '.',
    'meta_input'   => [
      '_simulator_url'          => $url,
      '_simulator_difficulty'   => 'beginner',
      '_simulator_duration'     => 15,
      '_simulator_version'      => '1.0.0',
      '_simulator_total_plays'  => 0,
      '_simulator_rating_avg'   => 0,
      '_simulator_rating_count' => 0,
      '_simulator_plan_access'  => 'free',
    ],
  ]);
  echo "CREATED: {$title} (ID {$post_id})n";
}

flush_rewrite_rules(true);

Upload, execute, and remove the script:

lftp -u user,pass ftp.steam-startup.org -e "put /tmp/script.php -o create-posts.php; bye"
curl -s "https://steam-startup.org/create-posts.php"
lftp -u user,pass ftp.steam-startup.org -e "rm create-posts.php; bye"

Post-Deployment Verification

Confirm all endpoints return HTTP 200:

# Simulator files
curl -s -o /dev/null -w "%{http_code}" "https://steam-startup.org/wp-content/uploads/simulators/my-simulator/index.html"
curl -s -o /dev/null -w "%{http_code}" "https://steam-startup.org/wp-content/uploads/simulators/my-simulator/config.js"

# Engine core files
curl -s -o /dev/null -w "%{http_code}" "https://steam-startup.org/wp-content/uploads/simulators/_core/simulator-engine.js"
curl -s -o /dev/null -w "%{http_code}" "https://steam-startup.org/wp-content/uploads/simulators/_core/simulator.css"

# WordPress page
curl -s -o /dev/null -w "%{http_code}" "https://steam-startup.org/simulator/my-simulator/"

# REST API
curl -s "https://steam-startup.org/wp-json/wp/v2/simulators?slug=my-simulator&_fields=id,slug,meta._simulator_url"


SimulatorEngine API Documentation : STEAM StartUp (steam-startup.org)
Last updated: May 2026