Platform

Diagnostics

The built-in frame-time performance metrics, the console FPS overlay, the pluggable logging system, and the device-failure signals that tell you when there is no GPU left to render on.

Meep ships three diagnostic tools: a set of ring-buffer performance metrics that the engine populates every frame and prints to the console on an interval, a pluggable logging system that routes structured messages to one or more backends, and a small set of signals that tell you the GPU device could not be acquired or has gone away.

Performance metrics

What the engine tracks

Every Engine instance owns a MetricCollection at engine.performance. The engine pre-creates three named metrics and records into them on every frame:

NameWhat is recorded
"frame_delay"Wall-clock time between animation frames (seconds)
"render_time"Time spent inside GraphicsEngine.render() (seconds)
"simulation_time"Time spent inside EntityManager.update(dt) (seconds)

simulation_time is recorded from the engine’s own ticker subscription, so it covers one whole simulation step across every registered system - and it stays empty if you handed the engine an external entity manager, because then nothing subscribes.

render_time is only recorded on frames that actually draw (engine.renderingEnabled true and graphics.needDraw set) and it measures CPU time to build and submit the frame, not GPU time. Submitted is not finished: the GPU is still working when graphics.on.postRender fires. For where the time went on the device, see GPU profiling.

Reading metrics

Each metric is a RingBufferMetric holding the last 128 samples. Call computeStats (zero-allocation) or access the .stats getter (allocates a new MetricStatistics):

import { MetricStatistics }
    from "@woosh/meep-engine/src/engine/development/performance/MetricStatistics.js";

const stats = new MetricStatistics();
engine.performance.get("render_time").computeStats(stats);

console.log(`render mean: ${(stats.mean * 1000).toFixed(2)} ms`);
console.log(`render max:  ${(stats.max  * 1000).toFixed(2)} ms`);
// stats also has .min and .median

getLastRecord() returns the most recent value without computing the full statistics.

Automatic console output

On engine.start() the engine begins a PeriodicConsolePrinter that fires every 15 seconds. It computes stats for all three metrics, clears each buffer, and logs a line like:

FPS: 59.97, RENDER: 2.34ms, SIMULATION: 1.12ms

This runs without any setup. To stop it - for instance in a production build - call engine.__performacne_monitor.stop() after start() resolves. (The field is private by convention, typo included; there is no public API for this yet.)

Adding custom metrics

import { MetricCollection }
    from "@woosh/meep-engine/src/engine/development/performance/MetricCollection.js";

// engine.performance is already a MetricCollection.
const metric = engine.performance.create({ name: "pathfinding_ms", buffer_size: 64 });

// in your update loop:
const t0 = performance.now();
runPathfinding();
metric.record(performance.now() - t0);

create({ name, buffer_size }) adds a RingBufferMetric and returns it. buffer_size defaults to 128; larger values give more accurate statistics at the cost of memory. metric.clear() flushes all samples without removing the metric from the collection.

Streaming to the console

MetricCollectionConsoleMonitor wraps any MetricCollection and prints all of its metrics on a configurable interval:

import { MetricCollectionConsoleMonitor }
    from "@woosh/meep-engine/src/engine/development/performance/monitor/MetricCollectionConsoleMonitor.js";

MetricCollectionConsoleMonitor.from(engine.performance, 5).start();
// prints all metric stats every 5 seconds

from(metrics, timeout_seconds) returns a PeriodicConsolePrinter. Call .start() to begin and .stop() to end.

FPS overlay widget

EngineHarness.addFpsCounter(engine) adds a FrameRateView (src/view/elements/FrameRateView.js) to the engine’s view stack: one number, refreshed twice a second, counting engine.graphics.on.postRender dispatches over a 500 ms window. It returns the view, so a settings screen can take it down again:

import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";

// after bootstrap:
const fps = EngineHarness.addFpsCounter(engine);

// later, from a graphics menu:
engine.viewStack.removeChild(fps);

FrameRateView binds itself: new FrameRateView({ frame_signal }) counts any signal that fires once per frame, links the binding when the view is linked and drops it when the view leaves the stack, so removing it stops the work. No package is involved. EngineHarness.buildBasics also accepts showFps and calls this for you, but it defaults to false.

Because the view counts postRender dispatches, it reports how fast frames are being submitted, which is the same caveat render_time carries above.


Graphics device failure

Meep 3 renders on WebGPU and only WebGPU. There is no WebGL fallback and no degradation tier, so hardware that cannot run the renderer produces a clean failure with a message written for a player rather than a lesser picture. See Rendering overview for what the renderer does with a device once it has one.

The device floor

Renderer.initialize() checks a fixed floor before it requests a device:

