indexpost archiveatom feed

Understanding Asteroids

2021-03-21

I read A guide to Windows application development using w64devkit and was amazed at how much was accomplished with so little. Key to the author's suggestions was simply using C and leveraging native libraries and APIs; the trouble is I don't even know what I don't know! Looking for a place to start I thought it would be instructive to pick apart the example program provided — Asteroids.

The entire program is pretty small to begin with but in the interest of brevity I stripped out a few niceties like audio and joystick handling because they seemed inessential to understanding the game. As a result I've pared the program down to just shy of 750 lines of code. While the entire listing is on this page along the left hand side, the entire file is also available in it's modified form here.

#include <math.h>
#include <stdint.h>

#include <windows.h>
#include <GL/gl.h>

Basic includes for both application window drawing as well as the OpenGL game bits.

#define COUNTOF(a) (int)(sizeof(a) / sizeof(0[a]))

A macro to return the number of elements in an array by dividing the array's sizeof by the sizeof a single element. The weird syntax sizeof(0[a]) is equivalent to sizeof(a[0]) because of the way array indexing in C translates to pointer addition for offsetting a start address. So here you can imagine a + 0 vs. 0 + a. As far as I know, the intent in using this particular syntax is to trigger errors in the case the macro is used in C++ with a type that has overloaded [].

#define PI 0x1.921fb6p+1f

#define FATAL(msg)                                      \
do {                                                \
MessageBoxA(0, msg, "Fatal Error", MB_OK);      \
ExitProcess(-1);                                \
} while (0)

Define π as 0x1.921fb6p+1f which is apparently the closest single-precision hexadecimal floating point constant. The value following the p is the binary exponent, the trailing f indicates float.

MessageBoxA is the first real Windows-ism we find, it is a modal dialog. The do-while loop is used to collect several statements in a way that looks like a function inside a C macro.

#define INIT_COUNT      8
#define LEVEL_DELAY     3
#define TIME_STEP_MIN   1.0f/15
#define SHIP_TURN_RATE  PI
#define SHIP_DAMPEN     0.995f
#define SHIP_ACCEL      0.5f
#define SHIP_SCALE      0.025f
#define SHOT_SPEED      0.5f
#define SHOT_TTL        1.0f
#define SHOT_COOLDOWN   0.2f
#define DEBRIS_TTL      1.2f

#define ASTEROID0_MIN   0.025f
#define ASTEROID0_MAX   0.050f
#define ASTEROID0_SCORE 1
#define ASTEROID1_MIN   0.012f
#define ASTEROID1_MAX   0.025f
#define ASTEROID1_SCORE 2
#define ASTEROID2_MIN   0.008f
#define ASTEROID2_MAX   0.015f
#define ASTEROID2_SCORE 5

enum asteroid_size {A0, A1, A2};

Lots of constants!

