Blog
The basics of a cross-platform game
From the start of the game's development, it seemed obvious to me that it had to be cross-platform: it had to run at least on Gnunux (makes sense, this is the OS I use), on Windows (the most popular OS on desktop / laptop) and on Android (the most popular OS on tablets and phones). Derivatives like LineageOS are included in “Android”. Also, I wanted to make a web port to be able to play it directly in the browser.
I will now explain to you how I managed to create these different versions using the same code base as much as possible (apart from a few adaptations, I'll come back to this).
This making-of will the first of several: this article is an introduction, I will then write a specific article for Android porting, for asset management, cross-compilation, etc.
Prerequisites & tools
Well, that may sound obvious, but to make a cross-platform game, it is better to use tools that are themselves cross-platform … In our case:
- the C++ language is quite widely supported by a whole bunch of platforms. For Android (which natively uses a language based on Java), the NDK allows you to develop applications in C ++, and for the web version, it is Emscripten which will translate our C ++ code into a JavaScript applet.
- the libraries used: the standard C ++ library (STL) of course, but also the SDL 2.0 (also ported to Android and Emscripten) and, for reading files, the Libyaml (a relatively simple and independent C library) . The latter is not directly ported to Android and Emscripten, but we will see that it is relatively easy to do.
- for the compilation itself, I rely largely on CMake, which allows me to efficiently manage the native compilers of each platform as well as the dependencies. The exception is Android, for which I use Gradle, the usual Android build system: note that Android is quite special at this level, because it is not “just” a compilation but a complete packaging of the software (= we produce the APK which will be used to install it on your phone / tablet).
CMake, preprocessor & constexpr
As I said in the intro, we are going to make sure that the code base remains as much as possible the same (avoiding as much as possible having sections of code dedicated to Android, Windows, etc.), but each platform having specificities, we will still have to manage these specificities. Let's distinguish three phases.
First, the configuration: this is the moment when we manage dependencies with CMake (or gradle), where we choose the way in which the software will be organized (in which directories the data goes, the executable, etc.) . All this varies quite a bit depending on the platform, and CMake allows you to make specific rules depending on the case, for example, to detect if you are on Gnunux:
if (CMAKE_SYSTEM_NAME STREQUAL Linux)
## config specialized for Gnunux
elseif (CMAKE_SYSTEM_NAME STREQUAL Windows)
## config specialized for Windows
endif()
(Yes, for some reason CMake calls Gnunux “Linux”, strange isn't it?)
Here, for example, we can tell Gnunux to install the executable in
/usr/local/bin
(or/usr/local/games
, I'll come back to that …) and
Windows to put it in C:\Program Files\My_game
.
Then comes the compilation phase: we create the executable for the
desired platform (an .exe
file on Windows, a file without extension
on Gnunux).
Here, each system is gentle enough to define C++ macros to indicate
that we are on that system. As it is a bit of a mess (you can always
dream of a standardized naming), I make myself a small platform.h
file to have constants with a uniform name:
namespace Sosage::Config
{
#if defined(__ANDROID__)
#define SOSAGE_ANDROID
constexpr bool android = true;
constexpr bool mac = false;
constexpr bool windows = false;
constexpr bool gnunux = false;
constexpr bool emscripten = false;
#elif defined(__APPLE__)
#define SOSAGE_MAC
constexpr bool android = false;
constexpr bool mac = true;
constexpr bool windows = false;
constexpr bool gnunux = false;
constexpr bool emscripten = false;
#elif defined(_WIN32)
#define SOSAGE_WINDOWS
constexpr bool android = false;
constexpr bool mac = false;
constexpr bool windows = true;
constexpr bool gnunux = false;
constexpr bool emscripten = false;
#elif defined(__linux__)
#define SOSAGE_GNUNUX
constexpr bool android = false;
constexpr bool mac = false;
constexpr bool windows = false;
constexpr bool gnunux = true;
constexpr bool emscripten = false;
#elif defined(__EMSCRIPTEN__)
#define SOSAGE_EMSCRIPTEN
constexpr bool android = false;
constexpr bool mac = false;
constexpr bool windows = false;
constexpr bool gnunux = false;
constexpr bool emscripten = true;
#endif
} // namespace Sosage::Config
(Yes, I also planned a possible port on Mac, same, I will come back to that.)
Note that here, I used two features of C++: the preprocessor via
commands of type #define SOSAGE_GNUNUX
, and theconstexpr
like
constexpr bool gnunux = true
. These are two ways of defining things
at compile time (not at runtime), with constexpr
being sort of the
“modern” way (since C++11) while directives from preprocessors are
inherited from C. The two are complementary: the constexpr
allow to
write a clear code which is exactly the same code as that executed …
at runtime (with similar compilation errors), but they do not allow
some things like disabling entire functions.
For example, if I want to handle the fact that the directory separator
character is /
on all systems except on Windows which uses \
, I
can write it the “old-fashioned” way with a directive of preprocessor:
#ifdef SOSAGE_WINDOWS
char folder_separator = '\\'; // yes, it has to be escaped, so we write \\ and not only \
#else
char folder_separator = '/';
#endif
But it's much more readable if I write it with a constexpr
:
constexpr char folder_separator = (Config::windows ? '\\' : '/');
In this case, since Config ::window
is a constexpr
, the condition
is only evaluated once at compile time (and not at each execution).
In both cases, if I then want to access a directory without worrying about the platform, I just have to do:
std::string folder = "folder1" + folder_separator + "subfolder1";
Small subtlety: I took care to test if the platform was Android
first. Indeed, Android being based on Gnunux, the system also
defines the macro _linux_
: if we therefore want to distinguish
Android from a basic Gnunux, we must first test whether we are on
Android, and in the if you are not, test if you are on Gnunux.
Finally, the last step is execution: at this level, we should normally no longer have any code that is specific to a platform. Indeed, the generated executable can only be used on a platform: it is useless for a Windows executable to contain instructions and algorithms dedicated to Android, since these tools will then never be used (and will therefore only unnecessarily inflate the executable size). Hence the idea of doing everything either at configuration or at compilation!
Conclusion
So, we have prepared a clean environment to be able to develop both a common code base AND manage the specific cases necessary to each platform.
Small details on the ports: a priori, the game is compatible with Mac OS. However, it doesn't seem possible to generate a Mac OS executable without owning a Mac, which is of course not my case (and I don't really want to buy one, to be honest). As the saying goes: “if it's not tested, it's broken”, so I doubt the game will work on Mac since I have never tested it, but theoretically nothing should prevent it.
For Windows, there are cross-compilation tools (I will come back to this in a dedicated article), so I was able to sort it out.
One last point: the SDL has also been ported to Nintendo Switch, and I admit that I would really enjoy porting the game to this console! There, however, the barrier is more administrative than technical: Nintendo's SDK is private, and you have to make a request to access it. Needless to say that an independent game embryo like mine has no chance of accessing it. On the other hand, I may try my luck when the game is done and released 🙂