Tprrt's Blog

Embedded Linux, Zephyr RTOS, open-source hardware, Linux gaming, retro gaming, and competitive fitness

Sep 25, 2026

One Entity, Many Bullets: Fitting a Projectile Pool Into the ECS

Introduction

The previous article on this blog was about the static, no-heap Entity Component System framer-engine runs on GBA and NDS: a fixed table of component slots and a fixed table of entities, sized to fit in a machine with no malloc(). That fixed sizing has a consequence the article didn't need to dwell on at the time: with today's component set, the table has room for 64 real entities. A caravan-style vertical shoot-'em-up, the first playable milestone this engine is building toward, wants several hundred bullets on screen during a dense pattern. Neither number moves to meet the other — the entity table isn't growing to fit a bullet count, and a bullet pattern isn't capping itself at 64 shots to fit the table.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

The tension is old and the usual answer is old too: don't make every bullet an entity. What took two attempts wasn't the bullet simulation itself — a flat array of structs, advanced by one system, is not a hard problem — it was deciding how that array gets drawn without quietly opening a second, ECS-shaped hole in the renderer. The first answer shipped, worked, and was wrong in a way no test had a reason to catch. The second answer was smaller, matched a pattern the engine already had, and went on to surface three real bugs across three different rendering backends, none of which were about bullets at all.

The first answer: a queue that isn't an entity

The obvious shape for "hundreds of short-lived things, updated every frame, drawn every frame, never touched individually by name" is a per-frame queue: a flat array of draw commands (position, sprite, frame), reset at the start of each frame, appended to by whatever wants something drawn, and consumed once by the renderer at the end of it. A bullet pool fits that shape exactly — push one entry per live bullet, once, after the physics step — and it required nothing new from the ECS at all, since nothing about it touched an entity, a component, or a query.

That was also exactly the problem. Every other thing this engine draws — a sprite, a tilemap, a HUD label — is an entity carrying a component, and every renderer's draw phase is systems iterating queries. A queue fed directly by application code and drained directly by the renderer is a second path with none of that: it isn't visible to the entity-inspection API a previous milestone added, it isn't something a capability query knows exists, and it isn't something any of the debugging tooling built around "the ECS is the whole scene" has any way to see. It worked, in the narrow sense that bullets appeared on screen in the right place moving at the right speed. It didn't survive being looked at as architecture: elements without an entity, bypassing the engine's own core model, aren't a smaller version of the real design — they're a different design that happens to render the same pixels.

The second answer: an entity that stands for many

framer-engine already had a component that solves this exact class of problem, for a different feature: a Tilemap is one entity whose component points at a grid the owner keeps, and the renderer draws the whole grid from that one entity every frame. Nothing about "many drawn things, one piece of external memory, one component pointing at it" is specific to tiles. The rewrite generalizes that shape and gives it a name — SpriteBatch — and the bullet pool becomes one more caller of it instead of a bespoke path of its own:

struct framer_batch_sprite {
    int32_t x, y;       /* center, world units, fixed point */
    uint32_t texture;
    uint8_t frame;
    uint16_t size;       /* drawn size, world units, fixed point */
};

struct sprite_batch {
    const void *first;   /* owner's element array */
    const uint16_t *count; /* owner's live-element count */
    uint16_t stride;      /* bytes between elements */
};

A framer_bullet places its own struct framer_batch_sprite as its first field, so the bullet pool's own storage array is the SpriteBatch's element array — no copy, no second buffer kept in sync. framer_bullet_pool_entity_create() gives the pool's one entity both a BulletPool component, stepped every frame by a physics system, and a SpriteBatch component built from the same array, drawn every frame by the renderer. Every renderer's draw phase still only ever does one thing: iterate a query and draw what it finds. Hundreds of bullets stay out of the 64-slot entity table; nothing draws that isn't reachable from an entity.

That much is a straightforward generalization of an existing pattern, and it's also where the interesting bugs started, because a component that every renderer backend has to independently learn to draw is a component that gives every renderer backend its own chance to get it wrong.

Bug 1 (Vulkan): a guard that was checking the wrong component

