In Part 1, I gave a little teaser introduction to SDL. I explained what it is exactly (a free, open-source, portable game programming API), how easy it is to use, along with some other basic stuff, such as the basic initialization functions, how graphical "memory" is represented in it, where to get it, etc.
Anyway, today I'll be walking you through the code of a Pong clone I wrote a little while back. I think it's the perfect game to start with when learning how to write a game -- it was the first game I wrote -- because it's simple and short (< 400 lines), but it still utilizes important concepts you'll need to know when writing other games, such as making graphics move around on the screen, detecting key presses and other events, capping the frame rate, drawing text, basic collision detection, writing a game loop, etc.
Prerequisites
If you haven't done so yet, you should probably read Part 1 of this two-part series (it's pretty short) because it'll definitely help out if you're new to SDL, and I do make the assumption that you're familiar with what I talked about in the article. Grab a copy of the SDL docs as well.
In order to compile the game, you'll need to have the SDL and SDL_TTF library and header files, and you'll need to point your compiler to them and append the appropriate linker flags (-lSDL -lSDL_ttf should work). In order to run the game, you'll also need the SDL and SDL_TTF runtime files. Place them in a directory where your program can find them; on Windows (.DLL files) your easiest bet is to place them in the directory that you'll be placing the game executable in. On *nix, you likely don't need to do anything if you've installed everything by way of your package management system.
Edit (4/26): If you're having some issues getting SDL set up, check out this excellent comment.
You'll probably want to order a pizza and/or a nice sandwich, too.
Everything you need in a package...
Note that I didn't post the pong.h header file code here, number one because I think header files are boring, and number two because it would take up too much unnecessary space. You can download the header file, as well as the source code, font, and images necessary to compile and run the Pong game, here.
The code
So enough of the chatter, let's get down to business. I think the best way to walk through this code is to go function by function, as each one is called, so here goes:
#include "pong.h"
paddleData lPaddle, rPaddle;
ballData pongBall;
SDL_Surface *main_screen = NULL;
TTF_Font *score_font = NULL;
int main(int argc, char **argv) {
srand(time(NULL));
if (SDL_Init(SDL_INIT_VIDEO) != 0) {
fprintf(stderr, "Unable to initialize SDL: %s\n", SDL_GetError());
exit(1);
}
main_screen = SDL_SetVideoMode(WIN_WIDTH, WIN_HEIGHT,
BITS_PER_PIXEL, SDL_SWSURFACE);
if (main_screen == NULL) {
fprintf(stderr, "Unable to get screen: %s\n", SDL_GetError());
quit_the_game();
}
SDL_WM_SetCaption("Pong Clone", NULL);
if (load_all_images() != 0) {
fprintf(stderr, "Unable to load all images.\n");
quit_the_game();
}
if (setup_score_font() != 0) {
fprintf(stderr, "Unable to load font!\n");
quit_the_game();
}
set_initial_coordinates_for_images();
run_game_loop();
quit_the_game();
return 0;
}As I described earlier, we call SDL_Init() to initialize SDL and the video subsystem, and SDL_SetVideoMode() to create a video screen that we can draw our graphics on. SDL_SWSURFACE lets SDL know we want the video surface to be created in system, rather than in video, memory. The video screen is returned as an SDL_Surface, which is what all graphical memory is represented as in SDL.
Loading up our images and font
The next function called (besides SDL_WM_SetCaption(), which I'm sure you don't need an explanation for) is load_all_images():
int load_all_images(void) {
SDL_Surface *paddle = image_load_from_file("images/paddle.bmp", TRUE);
if (paddle == NULL) {
return -1;
}
lPaddle.surface = paddle;
rPaddle.surface = paddle;
SDL_Surface *ball = image_load_from_file("images/ball.bmp", TRUE);
if (ball == NULL) {
return -1;
}
pongBall.surface = ball;
return 0;
}All we're doing here is loading an image into an SDL_Surface with image_load_from_file(), then setting the global image surface variables to these loaded surfaces, so we can draw them on the screen later. Note how the left and right paddle share the same surface data, since we don't mind if both the left and right paddles look exactly the same. We just have to remember to call SDL_FreeSurface() on only one of the surfaces when we're done with them. Here's the image_load_from_file() code:
SDL_Surface *image_load_from_file(const char *path, int apply_colorkey) {
SDL_Surface *loaded = SDL_LoadBMP(path);
if (loaded == NULL) {
return NULL;
}
if (apply_colorkey == TRUE) {
SDL_SetColorKey(loaded, SDL_SRCCOLORKEY | SDL_RLEACCEL,
(Uint32)SDL_MapRGB(loaded->format, 0, 0, 0));
}
SDL_Surface *optimized = SDL_DisplayFormat(loaded);
SDL_FreeSurface(loaded);
return optimized;
}SDL_LoadBMP() loads up the image at the path specified. If the apply_colorkey variable is TRUE, we call SDL_SetColorKey(). The color key is the RGB value that SDL should show as transparent on the screen. We call SDL_MapRGB() (a convenience function, to convert individual RGB values into a 32-bit integer) with all zeros to let SDL we want all pixels that are absolute black in our images to be transparent. SDL_RLEACCEL enables RLE acceleration when drawing the surface on screen.
Then we call SDL_DisplayFormat() with the loaded image surface. This isn't necessary, but it converts the image to the format and colors of the display, so SDL doesn't have to do it each time the image is blitted (drawn) on screen.
Back to the main() function, we make a call to setup_score_font():
int setup_score_font(void) {
if (TTF_Init() != 0) {
return -1;
}
score_font = TTF_OpenFont("fonts/SAVEDBYZ.TTF", 32);
if (score_font == NULL) {
return -1;
}
return 0;
}All this does is initialize the SDL_TTF system with a call to TTF_Init(), and then load up our font (I'm using Saved By Zero, which looks kind of retro-ish yet modern at the same time) at a point size of 32. You can use whatever font you'd like.
Setting the initial image coordinates
Returning once more to the main() function, we call set_initial_coordinates_for_images():
void set_initial_coordinates_for_images(void) {
lPaddle.x = 0;
lPaddle.y = rand_between(0, WIN_HEIGHT - lPaddle.surface->h);
rPaddle.x = WIN_WIDTH - rPaddle.surface->w;
rPaddle.y = rand_between(0, WIN_HEIGHT - rPaddle.surface->h);
pong_ball_reset();
}Here we set the X/Y coordinates for the left paddle, the right paddle, and the ball. Now's a great time to explain something you should always keep in mind when setting coordinates for graphics in your game: the graphic's width and height.
In SDL (and most other libraries, I'm sure), drawing starts at the coordinates you provide. So if you were to tell SDL you want to draw an image at an X/Y coordinate of 0,0 on the screen, drawing would start at 0,0 (top left-hand corner), and would finish at 0+image width, 0+image height.
In our case, we want to have each paddle on the very end of either side of the screen: the left paddle at the very left, the right paddle at the very right. To place the left paddle at the very left-hand side of the screen, we can just pass an X (left/right) coordinate of 0. However, for the right-paddle to be placed on the very right-hand side of the screen, we must factor in the paddle's width. If we didn't, and just passed an X coordinate of the width of the screen (WIN_WIDTH), the paddle would only start being drawn at that coordinate, which means you wouldn't see the paddle -- it would be past the right edge of the screen. The simple solution is to factor in the width of the paddle... so the X coordinate should be WIN_WIDTH - rPaddle.surface->w, instead.
The same goes for the Y (up/down) coordinates. When you want to draw the image starting at the very top of the screen, just use a Y coordinate of 0 -- because it'll start being drawn at that point. When you want to draw the image at the very bottom of the screen, use a Y coordinate of WIN_HEIGHT - (image height), so that the paddle finishes being drawn at the very bottom of the screen.
In short: when thinking about what coordinate to set for your image, think about where you want the image to start being drawn, so that it's fully visible. I hope I haven't confused you.
The game loop
We're finally through with our boring initialization code. The next function main() calls is our game loop, run_game_loop(). The purpose of a game loop is to... uhh, loop. But it's more than just that. On each loop, the game loop can handle user input (keypresses, mouse movement, etc), graphic movement, program events, drawing, clearing the screen, updating the score, etc. I guess you could say that the game loop is somewhat analgous to an operating system kernel. It's the driver of the game:
void run_game_loop(void) {
Uint32 start_ticks = 0, end_ticks = 0;
SDL_Event event;
Uint8 *keystate = NULL;
for (;;) {
start_ticks = SDL_GetTicks();
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_QUIT:
quit_the_game();
}
}
keystate = SDL_GetKeyState(NULL);
handle_keystate(keystate);
redraw_all_images();
pong_ball_handle_collision();
end_ticks = SDL_GetTicks();
cap_fps(start_ticks, end_ticks);
}
}Phew. Lots of stuff to deal with here. I use for (;;) instead of while (TRUE), simply because it looks cooler. Both loop infinitely, which is all that matters.
At the top of the loop we call SDL_GetTicks(), which returns the number of milliseconds elapsed since our game first started (we'll deal with this value further on down in the loop, when capping the framerate). Then we have a while loop, which calls SDL_PollEvent(). SDL_PollEvent() simply polls SDL's event queue for a pending event (such as a mouse movement, keypress, etc). If it returns 1, there was an event, and it was "popped" off the queue and the SDL_Event structure we passed to it is filled with info pertaining to the event. All we want to deal with here is the SDL_QUIT event type, which we'll automatically receive if the user presses the 'X' button. If that happens, we call our quit function, quit_the_game(), which I'll show you later on.
Handling the key state
Then we grab what's called the "key state" with a call to SDL_GetKeyState(). This returns a pointer to a byte-array, which contains pressed/unpressed (1/0) values for each key. You can get the state of each key simply by accessing the corresponding array subscript, which can either be one of SDL's defined keysym constants representing the key (such as SDLK_a for the 'a' key, SDLK_ESCAPE for the Escape key, etc... see the header file SDL_keysym.h for a complete list) or the ASCII value (such as 'a' for the 'a' key). Anyway, we pass this keystate array to handle_keystate(), which... handles the keystate array:
void handle_keystate(Uint8 *keystate) {
if (keystate[LPADDLE_UP_KEY]) {
paddle_move(&lPaddle, DIRECTION_UP);
}
if (keystate[LPADDLE_DOWN_KEY]) {
paddle_move(&lPaddle, DIRECTION_DOWN);
}
if (keystate[RPADDLE_UP_KEY]) {
paddle_move(&rPaddle, DIRECTION_UP);
}
if (keystate[RPADDLE_DOWN_KEY]) {
paddle_move(&rPaddle, DIRECTION_DOWN);
}
if (keystate[START_GAME_KEY]) {
if (pongBall.moving == FALSE) {
pong_ball_set_in_motion();
}
}
if (pongBall.moving == TRUE) {
pong_ball_move();
}
if (keystate[QUIT_GAME_KEY]) {
quit_the_game();
}
}Moving the paddle(s)
We check the state of the paddle movement keys in this function. If they're pressed, we call paddle_move() and pass it the paddle that should be moved depending on the key that was pressed, along with the direction we want to move it in:
void paddle_move(paddleData *paddle, unsigned int direction) {
int difference =
image_get_acceptable_y_movement(paddle->surface, paddle->y,
PADDLE_SPEED, direction);
paddle->y += difference;
}This function calls yet another function, image_get_acceptable_y_movement(), which returns the maximum number of pixels (no greater than PADDLE_SPEED, in our case) that the image's Y coordinate should be increased/decreased, depending on the direction:
int image_get_acceptable_y_movement(SDL_Surface *surface, int y,
int speed, unsigned int direction) {
int difference = 0;
if (direction & DIRECTION_UP) {
if (y - speed < 0) {
difference = -y;
} else {
difference = -speed;
}
} else if (direction & DIRECTION_DOWN) {
if (y + speed + surface->h > main_screen->h) {
difference = (main_screen->h - y) - surface->h;
} else {
difference = +speed;
}
}
return difference;
}If we didn't call this function, and instead increased/decreased the Y coordinate by a constant value (like PADDLE_SPEED) depending upon whether we wanted to move down or up, respectively, the new coordinate could potentially cause the image to be drawn off of the screen. For example: If the paddle was at a Y coordinate of 1 and we wanted to move up PADDLE_SPEED (7) pixels, that would cause the paddle's new Y coordinate to be -6 -- not good. So difference is calculated as PADDLE_SPEED if the new Y value won't be off-screen, or the number of pixels left between the paddle and the edge, if it will go off-screen. We also factor in the screen's height, and the paddle's height, if we're moving downward, since it'll be the bottom of the paddle that touches the bottom of the screen, first (see the explanation above).
Moving the ball; checking for collision
Back to handle_keystate(), we check to see if the START_GAME_KEY (Space) key is pressed. If it is, and the ball isn't already moving, we call pong_ball_set_in_motion(), which simply sets the ball's moving variable to TRUE -- to let our pong ball moving code know it is indeed allowed to move the ball -- and also sets it in a random direction:
void pong_ball_set_in_motion(void) {
pongBall.moving = TRUE;
pongBall.direction = 0;
pongBall.direction |= (rand() % 2 == 0) ? DIRECTION_UP : DIRECTION_DOWN;
pongBall.direction |= (rand() % 2 == 0) ? DIRECTION_LEFT : DIRECTION_RIGHT;
}Not the most brilliant logic, but a more realistic implementation can be left as an exercise to the reader. Once more to handle_keystate(): if the ball is supposed to be moving (as signified by the moving variable), then we call pong_ball_move(). This one's kinda big, and is the most complex in the game, so I'll split it up a bit:
void pong_ball_move(void) {
int difference =
image_get_acceptable_y_movement(pongBall.surface, pongBall.y,
BALL_SPEED, pongBall.direction);
pongBall.y += difference;
if (abs(difference) != BALL_SPEED) {
if (pongBall.direction & DIRECTION_UP) {
pongBall.direction &= ~DIRECTION_UP;
pongBall.direction |= DIRECTION_DOWN;
} else if (pongBall.direction & DIRECTION_DOWN) {
pongBall.direction &= ~DIRECTION_DOWN;
pongBall.direction |= DIRECTION_UP;
}
}Here we first get an acceptable Y coordinate movement value by making a call to image_get_acceptable_y_movement(). Note that it can be negative or positive depending on the direction. Then we "add" this to the ball's Y coordinate, and then compare the absolute (positive) value of this amount with the speed of the ball (BALL_SPEED). If the values are not equal, then we know that the ball has hit the top or bottom of the wall (otherwise the image_get_acceptable_y_movement() function would've returned BALL_SPEED), and we therefore set the ball in an opposite Y direction, to simulate a "bounce". Continuing in the same function:
if (pongBall.direction & DIRECTION_LEFT) {
pongBall.x -= BALL_SPEED;
} else if (pongBall.direction & DIRECTION_RIGHT) {
pongBall.x += BALL_SPEED;
}
pongBall.hit_paddle = FALSE;
pongBall.out_of_bounds = FALSE;First we increment/decrement the ball's X coordinate, depending on the direction it's going in. Of course, this could've made the ball collide with one of the paddles, or pass one of the paddles above or below it without hitting it (which we consider a collision with the wall for simplicity), so we check for both conditions:
if (pongBall.direction & DIRECTION_LEFT) {
if (pongBall.x < (lPaddle.x + lPaddle.surface->w)) {
if (pongBall.y + pongBall.surface->h < lPaddle.y ||
pongBall.y > lPaddle.y + lPaddle.surface->h) {
pongBall.out_of_bounds = TRUE;
} else {
pongBall.hit_paddle = TRUE;
pongBall.x = lPaddle.x + lPaddle.surface->w;
}
}
} else if (pongBall.direction & DIRECTION_RIGHT) {
if (pongBall.x + pongBall.surface->w > rPaddle.x) {
if (pongBall.y + pongBall.surface->h < rPaddle.y ||
pongBall.y > rPaddle.y + rPaddle.surface->h) {
pongBall.out_of_bounds = TRUE;
} else {
pongBall.hit_paddle = TRUE;
pongBall.x = rPaddle.x - pongBall.surface->w;
}
}
}
}The calculation is somewhat self-explanatory. If the ball's moving left, we check first to see if the left-hand side of the ball (which is simply the X coordinate) has passed the X coordinate of the paddle, plus the width of the paddle (see above for an explanation) because if we're moving left, the ball will hit the right side of the left paddle.
If the ball's moving right, we first check to see if the right-hand side of the ball (the X coordinate, plus the width of the ball) has passed the left side (simply the X coordinate) of the right paddle.
If either case has occurred, we have a collision of some sort... was it with the paddle, or was it with the point just past the paddle, above or below it? Depending on the case, we either set the out_of_bounds variable to TRUE, or set the hit_paddle variable to TRUE and place the ball so it's touching the paddle exactly -- because earlier, when we moved the ball, we may have moved it too far.
Drawing the images on screen
We're now actually done with most of the logic. Back in our run_game_loop() function, now we actually draw the images we just "moved", by modifying their X/Y coordinates, with a call to redraw_all_images():
void redraw_all_images(void) {
clear_screen();
blit_surface(lPaddle.surface, main_screen, lPaddle.x, lPaddle.y);
blit_surface(rPaddle.surface, main_screen, rPaddle.x, rPaddle.y);
blit_surface(pongBall.surface, main_screen, pongBall.x, pongBall.y);
draw_scores(lPaddle.score, rPaddle.score);
SDL_UpdateRect(main_screen, 0, 0, 0, 0);
}Finally, a simple function! First we clear the screen with a call to clear_screen():
void clear_screen(void) {
SDL_FillRect(main_screen, NULL, SDL_MapRGB(main_screen->format, 0, 0, 0));
}SDL_FillRect() fills the specified surface (in our case, the screen) with the RGB color specified in the last argument. The second argument is NULL to specify we want to fill the entire surface with the color we specified. You can also pass a pointer to an SDL_Rect to specify only certain sections instead -- which might be a good idea for a game that is more CPU-intensive. Our blit_surface() function, which is the next function called by redraw_all_images(), and which I also explained in Part 1, also utilizes an SDL_Rect to blit (draw) the images to a specific location on the screen:
int blit_surface(SDL_Surface *src, SDL_Surface *dest, Sint16 x, Sint16 y) {
SDL_Rect offset;
offset.x = x;
offset.y = y;
return SDL_BlitSurface(src, NULL, dest, &offset);
}Once again, this function simply draws the src surface onto the dest surface at coordinates x and y.
Drawing the scores
redraw_all_images() then calls draw_scores() to draw the score for each paddle:
void draw_scores(int left_score, int right_score) {
char lscore_text[10], rscore_text[10];
snprintf(lscore_text, sizeof(lscore_text), "%d", left_score);
snprintf(rscore_text, sizeof(rscore_text), "%d", right_score);
SDL_Surface *lscore_surface = render_text(lscore_text);
SDL_Surface *rscore_surface = render_text(rscore_text);
blit_surface(lscore_surface, main_screen,
(WIN_WIDTH / 2) / 2 - lscore_surface->w / 2, 0);
blit_surface(rscore_surface, main_screen,
((WIN_WIDTH/2)/2) + (WIN_WIDTH/2) -
(rscore_surface->w / 2), 0);
SDL_FreeSurface(lscore_surface);
SDL_FreeSurface(rscore_surface);
}First we convert each score to a string. Then we call our convenience function render_text() to render the score text into a surface suitable for drawing on the screen, with the RGB color we want (I like 176, 219, 255):
SDL_Surface *render_text(const char *text) {
SDL_Color color;
color.r = 176; color.g = 219; color.b = 255;
return TTF_RenderText_Solid(score_font, text, color);
}Rendering the score into a surface each time the score is updated probably isn't the most efficient method, but it's the simplest, and when running a benchmark, I didn't see any noticeable difference in CPU utilization with and without the code. Experienced game programmers, feel free to chastise me.
The rest of the draw_scores() function is pretty straightforward. We call blit_surface() with the proper coordinates for each paddle's score. Note the calculation we make for the X/Y coordinate of each score; we want the left paddle's score at the top of the center of the left-half of the screen, and we want the right paddle's score at the top of the center of the right-half of the screen. Of course, when we're done drawing the text, we free the surfaces with a call to SDL_FreeSurface().
Handling a collision
Back once more to the game loop, we call pong_ball_handle_collision(), to check and see if a collision occurred (our collision logic is in the pong_ball_move() function above). If it did, we handle it according to what the ball collided with:
void pong_ball_handle_collision(void) {
if (pongBall.hit_paddle == TRUE) {
pong_ball_move_in_opposite_direction();
pongBall.hit_paddle = FALSE;
} else if (pongBall.out_of_bounds == TRUE) {
if (pongBall.direction & DIRECTION_LEFT) {
rPaddle.score += 1;
} else if (pongBall.direction & DIRECTION_RIGHT) {
lPaddle.score += 1;
}
pong_ball_reset();
pongBall.out_of_bounds = FALSE;
}
}If the ball collided with the paddle, we clear the hit_paddle variable and set the ball in the opposite direction with a call to pong_ball_move_in_opposite_direction():
void pong_ball_move_in_opposite_direction(void) {
unsigned int direction = (rand() % 2 == 0) ? DIRECTION_UP : DIRECTION_DOWN;
if (pongBall.direction & DIRECTION_LEFT) {
direction |= DIRECTION_RIGHT;
} else if (pongBall.direction & DIRECTION_RIGHT) {
direction |= DIRECTION_LEFT;
}
pongBall.direction = direction;
}I'm not going to bother explaining what this does. This post is huge already! Back to the pong_ball_handle_collision() function, we check for an "out-of-bounds" collision. If it occurred, we increment the score of the player who paddled the ball to the player who failed to paddle it back. Then we call pong_ball_reset() to place the ball in exactly the middle of the screen at a random Y coordinate, and keep it from moving (until the user hits the Space key again):
void pong_ball_reset(void) {
pongBall.x = (WIN_WIDTH/2) - (pongBall.surface->w / 2);
pongBall.y = rand_between(0, WIN_HEIGHT - pongBall.surface->h);
pongBall.moving = FALSE;
}Capping the Frame Rate
Recall that at the top of the main game loop, we made a call to SDL_GetTicks(), before we did any logic/drawing functions. Now we make another call to this function, set the result in another variable, and call cap_fps() with the tick count we got in the beginning and the tick count we got just now:
void cap_fps(Uint32 start_ticks, Uint32 end_ticks) {
int sleep_delay = (1000 / FRAMES_PER_SECOND) - (end_ticks-start_ticks);
if (sleep_delay > 0) {
SDL_Delay(sleep_delay);
}
}Why bother capping the frame rate, you might ask? For one thing, you don't want your game eating 100% of the user's CPU when it doesn't have to. Secondly, if you don't cap the frame rate, the game loop will run as fast as each individual user's CPU will allow, which means on some CPUs, the ball and paddle will be moving crazy fast (because the game loop, and therefore the movement functions, will be executing much more often), and on other CPUs, the ball and paddle will be moving crazy slow. Capping the frame rate keeps the movement mostly uniform on all systems.
So how do you cap the frame rate? There are a few different methods. What I'm doing here is calculating the amount of time required to sleep (with SDL_Delay()) during each loop of the game loop in order to achieve a frame rate of FRAMES_PER_SECOND (which I have defined as 30). Then, I factor in the amount of time it took to execute all of the functions in the game loop -- end_ticks - start_ticks. If the result of this calculation is greater than zero, we sleep for that amount of time to achieve exactly FRAMES_PER_SECOND game loops (and therefore screen redraws) per second; otherwise, we don't sleep, because for some reason, the user's CPU can't handle this 2D game (time to upgrade the 486, likely), and we're actually running behind.
I've read that there are superior methods. If you know of one, feel free to share. This gets the job done in our case, though.
Finishing up
Our game code is complete, save for one important function, quit_the_game(), which is called whenever the QUIT_GAME_KEY (Escape) is pressed, an error occurs, or the 'X' button is pressed in the window:
void quit_the_game(void) {
SDL_FreeSurface(lPaddle.surface);
SDL_FreeSurface(pongBall.surface);
if (TTF_WasInit()) {
TTF_CloseFont(score_font);
TTF_Quit();
}
SDL_Quit();
exit(0);
}We free all allocated data, close down the TTF system (if it was initialized), and then close down the SDL system with a call to SDL_Quit(). Note that we only free one of the paddle surfaces, since the left and right paddle shared the same surface data, and we don't call SDL_FreeSurface() on our video screen surface -- SDL_Quit() does that for us, and the SDL docs say we shouldn't free it ourselves, anyway.
We're done!
Cool! If you copied everything correctly (or just downloaded the code package), you've setup your compiler to link against the SDL/SDL_TTF header and library files, and you've got the SDL/SDL_TTF runtime libraries installed in a directory where your system can find them (a directory in your PATH variable, or the directory in which the compiled game executable is located), the code should compile cleanly and run perfectly, regardless of what operating system you're using. When you run it, it should look something like this.
This Pong clone is pretty simple. See if you can't add support for computer controlled paddles and make the ball movement/direction changing code more realistic. The best way to learn is to do.
Have fun writing games.