01

Project File Structure

Every simulation lives in its own top-level folder. The folder name becomes the URL path — keep it lowercase, hyphenated, no spaces.

my-simulation/
  index.html ← your simulation page
  # optional extras:
  worker.js ← Web Worker for heavy compute
  shader.vert ← external GLSL vertex shader
  shader.frag ← external GLSL fragment shader

Shared assets (CSS, JS, icons) live in shared/ at the root. Do NOT copy shared files into your sim folder — reference them with a relative path.

Referencing shared files

The data-base attribute on navbar/footer elements tells shared/components.js how far up the tree to look:

HTML
<!-- navbar root — auto-populated by components.js -->
<div id="navbar-root" data-base="../"></div>

<link rel="stylesheet" href="../shared/theme.css">
<link rel="stylesheet" href="../shared/components.css">

<!-- footer root -->
<div id="footer-root" data-base="../"></div>
<script src="../shared/components.js" defer></script>
Depth: Top-level sim folders use data-base="../". Pages inside categories/ or content/ use data-base="../../".
✓ Checkpoint — folder created, shared files referenced correctly
02

Simulation Template

Start from this minimal skeleton. It gives you a full-viewport canvas, Three.js via import map, an animation loop, and a WebGL fallback message.

HTML
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Simulation — 3D Simulations</title>
  <meta name="description" content="Short description, max 160 chars.">
  <link rel="stylesheet" href="../shared/theme.css">
  <link rel="stylesheet" href="../shared/components.css">
  <style>
    body { margin: 0; overflow: hidden; background: #0a0a0f; }
    #canvas-wrap { position: fixed; inset: 0; }
    canvas { display: block; width: 100% !important; height: 100% !important; }
  </style>
  <!-- Three.js import map -->
  <script type="importmap">
  { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/" } }
  </script>
</head>
<body>
  <div id="navbar-root" data-base="../"></div>
  <div id="canvas-wrap"></div>
  <div id="footer-root" data-base="../"></div>
  <script src="../shared/components.js" defer></script>

  <script type="module">
  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

  // ── Scene Setup ─────────────────────────────────────────────
  const canvas = document.createElement('canvas');
  document.getElementById('canvas-wrap').appendChild(canvas);

  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(innerWidth, innerHeight);

  const scene  = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
  camera.position.set(0, 5, 15);

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;

  // ── Lighting ────────────────────────────────────────────────
  scene.add(new THREE.AmbientLight(0xffffff, 0.4));
  const sun = new THREE.DirectionalLight(0xffffff, 1.2);
  sun.position.set(10, 20, 10);
  scene.add(sun);

  // ── Your Simulation Objects ─────────────────────────────────
  const geo  = new THREE.SphereGeometry(1, 32, 32);
  const mat  = new THREE.MeshStandardMaterial({ color: 0x6366f1 });
  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);

  // ── Resize ──────────────────────────────────────────────────
  window.addEventListener('resize', () => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
  });

  // ── Animation Loop ──────────────────────────────────────────
  const clock = new THREE.Clock();
  (function animate() {
    requestAnimationFrame(animate);
    const t = clock.getElapsedTime();
    mesh.rotation.y = t * 0.5;
    controls.update();
    renderer.render(scene, camera);
  })();
  </script>
</body>
</html>
Tip: Keep the import map at the top of <head> — it must appear before any type="module" script tag.
✓ Checkpoint — blank canvas renders a rotating sphere at 60 fps
03

HUD & Controls Panel

All simulations in this project follow the same HUD pattern: a semi-transparent panel in the bottom-left (or bottom-right), absolutely positioned over the canvas.

Standard HUD markup

HTML + CSS
<!-- Add inside <body>, after canvas-wrap -->
<div class="hud" id="hud">
  <div class="hud-title">My Simulation</div>
  <div class="hud-row">
    <label class="hud-label" for="speed">Speed</label>
    <input class="hud-range" id="speed" type="range" min="0.1" max="5" step="0.1" value="1">
  </div>
  <div class="hud-row">
    <label class="hud-label" for="count">Particles</label>
    <input class="hud-range" id="count" type="range" min="100" max="5000" step="100" value="1000">
  </div>
  <button class="hud-btn" id="btn-reset">Reset</button>
</div>

