Architecture of a GBA game — gssa
This is part 2 of a series of blog posts on developing GSSA. The first part introduced why I ended up rewriting a C game in Rust for a 20 years old console.
Acronyms and characters
- GBA: The Game Boy Advance a 20 years old hand-held game console with a CPU clock of 16MHz and 340KB of internal memory (my phone, slightly smaller than a GBA, has eight 2GHz CPUs (×1000) and 6GB (×17650) of internal memory)
- VRAM: Video RAM, the memory to which you must send your stuff to change pixels on the screen
- UB: Undefined Behavior, things that a C compiler assumes a C programmer will never do, that oddly every C programmer do all the time, and result in nasty hair-tearing bugs (proven made-up fact: there is an inverse correlation between hair line and years lived while owning “The C Programming Language” by Kernighan and Ritchie)
mgba
: an accurate GBA emulator.- GSSA: Generic Space Shooter Advance a game for the GBA I wrote for a uni assignment in 2018, tried to rewrite in rust in 2020, and independently rewrote a second time in rust in 2022 (not to be confused with a Swiss pacifist NGO)
- Mr. Leone: The lecturer of the systems programming lecture I followed in 2018
- The little UBs: The author’s nasal demons, mostly manifested as broken games
- The author: clearly someone who needs to touch grass, calls himself “cyber-hippie”
This is where I narrate how I actually implement the menu in my first rewrite of 2020 in rust.
Architecture
A neophyte wrote the GSSA C code. Myself, writing a large program, not only for the first time in C, but for the first time in any language.
In 2020, I’m different. I wrote a whole frontend app in elm, and the corresponding backend in rust. 2020 me is also an enterprise™ developer. I am an acolyte of data driven programming, I’ve been the midwife of many software, seeing them from birth to their maturity.
The C version of GSSA was a struggle. How to structure code? I had never seen
game or C code before. I wrote most of the code in public transport, on a shitty
1st gen Microsoft surface, with the shitty “surface cover” finger-numbing keyboard.
I didn’t know git
. I wrote regressions I took days to fix.
Remember, it took weeks to fix the code to run mgba
, the accurate GBA emulator.
Dread fills me. What will I see? I’ve gazed in the abyss of legacy enterprise™ code. I’ve seen a massive visual basic app, I’ve seen a compiler written seemingly as an exercise of how to write the most confusing chain of JavaScript callbacks, unspeakable blasphemy, chtonic incantations that somehow a computer can parse, let alone a human being. Someone with less fortitude would have not survived, not with their sanity intact. Will I gaze into my own code, and witness my own spawn as a monstrosity?
Curiously no.
There are goto
s, the code may have more magic numbers and mutable globals
than local variables, I abuse criminally the C pre-processor,
and the code is indeed a barely working pile of little UBs riddled with bugs.
But I can read it. It is clean, I understand the purpose and intent of everything, architectural design decisions are documented, the code is structured coherently, using common patterns.
In fact, it’s almost better than the code I write today 🥲
tldr
bug ridden
does not imply bad code
.
Furthermore bad code
does not imply bug ridden
.
experience
does not imply better code
.
The design principle is clear: ownership of control flow. I structured the GSSA code like a bunch of happy little games that wait their turn and take the helm and drive exclusively the entirety of the console.
The main menu code looks like this:
// main.c
for (;;) {
bootMenu(gameState);
playGame(gameState);
}
// mainmenu.c
void bootMenu(GameState* globalState) {
// ...
for (;;) {
frameCount = (frameCount+1) % (BLANK_SEC<<1);
if (frameCount == BLANK_SEC){
drawText(" ", 10, 16, BOOT_SBB);
} else if (frameCount == 0) {
drawText("Press Start", 10, 16, BOOT_SBB);
}
if (CHECK_INPUT(KEY_START)) {
srand(frameCount);
goto gameMenu;
}
WAIT_BLANK;
}
// ...
gameMenu: {
// ...
if (TST_FG(curKeys,KEY_A)) { //Changes submenu.
drawText(" ", 5, currentRow, MAIN_SBB);
switch (currentRow) {
//Exits the game menu loop
case 0:
break;
case BEGIN_ROW:
return; //goes to game
case SHIP_ROW:
goto shipMenu;
case CHEAT_ROW:
goto cheatMenu;
}
}
// ...
}
shipMenu: {
// ...
}
cheatMenu: {
// ...
}
}
The bootMenu()
handles the initial screens,
including ship selection, difficulty selection, cheat code menu, and the title card.
Each one of those represent a different screen.
Each screen has a corresponding loop, when the user changes screen,
a goto
goes to the selected screen’s loop.
Without goto
, we bloat the stack,
we enter a new context whenever we enter or exit a menu,
potentially leading to a stack overflow on the poor GBA’s tinny memory.
This implies giving up the concept of functions, but it works.
The C GSSA uses liberally mutable global variables. I pick arbitrary location in memory and split them between all the happy little games:
#define OAMCLONE_MEM 0x03004000
#define oamClone ((spriteTag*) OAMCLONE_MEM)
// ... in another c header
// a single value of size sizeof(PlayerShip)
#define PSHIP_MEM (OAMCLONE_MEM + sizeof(spriteTag) * 128)
// An array of size MAX_BULLET * sizeof(ActiveBullet)
#define ACTIVBULLET_MEM (PSHIP_MEM + sizeof(char) * MAX_BULLET + 8)
// An array of size MAX_ENNEMY * sizeof(ActiveEnnemy)
#define ACTIVENNEMY_MEM (ACTIVBULLET_MEM + sizeof(ActiveBullet) * MAX_BULLET)
// An array of size MAX_PICKUP * sizeof(ActivePickup)
#define ACTIVPICKUP_MEM (ACTIVENNEMY_MEM + sizeof(ActiveEnnemy) * MAX_ENNEMY)
// ...
// In main.c
#define GAMESTATE_MEM 0x03003F00
int main()
{
GameState* gameState = (GameState*) GAMESTATE_MEM;
I’m sure a few readers ground their teeth to dust seeing this code. But on the GBA it is fine. Remember? No OS, just GSSA running on the hardware. Only risk is for the stack to grow so much they overwrite the hardcoded memory. (Though apparently I was an “ennemy” of spelling when I wrote this)
This is what I learned from Mr Leone’s lecture: some memory regions are faster to read/write than others, so make sure to use the fast ones for critical data structures.
So how will I write the rust version?
No such code in my rewrite.
I’ll leave it to the compiler to do the hard work of picking
where to place my beloved data structures.
(In any case, I will specify the stack memory position in the linker.ld
file,
this tells the linker which addresses to use for what purpose)
The acolyte of data-driven programming (DDP) than I am now has lost the ability to reason in terms of control flow.
DDP is all about data structures. I first define the game state, and design code as transformations on the state.
GameState
is now an enum,
each variant relevant to a different point in the game.
Each variant its own little state,
all describing what to draw on screen
and what the next state is based on button input.
There is a single loop,
that looks at the state and call its corresponding draw
and update
methods.
I need to split draw
and update
on the GBA,
changing VRAM memory at a narrow specific interval,
otherwise, I’ll see ugly screen tearing.
This is the elm architecture.
The discarded cargo, filled with bobbles of another kind of civilisation,
that I picked up at university
and to this day still praise as gospel of clean code.
Regardless, I have other better reasons to idolize cargo
(my savior).
In fact, there is a bit more to that.
I can split the GameState
in two:
- Mutable data changing with the game progression
- Purely descriptive constant data (text positions in menus, menu text, player speed, etc.)
Constants will replace all the magic values.
This is more code, but I don’t have to uncover what 5
or 10
means.
const MAIN_MENU: Menu<3> = Menu {
entries: ["BEGIN GAME !!!", "Ship select", "Code zone"],
pos: (7, 4),
dir: MenuDir::Verti(3),
};
A Menu
tells me how to draw
something based on the global GameState
. Each struct has a draw
method that
takes as argument everything that is mutable and it needs to know. In the
case of Menu
the signature of draw
is as follow:
fn draw(&self, selected: u8, tile_map: Sbb) {
(where tile_map
is going to be a point to the location in memory we need to
write to to display things, it has nothing to do with the Schweizerische
Bundesbahnen)
This architecture greatly reduces the size of what is going to be passed around in drawing and updating routines (the state), which is necessary for performance reasons. One positive side effect of this is that the menus are entirely declaratively defined.
The corresponding C code fails to decouple drawing from updating, but on the flip side, is dead easy to understand:
drawText("BEGIN GAME !!!", 7, BEGIN_ROW, MAIN_SBB);
drawText("Ship Select", 7, SHIP_ROW, MAIN_SBB);
drawText("Code zone", 7, CHEAT_ROW, MAIN_SBB);
Yes, you’ll find magic numbers all around the code, this is quite ugly, but not a difficulty to code comprehension.
There is more to it though. The blinking cursor. The damn blinking cursor.
You think it’s easy?
My draw
method only has access to the current state,
but I need to erase the cursor at the previous position.
The cursor is not in the struct definition of Menu
, it can’t since the
cursor position changes and Menu
is immutable. The cursor must be part of the
GameState
.
With the C model, the menu has an internal state updated every frames, and draws based on that state in the same loop:
frameCount = (frameCount+1) % BLANK_SEC;
if (frameCount == 0 || frameCount == 4800) {
drawText(">", curPos, 5, MAIN_SBB);
} else if (frameCount == 2400 || frameCount == 7200) {
drawText(" ", curPos, 5, MAIN_SBB);
}
This snippet is repeated in every submenu loops.
With the rust model, the cursor is represented as a text box:
fn cursor(pos: (u8, u8)) -> TextBox {
TextBox {text: ">",pos,blink_rate: Some(30)}
}
And the update:
fn update(&self, cycle: u16, selected: Diff<u8>, tile_map: Sbb) {
if selected.changed() {
cursor(self.cursor_pos(selected.prev)).clear(tile_map);
}
cursor(self.cursor_pos(selected.current)).update(cycle, tile_map);
}
More precisely, the blinking happens in the TextBox::update
method:
pub fn update(&self, cycle: u16, tile_map: Sbb) {
if let Some(blink_rate) = self.blink_rate {
let tick = cycle % blink_rate;
if tick == 0 {
self.clear(tile_map);
} else if tick == blink_rate / 2 {
self.draw(tile_map);
}
}
}
It’s a bit more verbose.
Note the Diff
for the selected
entry. I need to know the previous
state to clear the cursor if it changed position. I can do that, because
update
is not updating self
(notice the &self
, non-mutable!) it is
creating a new self
, this way, the old self
is kept around, and can be used
later to compare new and old value, and execute code when relevant.
This doubles memory requirement, but for simple menus, this works great and the memory footprint is tinny enough to not be a worry.
The C code has the following snippet wherever it needs to change the cursor position:
if (TST_FG(curKeys, KEY_UP)) {
drawText(" ", 5, currentRow, MAIN_SBB);
currentRow = (currentRow == FIRST_ROW)? LAST_ROW : (currentRow - 3);
drawText(">", 5, currentRow, MAIN_SBB);
} else if (TST_FG(curKeys, KEY_DOWN)) {
drawText(" ", 5, currentRow, MAIN_SBB);
currentRow = (currentRow == LAST_ROW)? FIRST_ROW : (currentRow + 3);
drawText(">", 5, currentRow, MAIN_SBB);
}
This demonstrates also the unabashed mixing between state change and drawing in the C version.
Let’s try this again
Forget everything you just read. Or rather, let me forget that I ever wrote that. The year is now 2022, no hoverboards, just even dimmer hopes, and me, a bored dude, alone with his computer. And I do what most lone bored dude with computers do: rewrite a C GBA game in Rust.
This time I take a different approach. My first action is to split hardware
access and game code, this is called a HAL (hardware abstraction layer).
I call it haldvance
, because I am a victim of puns.
The second decision I make, is to use a MVC approach. I first design the data, and then, I… I mean I just spent a thousand words telling you about that, but for me this was a new and innovative solution.
Architecture bis
Oh, but in the mean time, I forgot about all this fancy “diffing” I nicely
described earlier. My update
methods take a &mut self
, and the previous
state is not kept track of.
The split between draw
and update
has become my nemesis. Why?
When I change from one state to another, this typically requires “exceptional”
draw
operations. For example, when starting the game, I change the video
mode, generate a star map and load the game’s sprites into video memory. But
here is the question: How do I know that I “just entered” the game
state? I only have the current state, outside of any context of changes.
Oh I see several solutions to this, but I made the mistake of trying to solve it before thinking about it. Here are the possibilities:
- Callbacks. Create an API where you can return optionally a callback to run in
the
draw
interval - Diff again.
- A generic state machine, where I can query whether state was updated/entered/left.
- Just keep a struct with a bunch of flags and check them every time
- Build a more principled version of the naive “ownership of loops” design I used in the C GSSA
I initially went with callbacks. My game currently uses this design. It probably kills perfs by preventing potential inlining. But more than that, it only partially solves the issue, because now I have to surface callbacks from inner functions, handle conflicts of callbacks… Fun in perspective.
Disaster, my prophetic faith in DDP is shook. Maybe I should take another approach? This is a question for another time.
HAL (elujah)
Now let’s talk about the HAL.
Generally, designing and writing a completely generic, fully documented, opinionated and flexible library API takes much more time than going at the hardware with a raw hammer and accepting your hardware access bugs will surface all around your game logic.
For reference, currently the HAL is 2K lines of code with over 800 lines of documentation, while the game itself is about 1K lines of code (suspicious, he doesn’t say how much doc the game has).
And development is sluggish: every time I add a functionality, I have to first design an API for it, and wrap the hardware, thinking about the cheapest way of doing it, thinking of edge cases, explore rust features allowing them, and all of that is before I even get started writing game code, which itself is a slog, because this 2nd rust rewrite started with a crippling architectural decision.
Ah! But at least the separation between gameplay and hardware code did help me understand in isolation why certain things do not work. It’s also so much easier to read, and as I write the HAL, I’m writing an exhaustive manual on how to write GBA games in Rust, with directions in the more hairy parts of the API.
The HAL is great, but I worry it’s a massive waste of time, we are talking about an abstraction layer for a 20 years old console. And why? Just because. A weird obsession.
And more
Now my second rewrite reached feature parity with the first one. Ah, accidentally. It is time to start making strides toward feature parity with The C Version. Fun in perspective, to the next post!