MacDevCenter    
 Published on MacDevCenter (http://www.macdevcenter.com/)
 See this if you're having trouble printing code examples


Building a Game Engine with Cocoa

by Matthew Russell
12/19/2006

Classic board games like Checkers are hard to beat, but after so many holiday seasons and family get-togethers, variety becomes essential. Lines of Action (LOA) is a game slightly off the beaten path that you can play with an ordinary Checkers set the next time you decide to dust it off--but in the meantime, let's fire up Xcode and build a small game engine for playing board games like Checkers and LOA using some artificial intelligence.

In this episode, we'll put together a generic, minimalist framework for setting up the board and moving the pieces around using standard Cocoa drawing techniques. Next time, we'll finish off by writing a valid move generator for LOA, although you could really define one for any board game you can imagine. We'll also develop a computer opponent that you can customize with your own heuristics using an artificial intelligence technique called a game tree search.

Build the Skeleton

Like any other Cocoa project, you'll need to have a copy of Apple's developer tools installed on your Mac to follow along and run the sample project. Likewise, there are already a lot of great introductory tutorials here on Mac DevCenter about graphics and Cocoa 101, so take a few moments to brush up if it's been a while. We won't be doing anything all that advanced, but a basic working knowledge of Cocoa and Xcode is assumed from here on out.

First, fire up Xcode and use the assistant to start a new Cocoa application called "Board Game." We'll be adding two new classes right away: an application controller and a subclass of NSView that'll be our gameboard. Using the "File" menu, create a new Objective-C class entitled "AppController" for the former, and another Objective-C class called "GameBoard" for the latter. Under the "Groups & Files" pane in Xcode, drag the resulting four files that are created under the "Classes" folder. In the GameBoard header file, change the line that contains GameBoard : NSObject to be GameBoard : NSView instead, so that GameBoard inherits from NSView instead of NSObject.

Now, expand the NIB Files folder on the same part of the screen to reveal MainMenu.nib, and double-click it to open Interface Builder. In Interface Builder, drag the AppController.h file from Xcode onto the small window that's titled "MainMenu.nib (English)," and you should see AppController appear as a subclass of NSObject under the "Classes" tab. Making sure that "AppController" is selected from the browser view, choose "Instantiate AppController" from Interface Builder's "Classes" menu up on the top of the screen (Figure 1). With your controller instantiated, drag your GameBoard.h file onto the same window, but do not attempt to instantiate it.

Instantiate AppController
Figure 1. Instantiate AppController from the Classes menu in Interface Builder.

Next, drag a "CustomView" from the "Cocoa Container Views" tab of Interface Builder's "Cocoa-Containers" palette onto the big empty window titled "Window." Select the CustomView and press Cmd-3 to load the view's inspector so that you can set the size attributes. Make the width and height of the view equal so that the window will be square, and then set both of the inner springs in the "AutoSizing" box. With the CustomClass now square, resize the window itself so that there is a uniform margin around the view. Finally, select the CustomView, press Cmd-5 to bring up the inspector's section that allows you to qualify your custom class, and choose GameBoard. (GameBoard appears as an option because you dragged your header file onto Interface Builder's main window a few steps ago.) See Figure 2.

Set CustomView Settings Qualify Custom View
Figure 2. Customize the settings for the GameBoard and designate the CustomView as GameBoard in Interface Builder.

There are only two more steps before we finish in Interface Builder. First, click on the window itself and press Cmd-3 to reveal its size inspector, where you can specify a minimum width and height. Choosing 220x220 is a reasonable setting. Next, Ctrl-click on the "Window" icon in the main NIB file under the "Instances" tab, and drag it onto your GameBoard custom view. From the dialog that appears, set the GameBoard to be the window's "initialFirstResponder" (see Figure 3). After completing this step, you can close Interface Builder and head back into Xcode.

Set First Responder
Figure 3. Set the Window's initial first responder to be the GameBoard.

At this point, go ahead and try to "Build and Go" by clicking on the prominent hammer icon in the toolbar. Your project should produce a blank window that does absolutely nothing. But what about the custom view you dragged in? Shouldn't it at least show up? Not unless you have a method in GameBoard that specifies how it should draw itself. The particular method that a view uses to draw itself is called drawRect:. For now, substitute this placeholder version of that method into your GameBoard.m file:

