Retro Game Programming Tips, Part 1: The Game Clock

Retro computer games are characterized by their vivid visuals, featuring flying spaceships, blinking rocket thrusts, falling meteors, bullets soaring through the air, and explosions happening all around. The elements in these games can move at various speeds, and while the effects are temporary, the gameplay itself remains constant.

In this article, we'll explore a simple trick to synchronize all these elements using a straightforward game clock. Let's begin by defining the clock.

#define MAX_CYCLES 10
int clock = MAX_CYCLES;
bool game_over = false;

void main()
{
    while (!game_over)
    {
        if (!--clock)
            clock = MAX_CYCLES;
    }
}

In the provided code, the clock serves as a straightforward counter that counts down to zero and resets once it reaches that point. If you have two sprites, A and B, and sprite A needs to move twice as fast as sprite B, the simplest solution is to move sprite A every clock cycle and sprite B every second clock cycle. Here's how it works:

#define MAX_CYCLES 10
int clock = MAX_CYCLES;
bool game_over = false;

void main()
{
    while (!game_over)
    {
        printf("Move A\n");
        if (clock & 1) printf("Move B\n");
        if (!--clock)
            clock = MAX_CYCLES;
    }
}

Great job! You've successfully created your first game synchronization mechanism. It's quite simple, but not very versatile. Now, let's enhance its flexibility by providing a separate clock counter for each sprite and/or event that requires synchronization.

To achieve this, we'll use a typedef structure called "clk_t," which includes the following components:

  • ncycles: The total number of cycles for the clock.
  • cycle: The current cycle count for the clock.
  • action: A function pointer that specifies the action to be performed.

Additionally, we'll create an array of pointers to these clocks, allowing us to manage multiple clocks simultaneously. We'll set a predefined value, NUM_CLOCKS, to determine the maximum number of clocks available in the system.

Here's the updated code:

typedef struct clk_s
{
    int ncycles;
    int cycle;
    void (*action)();
} clk_t;

/* create an array of pointers to clocks*/
#define NUM_CLOCKS 10
clk_t *clocks[NUM_CLOCKS];

/* initialize all clocks to NULL */
void clk_init()
{
    for (int i = 0; i < NUM_CLOCKS; i++)
        clocks[i] = NULL;
}

Now that we have the initial data structures in place, let's proceed to create functions for managing the clocks. We need to implement functions to create a new clock and add it to the clocks array, destroy a clock and remove it from the array, and also update the clock ticks.

Here's the code:

static int _clk_find(clk_t *c)
{
    for (int i = 0; i < NUM_CLOCKS; i++)
        if (clocks[i] == c)
            return i;
    return -1;
}

clk_t *clk_create(int ncycles, void (*action)())
{
    /* find empty slot */
    int pos = _clk_find(NULL);
    if (pos >= 0)
    {
        /* allocate clock */
        clk_t *clk = malloc(sizeof(clk_t));
        clk->cycle = clk->ncycles = ncycles;
        clk->action = action;
        /* and add ptr to array */
        clocks[pos] = clk;
        /* return pointer */
        return clk;
    }
    /* not found! */
    return NULL;
}

void clk_destroy(clk_t *c)
{
    int pos=_clk_find(c);
    if (pos >= 0)
        clocks[pos] = NULL;
    free(c);
}

void clk_tick() {
    for (int i = 0; i < NUM_CLOCKS; i++) {
        if (clocks[i]) { /* not NULL */
            clocks[i]->cycle--; /* reduce cycle */
            if (!(clocks[i]->cycle)) {
                clocks[i]->action();
                clocks[i]->cycle=clocks[i]->ncycles;
            }
        }
    }
}

With these functions, you can effortlessly create and manage multiple clocks within your retro game. Each clock can be assigned its specific cycle count and associated action, enabling you to synchronize various game elements and events seamlessly. Let's see how the main program would utilize these functions:

void move_a() { printf ("Move A\n"); }
void move_b() { printf ("Move B\n"); }
int game_over=100;

void main() {
    clk_init();
    clk_t* a=clk_create(1,&move_a);
    clk_t* b=clk_create(2,&move_b);
    while(!game_over--)
        clk_tick();
    clk_destroy(a);
    clk_destroy(b);
}

Now, we have separate clocks for each event, and they are all synchronized with the same game clock. This forms the fundamental concept behind the game clock. Of course, on your platform, you have the option to write these functions in assembly instead of C, which would make them even faster. After all, this code will be responsible for controlling every particle of an exploding object on the screen.

Let's consider a scenario where we are developing the Moon Lander game, and we want to apply gravity to a sprite. Consequently, with each turn, the lander will fall faster and faster due to the applied gravity. As the lander's movement is governed by the clock, the clock must adapt accordingly. So, let's include a function that allows us to modify the clock.

void clk_change(clk_t *c, int ncycles2) {
    c->ncycles2=ncycles2;
}

But wait?! What's that ncycles2? This represents a pending change of the clock period. By adding this variable, we ensure that if the clock period changes in the middle of a cycle, the current cycle will complete before switching to the new clock period.

For this mechanism to function correctly, we need to modify the clk_t type and the functions responsible for creating clocks and handling clock ticks.

