the TinyBit game engine - part 7 - library
In previous blogposts on TinyBit I've hinted at it more than once: TinyBit runs on a lot of things. Currently SDL2 (windows, linux, mac, whatever runs SDL), ESP32, Raspberry Pi Pico, and as of a few weeks ago: WebAssembly (check it out). That last one gets its own blogpost later. For now I want to talk about how to take an existing C project, rip out all the platform-specific code, replace it with a pile of callback functions, and end up with something that runs basically anywhere.
platform code everywhere
So, what is platform-dependent code? That's the first question you have to answer. For a lot of projects, "platform-dependent" means the bits that don't survive a move between Linux, Windows, and macOS without #ifdef gymnastics. File handling is the classic example: FindFirstFileA on Windows vs readdir on POSIX. For many projects, wrapping both in preprocessor directives is enough and you can call it a day.
#ifdef _WIN32
WIN32_FIND_DATAA find_data;
HANDLE handle = FindFirstFileA("games\\*", &find_data);
// ... windows-flavoured misery
#else
DIR* dir = opendir("games");
struct dirent* entry;
while ((entry = readdir(dir)) != NULL) {
// ... posix-flavoured misery
}
#endif
TinyBit doesn't get to call it a day. I want it to run everywhere, and "everywhere" includes microcontrollers that don't have a filesystem, a clock, or in some cases any concept of "files" at all. That means stripping out everything a C compiler would lower to a syscall: anything touching files, audio, graphics, timing, networking, you name it. Since TinyBit is a game engine, and games tend to care about all of those things, this has consequences.
The trick is to never call those things directly from engine code. Instead, the engine asks the host to do it via function pointers the host registered at startup. The engine stays pure C with no syscalls; the host deals with the messy reality of whatever hardware it happens to be sitting on.
files
I gave an example above of reading files. That code works fine if you only target real operating systems, but TinyBit also targets a Raspberry Pi Pico, which considers a filesystem more of a suggestion. So file access gets abstracted away. Luckily TinyBit doesn't do file manipulation in-engine, so I only need two things: ask how many game files exist, and ask for one of them by index. These power the game selector and load games and cover art. I called them gamecount and gameload:
void tinybit_gamecount_cb(int (*gamecount_func_ptr)());
void tinybit_gameload_cb(void (*gameload_func_ptr)(int index));
bool tinybit_feed_cartridge(const uint8_t* cartridge_buffer, size_t bytes);
One returns the number of available game files. The other loads a game. It's up to the host to then push the actual bytes into TinyBit memory via tinybit_feed_cartridge. This can happen in one big blob, or, more realistically on a microcontroller with twelve kilobytes of RAM to its name, in smaller chunks.
audio
I wrote an entire blogpost on how TinyBit handles audio internally, so I'll keep the internals short here. From the library's point of view, there is exactly one callback.
void tinybit_audio_queue_cb(void (*audio_queue_func_ptr)());
The host is responsible for shoving the generated audio at whatever passes for an audio device on the target. On SDL2 that means handing the buffer to the audio subsystem. On the ESP32 it's I2S to a small DAC. On the Pico it's PWM into a low-pass filter and a speaker that cost less than a stroopwafel. The engine doesn't know or care which of those it is.
The important bit is the ordering. Every frame, a new chunk of audio is generated before the picture updates. That way, while the host is busy rendering the next frame, the audio device keeps happily streaming the chunk that was just queued. Do it the other way around and you get the lovely crackles and pops that scream "amateur hour" to anyone listening. Audio is unforgiving like that: a dropped pixel is invisible, a dropped sample sounds like someone stepped on a bag of crisps.
graphics
Same story for graphics, the gory details are in this blogpost.
void tinybit_render_cb(void (*render_func_ptr)());
Rendering a frame is mostly a matter of reading out the TinyBit framebuffer, which already lives in memory the host allocated. The engine has done all the drawing work by the time the callback fires; the host just needs to get those pixels onto a screen.
The one annoying detail is that this buffer is in RGB4444, and depending on the platform you'll need to convert it: RGB565 for the small LCD controllers I tend to use on microcontrollers, BGR888 for SDL2, and probably something else for whatever exotic display you've decided to torture yourself with. The conversion is cheap, but it has to happen somewhere, and putting it in the host means the engine doesn't need to know about every pixel format ever invented. Which is good, because I don't either.
timing
Not every platform has time.h, so timing is also platform-dependent.
void tinybit_get_ticks_ms_cb(int (*get_ticks_ms_func_ptr)());
This returns the number of milliseconds since the engine started. Not every platform has a real-time clock; microcontrollers usually have no idea what year it is, and frankly don't care. So this function is completely independent of wall-clock time. It's just "how long have we been running". That's enough for a small video game.
conclusion
Splitting an engine into a portable core and a thin platform layer is a bit of work up front, and feels silly the first time you write a callback that just forwards directly to SDL_GetTicks. The payoff comes later, when adding a new platform stops being "rewrite the engine" and becomes "implement five functions and go make coffee". The ESP32 port took a weekend. The WebAssembly port took an afternoon. The next one will probably take less, until eventually I'm porting TinyBit to my microwave on a dare.