Skip to content

Memory Management Guide - PixelRoot32 C++17 ​

Where these projects live. iso_dungeon and metroidvania were moved out of this repository's examples/ into PixelRoot32-Demo-Projects — graphics/iso_dungeon and gameplay/metroidvania respectively. The measurements below were taken on that code and remain valid; only its address changed. Paths are written relative to that repository from here on.

Overview ​

This guide covers modern memory management practices in PixelRoot32 using C++17 features. The engine has transitioned from manual memory management to smart pointers and RAII (Resource Acquisition Is Initialization) patterns for improved safety and maintainability.

Memory regions (ESP32-oriented overview) ​


Engine Memory Limits ​

Understanding the engine's memory limits is crucial for developing stable games on resource-constrained platforms.

Hard Limits (Compile-Time Constants) ​

LimitDefault ValueConfigurableDescription
Max Entities32✅ via MAX_ENTITIESMaximum entities per scene
Max Layers3✅ via MAX_LAYERSMaximum render layers (0=Bg, 1=Game, 2=UI)
Max Physics Pairs128✅ via PHYSICS_MAX_PAIRSMaximum collision pairs considered in broadphase
Max Physics Contacts128✅ via PHYSICS_MAX_CONTACTSFixed contact pool size; no heap per frame. Excess contacts are dropped.
Spatial Grid Cell Size32px✅ via SPATIAL_GRID_CELL_SIZESize of uniform grid cells
Max Entities Per Grid Cell24✅ via SPATIAL_GRID_MAX_ENTITIES_PER_CELLLegacy single-grid capacity
Max Static Per Cell12✅ via SPATIAL_GRID_MAX_STATIC_PER_CELLStatic layer capacity per cell
Max Dynamic Per Cell12✅ via SPATIAL_GRID_MAX_DYNAMIC_PER_CELLDynamic layer capacity per cell
Velocity Iterations2✅ via PIXELROOT32_VELOCITY_ITERATIONSPhysics solver iterations
Gameplay Event Queue Capacity32✅ via GAMEPLAY_EVENT_QUEUE_CAPACITYRing buffer slots for gameplay::GameplayEventBus (Gameplay Framework Phase 1)
Max Interactive Actors16✅ via GAMEPLAY_MAX_INTERACTIVE_ACTORSRegistry size for gameplay::InteractionTracker (Gameplay Framework Phase 1)
Spatial Query Max Radius128✅ via SPATIAL_QUERY_MAX_RADIUSClamp for queryRadius() to avoid Q16.16 squared-distance overflow (Gameplay Framework Phase 1)

Modular Compilation Impact:

When subsystems are disabled via PIXELROOT32_ENABLE_* flags, their memory allocations are eliminated entirely from the binary:

FlagRAM SavingsFlash SavingsSubsystems Removed
PIXELROOT32_ENABLE_AUDIO=0~8 KB~15 KBAudioEngine, MusicPlayer, audio buffers
PIXELROOT32_ENABLE_PHYSICS=0~12 KB~25 KBCollisionSystem, spatial grid, physics actors
PIXELROOT32_ENABLE_UI_SYSTEM=0~4 KB~20 KBUIElement, all layouts, UI containers, sprite elements
PIXELROOT32_ENABLE_PARTICLES=0~6 KB~10 KBParticleEmitter, particle pools
All disabled~30 KB~70 KBMaximum savings

Gameplay Framework Phase 1 flags (opt-in, default 0): unlike the flags above — which default to 1 because audio/physics/UI/particles are load-bearing for existing examples — these four capabilities are purely additive and default off, so none of the 15 existing examples pays their cost unless explicitly enabled:

FlagDefaultRAM Cost When EnabledSubsystem Added
PIXELROOT32_ENABLE_GAMEPLAY_EVENTS=10~512 B (ESP32-C3) / ~768 B (native)gameplay::GameplayEventBus — single-threaded, Engine-owned event ring buffer
PIXELROOT32_ENABLE_INTERACTION_TRIGGERS=10~704 B (ESP32, PHYSICS_MAX_CONTACTS=64) / ~1.2 KB (native default 128)gameplay::InteractionTracker — enter/exit edge detection over CollisionSystem contacts. Requires PIXELROOT32_ENABLE_PHYSICS=1
PIXELROOT32_ENABLE_SPATIAL_QUERY=100 B extra static storage (adds methods only)SpatialGrid::queryRadius/queryBox + CollisionSystem::queryRadius/queryBox. Requires PIXELROOT32_ENABLE_PHYSICS=1
PIXELROOT32_ENABLE_DEPTH_SORT=10~8 B per Scene (comparator pointer + bool) + 4 B per Entity (depthKey, see below)Scene::depthComparator / depthSortEnabled secondary sort key used by Scene::sortEntities(), plus Entity::depthKey and gameplay::compareByDepthKey

PIXELROOT32_ENABLE_INTERACTION_TRIGGERS=1 or PIXELROOT32_ENABLE_SPATIAL_QUERY=1 combined with PIXELROOT32_ENABLE_PHYSICS=0 fails the build at compile time via an #error in PlatformDefaults.h (CollisionSystem and SpatialGrid only exist when physics is enabled), rather than silently disabling the feature.

Gameplay Framework Phase 2 flags (opt-in, default 0): two more capabilities in pixelroot32::gameplay, each pure composition with zero engine-side wiring — no new member, virtual, or hook on Scene, Actor, or Entity. Unlike Phase 1's INTERACTION_TRIGGERS/SPATIAL_QUERY, neither depends on PIXELROOT32_ENABLE_PHYSICS or any other flag — neither header includes core/, physics/, math/, or any other capability's header, so no #error guard exists or is needed:

FlagDefaultRAM Cost When EnabledSubsystem Added
PIXELROOT32_ENABLE_GAMEPLAY_STATE_MACHINE=1020 B/instance (ESP32-C3) / 32 B/instance (native), plus one shared table in flash per classgameplay::StateMachine — non-template FSM over a caller-owned const state table
PIXELROOT32_ENABLE_GAMEPLAY_OBJECT_POOL=10N * sizeof(T) + 8-12 B bookkeeping per pool instantiation (see table below)gameplay::ObjectPool<T, N> — fixed-capacity, zero-heap slot pool with placement-new storage

