src_engine.js

/**
 * LittleJS - The Tiny Fast JavaScript Game Engine
 * MIT License - Copyright 2021 Frank Force
 *
 * Engine Features
 * - Object oriented system with EngineObject base class
 * - Automatic object lifecycle (update, physics, collision, rendering)
 * - Engine helper classes: Vector2, Color, Timer, RandomGenerator
 * - Hybrid rendering with WebGL batching and Canvas2D fallback
 * - Audio system with wave, mp3, or ZzFX sound effects
 * - Input system with keyboard, mouse, gamepad, and touch support
 * - Tile layer rendering and collision detection
 * - Particle effect system with emitters
 * - Medal/achievement system with local storage
 * - Comprehensive debug tools and visualizations
 * - Fixed 60 FPS timestep with configurable time scale
 * - Raycast and spatial query utilities
 * - Plugin system for extending engine functionality
 * - Start with engineInit() and provide your game callbacks
 * @namespace Engine
 */

'use strict';

/** Name of engine
 *  @type {string}
 *  @default
 *  @memberof Engine */
const engineName = 'LittleJS';

/** Version of engine
 *  @type {string}
 *  @default
 *  @memberof Engine */
const engineVersion = '1.21.0';

/** Frames per second to update
 *  @type {number}
 *  @default
 *  @memberof Engine */
const frameRate = 60;

/** How many seconds the update covers: 1/60 with the fixed time step, or with engineVariableStep the time of the
 *  display frame it runs on, times timeScale; while paused it keeps the last update's
 *  @type {number}
 *  @default 1/60
 *  @memberof Engine */
let timeDelta = 1/frameRate;

/** Array containing all engine objects
 *  @type {Array<EngineObject>}
 *  @memberof Engine */
let engineObjects = [];

/** Array with only objects set to collide with other objects this frame (for optimization)
 *  @type {Array<EngineObject>}
 *  @memberof Engine */
let engineObjectsCollide = [];

// the same objects with the static ones last, the order 2D physics checks them in
let engineObjectsCollideStaticLast = [];

/** Current update frame, used to calculate time
 *  @type {number}
 *  @memberof Engine */
let frame = 0;

/** Current engine time since start in seconds
 *  @type {number}
 *  @memberof Engine */
let time = 0;

/** Actual clock time since start in seconds (not affected by pause, timescale, or frame rate clamping; the debug speed keys scale it in debug builds)
 *  @type {number}
 *  @memberof Engine */
let timeReal = 0;

/** Is the game paused? Causes time and objects to not be updated
 *  @type {boolean}
 *  @default false
 *  @memberof Engine */
let paused = false;

/** Get if game is paused
 *  @return {boolean}
 *  @memberof Engine */
function getPaused() { return paused; }

/** Set if game is paused
 *  @param {boolean} [isPaused]
 *  @memberof Engine */
function setPaused(isPaused=true) { paused = isPaused; }

// Engine internal variables
let frameTimeLastMS = 0, frameTimeBufferMS = 0, averageFPS = 0;

// delta smoothing, after Time Delta Smoothing by Frank Force (2013): a frame is on screen for whole display frames,
// so each delta is rounded to them and the rest is carried to the next, keeping the total real time; the frame
// length is estimated from recent frames and kept internal, since the browser does not say what it is
const frameDeltaHistory = [];
let frameIntervalMS = 1e3 / 60, frameDeltaCarryMS = 0, frameDeltaCarryAverageMS = 0, frameDeltaSmoothing = true;
function engineSmoothDelta(deltaMS)
{
    if (!(deltaMS > 0)) return 0;
    if (deltaMS < 250) // a gap from a hidden tab is not a display frame
    {
        frameDeltaHistory.push(deltaMS);
        frameDeltaHistory.length > 64 && frameDeltaHistory.shift();

        // the frames each delta held are counted in the last estimate or in the lower quartile of the last 16,
        // whichever the deltas fit better: the quartile is one frame even when a busy game misses most of them, and
        // it takes over when the window moves to a display with another refresh rate, or the estimate went wrong
        const recent = frameDeltaHistory.slice(-16).sort((a, b)=> a - b);
        const [lastMS, lastOff] = engineFrameFit(frameIntervalMS);
        const [quartileMS, quartileOff] = engineFrameFit(recent[recent.length >> 2]);
        frameIntervalMS = lastOff <= quartileOff ? lastMS : quartileMS;

        // deltas far from whole frames mean the display has no fixed refresh, with a margin so it does not flip
        frameDeltaSmoothing = min(lastOff, quartileOff) < (frameDeltaSmoothing ? .2 : .12);
    }

    if (!frameDeltaSmoothing)
    {
        // deltas that are not whole frames of one length, like a variable refresh display, are the frame times
        // as they are, with any carry paid, and smoothing starts over once they fit again
        const rawMS = deltaMS + frameDeltaCarryMS;
        frameDeltaCarryMS = min(rawMS, 0);
        frameDeltaCarryAverageMS = 0;
        return max(rawMS, 0);
    }

    // whole frames, 0 for one that came early or 2 after one was missed, the carry stays within half a frame; a
    // little of its slow average is paid each frame, so jitter can not hold it at half a frame and flip every frame
    const pullMS = frameDeltaCarryAverageMS / 32;
    frameDeltaCarryMS += deltaMS;
    const frames = round((frameDeltaCarryMS - pullMS) / frameIntervalMS);
    if (frames < 1)
        return 0; // the carry keeps it
    const smoothMS = frames * frameIntervalMS + pullMS;
    frameDeltaCarryMS -= smoothMS;
    frameDeltaCarryAverageMS += (frameDeltaCarryMS - frameDeltaCarryAverageMS) / 32;
    return smoothMS;
}