Every renderer backend's sprite system is imported once per world, and every one of them has to answer the same small design question: how do you know "once" has already happened? The naive answer, comparing the stored world pointer against the one you were just called with, breaks the moment a different world gets allocated at the same address — which is exactly what every test in this codebase does, tearing a world down and creating a fresh one for the next case. The existing convention across this codebase's renderer imports is therefore to also check that some component the import function itself registers is actually present on the world in front of you, not just that the pointer matches.

The version of this check written for SpriteBatch picked the wrong component to check:

/* Wrong: Sprite is registered by the renderer's own import, before
 * this function ever runs -- it says nothing about whether THIS
 * function has run on this particular world. */
if (s_world == world && FRAMER_COMPONENT_IS_REGISTERED(world, Sprite))
    return;

On a fresh world reusing a previous world's address, Sprite is already registered — by the caller, earlier in the same framer_sprite_system_import() — before this guard is ever reached. The check reports "already set up" on the very first call, every time, and the function returns without ever registering SpriteBatch or its draw system. Sprites drew fine; batches never did.

This one didn't need staring at code to find — it failed exactly where a test already existed to catch it. The new pixel-readback test for SpriteBatch (draw an element, read the pixel back, expect it filled) passed against the software and OpenGL backends and failed against Vulkan specifically, because Vulkan's test suite happened to run a batch test on a world whose address a prior test had already used. Fixing the guard to check the component this function actually registers —

if (s_world == world && FRAMER_COMPONENT_IS_REGISTERED(world, SpriteBatch))
    return;

— was a one-line change once the failing test pointed at exactly which world-reuse case was wrong. The same wrong-component shape was checked for, by inspection, in the OpenGL and software backends before either one had actually failed a test; both had the identical guard, both got the identical fix, and both were then verified rather than assumed fixed.

Bug 2 (OpenGL): reading back a write that hasn't happened yet

The OpenGL backend's default shaders are compiled lazily, the first time anything asks for one, and cached in the world's own Shader singleton component so the next thousand callers don't recompile anything:

const struct shader *shaders = FRAMER_GET(world, FRAMER_ID(Shader), Shader);
if (!shaders) {
    unsigned int sprite = framer_shader_create(...);
    /* ... compile the other three default shaders ... */

    FRAMER_SET(world, FRAMER_ID(Shader), Shader, .sprite_shader = sprite, /* ... */);
    shaders = FRAMER_GET(world, FRAMER_ID(Shader), Shader);
    if (!shaders)
        return 0;
}

switch (type) {
case SHADER_TYPE_SPRITE: return shaders->sprite_shader;
/* ... */
}

This is correct exactly as long as a component write, from the caller's point of view, takes effect immediately. It doesn't, unconditionally: a write issued from inside a running system is staged and only merged once every system scheduled for that step has finished, so a second system in the same step can already see it, but the same call that just made the write cannot read it back synchronously. That's an ordinary property of this ECS, not a bug in it — every other caller of framer_shader_get_default() had always been either the very first mesh or sprite draw of an existing scene, where something upstream (loading a level, spawning a player) had already asked for a shader on some earlier frame, so the singleton already existed by the time this function's fast path ran.

The SpriteBatch draw system is the first thing in a brand-new scene that has nothing upstream of it — no mesh, no plain sprite entity, just a bullet pool's one entity. On that scene's very first frame, its render system is the very first caller of framer_shader_get_default() ever, from inside a system, in a world where nothing has asked before. The write lands; the read-back in the same call finds nothing; the function returns 0; that frame renders every batch element with no shader bound at all. The fix doesn't touch the ECS's timing at all, because there's nothing wrong with it — it stops asking the world to hand back something it was just told, seconds from now, to go store:

struct shader created = {.sprite_shader = sprite, /* ... */};

framer_component_set(world, FRAMER_ID(Shader), FRAMER_ID(Shader), &created);
return shader_of_type(&created, type);

Bug 3 (all three desktop backends): drawn correctly, in the wrong order

The first two bugs were both "nothing drew." The third one was quieter: every bullet drew, at the right position, the right size, the right texture — and the entity that fired them disappeared behind its own bullets the moment enough of them piled up at its position.