Here's the updated code:

typedef struct clk_s
{
    int ncycles;
    int ncycles2;
    int cycle;
    void (*action)(game_context_t *ctx);
} clk_t;

clk_t *clk_create(int ncycles, void (*action)(game_context_t *ctx))
{
    /* Find an empty slot in the array */
    int pos = _clk_find(NULL);
    if (pos >= 0)
    {
        /* Allocate memory for the new clock */
        clk_t *clk = malloc(sizeof(clk_t));
        clk->cycle = clk->ncycles = clk->ncycles2 = ncycles;
        clk->action = action;

        /* Add the pointer to the clocks array */
        clocks[pos] = clk;

        /* Return the pointer to the new clock */
        return clk;
    }
    return NULL;
}

void clk_tick(game_context_t *ctx)
{
    for (int i = 0; i < NUM_CLOCKS; i++)
    {
        if (clocks[i]) /* Check if the clock is not NULL */
        {
            clocks[i]->cycle--; /* Reduce the cycle count */

            if (!(clocks[i]->cycle)) /* Cycle reached 0? */
            {
                clocks[i]->action(ctx);
                clocks[i]->cycle = clocks[i]->ncycles = clocks[i]->ncycles2; 
            }
        }
    }
}

Finally, we've added a pointer to the game context in all functions. This context represents the current state of the game, holding information about objects' positions, draw status, game logic, and more. Each function called by the game clock receives this context and can modify it accordingly. Here's the complete code for our game clock:

typedef struct game_context_s {
    bool game_over;
} game_context_t;

typedef struct clk_s
{
    int ncycles;
    int ncycles2;
    int cycle;
    void (*action)(game_context_t *ctx);
} clk_t;

/* create an array of pointers to clocks*/
#define NUM_CLOCKS 10
clk_t *clocks[NUM_CLOCKS];

/* initialize all clocks to NULL */
void clk_init()
{
    for (int i = 0; i < NUM_CLOCKS; i++)
        clocks[i] = NULL;
}

int _clk_find(clk_t *c)
{
    for (int i = 0; i < NUM_CLOCKS; i++)
        if (clocks[i] == c)
            return i;
    return -1;
}

clk_t *clk_create(int ncycles, void (*action)())
{
    /* find empty slot */
    int pos = _clk_find(NULL);
    if (pos >= 0)
    {
        /* allocate clock */
        clk_t *clk = malloc(sizeof(clk_t));
        clk->cycle = clk->ncycles = clk->ncycles2 = ncycles;
        clk->action = action;
        /* and add ptr to array */
        clocks[pos] = clk;
        /* return pointer */
        return clk;
    }
    /* not found! */
    return NULL;
}

void clk_destroy(clk_t *c)
{
    int pos=_clk_find(c);
    if (pos >= 0)
        clocks[pos] = NULL;
    free(c);
}

void clk_tick(game_context_t *ctx) {
    for (int i = 0; i < NUM_CLOCKS; i++) {
        if (clocks[i]) { /* not NULL */
            clocks[i]->cycle--; /* reduce cycle */
            if (!(clocks[i]->cycle)) { /* cycle reached 0? */
                clocks[i]->action(ctx);
                clocks[i]->cycle=clocks[i]->ncycles=clocks[i]->ncycles2;
            }
        }
    }
}

void clk_change(clk_t *c, int ncycles2) {
    c->ncycles2=ncycles2;
}

void move_a(game_context_t *ctx) { printf ("Move A\n"); }
void move_b(game_context_t *ctx) { printf ("Move B\n"); }

game_context_t gc={false};

int j=0;

void main() {
    clk_init();
    clk_t* a=clk_create(1,&move_a);
    clk_t* b=clk_create(3,&move_b);
    while(!gc.game_over) {
        clk_tick(&gc);
        j++;
        if (j==10) clk_change(b,1);
        if (j==20) gc.game_over=true;
    }
    clk_destroy(a);
    clk_destroy(b);
}

We've developed a simple yet powerful framework. However, up until now, we've only used it for theoretical cases. To demonstrate its practical application, here's a code fragment that shows how a game of Froggy could look using our framework:

/* Frogger */

/* Create the game context */
game_context_t *gc = gc_create();

/* Clocks for various game events */
clk_create(1, kbd_handler);  /* Scan the keyboard every tick */
clk_create(1, dead_frog);    /* Check if the frog is dead every tick */
clk_create(3, move_row1);    /* Move the first row every 3 cycles */
clk_create(5, move_row2);    /* Move the second row every 5 cycles */
clk_create(3, move_row3);    /* Move the third row every 3 cycles */
clk_create(5, move_row4);    /* Move the fourth row every 5 cycles */

/* Main game loop */
while (!gc->game_over) {
    clk_tick(gc);
    update_screen(gc);
}

And that's all there is to it. Now, all you need to do is write event handlers for each specific event, and optimize the screen update function for smooth gameplay. With this straightforward code structure and our efficient framework, you're well-equipped to develop a captivating game like Froggy. Happy coding!

Retro Game Programming Tips, Part 2: Double Buffering with Page Switching