Matt Tuttle

Echoes of the Unread

A puzzle-platformer for the original NES, written in C and 6502 assembly, that fits a whole story with three endings into a 128KB cartridge. I spent nearly ten years on it and did the code, pixel art, music, and writing myself.

Platform
NES (MMC1 mapper, 128KB PRG, 8KB battery-backed save RAM) and Steam
Role
Solo developer: programming, pixel art, music, level design, story
Code
About 15,000 lines of C, 4,000 lines of hand-written 6502 assembly, 4,400 lines of Python tooling
Content
6 books plus a library hub, 38 levels, 5 boss fights, 420+ lines of dialogue, 3 endings
Release
Physical cartridge funded on Kickstarter with Mega Cat Studios, plus Steam (October 15, 2026) and itch.io

The game

Rhapsody falls asleep studying for finals and wakes up in a locked library full of creatures. The books on the shelves pull her into their stories: a western, a fantasy, a horror story, a romance, a sci-fi adventure, and one book nobody has ever checked out. She can't fight. She sings, and the notes change how things around her behave. A spiked enemy curls into a ball you can bounce on, a rolling armadillo stops, a buzzard freezes in the air so you can use it as a platform.

Health is water. You lose it when you get hit, refill it at drinking fountains, and the fountains double as save points. The game is a metroidvania at heart, with abilities you earn back over time and secrets that open up new routes.

Rhapsody singing music notes at a sleeping green slime on a library bookshelf
The library hub. Notes put enemies to sleep instead of hurting them.
Rhapsody next to a man in a top hat whose speech bubble says 'Did you know this?'
Comic-style dialogue. Characters have big heads so their mouths can animate.
Rhapsody jumping through the purple-tinted library basement
The basement swaps in darker, more purple palettes, a lights-off look without real lighting.
Rhapsody jumping near a waterfall and floating cloud platforms
The fantasy book started out as a test area for swimming.
Rhapsody in a dark graveyard with ghosts floating around her
The horror book.
Odele the stage magician on a theater stage saying 'Are you even listening?'
Odele, the antagonist, waiting in the unread book.

Ten years, three engines

The project started in 2016 as a birthday present for my sister: a Super Mario Bros. ROM hack about running to get coffee. I kept adding things (my own levels, a new boss, Tiled support, bank switching when I ran out of room) until the hack got too unstable to build on. So I started over in C, and the coffee shop turned into a library you could jump into books from.

After two years it was a tech demo with no real direction, and I shelved it. In 2020 I wrote a better scrolling engine for a Metroid-style prototype, liked it enough to port it back into the library game, and from 2022 on I worked on it steadily until it was finished. The repository has more than 2,000 commits.

A lot of the design came from throwing ideas away. Pages that kicked you out of a level like Mario 64's stars weren't fun. Water that drained every time you sang wasn't fun either. Dialogue wasn't part of the original plan at all, and adding it changed the whole direction of the game.

Fitting it in 128KB

I picked 128KB on purpose. I wanted a game that could plausibly have shipped when the NES was current, and 128KB is what Metroid and Zelda used. The MMC1 mapper splits that into eight 16KB banks: one fixed bank for the engine and seven that swap in and out. The book worlds share banks in pairs, and music, dialogue, and object logic each get banks of their own.

By the end every bank was full. The cartridge build has about 200 bytes free across all 128KB, and one bank has none. Getting there took a mix of real compression and a lot of byte-shaving:

  • Dialogue compression. The script is a plain text file that a Python compiler turns into bytecode. Text uses a 31-character alphabet, so each byte holds a 5-bit letter plus 3 flag bits for a following space, a line break, or a special code such as a speaker change or a dictionary word. The compiler counts how often each word appears, works out how many bytes each one would save as a dictionary entry, and keeps the 64 most valuable words, as long as the whole dictionary stays under 256 bytes.
  • The map in under a byte per screen. Players kept getting lost, so the game needed a map. That's rare on the NES and costs space. Each screen's map position is packed into one byte (6 bits for x, 2 for y) by the level export script, so I never had to do that bit-packing by hand.
  • Graphics compression. The cartridge has no graphics ROM, so tile graphics are compressed with jroatch's Donut codec and unpacked into video RAM when you enter an area.
  • Megatiles. Levels are grids of what I call megatiles: 32×32-pixel blocks made of four 16×16 metatiles, so each byte of level data covers a 2×2 block. I started out with run-length encoding, but RLE has to be decoded in order, which only works if the camera scrolls one way. With a grid, the engine can read any column directly, so you can scroll left as well as right.
  • Shaving bytes. Late in the project, commits look like "save bytes with buzzard logic" and "reduce song length slightly to save bytes." In one, I reordered an enum so each bullet's ID could double as its speed. That removed a single load instruction, two bytes in total. In another, I merged every character's mouth animation into one shared routine.