<style>
  .hud {
    position: fixed; bottom: 1.5rem; left: 1.5rem; z-index: 10;
    background: rgba(10,10,15,0.85); backdrop-filter: blur(12px);
    border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;
    padding: 1rem 1.25rem; min-width: 220px; color: #e2e8f0;
  }
  .hud-title  { font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
                letter-spacing: 0.1em; color: #6366f1; margin-bottom: 0.85rem; }
  .hud-row    { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.6rem; }
  .hud-label  { font-size: 0.72rem; color: #94a3b8; }
  .hud-range  { width: 100%; accent-color: #6366f1; }
  .hud-btn    { width: 100%; margin-top: 0.4rem; padding: 0.4rem;
                border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
                background: rgba(255,255,255,0.05); color: #e2e8f0;
                font-size: 0.78rem; cursor: pointer; transition: background 0.15s; }
  .hud-btn:hover { background: rgba(255,255,255,0.1); }
</style>

Wiring controls to your simulation

JavaScript
// In your module script, after scene setup:
const speedSlider = document.getElementById('speed');
const resetBtn    = document.getElementById('btn-reset');

let simSpeed = 1;
speedSlider.addEventListener('input', e => simSpeed = parseFloat(e.target.value));
resetBtn.addEventListener('click', resetSimulation);

function resetSimulation() {
  // re-initialise particles / geometry here
}

// Use simSpeed in the animation loop:
function animate() {
  requestAnimationFrame(animate);
  const dt = clock.getDelta() * simSpeed;
  updateParticles(dt);
  renderer.render(scene, camera);
}
Keyboard shortcut: Press H to toggle HUD visibility. Add document.addEventListener('keydown', e => { if (e.key === 'h') hud.classList.toggle('hidden'); })
✓ Checkpoint — HUD shows with working sliders and reset button
04

Mobile & Responsiveness

All simulations must work on touch screens. Three key areas: touch orbit controls, HUD sizing, and performance scaling.

Touch-friendly OrbitControls

OrbitControls handles touch events natively — one-finger rotate, two-finger pinch-zoom, two-finger pan. No extra code needed.

Responsive HUD

CSS
/* Collapse HUD on very small screens */
@media (max-width: 480px) {
  .hud {
    left: 0.75rem; right: 0.75rem; bottom: 0.75rem;
    min-width: unset;
  }
  /* Larger touch targets */
  .hud-range  { height: 24px; }
  .hud-btn    { padding: 0.65rem; font-size: 0.85rem; }
}

Performance scaling

JavaScript
// Detect mobile and reduce particle count / resolution
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
const PARTICLE_COUNT = isMobile ? 2000 : 10000;
renderer.setPixelRatio(isMobile ? 1 : Math.min(devicePixelRatio, 2));

// Adaptive quality: if FPS drops below 30, cut particle count in half
let frameCount = 0, lastTime = performance.now();
function checkFPS() {
  frameCount++;
  const now = performance.now();
  if (now - lastTime >= 2000) {
    const fps = frameCount / 2;
    if (fps < 30 && particleCount > 500) {
      particleCount = Math.floor(particleCount * 0.7);
      rebuildParticles();
    }
    frameCount = 0; lastTime = now;
  }
}
Always test on a real mobile device — Chrome DevTools emulation does not accurately reflect GPU performance or touch latency.
✓ Checkpoint — simulation runs smoothly on a mid-range phone
05

Quality Checklist

Before submitting a pull request or adding your simulation to the catalogue, verify every item below:

HTML & Metadata

  • Unique <title> in format Name — 3D Simulations
  • <meta name="description"> — 120–160 characters, plain text
  • <link rel="canonical"> with full absolute URL
  • lang="en" (or lang="uk" for Ukrainian version)
  • Valid og:title, og:description, og:type

Performance

  • Stable 60 fps on a mid-range device (test with Chrome DevTools CPU throttle 4×)
  • No memory leaks — geometry.dispose() and material.dispose() called on cleanup
  • Pixel ratio capped at 2 (Math.min(devicePixelRatio, 2))
  • Textures are power-of-two dimensions (64, 128, 256 … 2048)

Accessibility

  • All interactive controls have visible focus ring (:focus-visible)
  • HUD labels use <label for="…"> or aria-label
  • Canvas has aria-label describing the simulation
  • Pause button accessible without mouse navigation

Cross-browser & Mobile

  • Tested in Chrome, Firefox, Safari (WebKit)
  • Touch events work on iOS Safari (pinch, rotate)
  • Graceful WebGL fallback message if context unavailable
  • HUD is usable at 375px viewport width

Adding to the Catalogue

Once your simulation passes the checklist, add it to shared/data/simulations.json (if it exists) and add a card in index.html pointing to the new folder.

Tip: Open a PR with the sim folder, a screenshot preview/my-sim.jpg (1200×630), and a one-paragraph description. The review checklist will run automatically.
✓ All checks pass — ready to submit!

What next?

Continue building your skills with these resources