SpriteBatch and the ordinary per-entity Sprite draw are two separate systems in the same ECS phase, and nothing had ever said which one goes first. On GBA and NDS this was never a question worth asking, because the hardware answers it structurally: OAM sprites carry an explicit priority value, and entity sprites are deliberately drawn at a higher priority than batch elements, so an entity is in front of a batch by construction, regardless of which system happened to run first that frame. Sampling the actual OAM contents through mGBA's debug stub, mid-run, confirms it — the emitter (the round blue sprite at the center) sits visibly in front of the spiral of bullets it just fired:

The bullets example running under mGBA, OAM contents captured over its debug stub -- a blue emitter sprite is clearly visible on top of a spiral of orange bullet sprites radiating from it

The bullets example's spiral pattern, OAM contents read directly off a running GBA — the emitter draws in front of every bullet it fires, by hardware priority, regardless of draw order.

The three desktop backends (OpenGL, Vulkan, software) have no equivalent hardware guarantee — draw order is the only ordering mechanism they have — and the two systems simply registered in whatever order their import code happened to call them, which put SpriteBatch after the entity sprite system. Sampling actual framebuffer pixels at the emitter's screen position, once every fifty frames of a real Vulkan run, shows exactly the moment it stops working: the emitter's own blue for hundreds of frames, then — once the bullet pool has enough live bullets to reach that exact pixel — the pixel starts reading back as whatever bullet is currently passing through it instead:

frame  350: 2850c8 5a96ff 5a96ff 5a96ff 2850c8   (emitter blue)
frame  750: 2850c8 5a96ff 5a96ff 5a96ff 2850c8   (emitter blue)
frame  800: c81e28 ff7828 ffdc50 ff7828 c81e28   (bullet red/orange)
frame 1200: c81e28 ffdc50 fffff0 ff7828 c81e28   (bullet, still)

The fix is the same one line of intent, spelled out explicitly instead of left to registration order, on every desktop backend:

static const struct framer_system_dep batch_deps[] = {
    {.before = "sprite_render_system", .after = NULL}};

framer_system_register(world, "sprite_batch_render_system",
                        FRAMER_PHASE_ON_UPDATE,
                        sprite_batch_render_system, bq, batch_deps, 1);

A new renderer test pins this down the same way the fix does — a red entity sprite and a white batch element occupying the same pixels, one assertion that the pixel reads back red — so the ordering is now a property the test suite checks rather than an accident of import order.

Verification: one bug per backend, so one check per backend

Nothing about the design change itself is backend-specific, so the actual proof that the redesign works had to be, backend by backend, rather than inferred from any one of them: the desktop pixel-readback tests (software, OpenGL, Vulkan) each got their own pass through the same three scenes, examples/bullets was cross-compiled for both GBA and NDS and the resulting ROM's OAM contents were sampled through mGBA's debug stub — the same technique the previous dithering article used to catch its own hardware-only bug — and every GBA example ROM in the tree was booted headlessly to confirm the pooled component-storage change underneath all of this (a separate, smaller fix the redesign also needed, since one more component pushed the fixed-stride storage layout past EWRAM) didn't regress startup on anything else.

One number came out the same on every target it was checked on, which wasn't the point of the exercise but is a reassuring sign the redesign is actually target-neutral: with the pool's rotating spiral pattern running continuously, the live bullet count settles in roughly the high 90s on GBA, OpenGL and Vulkan alike, all three converging on the same rough capacity for the same pattern despite three completely different drawing paths underneath.

Where this goes next

FRAMER_STATIC_MAX_COMPONENTS (40) and FRAMER_STATIC_MAX_ENTITIES (104, leaving 64 for actual game use) both grew slightly to make room for the new component without pushing the pooled storage past EWRAM on GBA — a two-line configuration change today, in a codebase where each of those numbers has moved before and will again. The bullet-pool roadmap item itself narrows to what's left once "draws correctly, everywhere" is no longer the open question: measuring and documenting the actual per-target bullet ceiling, rather than the two capacity constants (examples/bullets currently uses 128 on GBA/NDS, 1024 on desktop) picked ahead of having real numbers to size them against.

posted at 21:40  · 11 min read  ·   ·  ecs  rendering  vulkan  opengl  gba  nds  embedded  retro  gamedev  c  article

Jun 25, 2026

No Heap, No Problem: a Static Micro-ECS for the Game Boy Advance

Introduction

