Telemetry And Debugging
Hs2d performance work starts with measured phases and bounded counters. Use telemetry to identify the slow phase, use snapshots to find the active layer or pool, then optimize that named source of work.
Source References
| Source | Why it matters |
|---|---|
../games/hosanna-ui/src/hosanna-game/hosanna2d/game/Hs2dTelemetry.ts | Optional phase timer wrapper, named accumulators, one-decimal formatting, and reset behavior. |
../games/hosanna-ui/src/hosanna-game/hosanna2d/game/Hs2dLevelScene.ts | Built-in telemetry around sceneSprite and particleUpdate, stats-window reset, and HUD text renderer hookup. |
../games/hosanna-ui/src/hosanna-game/runtime/GameRuntime.ts | Runtime frame stats for update, render, present, FPS, and collision-debug toggling. |
../games/hosanna-ui/src/hosanna-game/hosanna2d/Hs2dWorld.ts | World snapshots with camera, layers, tile layer stats, sprite pool stats, and particle layer stats. |
../games/hosanna-ui/src/hosanna-game/hosanna2d/types.ts | Snapshot and stats shapes used by debug tooling. |
../games/hosanna-ui/src/hosanna-game/collision/GameCollisionManager.ts | Collision debug overlay, global toggle, registered manager pruning, and collision stats. |
../hosanna-ui-game-samples-public/src/hosanna-game-examples/native-shoot-em-up/VerticalShooterLevelScene.ts | Reference phase timing for projectile update, projectile hit checks, sprite sync, particle update, render, and stats-window HUD. |
Telemetry Pieces
| Piece | Use |
|---|---|
Hs2dTelemetry.markPhase() | Reset or mark the platform phase timer before a measured block. |
Hs2dTelemetry.readPhaseMs() | Read current timer elapsed milliseconds. Returns 0 when no timer exists. |
Hs2dTelemetry.accumulatePhase(name) | Read elapsed phase time and add it to a named accumulator. |
Hs2dTelemetry.accumulate(name, ms) | Add a known value to a named accumulator. |
Hs2dTelemetry.getAccumulatedMs(name) | Read a named total for the current stats window. |
Hs2dTelemetry.resetAccumulators() | Clear named totals, usually once per stats window. |
Hs2dTelemetry.formatPerfMs(value) | Floor to one decimal place for HUD/status text. |
Hs2dTelemetry accepts an optional platform timer. Construct and call it behind the compile-time __TELEMETRY__ flag. On Roku-style paths, samples pass CreateObject('roTimespan') only when that flag is enabled. When the flag is disabled, level-scene timing and stats-window work is compiled out; when an enabled build has no timer, phase reads are zero.
Built-In Level Phases
Hs2dLevelScene.update uses this frame shape once the world is ready:
handleInputs
onUpdate
mark phase
updateSceneSprites
accumulate sceneSprite
mark phase
updateParticles
accumulate particleUpdate
if __TELEMETRY__: updateStats
This gives every level scene two common phase names:
| Phase | Meaning |
|---|---|
sceneSprite | Logical state synchronized into pooled sprites, text, visibility, and layer state. |
particleUpdate | Particle emitter updates and particle sprite movement. |
Scenes can add more phases around game-specific hot blocks.
Game-Specific Phase Timing
Instrument phases at the boundary you can act on:
protected override onUpdate(deltaMs: number, inputs: GameInput[]): void {
this.updatePlayer(deltaMs, inputs);
this.tickSceneCamera(deltaMs);
this.markPhaseTimer();
this.updateProjectiles(deltaMs);
if (__TELEMETRY__) this.telemetry.accumulatePhase('projectileUpdate');
this.markPhaseTimer();
this.checkProjectileHits();
if (__TELEMETRY__) this.telemetry.accumulatePhase('projectileHit');
this.floatingScoreTextPool?.update(deltaMs);
}
protected override onRender(screen: GameScreen, world: Hs2dWorld): void {
this.markPhaseTimer();
world.render(screen);
if (__TELEMETRY__) {
this.telemetry.accumulatePhase('render');
this.frameCount++;
}
}
Use phase names that describe the code you will optimize. slowStuff is not actionable. projectileHit, tileWindow, enemyVisibility, sceneSprite, and render are actionable.
Stats Windows
Do not rebuild long debug strings every frame. Use onStatsWindow:
protected override onStatsWindow(): void {
this.statusText =
`fps: ${this.fps}`
+ ` projectiles: ${this.activeProjectiles}`
+ ` particles: ${this.activeParticles}`
+ ` projHitMs: ${this.formatPerfMs(this.telemetry.getAccumulatedMs('projectileHit'))}`
+ ` spriteMs: ${this.formatPerfMs(this.telemetry.getAccumulatedMs('sceneSprite'))}`
+ ` particleMs: ${this.formatPerfMs(this.telemetry.getAccumulatedMs('particleUpdate'))}`
+ ` renderMs: ${this.formatPerfMs(this.telemetry.getAccumulatedMs('render'))}`;
this.projectileCollisionChecksThisSecond = 0;
this.projectileEnemyHitsThisSecond = 0;
this.particles?.resetCounters();
}
In telemetry builds, Hs2dLevelScene.updateStats computes FPS once per second and resets telemetry accumulators after onStatsWindow. Non-telemetry builds do not call this hook, so it must not own required gameplay work.
Runtime Stats
GameRuntime tracks:
| Group | Fields | Meaning |
|---|---|---|
| Target | targetFps, targetFrameMs | Configured 30/60 FPS target and its frame budget. |
| Current window | frameCount | Accepted frames accumulated in the current unpublished measurement window. It resets after each publish. |
| Published window | fps, windowFrameCount, windowElapsedMs | Last complete approximately one-second window. |
| Latest accepted frame | lastFrameMs, lastUpdateMs, lastRenderMs, lastPresentMs | Input delta and measured update, render, and present/swap phases. |
| Frame-delta distribution | frameDeltaP50Ms, frameDeltaP95Ms, frameDeltaP99Ms, frameDeltaMaxMs | Percentiles and maximum from the published window. |
| Budget pressure | overBudgetFrameCount, missedFrameBudgetCount, slowFrameCount, consecutiveSlowFrameCount, maxConsecutiveSlowFrames | Frames over tolerance, estimated missed budgets, and slow-frame streaks. |
| Bunching | shortFrameCount, longFrameCount, bunchedFrameCount, shortLongFrameCount | Short/long classifications and adjacent timing patterns that expose uneven delivery. |
| Phase aggregates | averageUpdateMs, maxUpdateMs, averageRenderMs, maxRenderMs, averagePresentMs, maxPresentMs | Average and maximum phase costs from the published window. |
The runtime populates timing windows only in __TELEMETRY__ builds. Outside those builds, target fields remain useful but timing values stay at their initialized/reset values. Use runtime stats when the whole controller is slow or present cost is suspicious. Use Hs2dTelemetry when the scene needs named sub-phase attribution.
World Snapshots
Hs2dWorld.getSnapshot() returns:
| Field | Meaning |
|---|---|
camera | Camera position, viewport, and scale. |
layerCount | Number of renderer layers. |
tileLayers | Pool size, visible tiles, recycled tiles, region changes, drawable changes, moved tiles. |
spritePools | Pool size and active sprites for registered pools. |
sprites | Sprite pool stats with layer IDs and pool type. |
particleLayers | Active, emitted, and moved particle counts. |
layers | Layer ID, type, order, HUD flag, and surface details where available. |
Expose it from scenes with getHs2dDebugSnapshot:
override getHs2dDebugSnapshot(): unknown {
const sceneSnapshot = this.world?.getSnapshot();
return {
source: 'MyLevelScene',
scene: {
camera: sceneSnapshot?.camera,
tileLayers: sceneSnapshot?.tileLayers ?? [],
spritePools: sceneSnapshot?.spritePools ?? [],
particleLayers: sceneSnapshot?.particleLayers ?? [],
},
};
}
Snapshots answer "how much is active?" Telemetry answers "where did the time go?" Use both.
Collision Debugging
Collision debug overlays are managed by GameCollisionManager:
GameCollisionManager.setDebugRenderingEnabled(true);
GameCollisionManager.toggleDebugRendering();
In __DEV__ builds, GameRuntime toggles the overlay with the runtime options input and renders colliders after controller render unless the active controller reports isCollisionDebugRenderingHandled(). The shortcut and runtime render pass are compiled out of non-development builds. Manual manager toggle/render calls still exist independently.
There is a current Hs2d integration limitation: Hs2dSprite.setCollision(...) does not forward debug shape/pretranslation data into its manager registration. The global overlay can render direct GameCollisionManager.registerSprite(...) registrations that provide debugShape, but it cannot automatically visualize every ordinary Hs2d sprite collider. Use the collider diagnostic for shape validation or fix that forwarding before treating the global overlay as complete.
Use the overlay to verify:
- collision shape size
- region pretranslation and anchor alignment
- active/inactive collider state
- stale offscreen colliders that still have flags
Use collision counters to verify:
- how many checks ran in a stats window
- how many hits were produced
- whether
checkFirstwould be enough instead ofcheck
Debug HUD Text
HUD debug text should use cached text and stats-window snapshots:
private hudStatusText = 'FPS 0 T 0 E 0 P 0';
override drawHud(screen: GameScreen): void {
this.drawCachedText(screen, 'status', this.hudStatusText, 36, 58, 0x55ff99ff, this.hudFont);
}
protected override onStatsWindow(): void {
const next = `FPS ${this.fps} T ${this.visibleTiles} E ${this.visibleEnemies} P ${this.activeProjectiles}`;
if (next !== this.hudStatusText) {
this.hudStatusText = next;
}
}
Avoid building interpolated status strings inside drawHud every frame unless the text is tiny and diagnostic-only.
Optimization Workflow
- Measure the frame. Check runtime FPS, update, render, and present timings.
- Measure scene phases. Add
Hs2dTelemetryaround the suspected hot blocks. - Correlate counters. Compare phase time with active sprites, visible tiles, active particles, collision checks, and text cache redraws.
- Disable one feature behind a flag. Confirm the phase changes before committing an optimization.
- Optimize the named phase. Reduce work in that phase, not unrelated code.
- Keep the counter. Leave useful stats-window counters in diagnostics while the feature is still being tuned.
Examples:
| Evidence | Likely action |
|---|---|
High sceneSprite, high active sprite count | Reduce active pools, cull earlier, amortize visibility refresh, avoid region churn. |
High particleUpdate, high moved particles | Lower emitter rates, reduce burst sizes, shorten lifetimes, lower pool size only if visuals still hold. |
High projectileHit, high collision checks | Use checkFirst, add broad-phase filters, skip offscreen projectiles, reduce projectile pool or fire rate. |
High render, low update phases | Inspect layer count, tile mode, cached surfaces, direct draw loops, and HUD draws. |
| High text cache redraws | Stabilize cache keys, call prewarmBitmapText(target, items) for common labels, and update HUD strings once per stats window. |
| High present time | Suspect platform or surface composition cost before rewriting gameplay logic. |
Do And Don't
| Do | Don't |
|---|---|
| Name phase accumulators after code blocks. | Add generic timers that do not point to an owner. |
| Use stats windows for HUD/debug text. | Rebuild long debug strings every frame. |
| Pair time with counts. | Optimize from milliseconds without knowing active sprites/tiles/particles/checks. |
| Use feature flags to isolate expensive visuals. | Disable features permanently without measuring the phase impact. |
| Keep collision overlays out of gameplay logic. | Use debug rendering as a collision system dependency. |
| Read world snapshots before changing pool sizes. | Guess that a pool is too large because FPS dropped. |
| Report caveats when telemetry is unavailable. | Treat zero timings from an absent timer as proof of no cost. |