Skip to content

Profiling & Diagnostics

Estella ships its diagnostics as ordinary SDK surface: a Stats resource your systems can read, an in-game overlay, a structured logger with pluggable sinks, a frame capture that records every draw call (and can replay the frame up to any one of them), GL error checking, GPU resource residency stats, and a per-app subsystem health registry. This guide covers each piece.

Add statsPlugin and the engine collects per-frame performance data into the Stats resource every frame (in the Last schedule, after everything else ran):

import { statsPlugin } from 'esengine';
app.addPlugin(statsPlugin);

statsPlugin is a ready-made instance with defaults. For options, construct StatsPlugin yourself:

import { StatsPlugin } from 'esengine';
app.addPlugin(new StatsPlugin({ overlay: true, position: 'top-right' }));
StatsPluginOptions Type Default Description
overlay boolean true Show the DOM overlay (skipped automatically where there’s no document).
position StatsPosition 'bottom-left' Overlay corner: 'top-left' / 'top-right' / 'bottom-left' / 'bottom-right'.
container HTMLElement document.body Element the overlay panel is appended to.

The plugin also calls app.enableStats(), which turns on per-system and per-phase timing collection (off otherwise, so shipped games don’t pay for it).

Stats is a normal resource — inject it with Res(Stats):

import { defineSystem, addSystem, Res, Stats } from 'esengine';
const watchPerf = defineSystem([Res(Stats)], (stats) => {
if (stats.fps > 0 && stats.fps < 30) {
console.warn(`slow frame: ${stats.frameTimeMs.toFixed(1)}ms, ${stats.drawCalls} draw calls`);
}
});
addSystem(watchPerf);

FrameStats fields (FPS and frame time are a 60-frame sliding-window average; the render counters are the current frame’s):

FrameStats field Type Description
fps number Frames per second, averaged over the last 60 frames.
frameTimeMs number Average frame time in milliseconds over the same window.
entityCount number Live entities in the world.
systemTimings Map<string, number> CPU ms per system this frame, keyed by system name.
phaseTimings Map<string, number> CPU ms per schedule phase this frame.
drawCalls number GPU draw calls issued this frame.
triangles number Triangles submitted this frame.
sprites number Sprites rendered this frame.
text number Text entities rendered this frame.
spine number Spine skeletons rendered this frame.
meshes number Meshes rendered this frame.
culled number Entities culled (not submitted) this frame.

With overlay: true (the default) the plugin renders a small fixed-position monospace panel: FPS + frame time, draw calls / triangles / sprites / culled, entity count, and the top 5 systems ranked by their worst frame — each as avg / max ms. The panel re-renders at most every 500 ms, accumulating system timings in between, so it costs next to nothing and a one-frame spike still shows up in the max column.

You can also drive a StatsOverlay yourself — for example to bind it to a debug hotkey:

import { StatsOverlay, defineSystem, addSystem, Res, Stats } from 'esengine';
const overlay = new StatsOverlay(document.body, 'top-right');
const feedOverlay = defineSystem([Res(Stats)], (stats) => {
overlay.update(stats);
});
addSystem(feedOverlay);
// overlay.hide() / overlay.show() to toggle, overlay.dispose() to remove it.

The render counters in FrameStats come from Renderer.getStats(), which you can also call directly (no stats plugin needed) — it returns a RenderStats with the same seven fields: drawCalls, triangles, sprites, text, spine, meshes, culled.

import { Renderer } from 'esengine';
const rs = Renderer.getStats();
console.log(`${rs.drawCalls} draw calls, ${rs.triangles} triangles`);

Two small utilities back the plugin and are exported for custom tooling:

  • StatsCollector — the 60-frame sliding window behind fps / frameTimeMs. Feed it delta times with pushFrame(deltaSeconds) and read getFps() / getFrameTimeMs(); reset() clears the window.
  • FrameHistory — a ring buffer of FrameSnapshots (default capacity 300, ~5 seconds at 60 fps) for building frame-time graphs. push(frameTimeMs, phaseTimings, systemTimings?) deep-copies the maps so snapshots stay valid; getLatest() returns the newest snapshot, getAll() the whole window oldest-first, and count / reset() do what they say.