// fit the delta history as whole frames of about unitMS: the frame length is the least squares slope of time over
// frames held, so jitter and whole ms timestamps average out, with how far the deltas are from whole frames
function engineFrameFit(unitMS)
{
    let timeMS = 0, frames = 0, offMS = 0, sumF = 0, sumT = 0, sumFF = 0, sumFT = 0;
    const count = frameDeltaHistory.length;
    for (const d of frameDeltaHistory)
    {
        const held = max(1, round(d / unitMS));
        timeMS += d;
        frames += held;
        offMS += abs(d - held * unitMS);
        sumF += frames, sumT += timeMS, sumFF += frames * frames, sumFT += frames * timeMS;
    }
    const spread = count * sumFF - sumF * sumF;
    return [spread ? (count * sumFT - sumF * sumT) / spread : unitMS, offMS / count / unitMS];
}

// where the fixed step's time counts from, moved when the variable step hands back so time goes on from there
let timeFixedStart = 0, frameFixedStart = 0;
let windowWidthLast = 0, windowHeightLast = 0, windowPixelRatioLast = 0;
let engineUpdateInternal; // assigned by engineInit so engineStep can drive it
let engineFrameScheduled = false; // a frame of the loop is asked for and has not run yet

// the pairs of objects asked about a collision this update, so the other's own physics does not ask again: each
// asker's others, with true for a pair both said to resolve, and false for one left overlapping, ignored or only
// nudged apart; a map for each asker so the lookup stays quick when many objects pile up on one spot
const engineObjectsCollidePairs = new Map;
function engineObjectsCollidePairAnswer(asker, other)
{ return engineObjectsCollidePairs.get(asker)?.get(other); }
function engineObjectsCollidePairAdd(asker, other, resolve=false)
{
    let others = engineObjectsCollidePairs.get(asker);
    others || engineObjectsCollidePairs.set(asker, others = new Map);
    others.set(other, resolve);
}
let engineInitialized = false; // engineInit ran, with or without a canvas
// the loads startup waits for, each counted for the loading screen, and how many are done; undefined once the game
// loop starts
let engineLoads, engineLoadsDone = 0;
let engineObjectsUpdateCount = 0; // passes of engineObjectsUpdate so far, how a child knows it moved this pass
const engineChildStack = []; // the children being updated, taken off the live lists so one leaving does not skip the next
let showEngineVersion = true;

///////////////////////////////////////////////////////////////////////////////
// plugin hooks

const pluginList = [];
class EnginePlugin
{
    constructor(update, render, glContextLost, glContextRestored, preRender)
    {
        this.update = update;
        this.render = render;
        this.glContextLost = glContextLost;
        this.glContextRestored = glContextRestored;
        this.preRender = preRender;
    }
}

/**
 * @callback PluginCallback - Update or render function for a plugin
 * @memberof Engine
 */

/** Add a new update function for a plugin
 *  - update runs on every fixed tick, paused and timeScale 0 included; a plugin that simulates should skip those
 *  @param {PluginCallback} [update]
 *  @param {PluginCallback} [render]
 *  @param {PluginCallback} [glContextLost]
 *  @param {PluginCallback} [glContextRestored]
 *  @param {PluginCallback} [preRender] - Called after the canvas is cleared and before gameRender
 *  @memberof Engine */
