Cartridge Engine: What I'm Learning by Building a Game Engine Like a Console

I'm building a constraint-first 2D game engine from scratch — not to ship it, but to understand what happens when you design a system around hard limits instead of flexibility.

Why build a game engine?

Not to use it. Not to compete with Unity or Godot. The honest answer is that I wanted to understand what happens when you design a system around constraints rather than flexibility — and building a game engine seemed like the most direct way to explore that.

I've used modern engines and frameworks enough to know that they're optimized for capability. You can do almost anything. That's useful, but it also means you spend a lot of time just making decisions. And the more configurable a system is, the harder it is to reason about what it's actually doing at any moment.

The question I kept coming back to: what if that wasn't the goal?


The console metaphor

SNES and Mega Drive developers didn't have a flexible engine. They had hardware. Fixed resolution. Fixed tile size. A specific number of sprites. Defined audio channels. Those weren't limitations they worked around — they were the shape of the problem. And games from that era are often easier to understand at a systems level than modern ones precisely because the constraints were visible and shared.

So I started building something I'm calling Cartridge Engine. The framing is:

  • The engine is the console — it defines what's possible
  • The game is a cartridge — it runs inside the console and can't escape it
  • New capabilities come from hardware expansions — not plugins, not configuration

The hardware spec is fixed: 320×180, 16×16 tiles, 128 sprites, 4 tile layers, 8 audio channels, 60Hz. These numbers are arbitrary enough to be honest — I picked them to be plausible, not to be optimal.

The principles I wrote down at the start, and have mostly stuck to:

  1. Constraints are features
  2. Determinism over flexibility
  3. One way to do things
  4. No hidden behavior
  5. Capabilities must be declared
  6. No runtime magic

What I've learned so far

Building this has been more educational than I expected, and in unexpected ways.

Constraints force decisions earlier. When you decide up front that collision is tile-based — 0 or 1 per 16px cell, no partial tiles — you stop having a whole category of design debates. You also discover which parts of your map don't actually align to the grid, and you fix them. The system is dumber but the result is cleaner.

We actually had a pixel-rect collision system first. It worked, but it was floppy — arbitrary rectangles, a visual editor to tune them, lots of per-map exceptions. Replacing it with a flat tile array was a step backward in expressiveness and a step forward in everything else. Simpler to implement, simpler to reason about, simpler to debug. I think that trade is usually worth it and I don't always remember that.

The cartridge boundary is surprisingly useful. Writing the game as a function that receives a fixed API — input, video, audio, system — and nothing else forces you to be explicit about what the game actually needs from the engine. It can't reach into engine internals. It can't do anything that isn't in the API contract. That sounds restrictive but it mostly feels like clarity.

Audio is harder than it looks. Not the Web Audio API itself — that's fine — but figuring out where audio lives architecturally. It needs to be pre-decoded (no runtime parsing), channel-limited (8 channels, enforced), and ID-based (no file paths at runtime). Getting that right took longer than expected, partly because the temptation to just expose AudioContext directly to the cartridge is strong and wrong.

The "one small game" constraint is doing real work. The current demo is a cabin on a hill with an interior you can enter. It's not a game. But building it has already surfaced things I wouldn't have found just designing the engine in the abstract — collision edge cases, sprite z-ordering across layers, scene transition timing, how footstep sounds should relate to the walk cycle. The game is load-bearing.


What's running today

To make this concrete: here's what the engine does right now.

A fixed 60Hz game loop. A 4-layer tilemap renderer with 128 sprites and per-sprite z-ordering. A 12-button input system mapped from keyboard. Tile-based collision. An 8-channel audio system with separate music and SFX channels. Scene transitions with music swapping. A demo cartridge with a hero character who walks around a 16-bit style cabin exterior, enters through the door, and explores the interior.

The cartridge code for the game entry point looks like this:

export default createCartridge(({ input, video, audio }) => {
    return {
        boot() {
            /* set up scene */
        },
        tick() {
            /* read input, move player, check triggers */
        },
        draw() {
            /* push sprite positions to video */
        },
    };
});

That's it. No engine internals leaking through.


What's next

The next things on the list are codegen (generating typed asset bindings from a manifest so the cartridge has compile-time safety on asset IDs), a CLI, and inspect tooling that shows ROM/VRAM/RAM usage against the hardware budget.

The longer goal is three playable demos — each one using the full feature set of the engine:

  • A top-down Zelda-like: rooms, combat, NPCs, inventory
  • A side-scrolling Mario 3-like: gravity, platforms, enemies, power-ups
  • An old-school match-3: grid logic, cascades, score, progression

These three were chosen deliberately. They cover very different engine requirements — different collision models, different camera behaviour, different game loops — so together they'll prove whether the engine is actually general or just a cabin demo with ambitions. If all three are playable within the constraints, the constraints were right.

I don't have a finish line beyond that. I'll write more as it develops.