in ,

How to write better (game) libraries | handmade.network Wiki, Hacker News

How to write better (game) libraries | handmade.network Wiki, Hacker News


        

            

The word “game” is in parentheses because most of the advice in this article also applies to libraries that are not game-related so hopefully you find this article useful and interesting even if you don’t work on game libraries .

This article presents a series of advice on how to write better libraries in a way that makes them easy to build and integrate into projects.

The core principles of this advice are:

  1. Maximize portability.
    1. Be easy to build.
  2. Be easy to integrate.

  3. Be usable in as many scenarios as possible.

C is the lingua franca of programming. There are many advantages to writing your library in C vs any other language:

  1. Every language out there has a way to call into C, so if you write your library in C everyone will be able to use it, in any language, and people can write wrappers for it easily. If you write a library in Python, chances are most people won’t be able to use it if they don’t also use Python. If you write a library in C however, someone who really likes it can make bindings for it in Python. C truly brings all of us together.
  2. If your code is slower than C, someone will rewrite it in C. So just write it in C to begin with! This is especially important for game developers and people who use low-level languages ​​in general.

  3. C is the most portable language in the world, if your library is written in C it means it can be used on any OS, console or mobile device and even on the web.

You might also be tempted to write the library in C , but please consider the following drawbacks:

  1. Not everyone wants to use C (some prefer C).
  2. It is easier in general for a C user to use a C library than it is for a C user to use a C library.

  3. C is not as easy to write wrappers for in other languages.

  4. If you use C unless you limit which C features you use (to the point where you are pretty much left with C) a lot of people won’t be able to use your library. Not everyone uses RAII, some people disable exceptions and RTTI and not everyone is willing to use a library with smart pointers, template metaprogramming, STL allocators, virtual dispatch, etc.

However, be mindful of the following aspects when writing your C library:

  1. Don’t use compiler extensions, keep your C as standard as possible and try to use C 823. Also, make sure it also compiles in C mode (at least the headers).
  2. Try not to use the hosted (OS-dependent) parts of the C standard library unless you really have to (and for most libraries you don’t) since they might work differently on other platforms (or might not work at all). If you need to use OS-dependent functions but want to maximize portability, request function pointers from the client.

  3. Always prefix your names to avoid name collisions (more on this later).

  4. Use the types from

    stdint.h

    ) for types of specific sizes and try not to typedef existing types, likeuint8_t

    to (u8

    ). If you do use typedefs, consider using macros that you can undef at the end of the file and / or prefix the typedef to avoid name collisions.

  5. Use header guards instead of# pragma once.

  6. Don’t use macros unless absolutely necessary, undef macros that should not be exposed to the user at the end (eg:my_min / maxmacros), do this even in C files.

  7. Make sure your library can compile as one compilation unit since a lot of developers choose to do single-compilation-unit builds (also known as unity builds).

This can be very useful for C developers since C has several features that improve considerably over C. Take into consideration that this is an extra piece of code you would have to maintain.