The previous article on this blog was about squeezing cycles out of framer-engine's GBA renderer. This one is about something that has to be settled before any of that renderer work is possible at all: how do you run an Entity Component System — the architecture pattern the whole engine is built around — on a console with 32KB of fast RAM, 256KB of slow RAM, no operating system, and no malloc() you can lean on?

framer-engine's answer is a second, much smaller ECS implementation living behind the same interface as the desktop one. Every game-logic line — component definitions, systems, queries — is identical between a PC build and a GBA build. Only one file differs: which world.c gets linked in.

Note

framer-engine is a personal side project. The source code will be made publicly available once the engine reaches a sufficient level of maturity.

Two ECS backends, one interface

framer-engine's default ECS backend is Flecs, a full-featured ECS used for desktop and handheld targets with a real OS underneath (PSP, 3DS, Switch, PC). Flecs is excellent at what it does — archetypes, queries, observers, relationships — but all of that bookkeeping assumes a working heap it can grow and shrink as entities and component types come and go. That's simply not available on a target like the GBA: 32KB of IWRAM, 256KB of EWRAM, both fixed-size and fully accounted for from the moment the ROM boots, with no MMU and no OS to page anything in. There's no heap to assume.

Rolling a second full-featured ECS to fit that budget wasn't the answer either — that's a lot of complexity to maintain for a problem that doesn't need it. What GBA-class scenes actually look like is a handful of objects (examples/simple_cube uses one mesh and a camera; examples/spinning_shapes uses three), not thousands, and the component types are known and fixed at compile time, not user-extensible at runtime. That's a much smaller problem than "general-purpose ECS," and src/ecs/static/world.c — about 550 lines of plain C — is sized to match it instead of to match Flecs:

/* Entities */
static bool s_alive[FRAMER_STATIC_MAX_ENTITIES];
static uint64_t s_comp_mask[FRAMER_STATIC_MAX_ENTITIES];

/* Component data store: row = component slot, column = entity slot.
 * Each entity slot is FRAMER_STATIC_MAX_COMPONENT_SIZE bytes wide. */
static uint8_t s_store[FRAMER_STATIC_MAX_COMPONENTS]
                       [FRAMER_STATIC_MAX_ENTITIES *
                        FRAMER_STATIC_MAX_COMPONENT_SIZE];

Every one of those arrays is a fixed-size global, sized by compile-time constants. There is no framer_entity_create() call anywhere in this file that can fail by running out of memory in some unpredictable way — it can only fail by running out of array slots, a number known before the program even starts.

Both backends sit behind the exact same include/framer/ecs.h — framer_world_t, framer_component_register(), framer_query_create(), framer_system_register(), the FRAMER_GET/FRAMER_SET/FRAMER_FIELD macros. A component defined with FRAMER_COMPONENT_DEFINE(Velocity) and a system registered with framer_system_register() compiles and runs unchanged on either backend; which one a build gets is a single Meson option, -Decs=flecs or -Decs=static, and GBA's cross file pins it to static (Flecs needs an OS, and bare-metal GBA doesn't have one) — the build refuses to configure any other way for that target.

Entities are array slots, nothing more

An entity in this backend isn't an object — it's a 1-based index into those parallel arrays. framer_entity_create() finds the lowest dead slot and claims it, first-fit:

framer_entity_t framer_entity_create(framer_world_t *world)
{
    int i;

    for (i = 0; i < FRAMER_STATIC_MAX_ENTITIES; i++) {
        if (!s_alive[i]) {
            s_alive[i] = true;
            s_comp_mask[i] = 0;
            if (i + 1 > s_entity_high)
                s_entity_high = i + 1;
            return EID(i);
        }
    }
    return 0; /* pool exhausted */
}

That's the entire allocator. No free list to maintain, no fragmentation to worry about — there's nothing to fragment when every slot is the same fixed size and lives at a compile-time-known address. The price for that simplicity is explicit and deliberate: destroying entity 5 and creating a new one immediately afterward hands back the same ID, 5, for a completely different logical entity. There's no generation counter to tell the two apart. For the scene sizes and lifetimes this backend targets — a handful of objects that mostly live for the whole level, not a churn of thousands spawning and despawning every frame — that's an acceptable trade, not an oversight; it's directly covered by a unit test (test_entity_slot_recycled_first_fit) precisely so it stays a known, intentional property instead of a surprise.

