- Published on
Spooky Run: the technical decisions
- Authors

- Name
- Ryan Flores
Spooky Run was my Solo Dev Jam 13 entry, built October 23 to 26. It is a puzzle game about one pawn crossing a board and reaching the other side. The board is not a fixed size. Each level file sets its own size, and the levels I shipped run from 5 to 10 squares across. The enemy pieces are static, and a few of them patrol a set route. This post is about the technical decisions in it, because those are most of what the weekend was. The commit history is 102 commits before the jam closed, and most of them are not new features. They are me changing my mind about a shape I had picked earlier.
Stack: C++20, raylib 5.5, nlohmann/json, CMake. WebAssembly build through Emscripten for itch.io, native build for testing at my desk.
Attacked squares are recomputed, and they are not shown
Enemy pieces attack squares, and each piece marks the squares it attacks into the board's cell state. The obvious design is to keep that as data that gets updated as pieces move. I did not do that. RefreshAttacks() clears every cell marked Attack and asks each piece to mark its squares again through SetHitSquares(), and it runs after anything changes: a piece added, a piece removed, a move finishing.
The part that is easy to get wrong about this system, and I got it wrong in an earlier draft of this post, is that the player never sees it. DrawCell does draw a rectangle over cells in the Attack state, but it draws it with RedAttack, which is Color{255, 0, 0, 0}: red with an alpha of 0, so fully transparent. The comment on that constant says "Maybe player can configure this to help them out", which is the idea it was left in for, an optional help display. It started at an alpha of 65 on Friday, when I first drew the rook's hit squares, and it was set to 0 on Saturday afternoon in a commit mostly about the button code. It has been 0 since, for the rest of the jam and in the shipped game. Pieces do not need the overlay to do anything. They hit the player through their own detection code, not by reading the Attack cells, so turning the display off changed nothing about how the game plays.
So the recompute decision is only about the cell state, not about a display. The cost is redoing work I could have kept up to date, and it scales with the board, but the boards are small: the biggest level is 10 squares across, so 100 playable squares, and fewer than a dozen pieces marking them. What it bought is that the attack state cannot drift out of sync with where the pieces actually are, because it is derived from them every time. If the overlay ever gets turned back on as a player option, it will be showing correct squares. I still hit attack bugs on Saturday (two commits that night are "fix pieces not attacking" and "stop infinite attack"), but they were bugs in how pieces marked their squares, not bugs where stored attack data had gone stale. Those would have been worse to find.
Pieces are removed later, not when they are captured
Captures happen in the middle of other code walking the piece list. Erasing a piece at that moment invalidates what that code is doing. So RemovePieceAt does not erase. It adds the piece to a pendingRemovals list, and Board::Update does the actual removal after the per piece updates have run, then refreshes attacks.
It is a small decision and it looks like a delay for no reason in the code. The reason is that the first version did not need it and the version with patrolling pieces and capture checks did. Deferring the removal to one place also means there is exactly one place where removal side effects happen.
One piece class, with detection in the base
Friday night, about six hours in, I stopped and pulled all the piece code into a Piece base class (the commit is called "huge refactor to create piece class"). Each piece type then only had to answer two questions: where do I move, and which squares do I attack.
On Saturday I moved player detection into the base class as well, because each piece class had grown its own copy of it and the copies were already starting to differ. Movement notification now runs in two passes over the pieces: every piece runs DetectPlayer(), then the pieces that are not animating and have not already hit the player run OnPlayerMoved(). If a piece is going to respond to the player, that code lives in one place.
The trade off with a base class is that pieces that do not fit the shape have to work around it. With six piece types that all move on a grid and attack squares, that did not happen during the jam. It might have if I had added a piece that behaved in a different way entirely, and I would have had to decide again then.
Levels are data, and the loader does not trust my data
All the levels live in one JSON file. Each level has a size and a list of pieces, with a type, a position, and an optional patrol route. Size is per level, and it is what the board gets built at: LoadLevel reads it and calls board.Reset(size). The only place a fixed 8 exists is DefaultBoardSize in config.h, used as the fallback value when a level does not set a size. The loader checks the file before it believes any of it: the parse itself, that a levels object exists, that the level number exists, that the size is in range, that each piece type is one the game knows, that each piece is inside the board, and that a patrol route is actually an array. A bad entry logs a warning and is skipped. A bad file gives you a shorter level and a log, not a crash.
This was not the first version. The first version read the file with direct subscripting, on the assumption that I had written the file so it must be fine. A crash during level loading on Sunday cost me time, and an unrelated bug in the level menu (the game was leaving the menu on its own) made the crash reports hard to read, so I never proved which access was the bad one. After that I changed the rule for the whole loader instead of fixing one line: check first, use value() with a default, skip what is wrong. I would rather have that rule than the answer to which line it was.
The grid was a template for about an hour
Friday afternoon I made the grid a class template. By Friday evening I had removed the template again ("remove template for now"). There was one grid, of one cell type, in one game. The template bought nothing and made the code harder to read at exactly the point where I was still figuring out what the board needed to do. It is the decision in this list I got wrong first and fixed fastest, and it is worth writing down because the pattern is the same one I have to watch in longer projects: general code is only worth it once a second use actually exists. Lantern, the framework I am extracting from this game now, is that second use arriving after the fact.
Scenes change halfway through the fade
The scene manager went through three shapes: static functions and an enum, then a manager class, then a templated GoTo<Level>(3). The part that stayed through the last two versions is the timing. GoTo does not construct the new scene. It stores a lambda that will construct it, and the construction happens halfway through a one second fade, at the point where the screen is covered.
Callers get a simple call and never think about the transition. The cost is a pending state and a transition flag that other code has to respect, and debugging a scene problem means first checking whether you are looking at the scene you think you are looking at, or the one still fading out.
I removed raygui and wrote my own button
On Saturday I had raygui in the project for buttons. That afternoon I took it out and wrote a UI::Button instead. Two commits, about a minute apart: "remove raygui integration and dependencies" and "create button method, add sound to it".
The reason showed up the next morning. The level select needed a locked state: a button that draws darker, does not respond to hover, and does not fire when clicked. In my own button class that is a flag and three small checks. In a library button it would have meant working out how that library wants state handled, or layering my own state on top of it anyway. For one button type and four days, writing it was the shorter path. For a bigger UI it would not have been, and I would have kept the library.
There is a bug from that button code worth mentioning because it wasted more time than the raygui removal saved. The lock check ended up placed after a comma in the condition, so the expression threw away the click result and evaluated the comparison instead. The comparison was true for every unlocked level, so the menu entered a level on its first frame without a click. It looked like the menu, the scenes, and the level loading were all broken at once.
Saving is one interface with two backends, added too late
Progress in Spooky Run is one number: the highest unlocked level. It sits behind a two method interface, SaveProgress and LoadProgress. The desktop backend writes that number to a JSON file. The web backend writes it to localStorage through the Emscripten HTML5 API, and the choice between them is a compile time #ifdef.
The decision I would change is when. The web backend was added at 11:57pm on Sunday, three hours before I stopped for the night, because I had assumed the file version would somehow work in the browser. It does not. The web build has no file the player keeps between visits. I knew the game was shipping as a web build from Friday, so the save system should have been designed for both targets at the start instead of being fixed at the end.
Three smaller bugs lived in this system at different points, and they were all the same shape. SaveProgress shipped with an empty body while LoadProgress worked, so saving looked finished and was not. The default for a missing save file was 0 unlocked levels, which under my own lock rule locked level 1 for a new player. And the interface destructor was declared virtual and never defined, which the linker found before I did. Declaring a thing and the thing existing are separate steps, and none of those three were caught by anything except playing the game and noticing.
Assets are registries, not a pile of members
The asset manager started as a different container per asset kind: textures in a map by piece type, the font as a single member, floor textures in vectors, sounds and music as individual members. Every kind had its own load and unload code, so adding an asset meant writing loading code.
The rewrite on Sunday afternoon replaced that with one registry per type, a map from an enum to a struct holding the asset and its path. Loading is one loop per type. Access is AssetManager::Get with the enum for whichever type you want. Adding an asset became adding an enum value and a map row.
I tested whether that was true by using it right away: twelve alternate character skins went in as their own registry, and wall and corner textures went in on Monday morning when the board border was added. Neither needed new loading code, which is the only real evidence that the new shape was better. The header comment I left on the class at the time said it could still be worked on. That is still true.
The board has a ring of cells the game cannot use
Late on Sunday the board grew a border. Reset adds 2 to the level size, and the outer ring of cells gets a Border state. InBounds treats border cells as outside, so movement, placement, and attack logic never see them. The renderer uses that ring to draw walls and corners.
The alternative was keeping the board at the size the level file asked for and teaching the renderer to draw walls around it with its own offset math. Putting the border in the data instead meant one coordinate system for everything, at the cost of the board being a different size than the level file says it is, which is a thing anyone reading Board cold will trip over once. A level that says 6 is held as an 8 by 8 board with an unusable ring around a 6 by 6 playing area. That trade was worth it for a weekend. In Lantern this is one of the pieces I am reconsidering, because a framework should not surprise people that way.
The web build settings were each a bug first
The Emscripten block in CMake is short, and every line in it exists because something broke without it. ASYNCIFY is there because raylib's web loop needs it. The exported runtime methods (the heap views) are there because web audio broke until they were exported; the commit is from Saturday night and just says so. Assets are preloaded into the web build, because the browser has no folder to read them from.
The desktop build does the opposite: a post build step copies the assets folder next to the executable. That one caused a quieter problem. A build folder with an old copy of levels.json will run the old data while you edit the real file, and you will spend time debugging your loader for a problem that is sitting in your output folder. I did that once during the jam and now I check the copy first whenever the game does something the data says it should not.
Where these decisions went next
Most of the systems above are not staying in this repo. The scene manager, the asset manager, the save backends, and the board and UI helpers are being extracted into Lantern, a small C++ framework, and Spooky Run has to keep working the same while they move. Some of these decisions will survive that move and some will not. The border ring is already in question, and the asset manager is getting a generated catalog instead of hand written enum entries.
Play it: https://ryanflorestt.itch.io/pawn-run Source: https://github.com/RyanFloresTT/SpookyRun
