Skip to content

Overview∶ The Game Loop

Marcus Huderle edited this page May 7, 2024 · 2 revisions

(Article originally by huderlem)

Main Game Loop

Many games have what is called a "game loop", which is the main control flow for updating the game's state. It looks something like this at a high level:

while (1)
{
    ReadPlayerInput();
    UpdateGameState();
    DrawScreen();
}

The gen 3 games are no exception, but their game loop doesn't look quite as clean as the above example. Let's take a look at src/main.c, where the game loop is located. The game loop is located inside the AgbMain() function, which is the entry point for the game's code. If you skim past the game initialization code, you'll see an infinite for loop, which is the game loop.

// src/main.c
for (;;)
{
    ReadKeys();

    if (gSoftResetDisabled == FALSE
     && JOY_HELD_RAW(A_BUTTON)
     && JOY_HELD_RAW(B_START_SELECT) == B_START_SELECT)
    {
        rfu_REQ_stopMode();
        rfu_waitREQComplete();
        DoSoftReset();
    }

    if (Overworld_SendKeysToLinkIsRunning() == TRUE)
    {
        gLinkTransferringData = TRUE;
        UpdateLinkAndCallCallbacks();
        gLinkTransferringData = FALSE;
    }
    else
    {
        gLinkTransferringData = FALSE;
        UpdateLinkAndCallCallbacks();

        if (Overworld_RecvKeysFromLinkIsRunning() == TRUE)
        {
            gMain.newKeys = 0;
            ClearSpriteCopyRequests();
            gLinkTransferringData = TRUE;
            UpdateLinkAndCallCallbacks();
            gLinkTransferringData = FALSE;
        }
    }

    PlayTimeCounter_Update();
    MapMusicMain();
    WaitForVBlank();
}

Ignoring link-related functions, it boils down to this high-level flow.

while (1)
{
	ReadPlayerInput();
	CheckForSoftReset();
	RunCallbacks();
	UpdatePlayTimeCounter();
	UpdateMusic();
	WaitForVBlank();
}

There are two major differences between the common game loop described earlier and the Gen 3 game loop.

  1. RunCallbacks() instead of UpdateGameState().
  2. WaitForVBlank() instead of DrawScreen().

RunCallbacks()

Gen 3's code makes heavy use of "callbacks", which are functions that are called at some time in the future, and perhaps at a repeating interval. The biggest examples of callbacks that are run in the game loop are Main callbacks, Sprite callbacks, and Tasks.

Main callbacks are the master callbacks. There are exactly two of them, and the game loop will call both of them once every frame.

// src/main.c
static void CallCallbacks(void)
{
    if (gMain.callback1)
        gMain.callback1();

    if (gMain.callback2)
        gMain.callback2();
}

Since a Main callback is called every single frame, it's where the majority of the game's logic lives. Many Main callback functions resemble this structure:

void MyBasicMainCallback(void)
{
    // Runs all active Task callbacks.
    RunTasks();

    // Runs the callbacks of all current Sprites.
    AnimateSprites();

    // Copies all Sprite data to buffer that gets copied to VRAM during V-Blank.
    BuildOamBuffer();

    // Fades all palettes in or out, if the fade is active.
    // This is used for screen transitions.
    UpdatePaletteFade();
}

In order to have the above callback executed once every frame, it must be assigned to one of the two Main callbacks.

gMain.callback1 = MyBasicCallback;
// or
SetMainCallback2(MyBasicCallback);
// or
gMain.callback2 = MyBasicCallback;

The Gen 3 games always use gMain.callback2 for the main game logic, while gMain.callback1 is reserved for more helper-related logic.

Sprite callbacks are functions attached to all individual struct Sprite objects. A Sprite callback is primarily used to animated the Sprite to which it's attached. These are called exactly once per frame, assuming AnimateSprites() is called in one of the Main callbacks. As you can see, AnimateSprites() iterates through all active sprites and invokes each of their callback functions.

// src/sprite.c
void AnimateSprites(void)
{
    u8 i;
    for (i = 0; i < MAX_SPRITES; i++)
    {
        struct Sprite *sprite = &gSprites[i];

        if (sprite->inUse)
        {
            sprite->callback(sprite);

            if (sprite->inUse)
                AnimateSprite(sprite);
        }
    }
}

A Sprite callback always returns void and takes struct Sprite * as its only argument. It's common for a Sprite callback to perform some animation or movement on the sprite, followed by destroying the sprite when its lifetime is over. struct Sprite contains an 8-element array called data for general purpose use by the Sprite callback. This data array is initialized to all 0s. Here is a simple example that moves a sprite across the screen, and then destroys itself:

MoveRight_SpriteCallback(struct Sprite *sprite)
{
    // Use data[0] as a frame counter.
    // If the frame counter is greater than 10, destroy the sprite.
    if (++sprite->data[0] > 10)
        DestroySprite(sprite);
    else
        sprite->pos1.x++; // Move the sprite 1 pixel to the right.
}

More on sprites in a later document.

Tasks are the third major callback used in the Gen 3 engine. They are similar to Sprite callbacks, but they are far more general in nature. Each active Task will be executed once every frame, assuming RunTasks() is called in one of the Main callbacks.

// src/task.c
void RunTasks(void)
{
    u8 taskId = FindFirstActiveTask();

    if (taskId != NUM_TASKS)
    {
        do
        {
            gTasks[taskId].func(taskId);
            taskId = gTasks[taskId].next;
        } while (taskId != TAIL_SENTINEL);
    }
}

Each task has a priority assigned to it, which determines the order they are executed. Whenever a new Tasks is created with CreateTask(), it is inserted into the sorted linked list of active Tasks, ordered from lowest priority to highest. Similar to Sprites, struct Task also has a general data array, but this one has 16 elements. Tasks are the basic building blocks for logic that spans multiple frames of gameplay, and they are very flexible in what they can accomplish. As a small example, you could make a Task that plays a sound every time the player presses the A button, up to 10 times:

// 5 is the priority, and is not important.
u8 taskId = CreateTask(ButtonPressSound_Task, 5);
...
void ButtonPressSound_Task(u8 taskId)
{
    if (gMain.newKeys & A_BUTTON)
    {
        PlaySE(SE_PC_LOGIN);
        if (++gTasks[taskId].data[0] == 10)
            DestroyTask(taskId);
    }
}

More on Tasks in a later document.

WaitForVBlank()

The second major difference between the common game loop and the Gen 3 game loop is that WaitForVBlank() is used instead of something like DrawScreen(). This is because of the way the Game Boy Advance graphics works. Rather than drawing pixels directly to the screen or pixel buffer, like how you would do in modern game development, the Game Boy Advance has a dedicated area of memory called VRAM (Video RAM) in which tiles, tilemaps, palettes, and sprite data live. The details of VRAM are outside the scope of this document. However, the important fact for the game loop is that something called the "VBlank interrupt" occurs 60 times per second. This is the time when the video controller has finished drawing the last row of pixels onto the screen, and therefore, the GBA's screen refresh rate is 60 fps. By calling WaitForVBlank() at the end of every game loop, it ensures that the game state progresses is capped at a consistent 60 fps.

Clone this wiki locally