function engineAddPlugin(update, render, glContextLost, glContextRestored, preRender)
{
    // make sure plugin functions are unique
    ASSERT(!pluginList.find(p=>
        p.update === update && p.render === render &&
        p.glContextLost === glContextLost &&
        p.glContextRestored === glContextRestored &&
        p.preRender === preRender));

    const plugin = new EnginePlugin(update, render, glContextLost, glContextRestored, preRender);
    pluginList.push(plugin);
}

///////////////////////////////////////////////////////////////////////////////
// Main Engine Functions

/** Add something the game loads to what startup waits for: while engineInit and gameInit run, the game loop starts
 *  once it is done, and the loading screen counts it; images from loadTexture and sounds from files are added on
 *  their own, and a load that fails counts as done; after startup it does nothing
 *  @param {Promise<any>} promise
 *  @return {Promise<any>} - The same promise
 *  @example
 *  async function gameInit() { level = await engineAddLoad(fetchJSON('level.json')); }
 *  @memberof Engine */
function engineAddLoad(promise)
{
    if (engineLoads)
    {
        engineLoads.push(promise);
        const done = ()=> { ++engineLoadsDone; };
        promise.then(done, done);
    }
    return promise;
}

// wait for every load, the ones added while waiting too, drawing the loading screen each frame meanwhile
async function engineWaitForLoads()
{
    let waiting = true;
    const start = performance.now();
    const drawFrame = ()=>
    {
        if (!waiting) return;
        engineLoadingScreenDraw((performance.now() - start) / 1e3);
        setTimeout(drawFrame, 16);
    };
    headlessMode || drawFrame();
    try
    {
        for (let count; count !== engineLoads.length;)
        {
            count = engineLoads.length;
            await Promise.allSettled(engineLoads);
        }
    }
    finally { waiting = false; }
}

// one frame of the loading screen, once loading has taken half a second, so a fast load never shows it; input
// while it shows is dropped, as it is under the splash
function engineLoadingScreenDraw(elapsed)
{
    if (headlessMode || !loadingScreen || elapsed < .5) return;
    inputClear();
    engineUpdateCanvas();
    loadingScreen(engineLoadsDone / engineLoads.length);
}

/**
 * @callback GameInitCallback - Called after the engine starts, can be async
 * @return {void|Promise<void>}
 * @memberof Engine
 */
/**
 * @callback LoadingScreenCallback - Draws the loading screen on mainContext, each frame while the game loads
 * @param {number} progress - The part of the loads done, 0 to 1
 * @memberof Engine
 */
/**
 * @callback GameCallback - Update or render function for the game
 * @memberof Engine
 */

/** Startup LittleJS engine with your callback functions
 *  @param {GameInitCallback} [gameInit] - Called once after the engine starts up, can be async for loading
 *  @param {GameCallback} [gameUpdate] - Called every frame before objects are updated (60fps), use for game logic
 *  @param {GameCallback} [gameUpdatePost] - Called after physics and objects are updated, even when paused, use for UI updates
 *  @param {GameCallback} [gameRender] - Called before objects are rendered, use for drawing backgrounds/world elements
 *  @param {GameCallback} [gameRenderPost] - Called after objects are rendered, use for drawing UI/overlays
 *  @param {Array<string>} [imageSources=[]] - List of image file paths to preload (e.g., ['player.png', 'tiles.png'])
 *  @param {HTMLElement} [rootElement] - Root DOM element to attach canvas to, defaults to document.body
 *    It keeps its own inline styles and the canvas centers inside it, but the canvas is still sized from the window,
 *    so set canvasFixedSize or canvasMaxSize to fit a smaller element
 *  @example
 *  // Basic engine startup
 *  engineInit(
 *    ()=> { LOG('Game initialized!'); },  // gameInit
 *    ()=> { updateGameLogic(); },         // gameUpdate
 *    ()=> { updateUI(); },                // gameUpdatePost
 *    ()=> { drawBackground(); },          // gameRender
 *    ()=> { drawHUD(); },                 // gameRenderPost
 *    ['tiles.png', 'tilesLevel.png']       // images to load
 *  );
 *  @memberof Engine */
