On this page:
5.1 Basic game mechanics
5.2 Defining the playing space
5.3 The player
5.4 Delegation
5.5 Ghosts
5.6 Drawing the game
5.7 Changing Places
5.7.1 Player in motion, or, I ain’t afraid of no ghosts!
5.8 World-building
5.92

5 PacMan

If interfaces and classes were all we needed to design object-oriented programs, our job would be done. We can successfully design small programs so far, but as our programs grow larger, we’ll find that we need additional abstraction mechanisms to keep our code understandable. So let’s build a larger system: we’ll write a simplified version of PacMan, using the skills we currently have. And as we extend the game to include additional features, we’ll introduce the new concepts that will keep the code clear.

5.1 Basic game mechanics

The first step in designing any program is to understand what information it must represent and manipulate.

Do Now!

What information is necessary for PacMan?

Brainstorming the basic gameplay of PacMan, we probably need to represent:

So our complete game will be structured something like

<pacman-game> ::=

    <data-definitions>

    <playing-area>

    <player>

    <ghosts>

    <in-game-items>

    <walls>

    <extras>

    <the-world>

5.2 Defining the playing space

For now, our playing area will just consist of an empty rectangular board: we’ll add walls and the ability to “loop around” from one side of the board to the other later. To represent this area, we’ll separate the logical board size from the physical size, just as we did with the rocket example in Objects = Data + Function:

<data-definitions> ::=

    (define BOARD-WIDTH 20)  ;; cells

    (define BOARD-HEIGHT 30) ;; cells

    (define SCALE-X 20)      ;; px/cell

    (define SCALE-Y 20)      ;; px/cell

    (define mtScene

      (empty-scene (* BOARD-WIDTH SCALE-X)

                   (* BOARD-HEIGHT SCALE-Y)))

5.3 The player

What game state is relevant to the player? We need to keep track of its position, its score, how many lives it has, and so on. Should these pieces of information be included in the definition of the player% class, or the world% class?

Do Now!

Which do you think will be easier to work with as we develop the game?

If we keep the player’s position in the world% class, then the world must be responsible for moving the player around. By similar reasoning, the world would need to be responsible for moving the ghosts around. But such movements are complicated (players, angry ghosts and scared ghosts all move differently), and placing all the code in the world% class would clutter it up. If we keep the player’s coordinates stored as fields within the player% class, then when the world needs to move the player, it might be as simple as invoking (the-player . move), and similarly for the ghosts. All the logic for how players and ghosts move would be placed in the most relevant classes.

This decision is one instance of a general pattern we will see many times: we are delegating responsibility over some computation (moving players and ghosts) from methods in one class (the world% object) to others (the player% class and the ghost classes).

Applying this reasoning again to the player’s score and lives, we can represent them as fields in the player class as well.

So our initial design for the player class is

<player-take-1> ::=

    ;; A Player is (new player% Natural Natural Natural Natural)

    (define-class player%

      (fields x y score lives)

      ...

      )

5.4 Delegation

TODO

5.5 Ghosts

Let’s start by just modeling angry ghosts, and come back to scared ghosts after we have the simplest possible skeleton of a game running. We don’t yet know our full data definitions, but we can at least say

<ghosts> ::=

    ;; A Ghost is one of

    ;; -- (new angry-ghost% ...)

    ;; -- (new scared-ghost% ...)

We know there will be multiple ghosts, distinguishable by their colors and positions, so our first design for our angry-ghost% class is

<angry-ghost-take-1> ::=

    (define-class angry-ghost%

      (fields x y color)

      ...

      )

5.6 Drawing the game

So far, our world just contains a player and a bunch of ghosts, and we know eventually there will be a bunch of items as well. This means we need an interface for working with a list of ghosts:

<data-definitions> ::= (part 2)

    ;; A List of Ghosts is one of

    ;; -- (new consLoG% Ghost LoG)

    ;; -- (new mtLoG%)

    (define-class consLoG%

      (fields first rest)

      ...)

    (define-class mtLoG%

      (fields)

      ...)

And an interface for working with a list of items:

<data-definitions> ::= (part 3)

    ;; A List of Items is one of

    ;; -- (new consLoI% Item LoG)

    ;; -- (new mtLoI%)

    (define-class consLoI%

      (fields first rest)

      ...)

    (define-class mtLoI%

      (fields)

      ...)

Now we can define our world:

<the-world> ::=

    ;; A World is (new world% Player LoG LoI)

    (define-class world%

      (fields player ghosts items)

      <world-drawing>

      <world-tick>

      <world-key-handling>

      )

Let’s get drawing! We know that the world must implement a method to-draw: -> Scene, and that it must somehow draw all its subcomponents into a single Scene.

5.7 Changing Places
5.7.1 Player in motion, or, I ain’t afraid of no ghosts!

At the very least, we want our player to be able to outrun the ghosts:

<data-definitions> ::= (part 4)

    (define PLAYER-SPEED 2)

    (define GHOSTS-SPEED 1)

Our player object needs to respond to keypresses, so that it moves when the users presses an arrow key. Following the design recipe, we start with examples of expected motion:

(check-expect ((new player% 3 5 40 1) . on-key "up") (new player% 3 (- 5 PLAYER-SPEED) 40 1))
(check-expect ((new player% 3 5 40 1) . on-key "down") (new player% 3 (+ 5 PLAYER-SPEED) 40 1))
(check-expect ((new player% 6 5 40 1) . on-key "left") (new player% (- 6 PLAYER-SPEED) 5 40 1))
(check-expect ((new player% 6 5 40 1) . on-key "right") (new player% (+ 6 PLAYER-SPEED) 5 40 1))

The template for the on-key method comes straight from our class definition: all we have available so far are our fields.

(define (on-key k)
  ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...)

We know that we have to handle four different keys, so we refine the template:

(define (on-key k)
  (cond
    [(string=? k "up")
      ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...]
    [(string=? k "down")
      ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...]
    [(string=? k "left")
      ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...]
    [(string=? k "right")
      ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...]
    [else
      ... ]))

Looking at the expected outputs guides our implementation:

(define (on-key k)
  (cond
    [(string=? k "up")
      (new player% (this . x) (- (this . y) PLAYER-SPEED) (this . score) (this . lives))]
    [(string=? k "down")
      (new player% (this . x) (+ (this . y) PLAYER-SPEED) (this . score) (this . lives))]
    [(string=? k "left")
      (new player% (- (this . x) PLAYER-SPEED) (this . y) (this . score) (this . lives))]
    [(string=? k "right")
      (new player% (+ (this . x) PLAYER-SPEED) (this . y) (this . score) (this . lives))]
    [else
      this ]))

Do Now!

Try running the game right now. Does it do what you expect?

Our code happily allows the player to exit the playing field. Our tests must be woefully incomplete, or they would have caught this behavior.

Do Now!

Add more tests to confirm that the player can never move outside the playing field

We clearly need to revise our implementation of on-key. We need to restrict the player’s position so that it is always within bounds. But wait...

Our player has a position, and can change positions over time. Our ghosts also have positions, and also can change positions over time. Leaving aside how players and ghosts choose where to move, should we implement the position-manipulating logic in players and ghosts? No! Instead, as we just discussed, we should factor out our position-related code into a posn% class and delegate the details of representing motion to that class.

<data-definitions> ::= (part 5)

    (define-class% posn%

      (fields x y)

      <posn-methods>

    )

Now we can revise our representation of player% to use a posn% instead of x and y fields:

<player> ::=

    ;; A Player is (new player% Posn Natural Natural)

    (define-class player%

      (fields p score lives)

      <player-methods>

      )

How might we refactor on-key? Each of the branches in that method do nearly the same thing: create a new player% with a position that has moved by some increment in some direction. Following the design recipe for abstraction, this suggests we might define a velocity% class, and add a move method to posn% that takes a vel%ocity and produces a new posn%:

<data-definitions> ::= (part 6)

    ;; A Velocity is (new vel% Natural Natural)

    (define-class velocity%

      (fields vx vy)

      ...

      )

<posn-methods> ::=

    ;; move: Velocity -> Posn

    ;; move creates a new Posn by adding the given Velocity to the current Posn

    (define (move v)

      (new posn% (+ (this #,dot x) (v #,dot vx)) (+ (this #,dot y) (v #,dot vy))))

(define (on-key k)
  (cond
    [(string=? k "up")
      (new player% (this . p . move (new vel% 0 (- PLAYER-SPEED)))
         (this . score) (this . lives))]
    [(string=? k "down")
      (new player% (this . p . move (new vel% 0 PLAYER-SPEED))
         (this . score) (this . lives))]
    [(string=? k "left")
      (new player% (this . p . move (new vel% (- PLAYER-SPEED) 0))
         (this . score) (this . lives))]
    [(string=? k "right")
      (new player% (this . p . move (new vel% PLAYER-SPEED 0)
         (this . score) (this . lives)))]
    [else
      this ]))

This is better, but still has not solved the problem of moving off the board. But notice that now our positions are all posn%s — if we had a method on posn%s that “clamped” them to within a boundary, we would be done. We’ll also take this opportunity to clean up our repetitive code:

<player-methods> ::=

    (define (on-key k)

      (local [(raw-moved-pos

        (cond

          [(string=? k "up") (this . p . move (new vel% 0 (- PLAYER-SPEED)))]

          [(string=? k "down") (this . p . move (new vel% 0 PLAYER-SPEED))]

          [(string=? k "left") (this . p . move (new vel% (- PLAYER-SPEED) 0))]

          [(string=? k "right") (this . p . move (new vel% PLAYER-SPEED 0))]

          [else (this . p)]))]

        (new player% (raw-moved-pos . clamp 0 0 BOARD-WIDTH BOARD-HEIGHT)

             (this . score) (this . lives))))

Much better. What should clamp do?

(check-expect ((new posn% 40 10) . clamp 0 0 10 10) (new posn% 10 10))
(check-expect ((new posn% -5 10) . clamp 0 0 5 5) (new posn% 0 5))
(check-expect ((new posn% 5 5) . clamp 1 1 10 10) (new posn% 5 5))

<posn-methods> ::= (part 2)

    ;; clamp : Number Number Number Number -> Posn

    ;; relocates the current point to within the provided rectangle

    (define (clamp min-x min-y max-x max-y)

      (new posn%

           (min (max (this . x) min-x) max-x)

           (min (max (this . y) min-y) max-y)))

5.8 World-building

<world-init> ::=

    (big-bang the-world)