Components: a bitmask and a flat array

Each entity slot carries one uint64_t bitmask, one bit per registered component type. framer_component_set() is a memcpy into a fixed-stride row of the flat s_store array, plus a bit set:

void framer_component_set(framer_world_t *world, framer_entity_t e,
                          framer_id_t id, const void *data)
{
    int ei = EIDX(e);
    int ci = cidx(id);
    /* ...bounds and liveness checks elided... */
    memcpy(&s_store[ci][ei * FRAMER_STATIC_MAX_COMPONENT_SIZE], data,
           s_comp[ci].size);
    s_comp_mask[ei] |= ((uint64_t)1u << ci);
    s_any_mask |= ((uint64_t)1u << ci);
}

A query is just a precomputed mask built from the component ids it asks for; matching an entity against it is one AND and one comparison ((s_comp_mask[ei] & mask) == mask). The uint64_t bitmask is also the hard ceiling on how many distinct component types can exist in one world — 64 — which is generous for a retro scene's needs but means the type system itself enforces "don't try to build something Flecs-shaped on top of this."

War story: a silent NULL deref vs. a loud abort()

The single most important property of this backend isn't the data layout — it's what happens when a limit is hit. Early on, framer_component_register() returned 0 when a component was too big or the registry was full, the same "just signal failure" convention used everywhere else in this API. That sounds reasonable until you trace what a 0 component id actually does downstream: framer_query_create() silently skips it when building a query's mask, and the system that registered that query goes on to call FRAMER_FIELD() for a field that was quietly dropped — which dereferences NULL on the system's very next matching entity. That's exactly what happened on a real GBA build: a Text component at 288 bytes against a 64-byte cap, and 17 registered component types against a cap of 12. The crash that surfaced wasn't "your component is too big," it was a NULL-pointer SIGSEGV deep inside a render system, two layers removed from the actual mistake.

The fix removes the silent path entirely:

if (size > FRAMER_STATIC_MAX_COMPONENT_SIZE) {
    fprintf(stderr,
        "framer_component_register: \"%s\" is %lu bytes, "
        "exceeding FRAMER_STATIC_MAX_COMPONENT_SIZE (%d)\n",
        name ? name : "?", (unsigned long)size,
        (int)FRAMER_STATIC_MAX_COMPONENT_SIZE);
    abort();
}

abort() rather than an assert() or a GCC/Clang-specific builtin, because this backend also has to compile under cc65 and SDCC for 8-bit targets — plain C89 abort() is the one failure primitive guaranteed to exist everywhere this code runs. The test suite verifies the contract, not just the arithmetic: test_ecs_static_limits.c forks a child process, registers one component past the limit inside it, and asserts the child died of SIGABRT rather than returning normally — proving the fail-loud path actually fires, not just that the size check's math is correct.

The broader lesson generalizes past this one bug: on a backend built entirely out of fixed-size arrays, every hard limit is a wall, not a suggestion. The only choice that matters is whether you hit that wall with a clear error message at the exact call site that caused it, or with a corrupted query and a crash three function calls away. This backend picked loud, on purpose, everywhere a hard cap exists.

Sizing the pools, and a second silent-failure bug

Each target's meson.build picks FRAMER_STATIC_MAX_COMPONENTS, FRAMER_STATIC_MAX_ENTITIES, and FRAMER_STATIC_MAX_COMPONENT_SIZE to fit what that platform actually needs — there's no universal default that's right for every target, because the engine registers its full set of core components (Transform, Velocity, Sprite, Light, Text, Camera, and so on — 19 today) unconditionally, regardless of whether a given example actually uses all of them:

# Embedded (system == 'none'):
-DFRAMER_STATIC_MAX_COMPONENTS=19
-DFRAMER_STATIC_MAX_ENTITIES=96
-DFRAMER_STATIC_MAX_COMPONENT_SIZE=64