async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, imageSources=[], rootElement)
{
    showEngineVersion && console.log(`${engineName} Engine v${engineVersion}`);
    ASSERT(!engineInitialized, 'engine already initialized');
    // runtime guard so release builds (where the assert is stripped) don't
    // double-register listeners / double-add canvases on a second call
    if (engineInitialized) return;
    engineInitialized = true;
    engineLoads = [], engineLoadsDone = 0;
    ASSERT(isArray(imageSources), 'pass in images as array');

    // allow passing in empty functions
    gameInit       ||= ()=>{};
    gameUpdate     ||= ()=>{};
    gameUpdatePost ||= ()=>{};
    gameRender     ||= ()=>{};
    gameRenderPost ||= ()=>{};

    // Called automatically by engine to setup render system
    function enginePreRender()
    {
        // the level editor's own view while it is open, in debug builds, before the camera goes to WebGL
        editorPreRender();

        // disable smoothing for pixel art
        mainContext.imageSmoothingEnabled = !tilesPixelated;

        // setup gl rendering if enabled
        glPreRender();
        setShader(); // a shader left set last frame does not carry into this one

        // plugins that draw underneath the 2D layer
        pluginList.forEach(plugin=>plugin.preRender?.());
    }

    // internal update loop for engine
    function engineUpdate(frameTimeMS=0)
    {
        const manualStepAtStart = engineManualStep;

        // update time keeping
        let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
        // skip delta on the very first frame so timeReal doesn't jump
        // by ~page-load-time when RAF starts handing real timestamps
        if (!frameTimeLastMS) frameTimeDeltaMS = 0;
        frameTimeLastMS = frameTimeMS;
        if (debug || debugWatermark)
            averageFPS = lerp(averageFPS, 1e3/(frameTimeDeltaMS||1), .05);
        // the time the frame will be on screen, in whole display frames; engineStep's steps are exact already
        if (!manualStepAtStart)
            frameTimeDeltaMS = engineSmoothDelta(frameTimeDeltaMS);
        audioUpdateVolume();
        // the time keys work while the debug overlay is open, or always when debugKeysAlways is set
        const debugKeys = debug && (debugOverlay || debugKeysAlways);
        const debugSpeedUp   = debugKeys && keyIsDown('Equal'); // +
        const debugSpeedDown = debugKeys && keyIsDown('Minus'); // -
        const debugScale = debugSpeedUp ? 10 : debugSpeedDown ? .1 : 1;

        // apply time deltas
        const frameTimeDeltaUnscaledMS = frameTimeDeltaMS;
        timeReal += frameTimeDeltaMS * debugScale / 1e3;
        const combinedScale = timeScale * debugScale;
        frameTimeDeltaMS *= combinedScale;
        let wasUpdated = false;
        if (engineVariableStep)
        {
            // one update for the frame with timeDelta the time it covers, per-frame values are the game's to scale;
            // engineStep's frames are exactly 1/60, its first too, which has no frame before it to take a delta from
            if (frameTimeDeltaUnscaledMS > 0 || manualStepAtStart)
            {
                // frozen stands still as in the fixed step, and timeDelta keeps the last update's
                const frozenTick = paused || !combinedScale;
                if (!frozenTick)
                {
                    timeDelta = manualStepAtStart ? combinedScale / frameRate :
                        min(frameTimeDeltaUnscaledMS, 50) * combinedScale / 1e3; // clamp min framerate
                    time += timeDelta;
                    ++frame;
                }
                engineTick(frozenTick);
            }
        }
        else
        {
            // paused or a time scale of 0 is frozen: it ticks on unscaled time, so the update rate stays fixed instead
            // of following however fast the display refreshes, and gameUpdatePost and input still run to leave it
            const frozen = paused || !combinedScale;
            frameTimeBufferMS += frozen ? frameTimeDeltaUnscaledMS : frameTimeDeltaMS;
            frameTimeBufferMS = min(frameTimeBufferMS, 50 * (frozen ? 1 : max(1, combinedScale))); // clamp min framerate

            // apply time delta smoothing, improves smoothness of framerate in some browsers
            let deltaSmooth = 0;
            if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
            {
                // force at least one update each frame since it is waiting for refresh
                deltaSmooth = frameTimeBufferMS;
                frameTimeBufferMS = 0;
            }

            // update multiple frames if necessary in case of slow framerate
            for (; frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate)
            {
                // read again each tick, so a pause set by the game stops the rest of this frame's catch-up ticks
                const frozenTick = paused || !(timeScale * debugScale);

                // increment frame and update time, frozen does not advance time
                if (!frozenTick)
                    time = timeFixedStart + (frame++ - frameFixedStart) / frameRate;
                engineTick(frozenTick);
            }

            // add the time smoothing back in
            frameTimeBufferMS += deltaSmooth;
        }

        // one tick of the loop: update game and objects, when frozen update everything except them
        function engineTick(frozenTick)
        {
            wasUpdated = true;
            engineUpdateCanvas();
            inputUpdate();
            if (!frozenTick)
                gameUpdate();
            pluginList.forEach(plugin=>plugin.update?.());
            if (frozenTick)
            {
                // update object transforms even when paused
                for (const o of engineObjects)
                    o.parent || o.updateTransforms();

                // objects made and destroyed while paused, like a menu's effects, still leave the list
                engineObjects = engineObjects.filter(o=>!o.destroyed);
            }
            else
                engineObjectsUpdate();

            // do post update
            debugUpdate();
            gameUpdatePost();
            inputUpdatePost();
            if (debugVideoCaptureIsActive())
                renderFrame();
        }

        // manual step turned on by this frame's updates, set the buffer the loop and smoothing just moved again
        if (engineManualStep && !manualStepAtStart)
            frameTimeBufferMS = -.5e3 / frameRate;

        // check if the window changed so a resize is picked up even when
        // the game is not updating, for example when timeScale is 0
        let windowChanged = false;
        if (!headlessMode)
        {
            const dpr = devicePixelRatio;
            windowChanged = windowWidthLast !== innerWidth ||
                windowHeightLast !== innerHeight || windowPixelRatioLast !== dpr;
            windowWidthLast = innerWidth;
            windowHeightLast = innerHeight;
            windowPixelRatioLast = dpr;
        }

        // render only when something changed, displays that refresh faster
        // than the fixed update rate would otherwise redraw identical frames
        if (!debugVideoCaptureIsActive() && (wasUpdated || windowChanged))
            renderFrame();
        engineManualStep || engineScheduleFrame();

        function renderFrame()
        {
            if (headlessMode) return;

            // canvas must be updated before rendering
            if (!wasUpdated)
                engineUpdateCanvas();

            // render the game and objects
            enginePreRender();
            gameRender();
            engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
            for (const o of engineObjects)
            {
                if (o.destroyed) continue;
                setShader(o.shader); // each object draws with its own shader, or none
                o.render();
            }
            setShader(); // back to the engine's for gameRenderPost

            // post rendering
            gameRenderPost();
            setShader(); // plugin, input and debug draws start from the engine's state
            setAdditiveBlendMode(false);
            pluginList.forEach(plugin=>plugin.render?.());
            inputRender();
            debugRender();
            glFlush();
            debugRenderPost();
            drawCount = 0;
            primitiveCount = 0;
        }
    }

    // skip setup if headless
    if (headlessMode) return startEngine([]);

    // ensure body exists for minimal HTML where the script runs before <body> is parsed
    if (!document.body)
        document.documentElement.appendChild(document.createElement('body'));
    rootElement ||= document.body;

    // setup webgl
    glInit(rootElement);

    // setup html
    let styleRoot =
        'margin:0;' +                 // fill the window
        'overflow:hidden;' +          // no scroll bars
        'background:#000;' +          // set background color
        'user-select:none;' +         // prevent hold to select
        '-webkit-user-select:none;' + // compatibility for ios
        'touch-action:none;' +        // prevent mobile pinch to resize
        '-webkit-touch-callout:none;'; // compatibility for ios
    // the canvases center on a root element with a height of its own, not the page; one sized only by its
    // children has none, since the canvases are placed apart from it, and would clip them all away
    if (rootElement !== document.body && rootElement.clientHeight && getComputedStyle(rootElement).position === 'static')
        styleRoot += 'position:relative;';
    rootElement.style.cssText = styleRoot + rootElement.style.cssText; // its own inline styles come after and win
    mainCanvas = rootElement.appendChild(document.createElement('canvas'));
    drawContext = mainContext = mainCanvas.getContext('2d');

    // init stuff and start engine
    inputInit();
    audioInit();
    debugInit();

    // setup canvases
    // transform way is still more reliable than flexbox or grid
    const styleCanvas = 'position:absolute;'+ // allow canvases to overlap
        'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen
    mainCanvas.style.cssText = styleCanvas;
    if (glCanvas)
        glCanvas.style.cssText = styleCanvas;
    setCanvasPixelated(canvasPixelated);
    engineUpdateCanvas();
    glPreRender();

    // create offscreen canvases for image processing
    workContext = createCanvasContext(64);
    workCanvas = workContext.canvas;
    workReadContext = createCanvasContext(64, 64, true);
    workReadCanvas = workReadContext.canvas;

    // create promises for loading images
    /** @type {Array<Promise<any>>} */
    const promises = imageSources.map((src, i)=> loadTexture(i, src));

    // no images to load
    if (!imageSources.length)
        promises.push(loadTexture(0));

    // load engine font image
    promises.push(imageFontInit());

    if (showSplashScreen)
    {
        // draw splash screen
        /** @type {Promise<void>} */
        const splash = new Promise(resolve =>
        {
            let t = 0;
            updateSplash();
            function updateSplash()
            {
                inputClear();
                drawEngineLogo(t+=.01);
                t>1 ? resolve() : setTimeout(updateSplash, 16);
            }
        });
        promises.push(splash);
    }

    // the splash first, the images load under it, then the loading screen for the rest
    showSplashScreen && await promises.at(-1);
    return startEngine(promises);

    // gameInit runs once the images are in, and the game loop starts once it and everything loaded while it ran are
    // done, the loading screen showing in the meantime; an error in gameInit reaches the caller
    async function startEngine(images)
    {
        const init = (async ()=> { await Promise.all(images); await gameInit(); })();
        engineAddLoad(init);
        await engineWaitForLoads();
        engineLoads = undefined;
        await init;
        engineUpdateInternal = engineUpdate; // engineStep only runs once the game is set up
        engineManualStep || engineUpdate();
    }
}