StateMachine::State byte budget (one table row — flash/.rodata, shared per class, not per instance):

Target3 function pointersid (StateId/uint8_t)paddingsizeof(State)
ESP32-C3 (32-bit)3 × 4 = 12 B1 B3 B16 B
native/PC (64-bit)3 × 8 = 24 B1 B7 B32 B

StateMachine instance byte budget (SRAM, one per stateful actor):

Targetowner_ + states_ (2 pointers)timeInStateMs_6 × 1 B fields (current_, previous_, pending_, stateCount_, inTransition_, transitionOverflows_)paddingsizeof(StateMachine)
ESP32-C3 (32-bit)8 B4 B6 B2 B20 B
native/PC (64-bit)16 B4 B6 B6 B32 B

Confirmed against the shipped include/gameplay/StateMachine.h layout — field order is owner_, states_, timeInStateMs_, then the six 1-byte fields, exactly as budgeted; no drift from the design.

Gameplay Framework Phase 3 flag (opt-in, default 0): one capability in pixelroot32::gameplay, header-only with no .cpp file, so there is no additional code cost when the flag is off. It depends on math/ (Vector2, MathUtil) but carries no #error guard, on the same terms as DepthCompare.h and GameplayEvent.h, which already depend on math/Scalar.h: math/ is always available regardless of PIXELROOT32_ENABLE_PHYSICS or any other flag:

FlagDefaultRAM Cost When EnabledSubsystem Added
PIXELROOT32_ENABLE_GAMEPLAY_GRID_SPACE=100 B SRAMgameplay::GridSpace.h — grid-to-world/world-to-grid coordinate conversion (GridSpec, cellToWorldX/Y, cellToWorld, worldToCellX/Y, containsCell)
PIXELROOT32_ENABLE_GAMEPLAY_GRID_SPACE=1020 B SRAM per moving actorgameplay::GridMotion.h — per-actor cell-to-cell step state (GridMotion, isMoving, placeAt, beginStep, tickStep, interpolatedWorld)

GridSpec byte budget: every shipped consumer (2048 and bomberbot, both in PixelRoot32-Demo-Projects) declares its grid as inline constexpr GridSpec. constexpr implies const, so the six-int aggregate lands in .rodata/flash, never .data/.bss — 0 B SRAM, at every optimization level, independent of whether the optimizer also folds the constant away entirely. sizeof(GridSpec) == 24 B (six ints — int is 4 B under both the ESP32-C3's ILP32 and native's LP64), identical on both targets. A non-constexpr (runtime) GridSpec would cost 24 B SRAM instead; no shipped consumer uses one.

GridMotion byte budget: unlike GridSpec, a GridMotion is inherently per-actor runtime state, so it does land in .bss. sizeof(GridMotion) == 20 B (five ints, identical on ILP32 and LP64). Worst case is one instance per grid-moving actor: bomberbot embeds one in PlayerActor and one in each of its kMaxEnemies pool slots. Against the ESP32-C3 ceiling of 24 entities that is 480 B if every entity moves on the grid — comfortably inside budget, and typically far lower since static actors (walls, bombs, pickups) need none. GridMotion shares GridSpace's flag rather than taking its own. The original reason — "interpolatedWorld() takes a GridSpec, so motion without space is not a reachable configuration" — stopped being true when the ProjectionSpec overload landed (see the projection section below); the flag is still not split because a separate one would widen the build matrix for a configuration no consumer has asked for. The consequence is that an isometric game wanting GridMotion must enable PIXELROOT32_ENABLE_GAMEPLAY_GRID_SPACE even if it never declares a GridSpec.

GridMotion scope, and what it deliberately excludes: it owns the logical cell, the in-flight target, the arrival edge and the cell-to-pixel lerp — the mechanics. Cell-enterability tests, direction selection, arrival reactions and input buffering stay in game code, because every shipped consumer answers them differently: bomberbot's player treats the bomb it just dropped as passable while its enemies treat every bomb as solid, and neither buffers direction input (both sample direction only at rest and ignore it in flight). Modelling those as engine callbacks would cost more configuration than the ~17 lines of mechanics it replaces.

Cell-to-screen projection (opt-in, default 0): the canonical implementation lives in math/Projection.h (pixelroot32::math, Layer 2), header-only with no .cpp. include/math/Projection.h includes nothing but PlatformDefaults.h — every function is pure int arithmetic, so unlike GridSpace.h it does not even depend on the rest of math/. include/gameplay/Projection.h now forwards to it: a thin using-alias header (pixelroot32::gameplay::ProjectionSpec and its free functions), so existing gameplay::-qualified callers keep compiling unchanged. It is independent of PIXELROOT32_ENABLE_GAMEPLAY_GRID_SPACE; the only place the two meet is the ProjectionSpec overload of interpolatedWorld(), which lives in GridMotion.h and is guarded on both flags.

FlagDefaultRAM Cost When EnabledSubsystem Added
PIXELROOT32_ENABLE_PROJECTION=100 B SRAMmath/Projection.h — cell-to-screen mapping for an arbitrary integer 2×2 basis (ProjectionSpec, cellToScreenX/Y, screenToCellX/Y, projectionDet, projectionSpecIsValid); gameplay/Projection.h forwards to it
PIXELROOT32_ENABLE_TILEMAP_PROJECTION=100 B SRAM (see executed-path note below)A separate, wholly flag-guarded Renderer::drawTileMap overload per tile format — TileMap (1bpp), TileMap2bpp, TileMap4bpp — each taking a const ProjectionSpec& (a reference, not the earlier pointer — there is no null form), backed by one shared drawTileMapProjectedImpl<TileT> so the projected geometry exists once regardless of format; the per-format tail (palette LUT vs single Color) is selected with if constexpr. The 1bpp overload's single Color argument and the reference are stack arguments, not fields, so no per-instance SRAM is added; requires PIXELROOT32_ENABLE_PROJECTION=1 (#error otherwise); the flag-off preprocessed translation unit is unchanged. cachedLUT[16] in the shared impl is sized for the widest LUT (4bpp); the 2bpp tail only ever writes/reads entries [0..3], leaving 24 of its 32 bytes unused on that path (stack cost, not SRAM/global), and 1bpp writes none of it. Flash, measured on esp32dev/examples/animated_tilemap (a real TileMap4bpp consumer that calls only the plain overload, clean builds, flags forced through PLATFORMIO_BUILD_FLAGS): flags off 325,840 B, flags on 325,808 B — -32 B, unchanged from the 4bpp-only measurement despite two more template instantiations existing in the source, because the projected overloads and their instantiated impls are unreferenced by a caller that never invokes them and --gc-sections strips them entirely. Neither figure exercises the feature at runtime — no shipped example passed a non-null projection at the time of that measurement. An earlier revision of this row measured graphics/iso_dungeon and reported +16 B; at that time the claim below it — that graphics/iso_dungeon never calls drawTileMap — was correct, so that figure reflected incidental linker noise, not this feature, and is superseded by the executed-path measurement below.