Basic things you can do when writing your C wrapper:

  1. Use .hpp and .cpp for your C wrapper to distinguish between the C and C code. Consider putting the C wrapper in another folder or repo and mention it in the readme.

  2. Try not to include the header from the C version in the hpp file. Instead, rewrite the declarations in the hpp in a more C style and in the cpp file include the C header and provide the definitions for all the wrapper functions.

    1. Expose constants to the user using constexpr variables. This is especially easy since C structs are constexpr by default. Make sure constants from C that use macros are not present in the C wrapper.
  3. Use namespaces and wrap all the functions like sonamespace mylib {foo bar () {return mylib_bar (); }.

  4. Try not to use exceptions and RTTI. Especially in game development, not all people use them and some just disable them.

  5. Consider using default parameter values ​​over function wrappers. C lacks default values ​​for parameters so usually, people write a function with a lot of parameters and then create several wrappers for it that calls the original function with different default parameter values. C has default parameter values ​​so consider removing the extra wrapper functions if you have any.

  6. Try not to use STL containers or smart pointers since your wrapper should just simply wrap the functions from C and also adding those that bring extra problems that you need to consider. (More on this later)

  7. If you decide to offer RAII wrappers for parts of your library, still provide wrappers for the non-RAII structs and functions. This is important because not all developers use RAII and if you don’t expose the non-RAII versions of structs and functions in your C layer they won’t be able to use it, at least not fully.

There are many build systems out there and chances are people won’t use the same one you do. Your library should simply present the source files, the header files and a list of the dependencies (if you have any).

If there are compiler flags needed to compile your library mention them in the readme, though preferably there won’t be any.

It’s fine to include optional CMake or Make files, but mention in the readme that they are optional and keep the simple structure that lets people integrate the library easily with their preferred build system.

Distribute your libraries such that people can simply include the source files in their project and be done with it.

This has many advantages such as:

    1. Letting people easily reason about your library.
    1. Easy to include in cross-platform projects, no craziness regarding libc versions and platform stuff.
  1. It allows people to step through the code in a debugger.

This really helps with reasoning about your library. A simple pair of header and source files is very nice because people know what they need to include, where they can read the interface of the library, what they need to compile, and where they can read the implementation. Try not to have .h files that people shouldn’t include themselves and are included only by other .c files since that can be confusing. If you can keep the library to just one .h file and one .c file that is even better. If you do decide to modularise the library into multiple files, consider how it affects the library user and also if it maybe makes more sense for some of those files to be separate libraries.

If your library has dependencies, try to list them in a file (such as the readme) so that users can easily reason about them. Also, try to only use dependencies if absolutely necessary. Don’t get a dependency if you only use 1 – 3 functions from it since that adds bloat. Instead, consider writing your own version or extracting those functions you need from another library into your own. For example, if you need some light text manipulation functions then consider writing your own instead of adding a dependency. Don’t fall into the trap of using a library for every single small thing (eg: see is-odd and is-even from npm).

Always ask for buffers or allocators from the user or use stack-allocated buffers (up to one or more kb) for small temporary operations (eg: some string manipulation). This is important because different projects have different performance considerations, if a library allocates memory using the general-purpose heap allocator then certain developers might not be able to use it. Also, if you decide to write your library in C , using STL containers, without a way for developers to specify the allocator, this would also make it unusable for them, which is another reason to prefer C 823 and write the C wrapper afterward.

If your library needs to do a lot of extra work and has a lot of tiny allocations that can occur (such as for temporary allocations when doing string manipulation), consider asking the user for a buffer or an allocator for temporary memory.

In C you can use default parameters for functions that ask for an allocator to use the heap allocator by default. You can also overload functions to have a version that allocates the memory for the user.

Some developers use const, some don’t. If you don’t use const for the function parameters in your functions however, this will only negatively affect developers who use choose to use const. So use const for pointers to data that you intend to only read from. Being const correct also helps with documenting code, it even helps developers who don’t use const.

Since we mentioned custom allocators and asking for buffers instead of allocating memory using the heap allocator, remember to always ask the user for the size of the provided buffer. Even if the buffer you receive is expected to have a sentinel value (such as a null terminator) still ask for the size of the buffer. The reason is, in game development and other high-performance software, we often pack buffers together in memory for efficiency reasons, so going out of the bounds of a buffer might not result in a crash from a memory access violation and this is a very hard to catch bug. Also, not all users use null-terminated strings all the time (for a variety of reasons). If you must use a null-terminated string, such as when interfacing with an OS function, consider informing the user about this and still maybe ask for the buffer size.

When dealing with strings also bear in mind that buffer size and string size are different, since the string size does not take into consideration the null terminator, so specify in comments and documentation if the user should provide the buffer size or the string size when asking for aconst char *.

Additionally, you can provide convenience functions that ask for a C Null Terminated String and invoke

strlento get the size and pass it to the function they wrap.

Programs are like a system of pipes that you pump data into, to convert it from one form to another. If your library looks just like a pipe, a thing that people can pump data into and get some other data out, then it will be very easy and pleasant to use. Your “pipe” shouldn’t care where the data comes from and what the user does with it afterward, that is the user’s problem to care about. Write your library such that a user can simply pass everything that it needs to it and then it gives the user back what they wanted. If your library needs something, consider asking the user for it instead of trying to get that thing on your own. Try not to handle memory or other resources in a way that is invisible to the user since they might encounter problems if the way you manage that resource is incompatible with that they are doing.

Consider offering optional versions of your functions that handle resources or allocate resources on behalf of the user. The user might want to use those (if it’s possible in their case) or at the very least they can use those to test out the library.

This might sound weird but sadly fopen doesn’t work as intended on all platforms that a game dev might want to support. You might remember the point about not using OS-dependent functions.

For example, on Android, you need to useAAssetManagerfor assets andfopenfor internal storage. Because of this, in order to use libraries that need fopen on Android, some devs do stuff like

# define fopenwhich is really hacky and error-prone. Also depending on the project, IO must be handled in different ways (eg: memory-mapped files, async IO).

Instead, you should do the following things:

  1. Ask for buffers to the file data and let the client of the library do the IO.
    1. Ask for function pointers for IO operations that the client of the library needs to provide.

    2. Offer optional variants of those functions that use fopen, most games are tested and developed on Windows or Linux where such functions can be used to integrate the library into the game quickly for testing purposes .

    In general, if you are unsure about the availability and support of a os-dependent function in libc and don’t want to have to manually provide support for every single platform that users might need (especially if you only need a few os-dependent functions), consider asking the user for function pointers to functions that achieve the goal of that os-dependent function. This maximizes your portability and it is simple for your users to do.

    The bigger a dependency is the more of a liability it is. If you want to do a lot, try writing several smaller libraries, maybe make the core into a shared dependency.

    If your library depends on OpenGL then don’t include any OpenGL headers and don’t try to load OpenGL yourself. Sadly OpenGL needs to be loaded very differently on each platform and it’s hard to load it without essentially enforcing a platform layer and maintaining it for all platforms. Not to mention on some platforms there are multiple, mutually exclusive ways to load OpenGL and depending on the scenario a game dev might use one over the other. So yeah, if your library handles OpenGL loading by itself then people can’t use it, not without having to fork it to remove your custom OpenGL loading code. Depending on another library which also does this is also bad.

    The situation seems dire, but there is actually a very simple solution. In your code, don’t include any OpenGL headers or anything. Instead, specify in the readme which version of OpenGL you use and tell users which .c files need OpenGL. What they can then very easily do is wrap those files like such:

    (******************************************************* // library_file_that_uses_opengl.c

    # include// Include a header with OpenGL definitions that works for my game
    # include**********************

    **************************

    This way users can compile your library which uses OpenGL and have it work with how they load OpenGL in their game.

    For DirectX and Metal, there are standard headers and you can check if the user defined a flag like

    MYLIB_USE_DIRECTXorMYLIB_USE_METALand include those headers based on that. For Vulkan, I think you need to do the same thing as with OpenGL.

    For maximum portability I find the subset of OpenGL from OpenGL ES 3 to be the best, so try to use that if you make a library that renders stuff and do not want to provide multiple implementations of your graphics code. Also, try not to use the pipeline from OpenGL 1 since some platforms (eg: Android, iOS) don’t have it.

    If possible, offer support for multiple graphics backends like DirectX and Metal. If you can only focus on adding one extra backend prefer Metal since OpenGL is deprecated on iOS and macOS. When writing the code for multiple different graphics backends or API versions, consider splitting the rendering code into different files depending on the backend and API version (eg: Project files:

    ) my_lib .h, my_lib_opengles2.c, my_lib_opengl4.c, my_lib_metal.c).

    Examples are a great way for people to get to know your library.

    1. Providing a folder with easy to compile 1-2 file example source code is amazing.
    2. Providing examples in the comments is awesome.
    3. Providing examples in the readme is great.
    4. Providing a Github wiki (though not necessary for most projects) is god-like.

    If your library is easy to understand and easy to compile that increases the chances of anyone using it exponentially.

    Another advantage of a suite of examples is that it can serve a double purpose as a testing suite. If you have lots of examples you can use them to validate that your library still works correctly. This is especially important for libraries that do rendering since you can’t normally unit test that.

    Always prefix any exported symbol like such

    mylib _

    . This includes macros, enum constants, function / struct names. Do this in the .c file as well since some people do single-compilation-unit builds. Consider using a different naming convention for internal functions that should not be exposed to the user (eg:

    ) mylib_impl _ormylib __), this helps people who do single-compilation-unit builds.

    There are moments when global variables are appropriate, such as for static data that must be available to the library. Global variables are not always the worst thing, and most people can tolerate them depending on their use (sometimes they even make the API easier to use).

    But try to keep them to a minimum in general as they can have implications:

    1. What if the user tries to use your library on multiple threads?

    2. Many people do hot reloading by compiling the game code into a DLL that gets refreshed on recompile while the game executable still runs. When they do this all global variables are reset. This means they can’t use your library in game code if they do hot reloading.

    The best way to handle errors is to not have them, which is why it is important that we minimize their presence in our code. It is also important to distinguish between real errors and precondition / invariant / postcondition violations.

    Precondition, invariant and postcondition violations are not errors.

    They are bugs, and should be handled using assertions. Use a macro like# define MYLIB_ASSERT (condition, msg) assert (condition)

    which is set by default to the libc assert but that the user of the library can modify to better fit their needs or to disable assertions for performance.

    Don’t offer error handling unless there is actually an error to recover from.

    Debuggers can catch assertion failures and the developer can see the stack trace.

    Additionally, messages and comments regarding what triggered the assert are great, do those.

    Errors that must be handled at runtime by the user

    If you do something that can fail and that needs to be handled by the client of the library (eg: you try to send a package over the internet but it fails) then simply return error codes (preferably enum constants) to the user. If you have additional information that is important to the user for the goal of handling the error then return a struct with the error code and extra data, but this is usually not the case.

    Example:

    (******************************************************* (ParsePNGFileResult) result=(ParsePNGFile) **********

    ************** ( () **********************************;if(result

    **************************error

    {

    / * handle error * /

    ************}}**************************

    Error handling for handles / context structs

    For a series of functions that use a handle or a context struct, you might want to consider including the error in the handle and return early instead of asserting if an error is set in the handle / context struct. This gives users maximum flexibility because they can either check for an error after every single function call (like with returning error codes) or they can execute a full code path and check for errors at the end.

    Example:

    (******************************************************* (FileHandle) file=OpenFile

    ************** (
    file ;

    // The file handle could be invalid after this, that information is stored in the file handle
    data=GetDataFromFile

    ************ () (

    ****************file ); ) // If the file handle is invalid the function just returns NULLDoStuffWithData(data

    **************************Note that if data is null the function just returns and does nothing (************************ CloseFile(file

    if(CheckError () (

    ****************file ) ({{**********************) / * handle error * / (************************}

    **************************

    In this example, the user had the option to either handle the error after an individual function call or at the end of the full code path. This pattern is used by many developers and by designing your library’s error handling this way you can satisfy both people who want to handle errors at the call site and people who want to handle it later on.

    Here is an example of how you can refactor that code to check the error at the call site:

    (******************************************************* (FileHandle) file=OpenFile

    ************** (
    file ;

    // The file handle could be invalid after this, that information is stored in the file handle
    if(CheckError  () (

    ****************file ) ({{**********************) / * handle error * / (************************}

    **************************

    Note that when you compare this example to the one where we returned an error code it is not that different, but in practice this makes error handling much more flexible when dealing with handles or context structs. It is important to remember however that this method only works when you have a handle or a context struct, if your function has no context or handle then use an error return value.

    While libraries like STB are very popular and great, consider the following:

    1. Header only libs have longer compilation times (the whole file needs to be included even if the implementation is then discarded)
    2. Header only libs are slightly harder to read. It’s very nice to have the header there as a separate file to read just the interface.

    3. It's not much harder to do header source than it is to do header only, so why not do header source?

      1. At the end of the day keeping your library header only will probably not be a deal-breaker, but some people do prefer if a library keeps .c and .h files separately.

      And finally, here are some great libraries that follow most of these principles and can be used in game dev:

      1. Sokol libraries (graphics, platform layer, audio, net):****************** (https://github.com/floooh/sokol) **********************************
      2. BGFX (graphics):https://github.com/bkaradzic/bgfx

      3. STB libraries (asset loading, utilities): https: //github.com/nothings/stb

      4. Cuteheaders (graphics, net, io, utilities, etc):https://github.com/RandyGaul/cuteheaders

      5. CGLTF (text rendering): (https://github.com/jkuhlmann/cgltf) ********************************

      6. DR Libs (audio loading): (https://github.com/mackron/dr_libs) ********************************

      7. Mini audio (audio playback): (https://github.com/dr-soft/miniaudio) ********************************

      8. Par headers (graphics, utilities, etc): (https://github.com/prideout/par) **********************************

      9. Physac (physics): (https://github.com/victorfisac/Physac) ***********************************

      10. Dear Imgui (ui): (https://github.com/ocornut/imgui) **********************************

      11. Soloud (audio): (https://github.com/jarikomppa/soloud) ********************************

      12. Meow Hash (non-crypto hash): https://github.com/cmuratori/meow_hash

        **********************

      More to be added ...

              

                (****************************************************(Read More) (**************************************