DEV Community

Beau
Beau

Posted on

Nyx: How to make a game loop

Design blog!

Whenever you create a program, you had to do some level of designing when you first started. This could have been small, like in the case of a throwaway script, or it could be much larger, like in the case of designing an entire library. This post is going to be looking into the design of Nyx, namely where the previous attempt, Aurora, failed, and what I've done with Nyx to--in my opinion at least--fix it.

SDL style

Aurora had a major flaw in that it put zero restriction on the structure of the program that was written. This was actually something I considered a feature of the library at the time: you could write your program however you want, and you'd insert the graphical stuff wherever it happened to need to go and be done with it. Unfortunately, in practice, this led to some pretty awkward things to deal with.

A lot of this stemmed from me unintentionally pulling from what I'd used in the past. The main library I used whenever I needed graphics was SDL2. SDL is great, I talked about that in the "libraries" article, but it has a lot of boilerplate code you are effectively required to put in place. For example, here's just opening a window in SDL2 (minus some initialization code):

#include <SDL2/SDL.h>

int main(int, char *[]) {
  /* initialize the window and renderer */

  bool running = true;
  SDL_Event e;
  do {
    while (SDL_PollEvent(&e)) {
      switch (e.type) {
      case SDL_QUIT:
        running = false;
        break;
      }
    }

    /* update game state */

    SDL_RenderClear(/* ... */);

    /* draw stuff */

    SDL_RenderPresent(/* ... */);
  } while (running);

  SDL_Quit();
}
Enter fullscreen mode Exit fullscreen mode

SDL is first and foremost a C library, and you can see that in the coding style. Everything is procedural, and it's up to you to call everything in the right order. Some things can be mixed around, but not much.

Now here's that same thing in Aurora:

#include "aurora/aurora.h"

int main(int, char *[]) {
  auto engine = aurora::Engine({"Example", {800, 600}});

  do {
    engine.update();

    /* update game state */

    engine.window->clear();

    /* draw stuff */

    engine.window->flip();
  } while (engine.is_running);
}
Enter fullscreen mode Exit fullscreen mode

Sure, it's shorter, but you can see the parallels between the two snippets of code. Open your window, then start your game loop. Within the game loop, run your events through, clear the window, draw, then flip. Check your exit condition, and either stop or continue. There's nothing wrong with this loop, and in fact basically every game library in existence--no I did not fact check this--likely works in a similar way. Why, though, is it up to the user to code that in? If it's the same every time, you just have boilerplate, and that's not reasonable.

LÖVE

Some libraries don't work this way, and take a more structured approach. LÖVE is a great example of this--being still pretty low level in terms of what the programmer is responsible for, but taking that boilerplate part of the code out of the equation. To make an application in LÖVE, your code will look something like this:

function love.load()
  -- initialization code
end

function love.update()
  -- update game state
end

function love.draw()
  -- draw stuff
end
Enter fullscreen mode Exit fullscreen mode

There are the 3 main callback functions in a LÖVE program, and they allow you to do quite a lot. They are called in a loop similar to this:

love.load()
while game is running:
  love.update()
  love.draw()
Enter fullscreen mode Exit fullscreen mode

The callback way of doing things keeps the focus on the content of the functions, rather than how they're structured in the code. Even better, it doesn't need to be rewritten every time, and and the library developers don't need to add in some new thing to update backend structures that the user has to call: they'll just add it to their existing game loop. This is less cognitive load on the developer, and requires less knowledge of the backend of the library--something that arguably you shouldn't need to know about especially for simple programs.

Nyx

I drew inspiration from LÖVE heavily for how I implemented the game loop--taking into consideration, of course, that C++ is a very different language with a different way of making programs. Here's that same example as above where a window is opened and simply cleared and flipped until the user closes it:

#include "nyx.hpp"

class Example : public nyx::App {
 public:
  Example(nyx::AppCfg const &cfg) : nyx::App(cfg) {};
  ~Example() = default;

  void update(double time, double delta) { 
    /* update game state */ 
  }

  void draw() {
    clear_window();
  }
};

int main(int, char *[]) {
  Example({"Example", {800, 600}}).mainloop();

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Admittedly, there's a lot going on here, but that's mostly because of C++ boilerplate, not because of the library itself. To create an application in Nyx, the App class is overridden, and at least the void draw() function must be implemented. The void update(double time, double delta) function is optional if you don't need it for some reason, but I showed it here to show that it exists. mainloop does a lot behind the scenes, but effectively boils down to doing event handling and assorted update functions, calling your update function, drawing the screen, then presenting it automatically. You also inherit some functions like clear_window than can be used in your draw loop or elsewhere that are needed for full functionality. This removes the need for the user to have to manually structure their program, as a structure is enforced this way.

You may argue that the way I've done this adds in its own boilerplate code, and to some degree I'd agree with you. Overriding a class in C++ isn't that short, and it is somewhat annoying that there's no way to really get around this without doing some GL3W style function callbacks. Working a few different ideas, I think this is the best balance between solving the issue I described with manually creating a game loop, and also having it still be relatively convenient to use. In a larger program, the extra code associated with this method is negligible.

Final remarks

Lack of structure plagued Aurora from all sides, but I'm not allowing those same mistakes to happen again with Nyx. I want an easy to grok, understandable structure in all programs written with the library--allowing me to add as much extra functionality as I want behind the scenes. In another post I'll go into what those behind the scenes things are. Soon I'll make a post about "stickies", which are mostly meant for debugging but have been incredibly valuable for development.

Nyx is coming along quite well--primitives are almost implemented--and then I'll be starting on implementing more and more drawing functionality. Stay tuned!

Top comments (0)