Executed-path measurement (graphics/iso_dungeon, post-conversion). graphics/iso_dungeon's RoomRenderer::drawTiles was converted from 49 hand-rolled drawSprite calls to one projected drawTileMap call (see Projected Tilemap: Producer Obligations), making it — as of that conversion — the first vehicle in this repo where the projected path is executed, not merely linked; every figure above measures "linked", this one measures "used". esp32dev/iso_dungeon firmware.bin, across the conversion boundary: 355,520 B → 357,264 B, +1,744 B. SRAM: 24,152 B → 24,232 B, +80 B, matching the ~81 B forecast (49 B index grid + ~32 B TileMap4bpp control struct) almost exactly. Not comparable to the animated_tilemap figures above — different vehicle, different call site, and that one still never executes the projected path.

ProjectionSpec byte budget: sizeof(ProjectionSpec) == 24 B — six ints, identical on ILP32 and LP64, the same shape and the same reasoning as GridSpec. A constexpr spec is const, so it lands in .rodata/flash, never .data/.bss: 0 B SRAM at every optimization level. The determinant is deliberately not a seventh field — it is computed by projectionDet(), because a derived field could be set inconsistently by an aggregate initializer and there is no constructor in which to maintain the invariant.

Why one type covers every layout. Orthogonal, isometric 2:1, isometric 1:1, oblique and mirrored layouts are all values: {0,0,16,0,0,16}, {0,0,16,8,-16,8}, {0,0,16,16,-16,16}, {0,0,16,0,8,16}. A general integer basis costs exactly the same arithmetic as a hardcoded diamond — four multiplies and two adds — so there is no per-layout function, enum or template parameter to pay for, and no API break the first time a game wants a different ratio.

Division cost, stated precisely. cellToScreenX/Y never divide. screenToCellX/Y invert the basis by Cramer's rule and perform exactly one integer division per axis — the same cost profile as worldToCellX(), whose detail::gridFloorDiv also performs exactly one div. What both headers avoid is Fixed16::operator/ (a 64-bit shift plus a 64-bit divide, i.e. a libgcc __divdi3 call on a 32-bit core). No Q16 reciprocal is precomputed: it would be exact only when the determinant is a power of two. With a constexpr spec the determinant is a compile-time constant, so the division is strength-reduced away entirely — for the documented layouts it is 256 or 512, a plain shift.

Entity::depthKey byte budget (under PIXELROOT32_ENABLE_DEPTH_SORT): paint order under a non-identity projection follows projected screen Y, which is not a monotone function of world Y — so gameplay::compareByBottomY is wrong there and gameplay::compareByDepthKey reads a game-written int16_t instead. Entity has a single trailing pad byte and an int16_t cannot occupy it, so the base class grows by 4 B on a 32-bit target (28 → 32) and 8 B on 64-bit native (32 → 40, measured). With the flag off the field is not declared and sizeof(Entity) is unchanged.

The per-entity worst case is an upper bound, and real actors usually pay less. A derived actor generally has trailing padding of its own, and the base-class growth is absorbed into it. Measured on bomberbot (native/64-bit, where the base grows by the larger 8 B):

TypeDEPTH_SORT=0DEPTH_SORT=1Delta
core::Entity32 B40 B+8 B
PlayerActor104 B104 B0 B — absorbed by existing tail padding
EnemyActor96 B96 B0 B — absorbed
BoardRenderer80 B88 B+8 B — no spare padding
BomberbotScene (whole scene, a global in .bss)2408 B2416 B+8 B total

So the honest figures are: upper bound 4 B × MAX_ENTITIES = 256 B on device (0.06 % of a 400 KB budget) when no derived type has spare padding, and measured cost in the only real consumer 8 bytes for the entire scene. Budget for the bound; expect the measurement.

Unlike Scene::depthComparator/depthSortEnabled, which are left ungated because they cost ~8 B once per scene, depthKey is gated: a per-entity field scales with the entity budget, and a game that does not depth-sort must not pay it.

Why a field and not a virtual getter. A virtual int16_t getDepthKey() would cost 0 B per entity — the vtable already exists — and it still loses. core::Entity must not know about a projection (that would invert the layer dependency), so the only possible base-class default is the world-Y expression, which is precisely the wrong answer under a non-identity projection and would be inherited silently by every game that forgets to override it. A field defaulting to 0 has no wrong answer to inherit: a game that never writes it gets stable layer-only ordering. The comparator is also on the hot path — Scene::sortEntities() is an insertion sort, O(n²) comparisons in the worst case — where two indirect calls per comparison are not free.

Sprite UI elements (UISprite / UISpriteRow, under PIXELROOT32_ENABLE_UI_SYSTEM): the UI system drew text and rectangles only, so any icon — an item slot, a dialog portrait, a button glyph, a resource HUD — had to be drawn by hand in a Scene::draw() override, outside the entity tree. UISpriteRef (include/graphics/ui/UISpriteRef.h) is the tagged union that lets one element handle all three sprite descriptors (Sprite, Sprite2bpp, Sprite4bpp), each of which has a different draw signature. The format switch exists in exactly one place, drawUISpriteRef() — type erasure over templating, the same trade the gameplay framework made, for the same reason: one copy in flash instead of one per instantiation.