That MAX_ENTITIES=96 number has its own bug story behind it. Each registered component type — not each entity actually created — reserves one sentinel slot out of the same entity pool, so the entities actually available to a scene is MAX_ENTITIES minus however many component types exist. At MAX_ENTITIES=64 and 19 components, that left 45 usable slots — comfortably enough for examples/simple_cube, but one short of examples/input_tester's 48 (47 on-screen panel entities plus one camera). The failure mode was, again, silent: framer_entity_create() returning 0 once the pool filled, and the caller that wanted one more entity for a gamepad-axis label simply never got it — the text just never appeared on screen, with nothing in the logs to say why. Raising the cap to 96 (77 free after the 19 sentinels) fixed it with headroom to spare. On the GBA build that growth costs about 544 bytes of IWRAM (three per-entity arrays scale with the cap), which was checked against the build's actual free IWRAM margin before landing — on a 32KB budget, guessing isn't good enough, you measure.

That measurement habit is the same one from the performance article: arm-none-eabi-size -A on a current GBA build shows exactly where this backend's memory actually goes. The flat component store (19 × 96 × 64 bytes ≈ 114KB) is placed in EWRAM's .sbss section — too big for IWRAM, and zero-initialized for free by the startup code without costing any ROM space:

#ifdef GBA
#define _FRAMER_STORE_ATTR __attribute__((section(".sbss")))
#else
#define _FRAMER_STORE_ATTR
#endif

static uint8_t _FRAMER_STORE_ATTR
    s_store[FRAMER_STATIC_MAX_COMPONENTS]
           [FRAMER_STATIC_MAX_ENTITIES * FRAMER_STATIC_MAX_COMPONENT_SIZE];

Everything else — the alive flags, the masks, the per-frame iterator's entity list — is small enough to live in IWRAM, the GBA's fast 32KB scratch memory, where the CPU actually wants its hot working data. On the current examples/simple_cube build that's roughly 19.5KB of IWRAM used out of 32KB, leaving real headroom for the next component or two — a number worth checking again every time that count grows, the same way the 96-entity fix had to be checked against it.

The scan loop, briefly

framer_world_progress() walks every registered system, in phase order, and for each one scans entity slots up to the high-water mark (s_entity_high, one past the highest slot any entity or component sentinel has ever occupied) looking for bitmask matches. That scan, and the sticky s_any_mask check that skips it entirely for systems whose component type no entity has ever had, was the single biggest win in the previous article's performance work — covered there in full, since it's a perf story more than a design story. The design point that matters here is simpler: this is a linear scan over a flat array, not a sparse-set or archetype-table lookup. That's the right trade at GBA scene sizes (tens of entities), and the wrong one at thousands — which is exactly the line where you'd reach for Flecs instead.

What this design explicitly gives up

None of the above is free, and being upfront about the trade-offs is the point of having two backends instead of pretending one ECS fits every target:

  • No entity generations. As covered above, IDs are recycled immediately and look identical to the entity that previously held them.
  • No archetypes, no sparse sets. Matching is a linear scan with a bitmask test, not a cache-optimized contiguous iteration over exactly the matching entities. Fine at dozens of entities; the wrong tool past that.
  • 64 component types, total, forever, for the whole world. Not per query — for every component type that exists anywhere in the engine, shared across every system. The engine's current 19 leaves room to grow, but a "just add a component" change always has a final cost attached: someone, somewhere, has to recheck that ceiling.
  • No relationships, no hierarchies, no observers. Flecs has all of these; this backend has entities, components, and queries, deliberately nothing more.

Every one of these is a real capability Flecs has and this backend doesn't. They're also exactly the features that cost the heap, the dynamic bookkeeping, and the unpredictable-at-compile-time memory use that a bare-metal ROM target can't afford. The two-tier split exists so that trade only has to be made once, explicitly, per target — not silently, by whichever ECS happened to compile.

Where this goes next

The same problem — "no OS, no heap, fixed memory map, known component set" — is true of every retro target on framer-engine's roadmap, not just the GBA. The 32-bit-era consoles mentioned in the previous article's closing section, starting with the PlayStation 1, sit in an interesting middle ground: dramatically more RAM and a real GPU compared to the GBA, but still no OS and still nothing resembling a desktop heap. The expectation going in is that they'll want this same static backend, just with much larger pool constants — not Flecs, and not a third ECS implementation. Whether that expectation survives contact with an actual PS1 build, the way the "obviously correct" tricks in the performance article sometimes didn't survive contact with measurement, is exactly the kind of thing a future article on this blog will have to report honestly either way.

posted at 23:30  · 11 min read  ·   ·  gba  ecs  embedded  retro  gamedev  c  article