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.
- 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-i18nattributes - 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
- 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>
<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) { },
};
'projectile-motion'['en', 'fr', 'de', 'es', 'it', 'pt', 'ro']. The engine generates the language bar from this array.
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 |
5. Controls
The controls array defines all interactive input elements. The engine supports four control types:
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);
}
}
type: 'toggle' : Checkbox switch for boolean options.
{
type: 'toggle',
id: 'showTrajectory',
labelKey: 'showTrajectoryLabel',
default: true
}
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' },
]
}
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); }
},
]
data-field-id in the DOM for onUpdateData to target.i18n[lang] for the display label.'m/s', 's', 'N').'#22d3ee'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',
}
]
i18n[lang] for the graph title.180'#0a1029''#22d3ee''#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,
}
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.
- 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
- Calculate initial velocities from slider values
- Set initial positions
- Reset history arrays if needed
- Set
state.hasStarted = true
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.
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
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
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
));
}
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
_resizeCanvas(). Do not manually resize canvases or apply ctx.scale() yourself, or rendering will appear blurry on retina displays.
style.minHeight for critical dimensions so the canvas has size even before Tailwind applies its styles.
import/export) in config.js or index.html. The engine uses global variables and prototype-based inheritance.
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.
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.
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