// Resize the canvas to fit the window and prepare it for a new frame
// Called automatically each frame and by the splash screen before the loop starts
// mainCanvasSize is css pixels and the backing store is that scaled by the
// pixel ratio, so the ratio only changes sharpness, never how big things look
function engineUpdateCanvas()
{
    if (headlessMode) return;

    // the backing store is scaled by this, every size below is css pixels
    const dpr = getCanvasPixelRatio();

    if (canvasFixedSize.x)
    {
        // set canvas fixed size
        mainCanvasSize = canvasFixedSize.copy();

        // fit to window using css width and height
        const innerAspect = innerWidth / innerHeight;
        const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
        const w = innerAspect < fixedAspect ? '100%' : '';
        const h = innerAspect < fixedAspect ? '' : '100%';
        mainCanvas.style.width  = w;
        mainCanvas.style.height = h;
        if (glCanvas)
        {
            glCanvas.style.width  = w;
            glCanvas.style.height = h;
        }
    }
    else
    {
        // get main canvas size based on window size, in css pixels so
        // canvasMaxSize caps how big the canvas looks, not its resolution
        mainCanvasSize.x = min(innerWidth,  canvasMaxSize.x) | 0;
        mainCanvasSize.y = min(innerHeight, canvasMaxSize.y) | 0;

        // responsive aspect ratio, of the size after canvasMaxSize, which can change its shape
        const innerAspect = mainCanvasSize.x / mainCanvasSize.y;
        ASSERT(!canvasMaxAspect || canvasMinAspect <= canvasMaxAspect);
        if (canvasMaxAspect && innerAspect > canvasMaxAspect)
        {
            // full height
            const w = mainCanvasSize.y * canvasMaxAspect | 0;
            mainCanvasSize.x = min(w, canvasMaxSize.x);
        }
        else if (innerAspect < canvasMinAspect)
        {
            // full width
            const h = mainCanvasSize.x / canvasMinAspect | 0;
            mainCanvasSize.y = min(h, canvasMaxSize.y);
        }

        // css size is the canvas size, the backing store is scaled up below
        mainCanvas.style.width  = mainCanvasSize.x + 'px';
        mainCanvas.style.height = mainCanvasSize.y + 'px';
        if (glCanvas)
        {
            glCanvas.style.width  = mainCanvasSize.x + 'px';
            glCanvas.style.height = mainCanvasSize.y + 'px';
        }
    }

    // clear main canvas and set size
    // only set the size when it changes, setting it invalidates the canvas
    // frame which makes the browser rebuild the display list for the page
    const bufferSizeX = mainCanvasSize.x * dpr | 0;
    const bufferSizeY = mainCanvasSize.y * dpr | 0;
    if (mainCanvas.width !== bufferSizeX || mainCanvas.height !== bufferSizeY)
    {
        mainCanvas.width  = bufferSizeX;
        mainCanvas.height = bufferSizeY;
    }
    else
    {
        // setting the size also resets the context state, match that
        mainContext.setTransform(1, 0, 0, 1, 0, 0);
        mainContext.globalCompositeOperation = 'source-over';
        mainContext.clearRect(0, 0, bufferSizeX, bufferSizeY);
    }

    // scale the context so 2d drawing is in css pixels
    mainContext.setTransform(dpr, 0, 0, dpr, 0, 0);

    // apply the clear color to main canvas
    if (canvasClearColor.a > 0 && !glEnable)
    {
        mainContext.fillStyle = canvasClearColor.toString();
        mainContext.fillRect(0, 0, mainCanvasSize.x, mainCanvasSize.y);
        mainContext.fillStyle = BLACK.toString();
    }

    // set default line join and cap, round on purpose: it looks better, suits text and keeps sharp corners from
    // spiking far out; WebGL outlines are square and mitered for speed, the two are not meant to match
    mainContext.lineJoin = 'round';
    mainContext.lineCap  = 'round';
}