@implementation GameBoard
- (void) drawRect: (NSRect) rect {
    NSRect bounds = [self bounds];
    [[NSColor whiteColor] set];
    [NSBezierPath fillRect: bounds];
}
@end

If you run your project again, you should now see a plain white canvas staring at you. So far, so good. We'll come back to drawRect: in just a bit.

Cover the Essentials

Believe it or not, all of the remaining work will be done right here in the GameBoard class. A sample project will be available at the end of this article, so instead of trying to actually develop and refactor the entire project file, let's step through the finished product at a high level, discussing the more interesting parts of the project. You can always work though some of the more specific details afterward and use the TalkBacks at the end of this article for specific questions or ideas you want to share. Of course, the full header and implementation files for GameBoard might be useful to have open in another tab while you follow along.

The first few methods we'll develop are fairly routine and get us through some of the typical initialization and setup.

//designated initializer for superclass
- (id) initWithFrame:(NSRect) frame {
    if (self = [super initWithFrame: frame]) {
        //set up player colors
        playerColors[PLAYER_1] = [NSColor redColor];
        playerColors[PLAYER_2] = [NSColor blackColor];
        
        //set up board colors
        boardColors[0] = [NSColor lightGrayColor];
        boardColors[1] = [NSColor darkGrayColor];
        
        //initialize board
        int i; int j;
        for (i=0; i< DIMENSION; i++)
            for (j=0; j <DIMENSION; j++) {
                board[i][j] = EMPTY;
                piecesPaths[i][j] = nil;
            }
                
        //set up initial layout for a game of LOA
        for (i=0; i <DIMENSION; i += DIMENSION-1)
            for (j=1; j <DIMENSION-1; j++) {
                board[i][j] = PLAYER_1;
                board[j][i] = PLAYER_2;
            }
        
        //nothing is hovered or selected yet
        hoveredCoord = NIL_POINT;
        selectedCoord = NIL_POINT;
        
        //current dimension of a square on the board
        squareDim = frame.size.width/DIMENSION;
    }
    
    return self;
}

//outlets, etc. have been setup by here...
- (void)awakeFromNib {    
    //keep the window a square if the user resizes
    //(also remember to set the springs in interface builder)
    [[self window] setContentAspectRatio:NSMakeSize(1.0,1.0)];
    
    //register for mouse events
    [[self window] setAcceptsMouseMovedEvents: YES];
}

//for receiving mouse events
- (BOOL) acceptsFirstResponder {
    return YES;
} 

//recalculate the dimension for a board square when the view is resized
- (void) setFrame: (NSRect) frame {
    [super setFrame: frame];
    squareDim = frame.size.width/DIMENSION;
}


//draws the view, although never called explicitly
- (void)drawRect:(NSRect)rect {
    [self drawBoardBackgroundInRect:rect];
   
    //draw all pieces on the board by using the "board"
    //also store the bezier paths for detecting mouseovers
    int i; int j;
    for (i=0; i < DIMENSION; i++)
        for (j=0; j < DIMENSION; j++)
            if (board[i][j] == PLAYER_1) {
                if (piecesPaths[i][j] != nil)
                    [piecesPaths[i][j] release];

                piecesPaths[i][j] = [[self drawPieceForCoord:NSMakePoint(i,j) andPlayer:PLAYER_1] retain];
            }
            else if (board[i][j] == PLAYER_2) {
                if (piecesPaths[i][j] != nil)
                    [piecesPaths[i][j] release];
                
                piecesPaths[i][j] = [[self drawPieceForCoord:NSMakePoint(i,j) andPlayer:PLAYER_2] retain];
            }
}

//clean up piecesPaths
- (void)dealloc {    
    int i; int j;
    for (i=0; i< DIMENSION; i++)
        for (j=0; j< DIMENSION; j++)
            if (piecesPaths[i][j] != nil)
                [piecesPaths[i][j] release];
    
    [super dealloc];
}

The initWithFrame: method is the designated initializer for NSView and is the area in which you can set up initial values for the gameboard, so it is natural that we assign colors for the board squares and each player here. One thing particularly worth pointing out is that we're calculating and storing an instance variable called squareDim that designates the size of a square on our board. Even though we could recalculate this value on the fly every single time we need it and probably never even begin to notice the impact, it's good form to go ahead and store this value away. As we'll see in setFrame:, we'll only recalculate this value whenever the window is resized, and the board needs to be redrawn proportional to that new size.