Additionally, we get a hint at the inner-working of the game with an enumeration of three different asteroid sizes. Correspondingly we see there are constants defined for each type (I'm guess they are equivalent to small, medium, and large).

/* format: ARGB */
#define C_SHIP      0xffffffff
#define C_ASTEROID  0xffffffff
#define C_SHOT      0xffffffff
#define C_THRUST    0xffffff00
#define C_FIRE      0x00df9f5f
#define C_SCORE     0xffffffff

More constants, this time defining colors for game objects. I'm guessing ARGB is alpha, red, green, and blue and each number is a composite of four different 8-bit numbers. It is easy to verify this theory. Change for example C_ASTEROID to 0xffff0000 and observe all the asteroids are now red instead of white:

struct v2 { float x, y; };

static const struct v2 ship[] = {
{+1.00f * SHIP_SCALE, +0.00f * SHIP_SCALE},
{-0.71f * SHIP_SCALE, +0.57f * SHIP_SCALE},
{-0.43f * SHIP_SCALE, +0.29f * SHIP_SCALE},
{-0.43f * SHIP_SCALE, -0.29f * SHIP_SCALE},
{-0.71f * SHIP_SCALE, -0.57f * SHIP_SCALE},
};
static const struct v2 tail[] = {
{-0.43f * SHIP_SCALE, -0.20f * SHIP_SCALE},
{-0.70f * SHIP_SCALE, +0.00f * SHIP_SCALE},
{-0.43f * SHIP_SCALE, +0.20f * SHIP_SCALE},
};

Finally to our first data structure, I'm guessing v2 is "vector 2". The array of XY values called ship and tail probably refer to the lines making up the player on-screen, it may be more obvious in the explicit calls to draw these objects but it seems like the points must be drawn in some fixed origin and moved about the screen independently.

Also suggestive is the scaling factor SHIP_SCALE — want about a gigantic ship? No problem:

/* 7-segment font for digits */
#define FONT_SX 0.015f
#define FONT_SY 0.025f
static const struct v2 segv[] = {
{0.0f*FONT_SX, 1.0f*FONT_SY}, {1.0f*FONT_SX, 1.0f*FONT_SY},
{0.0f*FONT_SX, 0.5f*FONT_SY}, {0.0f*FONT_SX, 1.0f*FONT_SY},
{1.0f*FONT_SX, 0.5f*FONT_SY}, {1.0f*FONT_SX, 1.0f*FONT_SY},
{0.0f*FONT_SX, 0.5f*FONT_SY}, {1.0f*FONT_SX, 0.5f*FONT_SY},
{0.0f*FONT_SX, 0.0f*FONT_SY}, {0.0f*FONT_SX, 0.5f*FONT_SY},
{1.0f*FONT_SX, 0.0f*FONT_SY}, {1.0f*FONT_SX, 0.5f*FONT_SY},
{0.0f*FONT_SX, 0.0f*FONT_SY}, {1.0f*FONT_SX, 0.0f*FONT_SY},
};
static const char seg7[] = {
0x77, 0x24, 0x5d, 0x6d, 0x2e, 0x6b, 0x7b, 0x25, 0x7f, 0x6f
};

I think these XY values represent start-end points for the lines in a seven-segment display. It wasn't at all apparent to me what the seg7 array was until viewing the elements in binary. I'm pretty sure the array will be used to mask out the lines to be drawn when a score is converted from a numeric value into the on-screen display.

0x77 1110111
0x24 0100100
0x5d 1011101
0x6d 1101101
static double
uepoch(void)
{
FILETIME ft;
GetSystemTimeAsFileTime(&ft);
unsigned long long hi = ft.dwHighDateTime;
unsigned long long lo = ft.dwLowDateTime;
return ((hi<<32 | lo)/10 - 11644473600000000) / 1e6;
}

static unsigned long long rng;

static unsigned long
rand32(void)
{
rng = rng*0x7c3c3267d015ceb5 + 1;
unsigned long r = rng>>32 & 0xffffffff;
r ^= r>>16;
r *= 0x60857ba9;
r &= 0xffffffff;
r ^= r>>16;
return r;
}

static float
randu(void)
{
return rand32() / 4294967296.0f;
}

Grab the system time which for reasons unknown to me comes in two parts (low-order and high-order), shift the high-order parts before OR-ing them, for one big number. The number 11644473600000000 is apparently the difference between Windows epoch time (01/01/1601) and Unix epoch time (01/01/1970) with some adjustments for nanoseconds vs. seconds etc.

rand32 is probably best taken on faith. I think this is a permuted congruential generator, also note RANDU. 4,294,967,296 is of course 232.

struct tf { float c, s, tx, ty; };

static struct tf
tf(float a, float tx, float ty)
{
struct tf tf = {
.c = cosf(a),
.s = sinf(a),
.tx = tx, .ty = ty,
};
return tf;
}

static struct v2
tf_apply(struct tf tf, struct v2 v)
{
struct v2 r = {
tf.tx + v.x*tf.c - v.y*tf.s,
tf.ty + v.x*tf.s + v.y*tf.c,
};
return r;
}

Aha! This looks a bit like the drawing offsets I speculated about. Going out on a limb I would guess that tf might be "transform" and holds a cosine, sine, x and y value. The function tf returns the struct based on an argument a (presumably angle). tf_apply is the most interesting part though, it takes a "transform" and a vector 2 (an XY position, I would guess) and returns a new vector 2 (another position).

Embarrassingly, I didn't immediately recognize the standard rotation transformation despite having implemented it myself at least twice. In my defense I am more accustomed to the matrix form: $\left\{\begin\left\{bmatrix\right\}\cos \theta &-\sin \theta \\\sin \theta &\cos \theta \\\end\left\{bmatrix\right\}\right\}$

static int
lltostr(char *buf, long long n)
{
int len = n ? 0 : 1;
for (long long x = n; x; x /= 10) {
len++;
}
buf[len] = 0;
for (int i = len - 1; i >= 0; i--) {
buf[i] = '0' + n%10;
n /= 10;
}
return len;
}

long long to string - count up the digits (by tens) before allocating a buffer of that length. Fill each digit position with the ASCII value of the appropriate single-digit value via moduluo arithmetic. Chop off a tens place as the loop progresses.

static int g_nlines;
static struct {
GLubyte r, g, b, a;
GLfloat x, y;
} g_linebuf[1<<16];
static int g_npoints;
static struct {
GLubyte r, g, b, a;
GLfloat x, y;
} g_pointbuf[1<<10];

Here we reach some of the OpenGL specifics. The struct isn't too interesting and looks similar to data already covered. The GL- datatypes seem obvious enough, though I did double check the documentation.

One thing to note is the curious duplication between lines and points. I'm not sure yet what to make of that, but there are far fewer points expected (1024 compared 65536). I wonder if points are used for the player shots. To test this out I both turned this way down and increased the firing rate (SHOT_COOLDOWN). It appears an insufficient points array allocation results in a number of oddities in drawing to the screen. While nothing crashed, there were instances where the ship didn't appear to be firing and where asteroids failed to render and would appear mid-screen.

static const signed char toroid[][2] = {
{+0, +0}, {-1, +0}, {+1, +0}, {+0, -1}, {+0, +1}
};

A two dimensional array called toroid. Probably related to the world "wrapping" but I don't yet see what it is for.

/* Push line segment onto rendering buffer. */
static void
g_line(struct v2 a, struct v2 b, uint32_t color)
{
int i = g_nlines;
g_nlines += 2;
g_linebuf[i+0].r = color >> 16;
g_linebuf[i+0].g = color >>  8;
g_linebuf[i+0].b = color >>  0;
g_linebuf[i+0].a = color >> 24;
g_linebuf[i+0].x = 2*a.x - 1;
g_linebuf[i+0].y = 2*a.y - 1;
g_linebuf[i+1].r = color >> 16;
g_linebuf[i+1].g = color >>  8;
g_linebuf[i+1].b = color >>  0;
g_linebuf[i+1].a = color >> 24;
g_linebuf[i+1].x = 2*b.x - 1;
g_linebuf[i+1].y = 2*b.y - 1;
}

Seems pretty straight-forward, take two XY positions (vector twos) and a color and push it into the buffer of lines. One interesting thing to note is how the index position is global variable g_nlines.

/* Push toroid-wrapped line segment onto the rendering buffer. */
static void
g_wline(struct v2 a, struct v2 b, uint32_t color)
{
for (int i = 0; i < 5; i++) {
float tx = toroid[i][0];
float ty = toroid[i][1];
struct v2 ta = {a.x+tx, a.y+ty};
struct v2 tb = {b.x+tx, b.y+ty};
g_line(ta, tb, color);
}
}

Aha — we find our toroid here. For each possible wrapping (I want to call them something like origin, N-E-S-W but I'm sure that would drive people crazy) offset the ends of a given line defined by two XY positions and a color.

 +0,+1 -1,+0 +0,+0 +1,+0 +0,-1
I do wonder whether the above diagram isn't conceptually backwards and the toroid is intended to wrap by adding/subtracting when object move off-screen.
static void
g_wlinestrip(const struct v2 *v, int n, struct tf tf, uint32_t color)
{
struct v2 a = tf_apply(tf, v[0]);
for (int i = 1; i < n; i++) {
struct v2 b = tf_apply(tf, v[i]);
g_wline(a, b, color);
a = b;
}
}

static void
g_wlineloop(const struct v2 *v, int n, struct tf tf, uint32_t color)
{
g_wlinestrip(v, n, tf, color);
g_wline(tf_apply(tf, v[n-1]), tf_apply(tf, v[0]), color);
}

Here it seems we finally answer my open question about drawing objects. Loop through an array of XY positions, applying a given transformation and pushing the result to the line buffer.

The final call in g_wlineloop looks like it is used to close the form, or completing a shape from last point back to first. Funnily enough I did the same sort of thing when plotting polygons.

static void
g_render(void)
{
glClear(GL_COLOR_BUFFER_BIT);

glInterleavedArrays(GL_C4UB_V2F, 0, g_linebuf);
glDrawArrays(GL_LINES, 0, g_nlines);
g_nlines = 0;

glInterleavedArrays(GL_C4UB_V2F, 0, g_pointbuf);
glDrawArrays(GL_POINTS, 0, g_npoints);
g_npoints = 0;
}

Here it seems we just call a number of OpenGL APIs. Unsurprisingly, glClear clears a buffer. glInterleavedArrays appears to take the array of two-dimensional lines, the second argument (0) indicates the array elements are stored consecutively. As far as I can tell from reading online this call merely indicates where to find data however no data is actually accessed until the draw call. Which is exactly what happens next with glDrawArrays. With the lines drawn the number of lines is reset to 0. The exact same thing happens again with the points.

static void
g_point(float x, float y, uint32_t color)
{
int i = g_npoints++;
g_pointbuf[i].r = color >> 16;
g_pointbuf[i].g = color >>  8;
g_pointbuf[i].b = color >>  0;
g_pointbuf[i].a = color >> 24;
g_pointbuf[i].x = 2*x - 1;
g_pointbuf[i].y = 2*y - 1;
}

static void
g_wpoint(float x, float y, uint32_t color)
{
for (int i = 0; i < 5; i++) {
g_point(toroid[i][0] + x, toroid[i][1] + y, color);
}
}

While the point drawing and wrapping looks largely similar to the line drawing I am curious about the multiplier on the x and y position. While it is the same as the line drawing I find I can focus more on it here because of how familiar the rest is. In an effort to understand what it is for I tried removing it. The effect of the change is odd, the player's shot is now split across three different positions:

I think the appearance of what looks like multiple positions is probably an effect of the wrapping. More than anything the doubling of both x and y values seems like a scaling factor or offset, but I don't know what for yet.

struct {
double last;
long level;
float transition;
long long score;
int lives;

#define I_TURNL  (1<<0)
#define I_TURNR  (1<<1)
#define I_THRUST (1<<2)
#define I_FIRE   (1<<3)
int controls;

float  px,  py,  pa;
float pdx, pdy, pda;

struct asteroid {
float  x,  y,  a;
float dx, dy, da;
struct v2 v[16];
short n;
short kind;
float shot_r2; // shot hit radius^2
float ship_r2; // ship hit radius^2
} asteroids[1024];
int nasteroids;

struct shot {
float  x,  y;
float dx, dy;
float ttl;
} shots[64];
int nshots;
float cooldown;

struct debris {
float  x,  y;
float dx, dy, da;
float age;
uint32_t color;
struct v2 v[2];
} debris[1024];
int ndebris;
} game;

A big bag of state from the looks of it. I've no idea why turn/thrust/fire are #define-ed in here. One interesting thing to note that makes sense in retrospect but I hadn't before paid attention to; the "bounding box" or contact area for the asteroid is a circle. Presumably it is a fixed size circle based on the three types we saw enumerated earlier, but consequently if you look closely while shooting especially craggy asteroids sometimes shows they blow up before the shot contacts them.

static int
game_asteroid(enum asteroid_size kind)
{
if (game.nasteroids == COUNTOF(game.asteroids)) return -1;

struct asteroid *a = game.asteroids + game.nasteroids;
float dx, dy;
do {
a->x  = randu();
a->y  = randu();
dx = a->x - 0.5f;
dy = a->y - 0.5f;
} while (dx*dx + dy*dy < 0.1f);
a->dx = 0.1f * (2*randu() - 1);
a->dy = 0.1f * (2*randu() - 1);
a->a  = 2 * PI * randu();
a->da = PI*(2*randu() - 1);

int n = 0;
float min = 0;
float max = 0;
switch (kind) {
case A0: n = 16; max = ASTEROID0_MAX; min = ASTEROID0_MIN; break;
case A1: n = 12; max = ASTEROID1_MAX; min = ASTEROID1_MIN; break;
case A2: n =  8; max = ASTEROID2_MAX; min = ASTEROID2_MIN; break;
}
for (int i = 0; i < n; i++) {
float t = 2*PI * (i - 1) / (float)n;
float r = randu()*(max - min) + min;
a->v[i].x = r * cosf(t);
a->v[i].y = r * sinf(t);
}
a->n = n;
a->kind = kind;

// Make hit radius favor player since it's imprecise
a->shot_r2 = max*max;
a->ship_r2 = (max+min)*(max+min)/4;

return game.nasteroids++;
}

After checking that we haven't met the required number of asteroids; offset into the array of asteroids inside the above game struct. Populate the asteroid position with random data until dx2 + dy2 < 0.1 . Then assign the asteroid's dx and dy values with more random data.

I'm not positive but I think this, paired with the wrapping around the edges, achieves a random layout with an affordance for the player's start position (so you don't begin the game with an asteroid on top of you).

dx and dy define the asteroids movement, so 1 would be a straight line for either of them and 1 for both of them is a diagonal line (and very fast!). da is the rotational speed of the asteroid, let's try cranking it way up!

The kind of asteroid determines how craggy they are, the switch statement sets a value n which is looped over to define vertices for the asteroid's exterior.

Finally, each asteroid is assigned a shot hit radius and ship hit radius. Interestingly the ship hit radius is almost half the size of the shot hit radius, which explains the comment about favoring the player.

static void
game_new_level(void)
{
game.px = game.py = 0.5f;
game.pdx = game.pdy = 0.0f;

game.nshots = 0;
game.ndebris = 0;
game.cooldown = 0;
game.transition = 0;
game.lives = 1;

game.nasteroids = 0;
for (int i = 0; i < game.level; i++) {
game_asteroid(A0);
}
}

New level resets the different values for debris, position etc.

Each time you advance levels the number of asteroids is increased.

static void
game_init(int size)
{
glEnable(GL_LINE_SMOOTH);
glHint(GL_LINE_SMOOTH_HINT, GL_NICEST);
glEnable(GL_POINT_SMOOTH);
glHint(GL_POINT_SMOOTH_HINT, GL_NICEST);

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glLineWidth(2e-3f * size);
glPointSize(4e-3f * size);

rng += uepoch() * 1e6;

game.level = INIT_COUNT;
game.last = uepoch();
game.pa = PI/2;
game.pda = 0.0f;
game.controls = 0;
game.score = 0;

game_new_level();
}

The calls to enable and hint turn on anti-aliasing. I think they really do mean "hint" though, I removed the calls and could not notice a difference. The calls to enable blending on the other hand are very noticeable, blending enables the alpha channel and determines whether debris fades away or just blinks out after a span of time. glClearColor determines the color to which the screen is cleared to (clear is invoked inside render). Of course, in RGBA (0.1,0.1,0.1,1.0) appears like this: dark!

The random number generator is set based off the time, which percolates through the different calls for random data like asteroid position.

static void
game_down(int control)
{
if (game.controls & control) return;
game.controls |= control;
switch (control) {
case I_TURNL:  game.pda += SHIP_TURN_RATE; break;
case I_TURNR:  game.pda -= SHIP_TURN_RATE; break;
case I_THRUST: break;
case I_FIRE:   break;
}
}

static void
game_up(int control)
{
if (!(game.controls & control)) return;
game.controls ^= control;
switch (control) {
case I_TURNL:  game.pda -= SHIP_TURN_RATE; break;
case I_TURNR:  game.pda += SHIP_TURN_RATE; break;
case I_THRUST: break;
case I_FIRE:   break;
}
}

Game control is guarded with complementary AND/OR on (what I assume is) keypress. Otherwise turning increments or decrements the player's rotational speed.

static void
game_debris(struct v2 *v, float x, float y, float dx, float dy, uint32_t c)
{
if (game.ndebris < COUNTOF(game.debris)) {
int i = game.ndebris++;
game.debris[i].x     = x;
game.debris[i].y     = y;
game.debris[i].dx    = dx;
game.debris[i].dy    = dy;
game.debris[i].da    = 2*PI*(2*randu() - 1);
game.debris[i].age   = 0;
game.debris[i].color = c & 0xffffff;
game.debris[i].v[0]  = v[0];
game.debris[i].v[1]  = v[1];
}
}

At this point this looks familiar, index an array of vector data with updates for the game state to reflect position, direction etc. but this time of debris.

static void
game_destroy_asteroid(int n)
{
struct asteroid *a = game.asteroids + n;
struct tf t = tf(a->a, 0, 0);
for (int i = 0; i < a->n; i++) {
int j = (i + 1)%a->n;
struct v2 v[] = {
tf_apply(t, a->v[i]),
tf_apply(t, a->v[j]),
};
float mx = (v[0].x + v[1].x) / 2;
float my = (v[0].y + v[1].y) / 2;
v[0].x -= mx; v[1].x -= mx;
v[0].y -= my; v[1].y -= my;
float dx = a->dx + mx*randu();
float dy = a->dy + my*randu();
game_debris(v, a->x+mx, a->y+my, dx, dy, C_ASTEROID);
}

float x = a->x;
float y = a->y;
enum asteroid_size kind = a->kind;
game.asteroids[n] = game.asteroids[--game.nasteroids];

switch (kind) {
case A0: game.score += ASTEROID0_SCORE; break;
case A1: game.score += ASTEROID1_SCORE; break;
case A2: game.score += ASTEROID2_SCORE; break;
}

if (kind != A2) {
int c = 1 + rand32()%2;
for (int i = 0; i < c; i++) {
int n = game_asteroid(kind + 1);
if (n >= 0) {
game.asteroids[n].x = x;
game.asteroids[n].y = y;
}
}
}
}

This is interesting. Index into the asteroids array, pull the rotation angle into a transformation, iterate over the points (line segments?) comprising the asteroid, applying the rotation. Construct values mx and my (I wonder if this is "movement" or similar) and create debris from this "movement" of the asteroid and a bit of randomization.

Asteroids are reduced along the order of sizes enumerated previously, if the asteroid isn't the smallest possible the newly reduced asteroid is positioned at the same location as the destroyed, larger asteroid. The number of pieces an asteroid is broken into is slightly variable.

static void
game_step(void)
{
double now = uepoch();
float dt = now - game.last;
if (dt > TIME_STEP_MIN) dt = TIME_STEP_MIN;
game.last = now;

if (!game.lives || !game.nasteroids) {
game.transition += dt;
}

if (!game.lives && game.transition > LEVEL_DELAY) {
game.score /= 2;
game_new_level();
} else if (!game.nasteroids && game.transition > LEVEL_DELAY) {
game.score += game.level * 100;
game.level++;
game_new_level();
}

Finally we find the most complicated part of the program, the function responsible for advancing forward the progress of each game cycle.

Check that the player hasn't died and that there are remaining asteroids. Otherwise transition to the next level or respawn.

Respawning halves the player score while advancing the level gives a bonus based on which level it is.

    game.pa   = fmodf(game.pa + dt*game.pda + 2*PI, 2*PI);
if (game.controls & I_THRUST) {
float c = cosf(game.pa);
float s = sinf(game.pa);
game.pdx += dt*c*SHIP_ACCEL;
game.pdy += dt*s*SHIP_ACCEL;

/* thruster fire trail */
if (randu() < 0.75f) {
float f = SHIP_SCALE*0.15f;
struct v2 v[] = {
{(2*randu() - 1)*f, (2*randu() - 1)*f},
{(2*randu() - 1)*f, (2*randu() - 1)*f},
};
float x = game.px + c*ship[3].x;
float y = game.py + s*ship[3].x;
game_debris(v, x, y, -c*0.1f, -s*0.1f, C_FIRE);
}
}
game.px   = fmodf(game.px + dt*game.pdx + 1, 1);
game.py   = fmodf(game.py + dt*game.pdy + 1, 1);
game.pdx *= SHIP_DAMPEN;
game.pdy *= SHIP_DAMPEN;

fmodf calculates the floating-point remainder. Update the player angle with respect to the current time, acceleration and previous angle.

If the player is using the thruster update the angle of movement along with player velocity. Draw a neat little thruster effect using the game_debris function along with a bit of randomization.

Apply dampening to slow player movement, regarless of thrusters.

    if ((game.controls & I_FIRE) &&
game.nshots < COUNTOF(game.shots) &&
game.cooldown <= 0) {

int i = game.nshots++;
float c = cosf(game.pa);
float s = sinf(game.pa);
game.shots[i].x = SHIP_SCALE*c+game.px;
game.shots[i].y = SHIP_SCALE*s+game.py;
game.shots[i].dx = game.pdx + c*SHOT_SPEED;
game.shots[i].dy = game.pdy + s*SHOT_SPEED;
game.shots[i].ttl = SHOT_TTL;
game.cooldown = SHOT_COOLDOWN;
} else if (game.cooldown > 0) {
game.cooldown -= dt;
}

for (int i = 0; i < game.nshots; i++) {
struct shot *s = game.shots + i;
if ((s->ttl -= dt) < 0) {
// TODO: hit detection for final partial step
game.shots[i--] = game.shots[--game.nshots];
} else {
s->x = fmodf(s->x + dt*s->dx + 1, 1);
s->y = fmodf(s->y + dt*s->dy + 1, 1);
}
}

Rate limit the player's firing. Write out to the shots array the new shot position, angle, speed. Set the inter-shot cooldown for rate limiting.

Age out shots that have expired their TTL. Update shot XY position based on the advance of the direction vector tracked on each shot struct.

Reading this makes me think of the sorts of "cheat codes" you might enable in old video games for infinite ammunition or double speed. I couldn't help trying a few cheat codes of my own here, extending the shot TTL, increasing turning rate, and increasing firing rate:

    for (int i = 0; i < game.nasteroids; i++) {
struct asteroid *a = game.asteroids + i;
a->x = fmodf(a->x + dt*a->dx + 1, 1);
a->y = fmodf(a->y + dt*a->dy + 1, 1);
a->a = fmodf(a->a + dt*a->da, 360);
}

// TODO: precise hit detection
for (int i = 0; i < game.nshots; i++) {
struct shot *s = game.shots + i;
for (int j = 0; j < game.nasteroids; j++) {
struct asteroid *a = game.asteroids + j;
float dx = s->x - a->x;
float dy = s->y - a->y;
if (dx*dx + dy*dy < a->shot_r2) {
game.shots[i--] = game.shots[--game.nshots];
game_destroy_asteroid(j--);
break;
}
}
}

Iterate each asteroid and update the position and angle.

Loop over each shot and over each asteroid per-shot to test whether the position of each overlaps within the bounds of the shot radius. If they do fall within the bounds the asteroid is destroyed.

    for (int i = 0; i < game.ndebris; i++) {
if ((game.debris[i].age += dt) > DEBRIS_TTL) {
game.debris[i--] = game.debris[--game.ndebris];
}
}

// TODO: precise hit detection
struct tf t = tf(game.pa, game.px, game.py);
for (int i = 0; game.lives && i < COUNTOF(ship); i++) {
struct v2 p = tf_apply(t, ship[i]);
for (int j = 0; j < game.nasteroids; j++) {
struct asteroid *a = game.asteroids + j;
float dx = p.x - a->x;
float dy = p.y - a->y;
if (dx*dx + dy*dy < a->ship_r2) {
game.lives = 0;
for (int i = 0; i < 256; i++) {
float s = 0.01f;
struct v2 v[] = {
{s*(randu()*2 - 1), s*(randu()*2 - 1)},
{s*(randu()*2 - 1), s*(randu()*2 - 1)},
};
float r = 0.25f*randu();
float a = 2*PI*randu();
float dx = game.pdx/2 + r*cosf(a);
float dy = game.pdy/2 + r*sinf(a);
uint32_t color = randu() < 0.7f ? C_SHIP : C_FIRE;
game_debris(v, game.px, game.py, dx, dy, color);
}
break;
}
}
}

}

Clear debris as it ages out.

Given the player's orientation, iterate over each asteroid to test their position against the ship position. If the positions overlap within the bounds of the ship strike radius set lives to zero and draw debris.

static void
game_render(void)
{
for (int i = 0; i < game.nasteroids; i++) {
struct asteroid *a = game.asteroids + i;
g_wlineloop(a->v, a->n, tf(a->a, a->x, a->y), C_ASTEROID);
}

for (int i = 0; i < game.nshots; i++) {
g_wpoint(game.shots[i].x, game.shots[i].y, C_SHOT);
}

if (game.lives) {
struct tf ship_tf = tf(game.pa, game.px, game.py);
g_wlineloop(ship, COUNTOF(ship), ship_tf, C_SHIP);
if ((game.controls & I_THRUST) && randu() < 0.5f) {
g_wlinestrip(tail, COUNTOF(tail), ship_tf, C_THRUST);
}
} else {
}

for (int i = 0; i < game.ndebris; i++) {
struct debris *d = game.debris + i;
float dt = d->age;
struct tf t = tf(dt*d->da, d->x + dt*d->dx, d->y + dt*d->dy);
uint32_t alpha = 255 * (1 - d->age/DEBRIS_TTL);
g_wlinestrip(d->v, COUNTOF(d->v), t, d->color | alpha<<24);
}

char score[32];
int scorelen = lltostr(score, game.score);
for (int i = 0; i < scorelen; i++) {
struct tf t = tf(0, pad + i*FONT_SY, 1 - pad - FONT_SY);
int segs = seg7[score[i] - '0'];
for (int s = 0; s < 7; s++) {
if (segs & 1<<s) {
struct v2 a = tf_apply(t, segv[s*2+0]);
struct v2 b = tf_apply(t, segv[s*2+1]);
g_line(a, b, C_SCORE);
}
}
}

g_render();
}

Rendering the game! g_wlineloop draws each asteroid given the orientation information tracked in each struct. Draw each shot as a point. If the player hasn't died, draw the ship. If the player is using the thruster, draw the "fire" effect.

When drawing the debris it is interesting to see the alpha value is dependent on the age over the TTL, which is how the fading is achieved.

The score is written using the previously explained lltostr function. The seg7 array reappears and we can verify earlier speculation about the format and use. Lines are drawn for each segment of each digit.

static BOOL win32_opengl_initialized;
static int win32_opengl_size;

static void
win32_opengl_init(HDC hdc)
{
PIXELFORMATDESCRIPTOR pdf = {
.nSize = sizeof(pdf),
.nVersion = 1,
.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER,
.iPixelType = PFD_TYPE_RGBA,
.cColorBits = 32,
.cDepthBits = 24,
.cStencilBits = 8,
.iLayerType = PFD_MAIN_PLANE,
};
SetPixelFormat(hdc, ChoosePixelFormat(hdc, &pdf), &pdf);
HGLRC old = wglCreateContext(hdc);
wglMakeCurrent(hdc, old);
win32_opengl_initialized = TRUE;
}

A few remaining pieces of OpenGL initialization. PIXELFORMATDESCRIPTOR and SetPixelFormat seem to be for configuring pixel information such as color depth and double buffering. Double buffering is, of course, "painting" to memory before swapping the "painted" scene onto the screen to reduce flicker.

wglMakeCurrent sets the context in which drawing operations happen (this all seems pretty boiler-plate to me).

static LRESULT CALLBACK
win32_wndproc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
switch (msg) {
case WM_CREATE:
win32_opengl_init(GetDC(hwnd));
game_init(win32_opengl_size);
break;
case WM_KEYUP:
switch (wparam) {
case VK_LEFT:  game_up(I_TURNL);  break;
case VK_RIGHT: game_up(I_TURNR);  break;
case VK_UP:    game_up(I_THRUST); break;
case VK_SPACE: game_up(I_FIRE);   break;
}
break;
case WM_KEYDOWN:
if (lparam & 0x40000000) break;
switch (wparam) {
case VK_LEFT:  game_down(I_TURNL);  break;
case VK_RIGHT: game_down(I_TURNR);  break;
case VK_UP:    game_down(I_THRUST); break;
case VK_SPACE: game_down(I_FIRE);   break;
}
break;
case WM_CLOSE:
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hwnd, msg, wparam, lparam);
}
return 0;
}

Input handling! Obviously the bulk of this is a boring old switch statement for handling the arrow keys and spacebar (for movement and firing, respectively). The actual message handling is defined by the window proc interface, in particular some oddities like if (lparam & 0x40000000) break; are a result of the following from the documentation:

30 The previous key state. The value is 1 if the key is down before the message is sent, or it is zero if the key is up.

Which is a non-obvious way of testing the 30th bit to see if the key was down ( 0x40000000 ≡ 1000000000000000000000000000000 (base-2)).

The default call to DefWindowProc is more boiler-plate to ensure all events are handled and comes nearly directly from the documentation.

static HWND
win32_window_init(void)
{
const char *title = "Asteroids";
WNDCLASS wndclass = {
.style = CS_OWNDC,
.lpfnWndProc = win32_wndproc,
.lpszClassName = "gl",
};
RegisterClass(&wndclass);
int w = GetSystemMetrics(SM_CXSCREEN);
int h = GetSystemMetrics(SM_CYSCREEN);
int size = win32_opengl_size = (h < w ? h : w) - 100;
int x = (w - size) / 2;
int y = (h - size) / 2;
DWORD style = WS_OVERLAPPED | WS_VISIBLE | WS_MINIMIZEBOX | WS_SYSMENU;
return CreateWindow("gl", title, style, x, y, size, size, 0, 0, 0, 0);
}

Windows™ window initialization including giving the window a title, an icon, registering the previous input handlers. GetModuleHandle(0) is how you get a handle on the current process.

Set a window size for the application along with some styles to define the behavior of the application. My understanding this is how you might configure things like launching in fullscreen, or with limited window controls (minimize, maximize, etc.).

CreateWindow is also responsible for sending the WM_CREATE message that is handled above to perform initialization.

int
main(void)
{
HWND wnd = win32_window_init();

HDC hdc = GetDC(wnd);
for (;;) {
MSG msg;
while (PeekMessage(&msg, 0, 0, 0, TRUE)) {
if (msg.message == WM_QUIT) {
ExitProcess(0);
}
TranslateMessage(&msg);
DispatchMessage(&msg);
}
if (win32_opengl_initialized) {
game_step();
game_render();
SwapBuffers(hdc);
}
}
return 0;
}

Finally we reach the end and the top-most calling code. Initialize our window, get a device context to draw inside, handle input events and once things are initialized start the game loop.

Thoughts

I've spent quite a while at this point picking through this game to understand it and how it leverages native Windows APIs. Even more impressive to me, I've been building and running it on Linux under Wine without issue. Considering how much time I spend in a given week tracking down issues caused by transitive dependencies and obscure tooling it is a breath of fresh air to avoid all of it.

While I don't yet feel as though I could build an entire application from scratch I can't help but think of this as a logical step forward based on my own past experiences. After all, what is the argument against doing it yourself?