// ask for the next frame of the loop, once however often it is called before that frame, and skip it if manual
// step was turned on since, so turning it off and on again within a frame can not start a second loop
function engineScheduleFrame()
{
    if (engineFrameScheduled) return;
    engineFrameScheduled = true;
    const next = (frameTimeMS)=>
    {
        engineFrameScheduled = false;
        engineManualStep || engineUpdateInternal(frameTimeMS);
    };
    if (typeof requestAnimationFrame === 'function')
        requestAnimationFrame(next);
    else // a headless server in Node has no display to wait for, a timer keeps the pace
        setTimeout(()=> next(performance.now()), 1e3 / frameRate);
}

// max frames engineStep can advance in one call, 10 minutes at 60fps
// large counts block until they finish, so this catches runaway values
const engineStepMaxFrames = 36000;

/** Advance the engine by a number of frames
 *  Requires setEngineManualStep(true), before engineInit or while running; it stops early if an update turns it off
 *  Respects paused exactly as the normal update loop does
 *  @param {number} [frames] - frames of 1/60 of a second to advance, max 36000; timeScale sets how many fixed
 *  updates they run, as in the normal loop, one each at timeScale 1
 *  @example
 *  setHeadlessMode(true);
 *  setEngineManualStep(true);
 *  await engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost);
 *  engineStep(600); // advance 10 seconds of game time
 *  @memberof Engine */