The method awakeFromNib is an ideal place to register for mouse events since it's safe to reference the window by this point. Once the GameBoard registers for mouse events via setAcceptsMouseMovedEvents, it'll receive them since it was set in Interface Builder to be the window's first responder. The actual way our board handles the mouse events is by implementing NSResponder's methods mouseMoved: and mouseDown: (more on these toward the end of the article.) In case you were wondering, NSResponder is an abstract class that the core event handling classes such as NSWindow and NSview inherit from.

In awakeFromNib, we also set the aspect ratio of the window to be 1:1 so that the window will remain square if it is resized. This property keeps the board drawing simple, since boards for these types of games are usually square.

In addition to registering for mouse events in awakeFromNib, the view must explicitly declare itself capable of being the first responder, so we take care of this in acceptsFirstResponder:. Setting the first responder status is the mechanism that allows the view to actually receive events such as mouse drags and mouse clicks. Setting setAcceptsMouseMovedEvents specifies that the view has methods in place for handling events such as the mouse moving.

Whenever a window resize occurs, setFrame: is called, so we'll use it to recalculate the size of squares on the board as previously mentioned, since we'd like for the board to be able to size along with the window. As you'll see, calculating the proper dimensions for board squares is the only remotely tricky thing that's necessary to make this happen.

Inside drawRect is where the drawing action happens, although it does use the helper functions drawBoardBackgroundInRect: and drawPieceForCoord: for a lot of the labor. Assuming the helper functions do what it sounds like they do, we can read through drawRect: and see that it first draws the empty board rectangle that defines the view, and then proceeds to iterate through each of the squares on the board using a simple matrix that keeps track of the pieces.

If a particular board spot is occupied, a routine is called that draws the appropriate piece in that spot using an NSBezierPath, and that path is retained so that we'll have access to it later. The reason for keeping these paths around is because NSBezierPath has a method that makes it rather trivial to determine whenever the mouse hovers it; we'll use this ability to provide a rollover effect. It is noteworthy that the retains you see in this method are the only ones that appear in the entire class, and scanning on down to dealloc shows that we clean them up when the time comes. All other drawing is accomplished with bezier paths that are autoreleased and consequently don't need to be retained.

Getting into the Details

That concludes the project setup and the core logic for drawing the board and the pieces on it. Now, let's have a closer look at a few of the drawing details:

//the board should have a checkered appearance
-(NSColor*)squareColorForBoardCoord:(NSPoint)p {
    return ((int)p.x % 2 == (int)p.y % 2) ? boardColors[0] : boardColors[1]; 
}

//draw the background of the board
- (void)drawBoardBackgroundInRect:(NSRect)rect {
    int i; int j;
    for (i=0; i < DIMENSION; i++)
        for (j=0; j < DIMENSION; j++) {
            [[self squareColorForBoardCoord:NSMakePoint(i,j)] set];
            [self drawRectForBoardCoord:NSMakePoint(i,j)];            
        }    
}

//call though to drawRectForBoardCoord:andHighlight: and default highlighting to NO
- (void) drawRectForBoardCoord:(NSPoint)p {
    [self drawRectForBoardCoord:p andHighlight:NO];
}

//given a board coordinate such as (1,1), draw that board square and optionally highlight it
- (void)drawRectForBoardCoord:(NSPoint)p andHighlight:(BOOL)h {
    if (!h)
        [NSBezierPath fillRect:[self rectForBoardCoord:p]];
    else {
        NSLog(@"highlighting");
        NSRect r = [self rectForBoardCoord:p];
        [[[self squareColorForBoardCoord:p] blendedColorWithFraction:0.6 ofColor:[NSColor yellowColor]] set];
        [NSBezierPath fillRect:r];
    }
}

//find the drawing area on the view that corresponds to the coordinate 
//board is like quadrant 1 of cartesian plane with origin in lower left.
- (NSRect) rectForBoardCoord:(NSPoint)p {
    NSRect rect = [self bounds];
    
    NSRect r;
    r.origin = NSMakePoint(rect.origin.x+ p.x*squareDim, rect.origin.y+ p.y*squareDim);
    r.size = NSMakeSize(squareDim, squareDim);
    
    return r;
}

