Performance Best Practices
When and how to optimize demos: object allocation, batching, and hot-path guidance.
This guide explains when and how to optimize your BLIT386 demos for performance, with a focus on object allocation patterns and rendering efficiency.
Introduction
When to optimize
Profile first, optimize second
Premature optimization often leads to complex, hard-to-maintain code without meaningful performance gains.
Follow this approach:
Write clear, simple code first - Use inline object creation (new Vector2i(x, y))
Measure performance - Use browser dev tools to identify bottlenecks
Optimize hot paths only - Focus on code that runs 50+ times per frame
Keep it simple elsewhere - Clarity matters more than micro-optimizations in UI code
Frame budget
Each render frame (one requestAnimationFrame callback) must finish before the next vsync. On a 60 Hz display that is
about 16.66 ms per frame (1000 / 60). That budget covers:
- Zero or more fixed
update()steps atBT.targetFPS(~2-4 ms total when one step runs) - One
render()pass (~8-12 ms) - Browser overhead (~2-4 ms)
If work exceeds the display's render cadence, you drop frames and see stuttering. That is independent of BT.targetFPS:
a 60 Hz simulation on a 120 Hz monitor still has a ~8.33 ms render budget per rAF callback.
Object allocation patterns
JavaScript's garbage collector can cause frame hitches if you allocate many objects per frame. BLIT386 provides two patterns for managing allocations:
When to use: UI code, one-time operations, and anywhere readability matters more than performance.
// Clear and readable - palette indices, not Color32
const = 1;
.(new (, ), );
.(new (10, 10, 50, 50), );
.(, new (10, 20), 'Hello');Pros:
- Easy to read and understand
- No boilerplate
- Self-documenting code
Cons:
- Creates new objects each call
- Can cause GC pressure in tight loops
Comparison
For a loop drawing 200 pixels per frame at 60 FPS:
| Pattern | Allocations/sec | GC Impact |
|---|---|---|
| Inline | 12,000/sec | Moderate GC pauses every ~1 second |
| Pre-allocation | 0/sec | Minimal GC activity |
Real-world impact: On modern browsers, inline allocation is fine for <50 operations/frame. Beyond that, pre-allocation provides measurable benefits.
Drawing performance
Batching
BLIT386 automatically batches draw calls for optimal GPU performance:
- Primitive batching: All primitives (pixels, lines, rects) are batched together
- Texture batching: Sprites from the same
SpriteSheetare batched - Batch breaks: Changing sprite sheets breaks batching
Optimization tips:
// Good: All sprites from same sheet
for (const of ) {
.(, ., ., .);
}
// Less optimal: Switching between sheets
.(, , );
.(, , ); // Batch break!
.(, , ); // Batch break!Sprite sheet organization
Pack related sprites into single sheets:
Good:
- enemies.png (all enemy sprites)
- ui.png (all UI elements)
- environment.png (tiles, props)
Less optimal:
- enemy1.png, enemy2.png, enemy3.png (separate files)Palette offset performance
Changing paletteOffset per draw is cheap - it shifts stored texel indices before palette lookup, not RGBA tint
multiplication:
// Same performance - offset is a uniform add, not per-pixel color math
.(, , );
.(, , , 16); // team-color rangeUse palette offsets liberally for team colors and damage flashes - batching cost dominates, not the offset itself.
Allocation-free draw calls
The sprite draw calls added in 1.8.0 allocate nothing per call, so they are safe in the hottest loop:
BT.drawTile(sheet, index, pos)andBT.drawTile(sheet, col, row, pos)reuse one internal source rectangle; there is noRect2ito build or cache per tile.BT.drawSprite(sheet, src, dest, params)with aSpriteDrawParamsobject resolves flags, scale, and pivot into engine-owned scratch. The engine reads the object and never retains it, so one params object rewritten per draw is enough - do not build a fresh literal per sprite.BT.drawNineSlice(panel, rect)is one draw call however many parts it emits. Build theNineSliceonce ininit(). Tiled edges and centers draw one quad per tile, so tiling a 1-pixel strip across a large box costs many quads; stretch flat fills instead and keep tiling for art that needs it.
Line rendering costs
The Renderer has been optimized to reduce vertex overhead: axis-aligned lines now render as single quads (6 vertices), while diagonal lines use Bresenham's algorithm. This section explains the cost tradeoffs and when optimization matters.
Line rendering performance varies significantly based on line orientation:
| Line Type | Vertices Used | Example (100px line) |
|---|---|---|
| Horizontal / Vertical | 6 (single quad) | 6 vertices |
| Diagonal | 6 per pixel (Bresenham) | ~600 vertices |
Why diagonal lines cost more:
Diagonal lines use Bresenham's algorithm to achieve authentic pixel-art rendering where each pixel is a discrete unit. This produces the classic "staircase" look expected in retro demos but requires rendering each pixel as a separate quad.
Optimization tips for diagonal lines:
// GOOD: Grid lines (axis-aligned) are very cheap
for (let = 0; < 800; += 40) {
.(new (, 0), new (, 600), ); // 6 vertices each
}
// EXPENSIVE: Many diagonal lines (e.g., Lissajous curves, circles)
// Each segment uses ~6 vertices per pixel
for (let = 0; < 200; ++) {
.(, , ); // Could be 600+ vertices per line
}
// ALTERNATIVE: Use filled rectangles for thick diagonal lines
// Or pre-render complex patterns to a textureWhen diagonal line cost matters:
- Drawing 10-20 short diagonal lines per frame: Fine
- Drawing 100+ diagonal line segments (curves, circles): Consider alternatives
- Static complex patterns: Pre-render to a sprite sheet
Loop best practices
Fixed timestep
BLIT386 uses a fixed simulation timestep by default (targetFPS: 60 → update() about 60 times per second). render()
still runs at the browser's refresh rate. This provides:
- Deterministic behavior: Same inputs always produce same results
- Predictable physics: Use
BT.ticksandBT.deltaSecondsinstead of render-frame timing - Simplified logic: Tick-based counters and
Timerintervals
Using ticks for timing
// Frame-based timer (recommended)
if (. - >= 60) {
();
= .;
}
// Alternative: Delta time (more complex)
+= ;
if ( >= 1.0) {
();
= 0;
}Ticks are simpler and more deterministic for most demo logic.
Frame budget management
If your update() or render() takes too long:
- Confirm the symptom - Set
isDetectingDroppedFrames: trueinconfigure()to log a console warning whenever the browser misses a vsync deadline. The detector auto-calibrates to the actual rAF cadence so it works on any refresh rate (60 / 120 / 144 Hz, etc.) and on Firefox where rAF often fires at the display rate rather than attargetFPS. - Profile with browser dev tools - Find the actual bottleneck
- Reduce draw calls - Cull off-screen objects
- Optimize hot loops - Use pre-allocation in tight loops
- Simplify logic - Can you defer expensive operations?
Common pitfalls
Performance rules of thumb
Object allocation guidelines
| Operations/Frame | Recommendation | Example |
|---|---|---|
| < 10 | Inline allocation fine | UI text, menu rendering |
| 10-50 | Borderline, prefer clarity | Simple animations, small particle systems |
| 50-100 | Consider pre-allocation | Complex scenes, medium particle systems |
| 100+ | Definitely pre-allocate | Pattern demos, large particle systems |
When to use each pattern
Use inline allocation for:
- UI rendering (< 10 operations)
- Menu systems
- Initialization code
- Debug rendering
- Anything that runs once or rarely
Use pre-allocation for:
- Particle systems (100+ particles)
- Complex pattern rendering
- Large scrolling worlds (camera example)
- Per-entity operations with 50+ entities
- Physics simulations
Example references
Demos live in packages/demos (one number-free kebab-case src/<slug>.js file per demo, e.g. basics.js). The
examples below demonstrate both approaches:
Clarity-first examples (inline allocation)
basics.js- Entry-level example teaching core conceptsbitmap-font.js- Bitmap font loading and palette indexize workflowprimitives.js- Drawing primitive shapessprites.js- Sprite rendering and palette offsetsfonts.js- System font and text rendering
These prioritize readability and learning over micro-optimizations.
Performance-optimized examples (pre-allocation)
patterns.js- Complex animations with 200+ operations/framecamera.js- Scrolling world with many buildingsbunnymark.js- Bouncing sprites by the hundred from one sheet, with the WebGPU sprite cap made visiblenine-slice-panels.js- OneNineSliceper panel style, drawn at every size from a single asset
These demonstrate when and how to optimize for performance-critical scenarios.
Effect and animation examples
animation.js- Tick-based timing (inline allocation, focus on clarity)sprite-effects.js- Palette offset effects for team colors and flashes
Conclusion
Start simple, optimize when needed:
- Write clear code with inline allocation
- Profile to find actual bottlenecks
- Optimize hot paths with pre-allocation
- Keep UI and one-time code simple
Remember
Clear code that runs at 60 FPS is better than complex code that runs at 65 FPS.
See also
Performance Testing
API: Rendering
Palette Guide
Overlay Guide
Last updated on October 18, 2026