function engineStep(frames=1)
{
    ASSERT(engineManualStep,
        'engineStep requires setEngineManualStep(true)');
    ASSERT(engineUpdateInternal, 'engineStep requires engineInit to complete');
    // runtime guard so release builds (where the asserts are stripped) can't
    // start a second requestAnimationFrame chain or call an undefined update
    if (!engineManualStep || !engineUpdateInternal) return;
    ASSERT(Number.isInteger(frames) && frames >= 0 && frames <= engineStepMaxFrames,
        'engineStep requires a whole frame count from 0 to ' + engineStepMaxFrames);
    frames = min(frames, engineStepMaxFrames); // release has no asserts, don't freeze
    for (let i = frames; i > 0 && engineManualStep; --i) // an update that turns manual step off hands back the loop
        engineUpdateInternal(frameTimeLastMS + 1e3 / frameRate);
}

/** Update each engine object and remove destroyed objects; time and frame are advanced by the engine loop, not here
 *  - Can be called manually if objects need to be updated outside of main loop
 *  @memberof Engine */
function engineObjectsUpdate()
{
    ++engineObjectsUpdateCount;
    engineObjectsCollidePairs.clear();
    // objects update in render order, which rendering keeps them in, so a headless run or a frame that rendered
    // nothing updates them the same way; the sort is stable, and nearly free on a list that is already sorted
    engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder);
    // get list of solid objects for physics optimization, in update order, which 3D collision pairs by;
    // 2D checks the static ones last, so a contact with a moving object can not leave something back inside a static
    // solid it was already pushed out of
    engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects);
    engineObjectsCollideStaticLast = engineObjectsCollide.filter(o=>o.mass)
        .concat(engineObjectsCollide.filter(o=>!o.mass));

    // update physics before object update
    for (const o of engineObjects)
        if (!o.parent && !o.destroyed)
            o.updatePhysics();

    // recursive object update: the children are walked from a copy on a shared stack, since a child that
    // destroys itself leaves its parent's list on the spot and the next child would slide past the loop
    function updateChildObjects(children)
    {
        if (!children.length) return; // most objects have none, and this runs for every one
        const start = engineChildStack.length;
        for (const child of children)
            engineChildStack.push(child);
        for (let i = start; i < engineChildStack.length; ++i)
            updateChildObject(engineChildStack[i]);
        engineChildStack.length = start;
    }
    const pass = engineObjectsUpdateCount;
    function updateChildObject(o)
    {
        if (o.destroyed || o.updatePass === pass) return;

        // its parent is up to date, so it updates from where it is now, and its children from where it is after
        o.updatePass = pass;
        o.updateTransforms(false);
        o.update();
        o.children.length && o.updateTransforms(false);
        updateChildObjects(o.children);
    }
    function updateTopObject(o)
    {
        if (o.parent || o.destroyed || o.updatePass === pass) return; // a child that let go is not updated twice

        // update top level objects, each child places itself before it updates so it sees this frame's position,
        // then the whole tree is placed again so what the children changed in their localPos lands before render
        o.updatePass = pass;
        o.update();
        updateChildObjects(o.children);
        o.updateTransforms();
    }
    for (const o of engineObjects)
        updateTopObject(o);

    // a child let go during the update from a place in the list already walked is on its own now, updated here
    for (const o of engineObjects)
        updateTopObject(o);

    // remove destroyed objects
    engineObjects = engineObjects.filter(o=>!o.destroyed);
}