The method squareColorForBoardCoord: takes a coordinate in the form of an NSPoint as its argument and, based on that coordinate, returns which color the square should be. This information is used repetitively in drawBoardBackgroundInRect: for drawing the board, which is simply a nested loop that iterates over each square on the board.

The method drawRectForBoardCoord: is defined next; it simply calls through to drawRectForBoardCoord:andHighlight with highlighting declined and produces a square on the board. Again, note that the instance variable squareDim is used in these calculations.

Coming up next is the most complex drawing routine and another block of code that determines the board coordinates that correspond to a mouse click from inside the view.

//given a board coordinate such as (1,1), draw the piece, which may get highlighted
//if the mouse is hovering over it.
- (NSBezierPath*)drawPieceForCoord:(NSPoint)p andPlayer:(int)player {    
    NSRect r = [self rectForBoardCoord:p];
    
    //shrink the rectangle a bit so that pieces don't touch the edges
    r.origin.x += PIECE_MARGIN/2;
    r.origin.y += PIECE_MARGIN/2;
    r.size.width -= PIECE_MARGIN;
    r.size.height -= PIECE_MARGIN;
    
    NSBezierPath *path;
    
    path = [NSBezierPath bezierPathWithOvalInRect:r];
    
    if (hoveredCoord.x == p.x && hoveredCoord.y == p.y) {
        [[NSColor yellowColor] set];
        [path fill];
        
        NSRect highlightedRect = r;
        
        highlightedRect.origin.x +=HIGHLIGHT_MARGIN/2;
        highlightedRect.origin.y +=HIGHLIGHT_MARGIN/2;
        highlightedRect.size.width -= HIGHLIGHT_MARGIN;
        highlightedRect.size.height -= HIGHLIGHT_MARGIN;
        NSBezierPath *body = [NSBezierPath bezierPathWithOvalInRect:highlightedRect];
        
        [playerColors[player] set];
        [body fill];
    }
    else { 
        [playerColors[player] set];
        [path fill];
    }
    
    //Give your board pieces a nice texture with something like this...
    NSBezierPath *texture;
    while (r.size.width > 0) {
        r.origin.x += 2;
        r.origin.y += 2;
        r.size.width -= 4;
        r.size.height -= 4;
        texture = [NSBezierPath bezierPathWithOvalInRect:r];
        [[NSColor whiteColor] set];
        [texture stroke];
    }
    
    return path;
}

//based on a mouse click, determine the corresponding board coordinate
-(NSPoint)boardCoordForClickPoint:(NSPoint)p {
    int x=-1; int y=-1;
    
    while (p.x > 0) {  
        p.x -= squareDim;
        x++;
    }
    
    while (p.y > 0) {   
        p.y -= squareDim;
        y++;
    }
    
    return NSMakePoint(x,y);
}

//used for moving
- (void)movePieceFromCoord:(NSPoint)p1 toCoord:(NSPoint)p2 {
    NSLog(@"Attempting to move piece from (%f,%f) to (%f,%f)", p1.x,p1.y,p2.x,p2.y);
    
    //make sure move isn't in place
    if (p1.x == p2.x && p1.y == p2.y)
        return;
    
    //make sure there is a piece at p1
    if (board[(int)p1.x][(int)p1.y] == EMPTY)
        return;
    
    //update the board
    int player = board[(int)p1.x][(int)p1.y];
    board[(int)p1.x][(int)p1.y] = EMPTY;
    board[(int)p2.x][(int)p2.y] = player;
    
    [self setNeedsDisplay:YES];
}

In reality, drawPieceForCoord:andPlayer: looks more complex than it is. When everything is said and done, this method simply draws a fancy circle on the board. In all cases, it uses a small margin that sizes a piece so that it fits neatly inside of a square, and uses some cheap parlor tricks with NSBezierPaths to texturize the board pieces so that they don't look so dull. The most interesting work, however, occurs if the mouse is over the piece. If the mouse is hovering over a piece, the piece is drawn in a highlighted fashion to provide a visual cue to the user that the piece can be selected. The actual mechanics of determining if the mouse is over the piece (not just the square, but the piece in the square) relies on the matrix of bezier paths (piecesPaths) that are stored outside this method. This method simply references the instance variable hoveredCoord, which is updated in mouseMoved (coming up shortly).

