Cartridge Engine: Three Demos Later

Four weeks ago I wrote about building a constraint-first game engine. Here's what happened when I actually had to ship three playable demos inside those constraints.

A few weeks ago I wrote about Cartridge Engine — a 2D game engine designed like a retro console, with fixed hardware specs and no escape hatches. The pitch was: constraints are features, determinism matters more than flexibility, the game declares its hardware and the engine enforces it.

That post was written at a fairly early stage. The engine ran. A demo cartridge existed. The architecture was sound. But the three games I listed as the real test — a Zelda-like, a platformer, a match-3 — weren't playable yet.

They are now. Here's what I learned building them.


What shipped

Three cartridges are running in-engine, each a different genre:

Adventure — a top-down game with rooms, a combat loop, enemies with distinct AI behaviors, dialogue, interactive objects, an inventory, and a win condition. It uses the most engine features of the three.

Platformer — a side-scroller with gravity, tile-based collision, a level-end goal, parallax backgrounds, per-level music, and a death/respawn flow. The engine's tile collision system was designed for top-down; making it work for a platformer revealed some interesting edge cases.

Puzzle — a match-3 with a board rendered from the dungeon tileset, gem swap animations, match flash feedback, a score target win condition, a timer, and a no-moves-left shuffle. This one was the most useful for stress-testing the animation and tween systems.

All three have a title screen, a game-over screen, and return to the in-engine cartridge selector when they end. Consistent HUD style across all three.


What the engine needed to make this work

Phase 1 laid the foundations — animation state machine, deterministic RNG, timers and tweens, save/load, cartridge self-registration. But building actual games exposed what was missing beyond that.

The biggest additions, in rough order of difficulty:

Dialogue and interaction zones. A first-person trigger zone system with action and enter semantics. A typewriter text box renderer that pauses the cartridge tick while open. Branching choice prompts with d-pad navigation. A .dlg script format compiled to typed constants via codegen, so cartridge code references IDs rather than raw strings.

Getting the "pause tick while dialogue is open" behavior right was trickier than expected. The engine owns the loop — it decides whether to call tick() this frame. The cartridge has no say. That's correct design, but it means the engine needs to know when dialogue is active, which creates a small coupling. The solution was a simple isBlocking() flag on the dialogue system that the runtime loop checks before calling tick().

Combat primitives. Separate hitbox and hurtbox per entity. Invincibility frames (i-frames) counted in ticks, not milliseconds — this matters for determinism. A onHit(attacker, victim, damage) hook the cartridge wires up. Floating damage numbers via a pooled HUD layer.

Enemy AI. A small finite state machine library: patrol, chase, flee, attack. Declarative wiring — the cartridge specifies what the AI should do, not how. Pathfinding on the collision grid via A*, with a bounded step count per tick so the CPU budget stays fixed.

Inventory. Fixed-size slots declared in the manifest. add, remove, has, count, equip. Stackable vs unique items declared on the item definition. No runtime surprises — overflow policy is explicit (reject, not silent drop).

Cutscenes and camera. A tick-based sequencer that chains entity moves, dialogue, SFX, and waits. It pauses player input automatically and restores on finish. Camera now supports lerp-to-target, directional lookahead, and bounds clamping to map edges.

Screen effects and particles. Flash, fade-to-color, screen shake with fixed amplitude presets. A particle emitter with pooled instances and built-in presets (dust, spark, smoke). These live in the engine's rendering pipeline, not in cartridge code.


Things the constraints got right

I said in the original post that constraints force decisions earlier. That turned out to be more important than I expected.

The inventory: { slots: 16 } line in the manifest is a good example. It forces you to decide, before writing any inventory code, how many item slots the game has. And it forces the engine to enforce it — full is full. That sounds minor but it means the question "what happens when the inventory is full?" always has a crisp answer. The cartridge either handles it or the engine rejects the add. There's no third option where you quietly have 17 items.