FrameSnapshot field Description
frameTimeMs The frame’s total CPU time.
phaseTimings Per-phase ms (copied at push time).
systemTimings Per-system ms (copied at push time).

Recording a capture from a shipped game — ProfileRecorder

Section titled “Recording a capture from a shipped game — ProfileRecorder”

The editor’s profiler only exists where the editor is attached, and what drops to 40 fps usually drops on a player’s device. ProfileRecorder records frames from a running game into a .esprof capture the editor’s Profiler panel opens — the same tree, the same rows, the same totals.

import { ProfileRecorder } from 'esengine';
const recorder = new ProfileRecorder(app, {
maxFrames: 1800, // ~30s at 60Hz; oldest dropped after
source: { platform: 'wechat', label: 'Redmi Note 12 · boss fight' },
});
recorder.start();
// … play the part that stutters …
recorder.stop();
const capture = recorder.take(); // a plain object
ProfileRecorderOptions Type Default Description
maxFrames number 1800 Frames kept before the oldest is dropped.
budgetMs number 1000 / 60 The budget a reader judges the capture against.
source CaptureSource {} Device, build, scene — whatever identifies this capture later.

start() turns on the instrumentation it reads — both the JS-side stats and the engine’s C++ profiling, since a capture missing the second looks like an engine that costs nothing rather than one that was not measured — and stop() puts them back down. Nothing is recorded, and nothing is measured, until you start it.

Saving it from a web build is ordinary DOM:

const blob = new Blob([JSON.stringify(recorder.take())], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'boss-fight.esprof';
a.click();

Then open it with Profiler ▸ Open… in the editor. parseProfileCapture refuses a file that is not a capture with the reason (not JSON, no version, frames that are not frames, a version newer than the editor reads) rather than throwing, and summarizeCapture is the same function the live view uses — so a file recorded on a phone and the editor’s own last second cannot report a different frame rate from the same frames.

Watching frames yourself — app.onFrameEnd

Section titled “Watching frames yourself — app.onFrameEnd”

The recorder is an ordinary consumer of app.onFrameEnd(fn), which fires once per frame after its systems have run and its timings are final. It is a broadcast, so your own frame-budget alarm can watch alongside the recorder:

const off = app.onFrameEnd((dtMs) => {
if (dtMs > 33) console.warn(`long frame: ${dtMs.toFixed(1)}ms`);
});

It returns a disposer. Pair it with app.getFrameCosts() for the per-system and per-scope costs of the frame that just ended.

The SDK routes its own diagnostics through a structured logger instead of raw console.*, and your game code can use the same channel. Every message has a level, a free-form category string ('physics', 'net', your own 'gameplay'…), a message, and optional structured data:

import { log, setLogLevel, LogLevel } from 'esengine';
setLogLevel(LogLevel.Debug); // default is LogLevel.Info
log.debug('gameplay', 'Wave spawned', { wave: 3, enemies: 12 });
log.info('save', 'Game saved');
log.warn('net', 'High latency', { rttMs: 240 });
log.error('boot', 'Asset manifest failed', err); // Error keeps its stack

LogLevel is Debug < Info < Warn < Error; setLogLevel sets the minimum level — anything below it is dropped before reaching any handler. The standalone debug / info / warn / error functions are also exported and forward to the same default logger; getLogger() returns it if you need the Logger instance itself.

By default one console handler is installed: it formats [time] [LEVEL] [category] message and picks the matching console method per level, passing Error data through as a real argument so the browser renders the stack trace.

A LogHandler receives every accepted message as a structured LogEntry — the hook for an in-game console, a file writer, or crash-report breadcrumbs. A handler that throws is caught and reported, never breaking the app:

import { getLogger, LogLevel, type LogEntry, type LogHandler } from 'esengine';
class Breadcrumbs implements LogHandler {
entries: LogEntry[] = [];
handle(entry: LogEntry): void {
if (entry.level >= LogLevel.Warn) {
this.entries.push(entry);
if (this.entries.length > 100) this.entries.shift();
}
}
}
const crumbs = new Breadcrumbs();
getLogger().addHandler(crumbs);
// getLogger().removeHandler(crumbs) to detach,
// getLogger().clearHandlers() to remove every handler (console one included).
LogEntry field Type Description
timestamp number Date.now() at log time (epoch ms).
level LogLevel Debug / Info / Warn / Error.
category string The channel string the caller passed.
message string The message.
data unknown Optional payload (object, Error, …).

Everything above assumes you’re watching. After a build ships you aren’t: the console that would show the error is on a player’s phone. The Diagnostics resource collects what went wrong and hands it to a destination you choose.

The engine never picks that destination. There is no default endpoint, no bundled vendor, and nothing opens a socket on its own. Stack traces carry file paths and messages carry whatever was interpolated into them, so where that goes is your decision, not ours. With no sink installed the plugin still does its whole job locally — events aggregate and you can read them — and nothing leaves the device.

The plugin is installed by default, so this is available without any setup:

import { Diagnostics, type DiagnosticEvent } from 'esengine';
const diagnostics = app.getResource(Diagnostics);
diagnostics.setSink((events: readonly DiagnosticEvent[]) => {
// Batched, distinct problems with counts — not one call per occurrence.
void fetch('https://your-service.example/errors', {
method: 'POST',
body: JSON.stringify({ build: '1.4.2', events }),
});
});

Your sink must not throw (one that does is caught and dropped) and should not block — awaiting a network round-trip inside it trades a bug report for a stutter. Batch it and send it yourself.

Kind Where it comes from
engine Everything the engine logs as an error — a system that threw during a schedule, an asset that would not load, a subsystem that refused to start.
unhandled Reached the host with nobody catching it: window.onerror / unhandledrejection, wx.onError / wx.onUnhandledRejection. Usually game code outside a system — a callback, a promise, a timer.
context-lost The GPU took the rendering context away. The frames after it draw nothing, and no error is thrown — this is the only way to find out it happened.
memory The OS says memory is running out: the warning that arrives before the process is killed, which is the crash that never gets a report of its own.
game Whatever you called report about.

The engine half needs no platform support — it listens to the same log broadcast the section above describes, which is why installing a Diagnostics sink takes nothing away from app.onError, app.onSystemError or your own LogHandler. They all keep working. The other kinds need the host to have a signal for them:

web WeChat / mini-game native (Android / iOS)
unhandled yes yes if the shell wires it
memory yes if the shell wires it
context-lost yes no — a mini-game canvas is not a DOM element and no vendor API reports it if the shell wires it

Warnings are not collected by default; pass captureLevel: LogLevel.Warn to the plugin when chasing a specific complaint.

A system that throws does it every frame — sixty identical reports a second, from every player at once. Sent one by one that isn’t telemetry, it’s an outage your game caused. So the unit is the distinct problem with a count:

diagnostics.setSink((events) => {
for (const e of events) {
console.log(`${e.kind} ${e.source ?? ''}: ${e.message} ×${e.count}`);
// engine physics: the world could not step ×1842
}
});

Two occurrences are the same problem when their kind, source, message and throw site match. The whole stack would split one bug across its callers; the message alone would merge two unrelated bugs into one. Numbers in messages are normalized, so Entity 41 has no Transform files one problem rather than one per entity.

DiagnosticEvent field Type Description
kind DiagnosticKind One of the five above.
id string Stable identity across repeats.
message string The message, as reported.
source string? Log category, or the system’s name.
stack string? Present when something with a stack was thrown.
count number Occurrences since it was first seen.
firstAt / lastAt number Epoch ms, first and most recent.
context object? Whatever the reporter attached; the newest wins.
diagnostics.report({
kind: 'game',
message: 'Cloud save rejected the payload',
context: { slot: 3, bytes: 41_233 },
});
try { risky(); } catch (err) {
diagnostics.reportError('game', err, 'shop');
}

Keep player-identifying data out of context for the same reason the engine picks no endpoint — it ends up wherever your sink sends it.

import { DiagnosticsPlugin } from 'esengine';
app.addPlugin(new DiagnosticsPlugin({
maxDistinct: 64, // distinct problems tracked at once
flushIntervalSec: 10, // seconds between handing a batch to the sink
}));

At maxDistinct new problems are dropped while known ones keep counting, and diagnostics.dropped says how many — a non-zero value means the batch is incomplete, and a sink should say so rather than let it read as the whole picture. The earliest distinct problems are the ones kept: when a game comes apart it comes apart in cascade, and the first failure is what explains the next fifty.

Flushing runs on the engine clock, not a timer, so a backgrounded tab or a suspended mini-game doesn’t report from a game that isn’t running. flush() sends immediately — useful right before a level transition — and shutdown flushes once on its own.

Frame capture — every draw call, explained

Section titled “Frame capture — every draw call, explained”

When “why is this 40 draw calls?” needs an answer, capture a frame. Arm the capture, let one frame render, then read back a record for every draw call — what it drew, with which texture/material/shader and state, and crucially why the previous batch broke:

import { Renderer, RenderType, BatchBreak } from 'esengine';
Renderer.captureNextFrame(); // arm: the NEXT rendered frame records
// …one frame later:
if (Renderer.hasCapturedData()) {
const capture = Renderer.getCapturedData(); // FrameCaptureData | null
for (const dc of capture!.drawCalls) {
console.log(
`#${dc.index} ${RenderType[dc.type]} tex=${dc.textureId} ` +
`tris=${dc.triangleCount} entities=${dc.entityCount} ` +
`break=${BatchBreak[dc.breakReason]}`
);
}
console.log(`${capture!.cameraCount} camera pass(es)`);
}

FrameCaptureData is { drawCalls: DrawCallInfo[], cameraCount }. Each DrawCallInfo:

DrawCallInfo field Description
index Draw call index within the frame (submission order).
cameraIndex Which camera pass issued it.
stage Render stage (RenderStage: Background / Opaque / Transparent / Overlay).
type What kind of content (RenderType, below).
blendMode Blend mode id in effect.
textureId / materialId / shaderId GPU resources bound for the call.
vertexCount / triangleCount Geometry submitted.
entityCount / entityOffset / entities The entity ids batched into this call.
layer Render layer of the batch.
breakReason Why this call started instead of joining the one before it (BatchBreak, below).
scissorX/Y/W/H, scissorEnabled Scissor rect state (UI masks).
stencilWrite / stencilTest / stencilRef Stencil state (mask writers/readers).
textureSlotUsage Texture slots the batch consumed.

RenderType tells you what a call drew: Sprite, Spine, Mesh, ExternalMesh, Text, Particle, Shape, UIElement.

BatchBreak is the batching story — each value names the state change that kept this call from joining the one before it, i.e. what to fix to batch better. Every member is a branch of the merge predicate itself, so the list cannot drift from the rule that produced it:

BatchBreak Meaning
RunStart Nothing to join — the first call of a run.
Instanced An instanced draw: one command per emitter, never coalesced.
Shader Shader switched — the state change nothing batches across.
Blend Blend mode switched — interleaved additive/normal content.
Layout Vertex layout switched (sprite quads vs mesh vertices).
Material Material switched — group entities by material.
Depth Depth test/write differ — opaque and blended content interleaved.
Cull Cull state differs.
State Some other render state flag differs.
Scissor Scissor rect changed (a UIMask boundary).
Stencil Stencil ref changed (mask write/test boundary).
IndexGap Indices are not contiguous — the sort put something between them.
TextureSlots Mergeable, but the combined set overflowed the 8 texture slots — atlas more textures together.

None is the merge’s own answer and never appears on a captured call: a command that reaches it was folded into the one before it instead of starting a new one.

The same reasons are published per frame as batch.break.* counters alongside batch.draws and batch.merged, so “6 draw calls” reads as “1 run start and 5 index gaps, with 41 commands merged away” without capturing anything.

A capture can be replayed up to any draw call to see the frame being built — this is what the editor’s frame inspector does. replayToDrawCall(i) re-renders draw calls 0…i into a snapshot; getSnapshotImageData() resolves with its pixels once the GPU readback lands (immediate on WebGL, a later tick on WebGPU):

import { Renderer } from 'esengine';
Renderer.replayToDrawCall(5); // draw calls 0..5 only
const img = await Renderer.getSnapshotImageData(); // ImageData | null
if (img) ctx2d.putImageData(img, 0, 0); // e.g. into a debug canvas

GLDebug toggles GL error checking inside the wasm renderer — off by default because checking has a cost:

import { GLDebug } from 'esengine';
GLDebug.enable(); // check GL errors at key points
const errors = GLDebug.check('after-spawn'); // explicit check; returns error count
GLDebug.diagnose(); // dump renderer diagnostics to the console
GLDebug.disable();

check(context) runs an immediate error check and returns the number of GL errors found, tagging any log output with your context string so you can bisect where in the frame an error appears.

Released textures stay resident in a byte-budgeted warm cache (the full model is in the assets guide). The diagnostics side of it:

import { getResourceStats, setTextureBudget, trimTextureCache } from 'esengine';
setTextureBudget(256 * 1024 * 1024); // resize the budget (0 = no warm cache)
const stats = getResourceStats(); // ResourceStats | null before engine init
if (stats && stats.textureBytes > stats.textureBudget * 0.9) {
const freed = trimTextureCache(); // drop every evictable texture now
console.log(`freed ${freed} cached textures`);
}
ResourceStats field Description
shaderCount Live compiled shaders.
textureCount Live GPU textures.
vertexBufferCount / indexBufferCount Live GPU buffers.
cacheHits / cacheMisses Resource-cache hit/miss counters.
textureBytes Resident texture bytes (RGBA8 estimate) — held + evictable.
textureBudget The current resident-byte budget (0 = eviction off).
textureEvictableCount Cached refcount-0 textures awaiting revive or eviction.

trimTextureCache() frees every evictable entry immediately and returns how many textures it freed — the engine calls it on OS memory warnings; call it yourself before a known memory spike. Held (referenced) textures and the budget are untouched.

evictTextureDimensions(handle) drops the SDK-side cached width/height for one texture handle so the next getTextureDimensions query re-reads it from the engine — only relevant to tooling that replaces texture contents under an existing handle.

Every App carries a SubsystemRegistry at app.subsystems that tracks each engine subsystem’s lifecycle phase (registeredinitializingready, error terminal) and derived liveness (stepping / idle / inactive) — the “is physics actually running?” question:

for (const s of app.subsystems.getStatuses()) {
console.log(`${s.displayName}: ${s.phase} (${s.activity})`, s.lastError ?? '');
}

recentEvents() returns the recent lifecycle transitions and subscribe(fn) notifies on phase changes. The full lifecycle model is covered in App & Lifecycle.

The desktop editor builds its profiling UI on these same surfaces: the Profiler panel shows a live frame-time graph with per-phase and per-system breakdowns (click a frame to inspect it), and the viewport has a small perf overlay for an at-a-glance FPS readout. For in-game builds, statsPlugin above is the equivalent.

  • Ship without statsPlugin (or gate it behind a debug flag) — timing collection is off by default for a reason; add it while profiling.
  • Blame before optimizing — read systemTimings / phaseTimings to find which system is slow before touching code; the overlay’s max column catches one-frame spikes an average hides.
  • Capture a frame to fix draw callsbreakReason names the exact state change that broke each batch (TextureSlots → atlas; Material → group by material).
  • Log with categories, not console.log — structured entries let a handler filter by channel and level, and setLogLevel(LogLevel.Debug) turns verbosity on without code changes.
  • Leave GLDebug off in production — per-call GL error checking is expensive; enable it only while hunting a rendering bug.
  • Watch textureBytes against textureBudget on memory-constrained targets, and trimTextureCache() before known spikes.
  • Assets — the texture warm cache and budget the resource stats observe.
  • App & Lifecycle — plugins, schedules, and the subsystem registry.
  • Systems — schedules and system names (the keys in systemTimings).
  • Editor — the desktop editor, including its Profiler panel.