Typenative/PC (64-bit)Notes
UIElement (base)40 BFor reference
UISpriteRef16 BPointer + format tag + tint/palette slot
UISprite64 BBase + one ref + flip flag. Smaller than UILabel (80 B), which carries a std::string
UISpriteRow136 BBase + kMaxStates (5) refs + value/capacity/spacing scalars

With PIXELROOT32_ENABLE_UI_SYSTEM=0 all three translation units compile to an empty object (434 B of container headers, zero code) — verified, not assumed.

Why UISpriteRow is one element and not a layout of N UISprite: the obvious composition — UIHorizontalLayout holding one UISprite per icon — costs one scene entity per icon. A 16-heart bar would take two thirds of the 24-entity budget recommended for the ESP32-C3 (see the variant table above), and every one of those entities re-enters Scene::sortEntities() — an insertion sort that runs each frame once depth sorting is on — to produce an order that never changes. UISpriteRow draws N icons from one entity and 136 B instead. Its capacity is a plain uint8_t counter, not a per-icon array, so growing the row at runtime (a heart container) costs no additional storage.

Gameplay Framework Phase 3 part 2 — Room/Screen (opt-in, default 0): RoomGraph<N> is a header-only template class under PIXELROOT32_ENABLE_GAMEPLAY_ROOM. A Scene owns it via a type-erased RoomGraphBase* pointer (composition, no inheritance). Entering a room updates camera bounds and fires an optional onEnter callback. The flag defaults to 0 — when disabled the entire #if block is excluded and the engine contributes zero bytes.

ItemESP32-C3 (32-bit)native/PC (64-bit)Notes
RoomGraphBase* ptr on Scene4 B per Scene8 B per SceneType-erased pointer; nullptr when flag=0 or no graph is registered
RoomGraphBase vtable12–16 B in flash12–16 B in flashOne shared vtable per program (not per instance)
RoomGraph<N> vptr (from RoomGraphBase)4 B per instance8 B per instancePer-instance vtable pointer; one per RoomGraph<N> regardless of N
RoomGraph<32> (max rooms)~1296 B (32 × 40 B/room + 12 B bookkeeping + 4 B vptr)~1564 B (32 × 48 B/room + 16 B bookkeeping + 8 B vptr)Bookkeeping: roomCount_ (2 B), currentRoomIndex_ (2 B), onEnter_ fn ptr + userData_ ptr (8 B on 32-bit, 16 B on 64-bit). No per-room allocated by a game that never instantiates RoomGraph<N>.
RoomGraph<2> (smallest useful graph)~100 B (2 × 40 B + 12 B)~120 B (2 × 48 B + 16 B)Sizing shown for N=2; legend_of_clone ships RoomGraph<4>
Per-Room size (sizeof(Room))40 B48 BFour Scalar fields (camera rect, 4×4 B), tile window (8 B + 1 B flag + 1 B pad), connections_[4] (16 B), connectionCount_ (1 B + 3 B pad)
Flag = 00 B0 BWhole header is an empty #if block; no code, no data

Design: #3081 (sdd/room-screen-abstraction/design).