Space wasn't the only budget. The NES can only copy a small amount of data to video memory each frame, so animated background tiles take turns with scrolling updates. When a room has a lot going on, enemies take turns updating so the game holds 60fps.

Tools that catch mistakes early

Before tools like NES Maker existed, I already used Tiled for my Flash games, so I wrote Python scripts that turn Tiled maps and tilesets into binary data the NES can read. Each 16×16 tile gets a collision type, such as solid, one-way, hurt, door, fountain, or liquid. "Liquid" is water in one book, quicksand in another, and toxic sludge in a third. The scripts also check for errors while the game builds, so many mistakes fail the build instead of turning into bugs I have to track down later.

For debugging I used an emulator with Lua scripting and wrote my own debug overlay. It reads the assembler's label file at startup, so it can look up any variable in the game's RAM by name and draw it on screen next to the dialogue and object data. Because I could record a session and step through it frame by frame, this caught bugs that only lasted a single frame. The worst one took months to find: an audio glitch caused by copying two bytes too few.

Puzzles as a broadcast system

Anything with cause and effect, like a lever opening a gate or a button moving a platform, is a "puzzle" in the code. Any object can carry a single puzzle byte: a 1-bit channel and a 7-bit value. A tuning fork that's spinning adds to a level-wide counter, and a gate checks that counter on the next frame to decide whether to open. Since it's a broadcast, any object can affect any other without either one knowing about the other. Some bosses use the same system to coordinate their separate parts.

Bringing it to Steam

I didn't want the Steam version to be a ROM file in a generic emulator wrapper, so I built a custom one. It's based on Antonio Maiorano's open-source nes-emu, which I extended in C++17 with SDL3 into a full product: menus, controller support, scaling options, backdrops, and Steamworks integration.

It reuses the debug overlay idea. The emulator's build compiles the game itself, embeds the ROM in the executable, and converts the label file into a C++ header, so the emulator can read the game's memory by name (player health, the current zone, quest flags). That's what powers 29 Steam achievements, controller rumble, and on-screen button prompts that appear when you're standing at a door you can open or a fountain you can drink from. It works in both directions. The Steam build of the ROM writes a few bytes just for the emulator, such as which storybook illustration to show behind the screen, and the emulator writes bytes the ROM checks, such as one that skips the splash screen.

The dialogue compiler also exports the script as JSON. When the game shows a line, the emulator looks up its ID and can draw that line as a sharp, high-resolution overlay instead of the NES's 8×8 font. That's also the groundwork for translations, which come from a spreadsheet. The Steam build has twice the cartridge space (256KB), so these hooks never had to fight the cartridge version for bytes.

Design for everyone

"NES hard" usually means unfair deaths left over from arcade design. I didn't want that to be the default. Relaxed and Normal modes save every time you go through a door. Hard mode only saves when you enter a book, and some bosses get extra phases. Collecting every page unlocks Retro mode, which swaps water bottles for lives and has a real game over. The three endings depend on who you help along the way. In the best one, you end the final fight by singing instead of fighting.

What I learned

The biggest lesson wasn't technical. For years, ideas I forgot about and an empty to-do list killed my motivation more than any hard bug. Things only started moving once I wrote everything down: piles of sticky notes, then an issue tracker I could plan each week from. As long as I made a little progress, I'd eventually finish.

I'd also be less stubborn about the cartridge size. Nobody would have noticed 256KB, and the last few months of byte-shaving were time I could have spent on the game. But fitting everything into 128KB is also a big part of why I'm proud of it.