RequirementValueHow it is enforced
indirect-first-instanceadapter feature; indirect draw is used throughoutchecked on the adapter, BelowFloor naming the feature
float32-blendableadapter feature; order-independent transparencysame
maxStorageBuffersPerShaderStageat least 10checked on the adapter before the device is requested, so the failure names the number it offered
maxColorAttachmentBytesPerSample32 - the G-buffer is wideasked for in requiredLimits, so an adapter that cannot supply it fails inside requestDevice

maxBufferSize and maxStorageBufferBindingSize are requested at whatever the adapter offers, because the spec defaults are far too small.

timestamp-query, subgroups and texture-formats-tier1 are requested when the adapter offers them and never required. That matters for diagnostics: some browsers withhold timestamp-query on hardware that is otherwise fine, and when it is absent the GPU timers go quiet rather than startup failing.

One caveat the floor does not cover: in practice the engine also needs the WGSL immediate_address_space extension, which shipped in Chrome 149/150. A browser below that passes the floor, acquires a device, and then fails when it tries to dispatch shaders.

The signals

engine.graphics.on carries five signals. Two are frame hooks (preRender, postRender); three are about the device:

SignalPayloadFires when
contextFailedthe error Renderer.initialize() threwgraphics.start() could not get a usable device
contextLostShadeDeviceFailure with reason DeviceLosta working device went away mid-session
contextRestored-never

contextRestored exists because the renderer contract has it and subscribers expect the shape, but Shade does not recover a lost device: everything it built lived on that device and went with it. A subscriber there is waiting for something that will not happen. Treat a loss as terminal.

contextFailed usually carries a ShadeDeviceFailure, but not always - a browser with no navigator.gpu at all, or a canvas that will not hand over a webgpu context, raises a plain Error. Test with isShadeDeviceFailure before reading reason.

The failure object

import { ShadeDeviceFailureReason }
    from "@woosh/meep-engine/src/shade/device/ShadeDeviceFailureReason.js";

engine.graphics.on.contextFailed.add((failure) => {
    // failure.message is written to be shown to a person
    // failure.detail is the specific missing thing, for the log
    if (failure.isShadeDeviceFailure !== true) {
        reportStartupFailure(String(failure));
        return;
    }

    if (failure.reason === ShadeDeviceFailureReason.BelowFloor) {
        reportUnsupportedHardware(failure.detail);
    }
});

ShadeDeviceFailure (named export of src/shade/device/ShadeDeviceFailure.js) extends Error and adds three fields:

FieldMeaning
reasona ShadeDeviceFailureReason value
detailthe specific feature, limit and value, or lost-device reason - for the log, not the player. Empty when there is nothing more specific to say
isShadeDeviceFailurealways true; the safe way to tell it from a plain Error

ShadeDeviceFailureReason (named export of the sibling module) is a numeric enum:

ValueNumberMeaning
WebGPUUnavailable0no WebGPU at all - old browser, or behind a flag
AdapterUnavailable1WebGPU is present but would not hand over an adapter
BelowFloor2an adapter that does not meet the floor above; detail names which requirement
DeviceRequestFailed3the adapter was acceptable but the device request itself failed
DeviceLost4a working device went away - driver reset, GPU removed, browser reclaiming resources

Two of those five are not raised by 3.21.0 in practice, which is worth knowing before you branch on them. Renderer.initialize() checks 'gpu' in navigator first and throws a plain Error there, so a browser without WebGPU reaches you as that rather than as WebGPUUnavailable. And nothing constructs DeviceRequestFailed: requestDevice is called unguarded, so a refused device arrives as the browser’s own DOMException. The reasons you will actually see are AdapterUnavailable, BelowFloor and DeviceLost; handle the rest by falling back on failure.message.

The static constructors (ShadeDeviceFailure.below_floor(detail), .device_lost(detail) and so on) are how the renderer builds these; you catch them rather than construct them.

What the engine already does

Engine subscribes to all three signals itself. On contextFailed it logs the error, makes sure the simulation is not left running (nothing can be drawn, so there is no point burning cycles invisibly), and adds a GraphicsContextFailureView to engine.viewStack - a full-screen overlay with a title, an explanation and a reload button. Do not add one yourself in a contextFailed handler; you will get two stacked overlays.

GraphicsContextFailureView (named export of src/view/graphics/GraphicsContextFailureView.js) takes { title, text, action_label, action }, all optional; action defaults to reloading the page. Its strings default to English on purpose - this view exists for the case where the renderer is dead, which is a bad moment to depend on the localization pipeline having the right keys. The engine constructs it with no arguments and there is no flag to suppress that, so localizing the wording means styling or replacing the view yourself. Its CSS class is webgl-context-failure-view; the name is historical.