Item definitions follow the same pattern. Items are typed constants generated from the manifest. The cartridge cannot reference an item that isn't declared. This caught several mistakes during development where I would reference an item ID that I hadn't wired up yet. Compile-time error, not a runtime null.

Enemy AI being declarative also worked out. The chase and patrol states are implemented once in the engine. The cartridge says which state the enemy starts in and what triggers transitions. The adventure cartridge has two distinct enemy behaviors — they're distinct not because I wrote different code but because the transition tables are different. One chases on sight and attacks on contact. One patrols until attacked, then chases. That differentiation is just data.


Things the constraints got wrong

The tile-based collision worked perfectly for the top-down game. It worked less perfectly for the platformer.

The issue isn't the model — a flat tile array is still the right call. The issue is that platformer physics have some expectations about collision resolution that don't come for free. Specifically: when you're moving horizontally and hit a wall, you should stop moving horizontally without being pushed down. When you're falling and land on a tile, you should land cleanly on top of it, not clip into it slightly.

The separated X/Y movement system handles this, but it required a few non-obvious corner case fixes when the player is in the corner of two tiles simultaneously. The adventure cartridge never exposed these because top-down movement doesn't have gravity and the player is rarely moving in two axes at full speed simultaneously.

The lesson isn't that the collision model was wrong. It's that the constraint — tile-based collision only — is right for both genres, but the engine code needed to be a bit smarter about resolution order to be actually correct in both.

The other tension is in audio. Eight channels, four music and four SFX. That's fine for one game. It's fine for three games. But writing a match-3 that has distinct sounds for each gem type under a four-SFX budget forced some creative decisions — you can have distinct sounds per gem type or distinct sounds per combo size, but not both. That's probably the right tradeoff for a constraint-first engine, but it's worth calling out: the constraint is real and it shapes what games are possible.


The tooling turned out to matter

I underestimated how much time I'd spend in the tools, not the engine.

A sprite sheet editor. A palette editor (256 entries, palette-indexed). A tilemap editor with multi-layer editing, per-layer parallax scroll factors, and a collision-layer painter. An animation editor for wiring up the state machine visually. A dialogue editor with a graph view for branching. A chiptune/SFX tool over the synth parameters. A font browser for the four shipped fonts.

None of these are sophisticated tools. They're all just good enough to make actual content. But "just good enough" is a real bar. The alternative is hand-editing JSON and pixel arrays, which is slow and error-prone.

The CLI also shipped: cart init, cart validate, cart codegen, cart inspect, cart build, cart watch, cart package. The inspect output in particular is useful:

ROM:   18 MB / 64 MB
VRAM:   3 MB / 8 MB
RAM:   10 MB / 32 MB
Sprites: 96 / 128

Seeing the hardware budget at a glance keeps the constraints visible. It's easy to forget you're on "fixed hardware" when you're just writing TypeScript. The inspect output makes it concrete.


What's next

A few things remain unbuilt that I want to finish:

Composite sprites. Multi-part characters where each part is an independent sprite slot but they move together as a unit. This is needed for larger characters and character customization. The engine currently has no concept of a sprite group — each sprite is independently positioned.

Capability validation. Static analysis of cartridge code against the manifest-declared capabilities. If you call inventory.add(...) but your manifest doesn't declare an inventory, the build should fail. Right now this is an honor system — the API just returns undefined for undeclared capabilities. I want the build to catch it.

Playtest recorder. Record { tick, inputs } during play, replay deterministically. This is the real test of the determinism contract. Same seed plus same input sequence should always produce the same game state. If it doesn't, something is wrong.

The three demos proved the architecture holds. The constraint-first model produced three very different games that all run in the same engine without any of them needing engine modifications or workarounds. The expansion system isn't built yet — that's how the engine is supposed to handle capabilities beyond the base console — but I haven't needed it for any of the three demos.

That's probably the most honest validation: I built three games that required dialogue, combat, pathfinding, inventory, cutscenes, and particle effects, and none of them needed a plugin, a config option, or an escape hatch. They just used the API.