π Live demo: https://axisrow.github.io/demoscene_classics/
Ten configurable demoscene effects distributed as ordinary browser scripts β
no framework, no server, no runtime dependency. The build produces plain
<script>-loadable IIFE bundles that work over HTTP and via file://.
API v3 is the current public API. It is a clean break from the flat v2 options object. v2 calls fail fast with a migration hint β see
docs/api-v3.mdfor the full migration guide and reference.
Load the complete bundle, then call an effect on a <canvas>:
<canvas id="proof" style="width:100vw;height:100vh"></canvas>
<script src="dist/demoscene.js"></script>
<script>
// Standalone pages use the fullscreen default surface.
Demoscene.fire('#proof');
</script>The ten available functions are plasma, fire, starfield, metaballs,
tunnel, mandelbrot, sineScroller, rotozoom, feedback, and
copperBars. Each standalone effect script also remains available under
dist/effects/<name>.js.
Every effect accepts the same descriptor. Nothing is flat any more:
Demoscene.fire(canvas, {
skin: 'classic', // named visual preset, or { preset, overrides }
surface: 'fullscreen', // 'fullscreen' (standalone) or 'preview' (gallery card)
device: 'auto', // 'auto' | 'desktop' | 'mobile'
config: { // expert escape hatch β algorithmic overrides only
simulation: { cooling: 0.4 }
}
});Defaults
| Field | Default | Meaning |
|---|---|---|
skin |
'classic' |
The named visual preset shipped by every effect. |
surface |
'fullscreen' |
'fullscreen' for standalone pages, 'preview' for cards. |
device |
'auto' |
Resolved once at mount from matchMedia (see below). |
config |
{} |
Algorithm/geometry/camera overrides; validated with paths. |
A bare Demoscene.fire(canvas) therefore resolves to
classic / fullscreen / desktop (device:'auto' resolves to desktop
in a non-mobile environment).
A skin carries visual choices only (palette, brightness, glow). Use the
object form to layer appearance overrides on top of the classic preset:
Demoscene.fire(canvas, {
skin: {
preset: 'classic',
overrides: { appearance: { backgroundColor: '#050102' } }
}
});Skins may only touch the visual groups (runtime, render, motion,
appearance). Algorithmic groups (simulation, field, camera, geometry,
β¦) are out of scope for a skin and are rejected β pass those under config
instead. See Authoring skins & profiles.
Configuration is resolved in a fixed order, then deeply frozen:
- effect defaults
- selected skin preset
- custom skin overrides
- matched profile slot (
surfaceΓ resolveddevice) - resolved device (already folded into the slot above)
- explicit
config(the expert escape hatch β always wins)
Each step is a recursive deep merge where input leaves replace default leaves;
arrays replace wholesale. Neither caller input nor exported presets are mutated.
The final object is Object.freeze-d recursively; to change anything, destroy
and remount.
device:'auto' resolves exactly once at mount using:
matchMedia('(max-width: 767px), (hover: none) and (pointer: coarse)')If matchMedia is unavailable it resolves to desktop. An explicit
device:'desktop'/'mobile' always wins. A matchMedia change, resize, or
orientation change after mount does not recreate the renderer or change the
resolved profile.
const fire = Demoscene.fire(canvas, { surface: 'preview', device: 'auto' });
fire.getConfig(); // β a fresh, deeply frozen clone of the final config
fire.getSelection(); // β { requestedSkin, preset, surface, requestedDevice, resolvedDevice }getSelection().requestedDevice echoes what you passed ('auto');
resolvedDevice is what auto actually resolved to ('desktop' or 'mobile').
Every effect returns the same controller:
fire.stop(); // pause the shared rAF tick for this effect
fire.start(); // resume
fire.resize(); // re-measure the canvas (also automatic via ResizeObserver)
fire.renderOnce(0); // render a single static frame at time t (seconds)
fire.getConfig(); // frozen final config (clone)
fire.getSelection(); // frozen { requestedSkin, preset, surface, requestedDevice, resolvedDevice }
fire.getStats(); // { backend, renderedFrames, lastFrameMs, averageFrameMs }
fire.destroy(); // stop, detach observers/listeners, release the rendererautoStart defaults to true; set config.runtime.autoStart = false to mount
paused. Multiple effects on one page share a single requestAnimationFrame
loop, so ten cards cost one rAF tick.
These four groups appear on every effect and are valid in both skins and
config:
runtimeβautoStart(bool),maxFps(1..240),pixelRatio(1..2),pauseWhenHidden(bool).pauseWhenHiddenpauses the effect when its canvas leaves the viewport (preview only).renderβresolution(0.1..1, fraction of the backing canvas actually computed),smoothing(bool, upscale interpolation). Mandelbrot addsrender.backend.motionβspeedplus effect-specific movement values.appearanceβpalette(2β64 hex stops),colorCount(2..4096),backgroundColor, plus effect-specific colours.
resolution changes sampling cost, not composition: every effect's geometry
is expressed in normalized viewport/texture coordinates, so the same descriptor
renders the same picture at any buffer size.
| Effect | Algorithmic groups (pass under config) |
|---|---|
plasma |
field (frequencies, amplitudes, phase rates, radial centre) |
fire |
simulation (seed, stepHz, source geometry, cooling, rise) |
starfield |
particles (count, seed, fov, depth, speed, density budget) |
metaballs |
field (pointCount/points, radius, strength, threshold, band) |
tunnel |
geometry (centre, wall/angle frequencies, guarded depth, fog) |
mandelbrot |
camera, algorithm (iterations, escape radius) + render.backend |
sineScroller |
text, wave, stars |
rotozoom |
texture (tiles, checker, rings, spokes, centre) |
feedback |
geometry, feedback (decay, scale, rotation, fade) |
copperBars |
bars (array), shading |
Unknown keys fail with their full path (e.g. mandelbrot.camera.minZom).
// A gallery-style card on a touch device. device:'auto' resolves to 'mobile'
// from a coarse pointer, which selects the preview.mobile slot (24 FPS).
const fire = Demoscene.fire(canvas, {
surface: 'preview',
device: 'auto',
config: { simulation: { cooling: 0.4 } } // cooling is a height-fraction per step, bounded [0, 1]
});
fire.getSelection(); // { surface: 'preview', requestedDevice: 'auto', resolvedDevice: 'mobile', ... }
fire.getConfig(); // { runtime: { maxFps: 24, ... }, simulation: { cooling: 0.4, ... }, ... }
fire.destroy();// The default backend is canvas2d (stable, deterministic). Set backend:'auto'
// (or 'webgl2') to opt into the perturbation WebGL2 renderer, which safely
// falls back to Canvas 2D if the context or shaders are unavailable.
const mandel = Demoscene.mandelbrot(canvas, {
skin: {
preset: 'classic',
overrides: { appearance: { colorScale: 0.5, colorCurve: 2 } } // continuous-coloring knobs
},
surface: 'fullscreen',
device: 'desktop',
config: {
render: { backend: 'auto' }, // try WebGL2, fall back to Canvas 2D
camera: { maxZoom: 250000 },
algorithm: { maxIterations: 140 },
appearance: { interiorColor: '#101010' }
}
});
mandel.getStats(); // { backend: 'webgl2' | 'canvas2d', renderedFrames, ... } β the RESOLVED renderer
mandel.destroy();More worked examples (including the full migration table) are in
docs/api-v3.md.
npm run build emits:
dist/demoscene.jsβ all ten effects on oneDemoscenenamespace.dist/effects/<name>.jsβ one standalone IIFE per effect.dist/manifest.jsonβ{ version, apiVersion: 3, bundle, effects[] }, enumerating each effect'sskins,surfaces, anddevices.
The public bundle filenames and the named Demoscene.<effect>() functions are
unchanged across the v2 β v3 break. Standalone bundles merge onto the same
shared scheduler when loaded together.
Each effect function exposes a frozen appearanceKeys array for filtering one
shared appearance object without duplicating the effect schemas:
const appearanceFor = (effect, shared) => Object.fromEntries(
Object.entries(shared).filter(([key]) => effect.appearanceKeys.includes(key))
);
Demoscene.metaballs(canvas, {
config: { appearance: appearanceFor(Demoscene.metaballs, sharedAppearance) }
});Unknown keys passed to config.appearance remain validation errors.
npm install
npm run build # rebuild dist/ via esbuild
npm test # build, then run the Node test suite
npm run benchmark:mandelbrot
npm run build:siteSource modules live in src/. Each effect owns an isolated package under
src/effects/<name>/ β see Contributing: skins & profiles
for the per-effect folder contract and how to add a new effect.
A deterministic browser harness captures the ten effects across four responsive profiles Γ three timestamps (120 images) and compares them against committed baselines, using a pinned Python Playwright + Chromium pair.
npm run test:visual # compare captures vs committed baselines
npm run visual:update # replace baselines (review the contact sheet first)
npm run visual:contact-sheet # build review contact sheetsSee visual/README.md for the pinned runtime, determinism
invariants, the capture matrix, and the bounded tolerance policy.