While rectForBoardCoord: takes a coordinate and returns a rectangle for drawing on the view, the method boardCoordForClickPoint: basically does the reverse. Given a click on the view (which takes place inside of a board square), it returns the coordinate for this board square. This method comes in handy when it's time to move pieces on the board, since it allows us to know the source and destination for a piece that we can use to redraw. In fact, this is exactly what movePieceFromCoord: does to create the effect of moving a piece. It first verifies that an actual piece is being moved to a different square, and then performs the swap in the board matrix and flags the display as stale. The display updates when drawRect: kicks in and handles the redraw.

The final two routines are methods that NSView inherits from NSResponder and involve mouse events.

- (void)mouseMoved:(NSEvent *)event {
    // get the mouse position in view coordinates
    NSPoint mouse;
    mouse = [self convertPoint: [event locationInWindow]  fromView: nil];
    
    //no need to redraw anything in this case, the piece is still highlighted
    if (hoveredCoord.x != NIL_POINT.x && hoveredCoord.y != NIL_POINT.y && 
        [piecesPaths[(int)hoveredCoord.x][(int)hoveredCoord.y] containsPoint: mouse])
        return;
    
    //otherwise, check to see if a piece is being moused over
    int i; int j;
    for (i = 0; i < DIMENSION; i++) 
        for (j = 0; j < DIMENSION; j++) 
            if (board[i][j] != EMPTY)
                if ([piecesPaths[i][j] containsPoint: mouse]) {
                    hoveredCoord = NSMakePoint(i,j);
                    [self setNeedsDisplay:YES];
                    return;
                }
                    
                    //no pieces were being moused over. no need to redraw unless we need
                    //to un-highlight a piece
                    if (hoveredCoord.x != NIL_POINT.x && hoveredCoord.y != NIL_POINT.y) {
                        [self setNeedsDisplay:YES];
                        hoveredCoord = NIL_POINT;
                    }
} 

- (void)mouseDown:(NSEvent*)event {
    //get the mouse position in view coordinates
    NSPoint mouse;
    mouse = [self convertPoint: [event locationInWindow]  fromView: nil];
    
    //if there was a previous click, save it
    NSPoint previousPoint = NIL_POINT;
    if (selectedCoord.x != NIL_POINT.x && selectedCoord.y != NIL_POINT.y)
        previousPoint = selectedCoord;
    
    //get the new board square that was clicked in.
    selectedCoord = [self boardCoordForClickPoint:mouse];
    
    if (previousPoint.x != NIL_POINT.x && previousPoint.y != NIL_POINT.y) {
        [self movePieceFromCoord:previousPoint toCoord:selectedCoord];
        selectedCoord = NIL_POINT;
    }
}

The mouseMoved: routine is the more complex of the two, because it has some explicit checks in place to make sure that the view is redrawn only when necessary. Taking a moment to tinker with some of these checks is worthwhile, and you can insert an NSLog statement in drawRect: to see the impact. Though there might not be a noticeable change on the actual display, you'll find that it's very easy to redraw the display far more often than is really necessary.

Finally, mouseDown: simply keeps track of mouse clicks and maps them to board coordinates using boardCoordForClickPoint:. If a piece is clicked, then the next click results in movePiecefromCoord:toCoord trying to move the piece to a new location. Figure 4 shows the board in action.

Our board in action
Figure 4. Our board in action!

Next Time

Next time, we'll write some custom logic for Lines of Action that enforces valid (instead of arbitrary) moves to be made, and we'll give the user some better visual cues. We'll also implement a game tree search and develop a computer opponent that we can play against.

But until then, we're off to a fine start. If you're really liking this project, consider taking some time to dig into the Cocoa Drawing Guide and try to improve upon the existing board. Or go out and read about game trees.

You can download the complete project file for this article here.

Matthew Russell is a computer scientist from middle Tennessee; and serves Digital Reasoning Systems as the Director of Advanced Technology. Hacking and writing are two activities essential to his renaissance man regimen.


Return to the Mac DevCenter.

Copyright © 2009 O'Reilly Media, Inc.