/** Destroy and remove all objects
 *  - This can be used to clear out all objects when restarting a level
 *  - Objects with the persistent flag set are left alone, for things that outlive a level
 *  - Objects can override their destroy function to do cleanup or stick around
 *  @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first
 *  @memberof Engine */
function engineObjectsDestroy(immediate=true)
{
    for (const o of engineObjects)
        o.parent || o.persistent || o.destroy(immediate);
    engineObjects = engineObjects.filter(o=>!o.destroyed);
}

/** Collects all object within a given area
 *  - An object is collected when its box overlaps the area, or with testCenters when its center is inside it
 *  - Objects destroyed this frame are left out, they are only in the list until the frame ends
 *  @param {Vector2} [pos] - Center of test area, or undefined for all objects
 *  @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2,
 *                                   left out or 0 the objects that overlap the point at pos
 *  @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
 *  @param {boolean} [testCenters] - Test only each object's center, a little faster, and ignores object sizes
 *  @return {Array<EngineObject>} - List of collected objects
 *  @memberof Engine */
function engineObjectsCollect(pos, size, objects=engineObjects, testCenters=false)
{
    const collectedObjects = [];
    if (!pos)
    {
        // all objects
        for (const o of objects)
            o.destroyed || collectedObjects.push(o);
    }
    else if (!size || size instanceof Vector2)
    {
        // bounding box test, a point when there is no size or a size of 0
        const boxSize = size instanceof Vector2 ? size : vec2();
        for (const o of objects)
            o.destroyed || (testCenters ? isOverlapping(pos, boxSize, o.pos) : o.isOverlapping(pos, boxSize))
                && collectedObjects.push(o);
    }
    else
    {
        // circle test, a diameter like every other size, against the nearest point of each box
        const radiusSquared = (size/2)**2;
        for (const o of objects)
        {
            if (o.destroyed) continue;
            const dx = testCenters ? pos.x - o.pos.x : max(abs(pos.x - o.pos.x) - o.size.x/2, 0);
            const dy = testCenters ? pos.y - o.pos.y : max(abs(pos.y - o.pos.y) - o.size.y/2, 0);
            dx*dx + dy*dy < radiusSquared && collectedObjects.push(o);
        }
    }
    return collectedObjects;
}

/**
 * @callback ObjectCallbackFunction - Function that processes an object
 * @param {EngineObject} object
 *  @memberof Engine
 */

/** Triggers a callback for each object within a given area, objects destroyed this frame left out
 *  @param {Vector2} [pos] - Center of test area, or undefined for all objects
 *  @param {Vector2|number} [size] - Diameter of a circle if a number, full size of a rectangle if a Vector2
 *  @param {ObjectCallbackFunction} [callbackFunction] - Calls this function on every object that passes the test, needed
 *                                                     (marked optional only because the area before it is)
 *  @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
 *  @param {boolean} [testCenters] - Test only each object's center, see engineObjectsCollect
 *  @memberof Engine */
function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects, testCenters=false)
{
    // an object an earlier callback destroyed is skipped
    for (const o of engineObjectsCollect(pos, size, objects, testCenters))
        o.destroyed || callbackFunction(o);
}

/** Return a list of objects intersecting a ray, objects destroyed this frame left out
 *  - Only objects with collideRaycast set are hit, which setCollision turns on
 *  @param {Vector2} start
 *  @param {Vector2} end
 *  @param {Array<EngineObject>} [objects=engineObjects] - List of objects to check
 *  @return {Array<EngineObject>} - List of objects hit
 *  @memberof Engine */
function engineObjectsRaycast(start, end, objects=engineObjects)
{
    const hitObjects = [];
    for (const o of objects)
    {
        if (o.collideRaycast && !o.destroyed && isIntersecting(start, end, o.pos, o.size))
        {
            debugRaycast && debugRect(o.pos, o.size, '#f00', 0, 0, false, false);
            hitObjects.push(o);
        }
    }

    debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02, 0, false);
    return hitObjects;
}