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.
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:
<!-- 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>
data-base="../". Pages inside
categories/ or content/ use
data-base="../../".
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.
<!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>
<head> — it must appear before any
type="module" script tag.
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
<!-- 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
// 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);
}
H to toggle
HUD visibility. Add
document.addEventListener('keydown', e => { if (e.key ===
'h') hud.classList.toggle('hidden'); })
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
/* 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
// 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;
}
}
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"(orlang="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()andmaterial.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="…">oraria-label -
Canvas has
aria-labeldescribing 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.
preview/my-sim.jpg (1200×630), and a one-paragraph
description. The review checklist will run automatically.
What next?
Continue building your skills with these resources