RoomLayer byte budget (room authoring): gameplay/RoomLayout.h adds the data contract the Tilemap Editor exports rooms into — RoomData (one room's tile rect + 4 connection slots) and RoomLayer (the array header) — plus the header-only buildRoomGraph<N>() that fills a RoomGraph<N> from it. Both structs are trivially copyable, and the editor emits them as static const arrays, so a room layer lands in .rodata/flash and costs 0 B SRAM. Sizes are pinned by static_assert in test/unit/test_gameplay_room_layout/, since the editor writes this layout byte-for-byte:

ItemESP32-C3 (32-bit)native/PC (64-bit)Notes
sizeof(RoomData)16 B16 BFour uint16_t rect fields + connections[4]; 2-byte aligned, no padding between entries
RoomLayer header8 B in flash16 B in flashOne const RoomData* + roomCount (2 B) + two uint8_t tile dimensions
A 2-room layer40 B flash, 0 B SRAM48 B flash, 0 B SRAM2 × 16 B rooms + the layer header
buildRoomGraph<N>()0 B SRAM0 B SRAMRuns once in Scene::init(); no state of its own, writes straight into the caller's RoomGraph<N>
Flag = 00 B0 BWhole header is an empty #if block, gated by the same PIXELROOT32_ENABLE_GAMEPLAY_ROOM

Format reference: Tilemap Editor — Room Layer.

CameraTween<N> byte budget: a Scene-owned fixed-capacity pool of N tween slots, each holding a from Vector2, a to Vector2, two uint16_t counters, an easing uint8_t, and an active bool. Composes with Camera2D (no change to Camera2D itself) for room transitions, cinematic pans, and cutscene movement. All easing math in Q16.16 integer arithmetic — no float, no std::function:

ComponentESP32-C3 (32-bit)native (64-bit)Notes
CameraTween<4> (default N)~101 B (4 slots × 24 B + 1 B activeCount_ + 4 B pad)~161 B (4 slots × 40 B + 1 B + padding)Per Scene that owns one; Vector2 is 8 B on 32-bit, 16 B on 64-bit
Per-slot (sizeof(Slot))24 B40 BTwo Vector2 (16 B + 32 B), two uint16_t (4 B), one uint8_t (1 B), one bool (1 B + 1 B pad on 32-bit, 7 B pad on 64-bit)
activeCount_1 B1 BNumber of currently-running tweens
Flag = 01 B (stub)1 B (stub)When disabled, CameraTween<N> is a stub template with no slots, no easing math, and all methods are no-ops. Storage is 1 byte for the stub object.

A Scene that wants smooth camera transitions declares a CameraTween<N> tweens_ member and calls tweens_.startTween(...) + tweens_.update(deltaMs, &camera) from its update(). The tween writes positions via the existing Camera2D::setPosition() — no new methods on Camera2D. Genre-agnostic; useful for any game with smooth camera movement (top-down, metroidvania, platformer, puzzle, RPG). Design: #3105 (sdd/camera-tween/design).

ObjectPool<T, N> byte budget: N * sizeof(T) for the aligned slot storage, plus target-independent bookkeeping (a uint32_t liveWords_[(N+31)/32] bitmask plus two uint16_t counters, liveCount_ and scanHint_) — identical on ESP32-C3 and native because every bookkeeping field is a fixed-width type:

N (pool capacity)liveWords_counters (liveCount_ + scanHint_)bookkeeping totalper-slot equivalent
81 × 4 B4 B8 B1.00 B/slot
161 × 4 B4 B8 B0.50 B/slot
321 × 4 B4 B8 B0.25 B/slot
642 × 4 B4 B12 B0.19 B/slot

Total instance size is N * sizeof(T) + bookkeeping, plus up to alignof(T) - 1 bytes of tail padding for an over-aligned T. Since it is a template, only the (T, N) pairs a game actually instantiates emit any code or data — an unused instantiation costs nothing.

GameplayEvent byte budget (GAMEPLAY_EVENT_QUEUE_CAPACITY slots, default 32):

Targetvoid*Scalarids (2× uint16_t)type tagpaddingsizeof(GameplayEvent)Queue total
ESP32-C3 (32-bit, Scalar = Fixed16)4 B4 B4 B1 B3 B16 B512 B
native/PC (64-bit, Scalar = float)8 B4 B4 B1 B7 B24 B768 B

Non-atomic bus contract: gameplay::GameplayEventBus is produced and consumed entirely inside the single-threaded Scene::update()/Scene::draw() loop driven from SceneManager::update(). Its head/tail/count indices are plain uint16_t, not std::atomic — unlike AudioCommandQueue, which bridges the game thread and the audio task. Publishing from an ISR or the audio task is explicitly unsupported and will corrupt the ring buffer's indices; it is not a replacement for AudioCommandQueue. Overflow policy is drop-newest with a monotonic getDroppedCount() diagnostic (preserves enter/exit pairing rather than risking a dangling TriggerExit for an evicted TriggerEnter). The bus is drained (clear()) on every SceneManager::setCurrentScene() call (including SceneSwap transitions), but not on pushScene()/popScene(), since a paused scene under an overlay never runs and should not lose events it expects to consume on resume.

TileConsumptionConfig byte budget: the requiredHits field (added in tile-consume-generalization) adds +1 byte per config instance (uint8_t, default 1). Typical call sites construct a stack-local TileConsumptionConfig per collision event, so the cost is one uint8_t on the stack, amortized per collision — no persistent SRAM cost.

Subsystem Compilation Patterns ​

File-level guards:

cpp
// src/audio/MusicPlayer.cpp
#include "core/EngineModules.h"
#if PIXELROOT32_ENABLE_AUDIO

// ... full implementation ...

#endif // PIXELROOT32_ENABLE_AUDIO

Constructor initialization:

cpp
Engine::Engine(DisplayConfig&& displayConfig, ...)
    : renderer(std::move(displayConfig)),
#if PIXELROOT32_ENABLE_AUDIO
      audioEngine(audioConfig, capabilities),
      musicPlayer(audioEngine),
#endif
      // ... other members ...

Runtime initialization:

cpp
void Engine::init() {
    renderer.init();
    inputManager.init();
#if PIXELROOT32_ENABLE_AUDIO
    audioEngine.init();
#endif
}

For projects with severe memory constraints, use predefined profiles:

ini
# platformio.ini

[profile_minimal]
build_flags =
    -DPIXELROOT32_ENABLE_AUDIO=0
    -DPIXELROOT32_ENABLE_PHYSICS=0
    -DPIXELROOT32_ENABLE_PARTICLES=0
    -DPIXELROOT32_ENABLE_UI_SYSTEM=0
    -DMAX_ENTITIES=16
    -DPHYSICS_MAX_CONTACTS=0

[profile_arcade]
build_flags =
    -DPIXELROOT32_ENABLE_AUDIO=1
    -DPIXELROOT32_ENABLE_PHYSICS=1
    -DPIXELROOT32_ENABLE_PARTICLES=1
    -DPIXELROOT32_ENABLE_UI_SYSTEM=0

Memory Budget Planning ​

When planning memory usage, subtract subsystem overhead from available RAM:

Available RAM (ESP32):     ~400 KB (classic) / ~512 KB (S3)
├─ Framebuffer (240x240):  ~57 KB
├─ Engine overhead:         ~20 KB
├─ Subsystem RAM:          Variable (see table above)
└─ Game entities:         Remaining

Example budget calculation for 240x240 game on ESP32 classic:

ItemRAM
Framebuffer57 KB
Engine overhead20 KB
Physics (if enabled)12 KB
Audio (if enabled)8 KB
Reserved~97 KB
Available for game~423 KB

Memory Footprint by Resolution ​

ResolutionFramebufferScaling LUTsTotal (approx)
128x128~16 KB~1 KB~17 KB
160x160~25 KB~1.5 KB~26.5 KB
240x240~57 KB~2 KB~59 KB

Note: These values are for TFT (16-bit) displays. OLED displays use significantly less memory.

Optional StaticTilemapLayerCache (4bpp tilemap snapshot): when enabled (PIXELROOT32_ENABLE_STATIC_TILEMAP_FB_CACHE, default 1), scenes may allocate a second logical W×H byte buffer (same order as one fullscreen 8bpp logical surface) via allocateForRenderer / allocateForLogicalSize during Scene::init() only—no heap traffic in draw/update. Budget an extra ~57 KB at 240×240 if you use the fast path; set the flag to 0 or skip allocate* to avoid that cost (full redraw fallback).

Optional StaticLayerSnapshot (projection-agnostic snapshot): same W×H byte cost and the same allocate-in-Scene::init() rule as the tilemap cache above, but gated on PIXELROOT32_ENABLE_STATIC_LAYER_SNAPSHOT, default 0. Use it when the static layer is drawn by game code rather than by drawTileMap—an isometric or oblique floor, for instance, which has no TileMap4bpp to hand the tilemap cache. Budget the same ~57 KB at 240×240. A scene that never calls allocate* pays nothing, and with -ffunction-sections/--gc-sections a build that never instantiates the class links none of it in.

The two caches solve the same problem from opposite ends and there is no reason to allocate both for one layer: StaticTilemapLayerCache owns and redraws the tilemaps it caches, while StaticLayerSnapshot never draws anything and caches whatever the framebuffer already holds.

Per-Entity Memory Costs ​

ComponentMemory Cost
Base Entity~32 bytes
Actor~64 bytes
PhysicsActor~128 bytes
KinematicActor~144 bytes
Sprite (1bpp)(width * height / 8) bytes
Sprite (2bpp)(width * height / 4) bytes
Sprite (4bpp)(width * height / 2) bytes
PlatformEntitiesDynamic Physics ObjectsSpritesNotes
ESP32 (classic)321664520KB SRAM total
ESP32-S3482496512KB SRAM + PSRAM
ESP32-C3241248400KB SRAM, no FPU

Configuration Examples ​

For maximum performance (128x128, low entity count):

cpp
// platformio.ini build_flags
-D LOGICAL_WIDTH=128
-D LOGICAL_HEIGHT=128
-D MAX_ENTITIES=24
-D PHYSICS_MAX_PAIRS=64
-D PIXELROOT32_ENABLE_UI_SYSTEM=0  ; Disable UI for minimal build
-D PIXELROOT32_ENABLE_PARTICLES=0  ; Disable particles for minimal build
-D PHYSICS_MAX_CONTACTS=64

For richer scenes (with PSRAM):

cpp
// platformio.ini build_flags
-D LOGICAL_WIDTH=240
-D LOGICAL_HEIGHT=240
-D MAX_ENTITIES=64
-D PHYSICS_MAX_PAIRS=256
-D PIXELROOT32_ENABLE_AUDIO=1     ; Full audio system
-D PIXELROOT32_ENABLE_PHYSICS=1   ; Full physics System

Minimal embedded build (ESP32-C3, no audio):

cpp
// platformio.ini build_flags
-D LOGICAL_WIDTH=128
-D LOGICAL_HEIGHT=128
-D MAX_ENTITIES=16
-D PIXELROOT32_ENABLE_AUDIO=0     ; No audio system
-D PIXELROOT32_ENABLE_PHYSICS=0   ; Basic collision only
-D PIXELROOT32_ENABLE_UI_SYSTEM=1  ; Keep UI for user interface
-D PIXELROOT32_ENABLE_PARTICLES=0  ; No particle system
-D PHYSICS_MAX_CONTACTS=256

Collision system memory (v1.0+): The solver uses a fixed contact array (PHYSICS_MAX_CONTACTS entries) and a dual-layer spatial grid (static + dynamic cells). No heap is allocated during detectCollisions(); only the static/dynamic grid buffers and the contact array occupy static memory. Reducing PHYSICS_MAX_CONTACTS or the per-cell limits lowers RAM use at the cost of dropping contacts or actors when limits are exceeded.

ESP32 DRAM and Build Configuration ​

On ESP32 (e.g. esp32dev), the linker places static and global data in .dram0.bss. If the project fails with region dram0_0_seg overflowed by N bytes, reduce one or more of the following (via platformio.ini build_flags or scene buffers):

What to reduceFlag or changeEffect
Logical resolution-D LOGICAL_WIDTH=128 -D LOGICAL_HEIGHT=128 (keep PHYSICAL_DISPLAY_* at 240)Smaller SpatialGrid and tilemap indices; rendering scales to physical size.
Spatial grid per cell-D SPATIAL_GRID_MAX_STATIC_PER_CELL=4 -D SPATIAL_GRID_MAX_DYNAMIC_PER_CELL=4Less static RAM for grid (default 12).
Contact pool-D PHYSICS_MAX_CONTACTS=64 -D PHYSICS_MAX_PAIRS=64Smaller contact array per scene (default 128).
Scene arena / buffersReduce scene static buffers in scene .cpp (e.g. sceneArenaBuffer[8192] in examples/animated_tilemap/src/AnimatedTilemapScene.cpp:65, sceneBuffer[12288] in examples/physics/src/PhysicsDemoScene.cpp:89)Fewer bytes in .dram0.bss.

Recommended for ESP32 when linking fails (240×240 physical):

ini
build_flags =
  -D LOGICAL_WIDTH=128
  -D LOGICAL_HEIGHT=128
  -D PHYSICAL_DISPLAY_WIDTH=240
  -D PHYSICAL_DISPLAY_HEIGHT=240
  -D SPATIAL_GRID_MAX_STATIC_PER_CELL=4
  -D SPATIAL_GRID_MAX_DYNAMIC_PER_CELL=4
  -D PHYSICS_MAX_CONTACTS=64
  -D PHYSICS_MAX_PAIRS=64

The engine library compiles only from its src/ directory (library.json srcDir); the test/ folder is not linked into the firmware.

Runtime Memory Monitoring ​

cpp
// In your scene or debug overlay
void debugMemory() {
    #ifndef PLATFORM_NATIVE
        uint32_t freeHeap = ESP.getFreeHeap();
        uint32_t totalHeap = ESP.getHeapSize();
        uint32_t minFreeHeap = ESP.getMinFreeHeap();  // Since boot
        
        Serial.printf("Heap: %u/%u bytes free (min: %u)\n", 
                      freeHeap, totalHeap, minFreeHeap);
                      
        // Warn if below safety threshold (e.g., 20KB)
        if (freeHeap < 20480) {
            Serial.println("WARNING: Low memory!");
        }
    #endif
}

Heap Fragmentation Warning ​

Long-running games may experience heap fragmentation. Symptoms:

  • Gradual decrease in free heap despite stable entity count
  • Sudden crashes when allocating new objects
  • Performance degradation over time

Mitigation strategies:

  1. Use Object Pooling for bullets/particles
  2. Pre-allocate in Scene::init(), not during gameplay
  3. Use SceneArena for temporary allocations
  4. Avoid frequent std::vector reallocations (reserve capacity upfront)

Key Changes in v0.9.0 ​

The engine migrated from C++11 to C++17 and adopted modern memory management patterns:

  • Smart Pointers: std::unique_ptr for exclusive ownership
  • RAII: Automatic resource management
  • Zero Manual Delete: No explicit delete calls needed
  • Move Semantics: Efficient ownership transfer

Smart Pointer Patterns ​

Basic Usage ​

Creating Objects:

cpp
// Modern approach (v0.9.0+)
auto player = std::make_unique<PlayerActor>(position, width, height);
auto bullet = std::make_unique<BulletActor>(x, y, velocity);

// Pass to scene (non-owning)
scene.addEntity(player.get());
scene.addEntity(bullet.get());

Ownership Transfer:

cpp
// Transfer ownership to engine
auto customRenderer = std::make_unique<CustomRenderer>(config);
engine.setRenderer(std::move(customRenderer));

// Custom display driver
auto display = std::make_unique<CustomDisplay>(width, height);
DisplayConfig config = PIXELROOT32_CUSTOM_DISPLAY(display.release(), width, height);

Container Storage ​

Vector of Game Objects:

cpp
class GameScene : public Scene {
private:
    std::vector<std::unique_ptr<EnemyActor>> enemies;
    std::vector<std::unique_ptr<Projectile>> projectiles;
    std::unique_ptr<PlayerActor> player;
    
public:
    void spawnEnemy(Vector2 position) {
        auto enemy = std::make_unique<EnemyActor>(position, 32, 32);
        enemies.push_back(std::move(enemy));
        addEntity(enemies.back().get());
    }
    
    void removeEnemy(EnemyActor* enemy) {
        // Find and remove from vector
        enemies.erase(
            std::remove_if(enemies.begin(), enemies.end(),
                [enemy](const std::unique_ptr<EnemyActor>& e) {
                    return e.get() == enemy;
                }
            ), enemies.end()
        );
        // Scene will handle entity removal
    }
};

Object Pooling with Smart Pointers ​

Fixed-Size Pool Pattern ​

Modern Pool Implementation:

cpp
class BulletPool {
private:
    static constexpr size_t MAX_BULLETS = 50;
    std::array<std::unique_ptr<BulletActor>, MAX_BULLETS> pool;
    std::bitset<MAX_BULLETS> activeFlags;
    
public:
    void init() {
        // Pre-allocate all bullets
        for (size_t i = 0; i < MAX_BULLETS; ++i) {
            pool[i] = std::make_unique<BulletActor>(0, 0, 0, 0);
            pool[i]->setEnabled(false);
        }
    }
    
    BulletActor* spawn(Vector2 position, Vector2 velocity) {
        for (size_t i = 0; i < MAX_BULLETS; ++i) {
            if (!activeFlags[i]) {
                activeFlags[i] = true;
                pool[i]->reset(position, velocity); // Custom reset method
                pool[i]->setEnabled(true);
                return pool[i].get();
            }
        }
        return nullptr; // Pool exhausted
    }
    
    void despawn(BulletActor* bullet) {
        for (size_t i = 0; i < MAX_BULLETS; ++i) {
            if (activeFlags[i] && pool[i].get() == bullet) {
                activeFlags[i] = false;
                pool[i]->setEnabled(false);
                break;
            }
        }
    }
};

RAII for Resources ​

Audio Resource Management ​

cpp
class AudioManager {
private:
    std::unique_ptr<AudioEngine> audioEngine;
    std::unique_ptr<MusicPlayer> musicPlayer;
    
public:
    AudioManager(const AudioConfig& config) {
        audioEngine = std::make_unique<AudioEngine>(config);
        musicPlayer = std::make_unique<MusicPlayer>();
    }
    
    ~AudioManager() {
        // Automatic cleanup - no manual delete needed
        // AudioEngine and MusicPlayer are automatically destroyed
    }
    
    void playSound(const AudioEvent& event) {
        audioEngine->playEvent(event);
    }
};

Display Resource Management ​

cpp
class DisplayManager {
private:
    std::unique_ptr<Renderer> renderer;
    std::unique_ptr<DrawSurface> surface;
    
public:
    DisplayManager(const DisplayConfig& config) {
        // Create custom surface
        surface = std::make_unique<CustomDrawSurface>(config.width, config.height);
        
        // Create renderer with surface
        renderer = std::make_unique<Renderer>(
            PIXELROOT32_CUSTOM_DISPLAY(surface.get(), config.width, config.height)
        );
        
        // Transfer ownership
        surface.release(); // Renderer now owns the surface
    }
};

Memory Safety Patterns ​

Avoiding Common Pitfalls ​

❌ Don't: Mix raw pointers and smart pointers

cpp
// Bad - potential double delete
Actor* rawPtr = new Actor();
std::unique_ptr<Actor> smartPtr(rawPtr);
scene.addEntity(rawPtr); // Dangerous!

✅ Do: Use .get() for non-owning access

cpp
auto actor = std::make_unique<Actor>();
scene.addEntity(actor.get()); // Safe - scene doesn't own
actors.push_back(std::move(actor)); // Transfer ownership

❌ Don't: Use after move

cpp
auto actor = std::make_unique<Actor>();
scene.addEntity(std::move(actor));
actor->update(); // ❌ Undefined behavior - actor is nullptr

✅ Do: Check before use after potential move

cpp
auto actor = std::make_unique<Actor>();
if (condition) {
    scene.addEntity(std::move(actor));
}
if (actor) { // ✅ Safe - check if still valid
    actor->update();
}

Performance Considerations ​

Move Semantics Efficiency ​

cpp
class GameScene {
private:
    std::vector<std::unique_ptr<Actor>> entities;
    
public:
    // Efficient - uses move semantics
    void addEntity(std::unique_ptr<Actor> entity) {
        entities.push_back(std::move(entity));
    }
    
    // Even more efficient - perfect forwarding
    template<typename T, typename... Args>
    void createEntity(Args&&... args) {
        auto entity = std::make_unique<T>(std::forward<Args>(args)...);
        entities.push_back(std::move(entity));
        Scene::addEntity(entities.back().get());
    }
};

Memory Fragmentation Prevention ​

  • Pre-allocation: Create objects in init() or constructor
  • Fixed-size containers: Use std::array instead of std::vector when size is known
  • Object pooling: Reuse objects instead of creating/destroying
  • Move semantics: Transfer ownership instead of copying

Hardware-Specific Memory (ESP32) ​

When working with high-performance drivers (TFT, I2S), memory must be allocated with specific capabilities.

DMA-Capable Memory ​

For SPI or I2S transfers to work without CPU intervention, the buffers must be in a specific region of SRAM.

cpp
// Correct way to allocate a DMA buffer
uint16_t* dmaBuffer = (uint16_t*)heap_caps_malloc(
    bufferSize, 
    MALLOC_CAP_DMA | MALLOC_CAP_8BIT
);

// Always check for success
if (dmaBuffer == nullptr) {
    // Fallback or error
}

// Memory allocated with heap_caps_malloc must be freed with heap_caps_free
heap_caps_free(dmaBuffer);

Cross-Platform Flash Memory Access (v1.0.0+) ​

When developing for ESP32, large static assets like tilemaps, sprites, and melodies are stored in Flash memory (PROGMEM) to save limited SRAM. However, standard C functions like strcmp or memcpy cannot read from Flash memory on some architectures.

The engine provides a platform abstraction layer in platforms/PlatformMemory.h to handle this transparently.

Unified Memory API ​

MacroDescriptionESP32 MappingNative Mapping
PIXELROOT32_FLASH_ATTRAttribute to store data in FlashPROGMEM(empty)
PIXELROOT32_STRCMP_PCompare string with Flash stringstrcmp_Pstrcmp
PIXELROOT32_MEMCPY_PCopy from Flash memorymemcpy_Pmemcpy
PIXELROOT32_READ_BYTE_PRead 8-bit value from Flashpgm_read_bytedirect access
PIXELROOT32_READ_WORD_PRead 16-bit value from Flashpgm_read_worddirect access
PIXELROOT32_READ_DWORD_PRead 32-bit value from Flashpgm_read_dworddirect access
PIXELROOT32_READ_FLOAT_PRead float value from Flashpgm_read_floatdirect access
PIXELROOT32_READ_PTR_PRead pointer from Flashpgm_read_ptrdirect access

Best Practice Example ​

When querying tile attributes or using exported scene data:

cpp
#include "platforms/PlatformMemory.h"

void checkTile(int x, int y) {
    // get_tile_attribute returns a pointer to Flash memory on ESP32
    const char* type = levels::level_1::get_tile_attribute(0, x, y, "type");
    
    if (type != nullptr) {
        // ✅ ALWAYS use PIXELROOT32_STRCMP_P for cross-platform compatibility
        if (PIXELROOT32_STRCMP_P("lava", type) == 0) {
            player->takeDamage(100);
        }
    }
}

Memory-Performance Trade-offs (v1.0.0) ​

In v1.0.0, the TFT_eSPI_Drawer uses double-buffering for DMA. Increasing LINES_PER_BLOCK improves throughput but increases memory usage linearly:

  • Baseline: 20 lines = ~10KB (at 240 width)
  • Optimized: 60 lines = ~30KB
  • Max: 120 lines = ~60KB (Half frame)

NOTE

Figures above are per buffer, and the driver allocates two. The 60-line setting was unreachable before d6dc9ae (a buffer-selection bug always forced the 30-line fallback); builds from that commit onward get the documented size, falling back only when DMA-capable internal RAM is short. Enabling PIXELROOT32_TFT_12BIT_COLOR=1 shrinks each buffer by 25% (60 lines at 240 width: ~28.8KB → ~21.6KB) at the cost of a 768-byte pair LUT — see ESP32 Performance Guide.

IMPORTANT

Non-FPU platforms like ESP32-C3 have more limited SRAM. Be cautious when increasing DMA block sizes or logical resolutions.


Migration from Manual Memory Management ​

Before (C++11 Style) ​

cpp
class OldGame {
private:
    Actor* player;
    std::vector<Actor*> enemies;
    
public:
    void init() {
        player = new PlayerActor(100, 100, 32, 32);
        for (int i = 0; i < 10; i++) {
            enemies.push_back(new EnemyActor(rand() % 200, rand() % 100, 16, 16));
        }
    }
    
    ~OldGame() {
        delete player;
        for (auto enemy : enemies) {
            delete enemy;
        }
    }
};

After (C++17 Style) ​

cpp
class NewGame {
private:
    std::unique_ptr<PlayerActor> player;
    std::vector<std::unique_ptr<EnemyActor>> enemies;
    
public:
    void init() {
        player = std::make_unique<PlayerActor>(100, 100, 32, 32);
        for (int i = 0; i < 10; i++) {
            auto enemy = std::make_unique<EnemyActor>(rand() % 200, rand() % 100, 16, 16);
            enemies.push_back(std::move(enemy));
        }
    }
    
    // ✅ No manual destructor needed!
};

Debugging Memory Issues ​

Common Tools ​

cpp
// Track object creation/destruction
class DebugActor : public Actor {
    static int instanceCount;
public:
    DebugActor() { instanceCount++; }
    ~DebugActor() { instanceCount--; }
    static int getInstanceCount() { return instanceCount; }
};

// Use in Scene
void update() {
    Serial.print("Active actors: ");
    Serial.println(DebugActor::getInstanceCount());
}

Memory Leak Detection ​

  • ESP32: Use ESP.getFreeHeap() to monitor memory
  • Native: Use Valgrind or AddressSanitizer
  • PlatformIO: Enable memory checking in test builds

Best Practices Summary ​

  1. Always use std::make_unique for object creation
  2. Use .get() for non-owning raw pointer access
  3. Use std::move() for ownership transfer
  4. Pre-allocate in constructors or init() methods
  5. Avoid manual delete - let RAII handle cleanup
  6. Use object pooling for frequently created/destroyed objects
  7. Check pointers after potential move operations
  8. Monitor memory usage on constrained platforms

References ​

Released under the MIT License.