Skip to content

Diagnostics

The debug overlay draws two things: the named values a game registers, and the engine’s own frame-time metrics. A game registers its sources once, from InitApi.diagnostics; the engine owns everything around them.

type DiagnosticValue = string | number | boolean;
readonly diagnostics: {
register(
name: string,
source: (state: DeepReadonly<S>) => DiagnosticValue,
): void;
};

source is a function from the game’s state to the value to display. It is invoked on each read with the state current at that read, never sampled at registration. The overlay reads after render, so the state a source receives is the one this frame’s update returned. S is the InitApi<S> type parameter, the game’s own state type.

A source reports one of the three types DiagnosticValue names. A value the game holds in some other shape is reduced to one of the three inside the source.

const game: Game<State, null> = {
initialize(api) {
api.diagnostics.register(
"ball",
(state) => `${state.ball.x}, ${state.ball.y}`,
);
api.diagnostics.register(
"score",
(state) => `${state.score.left}-${state.score.right}`,
);
api.diagnostics.register("rallies", (state) => state.rallies);
return [initialState(), null];
},
update: (state, api, dt) => step(state, api, dt),
render: (state, api) => draw(state, api.ctx),
};

A source always returns a value. Where the thing it names is absent, it returns a short placeholder string in the game’s own vocabulary, such as "-" or "none".

Re-registering a name replaces its source and retains the name’s original position in the registry.

The engine holds the registry and drives it.

setEnabled(enabled: boolean): void;
enabled(): boolean;
toggle(): void;
read(): readonly DiagnosticReading[];
metrics(): FrameMetrics;
draw(ctx: CanvasRenderingContext2D, width: number, height: number): void;
MemberReturnsBehavior
setEnabled(enabled)voidShows the overlay when true, hides it when false.
enabled()booleanWhether the overlay is currently drawn.
toggle()voidInverts the enabled state.
read()readonly DiagnosticReading[]Evaluates every registered source and returns what each one reports.
metrics()FrameMetricsThe frame-time metrics over the current window.
draw(ctx, width, height)voidDraws the overlay onto ctx. Called by the engine after the game’s render.

The overlay is hidden when the engine is created.

interface DiagnosticReading {
readonly name: string;
readonly value?: DiagnosticValue;
readonly error?: string;
}

Returns one reading per registered source, in registration order, which is the order the panel draws them in. Exactly one of value and error is present on each reading. Values are returned unformatted; the formatting below applies to the overlay alone.

A source that throws yields a reading carrying error and no value: the message of a thrown Error, otherwise the String form of what was thrown. A failure is therefore distinguishable from a reading of any type, and read itself always returns.

read evaluates the sources and changes nothing else, so the state, the frame counter, and the overlay’s visibility are the same after a read as before it. read is independent of enabled(), so a hidden overlay is still readable.

engine.diagnostics() is how a caller holding the engine reaches this, and is what a case’s checks read to assert the sources a build registered.

The engine times each frame it runs, measuring the wall time spent in update, render, and the overlay itself. One sample is recorded per frame that ran.

interface FrameMetrics {
samples: number;
meanMs: number;
p95Ms: number;
p99Ms: number;
}
FieldMeaning
samplesHow many frames the window holds.
meanMsThe arithmetic mean of the window’s samples, in milliseconds.
p95MsThe 95th percentile of the window’s samples, in milliseconds.
p99MsThe 99th percentile of the window’s samples, in milliseconds.

Percentiles are nearest-rank over the window’s samples sorted ascending, so p95Ms is the sample at index ceil(0.95 * samples) - 1. An empty window reports 0 for all three.

The window is the last 10 seconds of frames, held in a ring buffer whose capacity is 2048 samples. A frame rate above 204 frames per second therefore reports over the most recent 2048 frames, which keeps the window bounded at any frame rate.

Age is measured against the frame loop’s simulated time, so the window covers 10 seconds of the time the game was stepped by rather than 10 seconds of real time. A sample older than the window is dropped as each new frame arrives.

width and height are the dimensions of the surface being drawn on, in device pixels. The engine resets the context transform to the identity before calling draw, and draw saves and restores the context around all of its own work, including when measuring or drawing throws.

The overlay draws the registered lines first, then a metrics line reading `frame: ${meanMs} / ${p95Ms} / ${p99Ms} ms`, then the frame-time graph. draw performs no drawing when the overlay is hidden.

The graph plots the window’s samples oldest at the left and newest at the right, one column per sample. The vertical scale runs from 0 to the largest sample in the window, with a floor of 33.3 milliseconds so an even run reads as flat rather than as amplified noise.

The engine listens for keydown on the event target EngineOptions.surface supplies, and on the canvas’s owning document when the engine was built without a surface. It calls toggle() when KeyboardEvent.code is "Backquote" and KeyboardEvent.repeat is false. The key is engine chrome rather than a registered input action.

One line per reading, formatted `${name}: ${value}`.

ReadingDisplayed as
string valueThe string itself.
Integer number valueString(value).
Non-integer number valuevalue.toFixed(3).
boolean value"true" or "false".
A source that threwThe error message, in the value’s place.

A number that is not finite displays as NaN, Infinity, or -Infinity.

DiagnosticValue, DiagnosticReading, and FrameMetrics are exported as types from @clockwyrks/simple-2d.