Two consequences worth planning for:

  • graphics.start() re-throws after dispatching contextFailed, and Engine.start() awaits it first, so await engine.start() rejects with the same error.
  • The engine never mounts engine.viewStack.el into the document; the host does. EngineHarness mounts it after engine.start() resolves, so under EngineHarness.bootstrap a startup device failure leaves the overlay parented to a view stack that never reaches the page, and the promise bootstrap() returned never settles. If you need a device-failure screen on that path, mount engine.viewStack.el yourself, or render your own message from a contextFailed handler.

Handling a loss mid-session

contextLost is the asymmetric one. Engine handles it by starting a two-second timer and pausing the simulation if the outage outlasts it - it does not add the failure view, because that path was written for an outage that might end. Since restoration never comes, a shipped game should say something itself:

import { GraphicsContextFailureView }
    from "@woosh/meep-engine/src/view/graphics/GraphicsContextFailureView.js";

engine.graphics.on.contextLost.add((failure) => {
    // nothing will render after this; the simulation pauses two seconds from now
    engine.viewStack.addChild(new GraphicsContextFailureView({
        text: failure.message
    }));
});

Application state is left intact when the simulation pauses, which keeps a save-on-failure handler viable.

Running without a device at all

new Engine(platform, { enableGraphics: false }) skips the graphics engine entirely and logs that it did. engine.graphics is then null - there is no on to subscribe to, render() returns immediately, and only frame_delay and simulation_time are ever recorded. That is enough for headless simulation. Tests that need the renderer’s own code paths without a GPU use the software device instead - see Testing without a GPU.


Logging

Global logger

logger is a process-wide Logger instance exported from engine/logging/GlobalLogger.js. It starts with no backends - messages are silently dropped until you add one.

import { logger }
    from "@woosh/meep-engine/src/engine/logging/GlobalLogger.js";
import { ConsoleLoggerBackend }
    from "@woosh/meep-engine/src/engine/logging/ConsoleLoggerBackend.js";

logger.addBackend(ConsoleLoggerBackend.INSTANCE);

EngineHarness calls this automatically during construction.

Log levels

LogLevel is a numeric enum - lower numbers are more severe:

NameValueconsole method used by ConsoleLoggerBackend
Severe0console.error
Error1console.error
Warning2console.warn
Info3console.log
Debug4console.log

Each backend has a level threshold. A message is only forwarded if its level is at or below the backend’s threshold. The default threshold is Info (3), so Debug messages are suppressed by default.

import { LogLevel }
    from "@woosh/meep-engine/src/engine/logging/LogLevel.js";

backend.setLevel(LogLevel.Debug);   // see everything
backend.setLevel(LogLevel.Warning); // suppress Info and Debug

Logging from application code

logger.info("scene loaded");
logger.warn("asset missing, using fallback");
logger.error("physics body escaped the world");

// or with explicit level:
logger.log(LogLevel.Debug, "collision narrowphase iteration 42");

Backends

ClassBehavior
ConsoleLoggerBackendForwards to console.log / .warn / .error. Singleton at .INSTANCE.
VoidLoggerBackendDiscards all messages. Useful in tests or production builds. Singleton at .INSTANCE.
ElasticSearchLoggerBuffers records in a deque and bulk-posts to an Elasticsearch index via XMLHttpRequest.

Elasticsearch backend

import { ElasticSearchLogger }
    from "@woosh/meep-engine/src/engine/logging/elastic/ElasticSearchLogger.js";

const es = new ElasticSearchLogger({
    url: "https://logs.example.com",
    target: `game-log-${new Date().toISOString()}`
});

logger.addBackend(es);

The backend buffers records locally and flushes when either 500 records accumulate or 2 000 ms pass since the last flush, whichever comes first. Each flushed batch is a POST to <url>/<target>/_bulk. Timestamps come from performance.now().

Registering multiple backends

logger.addBackend and logger.removeBackend work on the same list. A message is dispatched to every backend whose threshold allows it:

logger.addBackend(ConsoleLoggerBackend.INSTANCE);  // development
logger.addBackend(es);                              // production telemetry

// later:
logger.removeBackend(ConsoleLoggerBackend.INSTANCE);

Writing a custom backend

Extend LoggerBackend and implement log(level, message):

import { LoggerBackend }
    from "@woosh/meep-engine/src/engine/logging/LoggerBackend.js";
import { LogLevel }
    from "@woosh/meep-engine/src/engine/logging/LogLevel.js";

class RemoteBackend extends LoggerBackend {
    constructor() {
        super();
        this.setLevel(LogLevel.Warning);   // only warnings and above
    }

    log(level, message) {
        fetch("/api/logs", {
            method: "POST",
            body: JSON.stringify({ level, message, ts: Date.now() }),
            headers: { "Content-Type": "application/json" }
        });
    }
}

logger.addBackend(new RemoteBackend());