Lac-Man: An inferior clone of Pac-man

Lac-Man: An inferior clone of Pac-man

This post consists of me sharing some embarrassingly simple programming practice I’ve done lately. Technically, I’ve been programming in some form or another for, geez, over a decade at this point. But the vast majority of that time has been spent doing data analysis in R. This is as opposed to making actual software that anyone else should or even could use. So despite the fact that I’ve spent the majority of my adult life telling computers what to do, I realized recently that I’m not sure how much I actually know about programming. I decided to test my skills doing something roughly at the level of a final project for a comp-sci 101 course: Building a basic (very basic) Pac-man clone.

I’ve decided to call it “Lac-man” because it’s lacking just about every feature one could want in a video game. It has no animations, no scoreboard, and no real enemy AI. I’ll try to mark a few more things it lacs over the course of this post. But it is technically a game! It has a win condition and a failure state. I can proudly say I even lost once! Achieving either of these results in basically the same outcome. The game resets. I didn’t even include a little “You did it!” or “You suck!” to separate the two. The player is just kicked back to the starting condition. Still, it meets the bare minimum functional definition of a game. I decided to stop where I did because if I didn’t, I’d be tempted to keep fiddling with the details forever and never actually show the game to anyone. I’d learned what I originally set out to learn, which is how comfortable I am with the basics of programming. I think that any further learning would be better served by a new project. More on that at the end. 

I elected to use JavaScript despite having basically no experience with it because it seemed like the easiest way to get a minimal game running without using an engine or a bunch of libraries. Like I said, I’m used to programming in R. R is a bit of a quirky language in that there’s usually a million ways to do things, and most of them aren’t how you would do things in a normal programming language. This blog post perfectly sums up this problem. To quote that article, it’s easy to know “enough R to be clever, but not enough R to avoid being stupid.” For that reason, I spend a lot of time trying to build clever, pseudo functional ways of doing basic things when, in any other language, I would have just brute-forced the thing with a for-loop and a hash table. Speaking of brute force, since the purpose of this project was to test how well I already understood the basics of programming, my strategy was to do things as quickly, simply, and dumbly as possible. Using JavaScript felt absolutely liberating. While I know it’s possible to do plenty of fancy things with JS, I solved my problems with Comp Sci 101 techniques. Your iteration, your conditionals, maybe throw in a functional here or there, and writing basic OOP like an caveman. Not saying OOP is for cavemen. Just that I happen to write it like a caveman.

I tried to trust my gut and always tried to implement something in a way I already understood before reaching out to Google for a fancier solution. That being said, I started out by completing this bouncing balls tutorial on Mozilla Development Network. This felt like a reasonable first step, since I had very little experience with JavaScript and wasn’t aware all the ways it was possible to make a game using only vanilla JS. So you may notice some similarities between the way I set up my basic “sprite” class and that tutorial’s “ball” class. This tutorial is also why I decided to use the canvas API, as opposed to developing the game using, for example, HTML tables. I also did some preliminary Googling of “Pac-man vanilla JS” back in December before coming back to the project recently. I think I watched a combined five minutes worth of video tutorials from this search and don’t recall copying any code. But there’s a chance something I saw there influenced my thinking subconsciously, so I wanted to mark that as a disclaimer so I don’t accidently plagiarize somebody.

To make a Pac-man-like, we need a player character, some ghosts, a map for them to move around on, and a function to update the game state. The main action of this script (line 302 if you’re following along on GitHub) begins by initializing these elements. We then draw our initial map to the screen with the “fillScreen” method and draw our characters with the “draw” method. We also set up an “is playing” flag which we’ll use to pause the game. Finally, we create a “loop” function to keep everything in motion. This will redraw the map every time the loop function is called, check the position of the characters, and make any necessary updates to the game state, i.e., erasing pellets that Lac-man has already eaten, changing the color of the ghosts when Lac-man eats a power pellet, etc.

const screen = new gameBoard(initialBoard);
const lac = new lacMan(1, 1, 25, "yellow", screen);
const inky = new Ghost(15,13, 25, "cyan", screen, 0, "right");
const pinky = new Ghost(6,13, 25, "pink", screen, 0, "left");
const blinky = new Ghost(7,18, 25, "red", screen, 0, "down");
const clyde = new Ghost(14,18, 25, "orange", screen, 0, "down");
const ghosts = [inky, pinky, blinky, clyde];

screen.fillScreen();
lac.draw(lac.size);
ghosts.forEach((x) => {x.draw(x.size)})

let isPlaying = true;

function loop() {
if(isPlaying){
        screen.fillScreen();
        screen.checkSprites(lac, ghosts);
        lac.draw(lac.size);
        
        for (const ghost of ghosts){
            ghost.updatePosition();
            ghost.draw(ghost.size)
        }
        requestAnimationFrame(loop);
}
}

window.addEventListener('keydown', function (e){
    if (e.key === "Escape"){
        if(isPlaying){
            isPlaying = false;

        }else{
            isPlaying = true;
            loop()
        }
    }
})
loop();

Now let’s take a look at the map.

I created an excel spreadsheet and filled it with various characters to form the layout of the map. An “x” in a cell represented a wall, an “o” a regular pellet, a “P” a power pellet, and “g” the “ghost zone.” This was originally intended to be an off-limits area where the ghosts could retreat to when they were eaten, but it wound up just being an empty space in the middle of the map. This actually caused a few problems further down the line with the ghosts’ pathfinding. Also, the map is more inspired by the original Pac-man map than an exact copy. The top half is pretty similar, but underneath the ghost zone there are a few differences in layout. Chalk it up to creative license on my part. Gotta be different enough to be legally distinct anyway.

Despite my goal of relying on what I already knew, I struggled for a long time looking for an elegant way to make the map. I thought about downloading an image of the original Pac-man map and trying to copy the color pixel-by-pixel. That was a bust. I tried to read this spreadsheet directly into the game itself, but it turns out vanilla JS doesn’t make it trivial to read files from on one’s computer. So, we come to our first dumb-dumb solution. I Googled how to convert a CSV into a JSON, did that in a separate script using Node.js, then just copy-and-pasted that JSON into my game script as an array-of-arrays. Not the most elegant solution, but our first examples of “hey, it works.” 

  • LAC 1: The map is absolutely enormous, so much so that I was only able to view it when I zoomed all the way out on my browser. A way to adjust the size of the map in the window would come in handy.

Here we enter the “turn off-brain, write classes” section of the program. For the bulk of the rest of this post, baby’s first OOP is going to be our hammer and every problem will be our nail. Most of the work is done by three classes: “gameSquare,” “gameBoard,” and “sprite”, with the latter breaking down into two subclasses, “pacMan” (whoops, I mean “lacMan”) and “ghost.”

First up, “gridSquare,” or just, “grid square” so spellcheck stops yelling at me. The constructor for this class takes two arguments, an x and a y coordinate, which together define a location on our canvas. It has one method (drawPlaySpace) for drawing the maze itself and its walls, and one (drawPellet) for drawing pellets and power pellets. Game board is our next object. Its constructor takes an initial board configuration as a parameter and the object itself has two properties. The first property, “board,” is initially identical to the board provided as an input to the constructor and is updated as the game-state changes. I initialize this by stringifying and then parsing the board as a JSON object, because apparently JS is a buck wild language where that is somehow the most efficient way to create a copy of the original argument. And in this case, it is necessary to copy that argument rather than modify it in place so that we can keep a copy of the original map for when the game restarts. Anyway, the second property is the “grid”, which consists of an array of arrays of grid squares to visually represent the contents of each cell of the board.  

class gridSquare{
    constructor(x, y){
        this.x = x;
        this.y = y;
    }
    drawPlaySpace(color){
        ctx.fillStyle = color;
        ctx.fillRect(this.x, this.y, squareWidth, squareHeight);
    }
    drawPellet(size){
        ctx.beginPath();
        ctx.fillStyle = "white";
        ctx.arc(this.x + (squareWidth / 2), this.y + (squareHeight / 2), size, 0, 2 * Math.PI);
        ctx.fill();
    }
}

The game Board class has two methods. The first, “Fill Screen” does exactly what it says it does. It checks each cell of the board property and creates a grid square to draw the correct image on the screen according to that cell’s contents. This is also how I check whether the player has reached the win state. As the function loops through to draw each square, it also checks whether there are still any pellets on the screen. If it finds none, the player “wins.” “Win” in quotes because all this does is refill all the pellets on the map. It doesn’t even move Lac-man back to his starting position. It may have been a cleaner separation of logic to have the win-condition check separate from the function that fills the board, but while performance wasn’t exactly a top concern, I did periodically decide to pretend I was actually optimizing this thing. I was worried having another for-loop run every time the game updated would risk dropping me below 60 fps if the player didn’t have at least a 4070.

class gameBoard{
    constructor(board){
        this.board = JSON.parse(JSON.stringify(board));
        this.grid = [];
    }

    // it might would be better "seperation of concerns" to have the "check win" and "fill screen"
    //tasks as separate functions. But then I'd have to loop through the whole screen twice per frame
    fillScreen(){
        let xPoint = 1;
        let yPoint = 1;
        let winFlag = true;
        for(const row of this.board){
            xPoint = 1;
            let rowGrid = []
            for(const cell of row){
                let square = new gridSquare(xPoint, yPoint);
                rowGrid.push(square)
                if(cell == "x"){
                    square.drawPlaySpace("blue");
                }
                else{
                    square.drawPlaySpace("black");
                    if(cell == "P"){
                        square.drawPellet(20);
                        winFlag = false;
                    }
                    else if(cell == "o"){
                        square.drawPellet(10);
                        winFlag = false;
                    }
                }
                xPoint += squareWidth;
        }
        this.grid.push(rowGrid)
        yPoint +=squareHeight;
        }
        if(winFlag == true){
            this.board = JSON.parse(JSON.stringify(initialBoard));
        }
        }
        
    checkSprites(lacs, ghosts, home = {x: 13, y: 10}){
        switch (this.board[lacs.yCord][lacs.xCord]){
            case "o":
                this.board[lacs.yCord][lacs.xCord] = "e";
                this.grid[lacs.yCord][lacs.xCord].drawPlaySpace("black"); 
                break;
            case "P":
                this.board[lacs.yCord][lacs.xCord] = "e";
                ghosts.forEach((x)=>x.flee());
                break;
        }
        //every frame dawg
        //wait a minute. This passes the old lac by REFERENCE. I just realized how insane that is
        //OOP is whack
        for (const ghost of ghosts){
            if(lacs.yCord === ghost.yCord && lacs.xCord === ghost.xCord){
                if (ghost.color === "#66ff00"){
                    ghost.xCord = home.x;
                    ghost.yCord = home.y;
            } else if (lacs.lives > 0){
                lacs.move(1,1)
                lacs.lives -= 1;
            } else {
                lacs.move(1,1)
                this.board = JSON.parse(JSON.stringify(initialBoard))
                lacs.lives = 3;
            }
            }
        }
    }
}

The second method, “checkSprites,” is responsible for checking the game-state of all the sprites on the board. It first checks Lac-man’s position to see if the grid square he’s occupying contains a pellet. If it does, it erases the pellet and changes the square to an empty one. Similarly, if Lac-man is occupying a square with a power pellet, it causes the ghosts to go into “flee” mode. In this state, all the ghosts turn bright green for ten seconds and can be eaten. If they are, they teleport back to the ghost zone. After checking the position of Lac-man, the method checks the positions of all the ghosts to see if they occupy the same game square as Lac-man. If they do, and the ghosts aren’t in “flee” mode, the player loses one life and, unlike in the win condition, is kicked back to the starting position in the top left of the map. If this happens three times, the game ends, the player goes back to the start position, and the board is refilled.

  • LAC 2: Since there’s no scoreboard, eating the ghosts doesn’t have any other effect besides sending them back to the ghost zone. At best, it serves as a way to save yourself if one happens to be right on your tail as you happen to be approaching the power pellet.

The base sprite class is relatively simple. All sprites have an x-coordinate, a y-coordinate, a size and a color. These are all pretty self-explanatory. They also take the gameboard as a parameter, just because I wanted to pretend every sprite wasn’t referencing the same global object. This is done for the “move” method, which takes an x and y coordinate as input. It then checks that coordinate on the gameboard to make sure it’s not a wall. If it isn’t, the sprite’s coordinates are changed to that location.  The “draw” method is also straightforward. It just draws circle of a given radius and color. (Admittedly, this does repeat the logic of the “draw” function from the “draw pellet” function). All the lacMan class does is extend sprite with a number of lives (three by default) and creates an event listener to allow the player to control Lac-man with the WASD keys.

  • LAC 3: Pac-man moves continuously in a given direction until he either hits a wall or the player turns. Lac-man only moves when the player presses one of the WASD keys. This gives the player more control, but also risks giving them carpel tunnel.

Coding the ghosts was my favorite part of this whole exercise. As might be obvious, the ghosts’ “direction,” attribute encodes the direction they’re currently traveling. The “predictMove,” method acts as a pathfinding algorithm. It’s very basic, but I came up with it all by myself. The ghosts will keep heading in their current direction until they hit a turn, at which point they’ll randomly select a new direction that doesn’t run them into a wall. It has the major drawback of not dealing well with situations when the directions available for movement are wider than one cell. This is most clearly seen in the way the ghosts behave in the ghost zone. Rather than finding a path outside the zone, or continuing in a straight line, the ghosts sort of Brownian motion their way around the zone, bopping around until they get lucky enough to escape. Funnily enough, this actually shaped the meta of how I played the game. I usually declared it too risky to collect the pellets that border the ghost zone when the ghosts were in there, and usually made it a point to collect the pellets at the corners at that time. Move over, Europa Universalis III, this is where the real strategy is! The other noteworthy thing about the ghosts is that they have a “clock” property that makes it so they only move every fifteen loops. If they didn’t, they just moved way too quickly.

class Ghost extends sprite{
    constructor(xCord, yCord, size, color, boardGrid, clock = 0, dir = "right"){
        super(xCord, yCord, size, color, boardGrid);
        this.clock = clock;
        this.dir = dir;
        this.baseColor = color;
    }

    predictMove(){
        let possibleMoves = []
        
        let straight = this.boardGrid.board[this.yCord + this.turn(this.dir).y]
        [this.xCord + this.turn(this.dir).x];
        
        if (straight != "x"){
            possibleMoves.push(this.dir);
        }
  
        if(this.dir == "left" || this.dir == "right"){
            if (this.boardGrid.board[this.yCord + this.turn("up").y]
            [this.xCord + this.turn("up").x] != "x"){
                possibleMoves.push("up");
            }

            if (this.boardGrid.board[this.yCord + this.turn("down").y]
            [this.xCord + this.turn("down").x] != "x"){
                possibleMoves.push("down");
            }
        }
        
        if(this.dir == "up" || this.dir == "down"){
            if (this.boardGrid.board[this.yCord + this.turn("left").y]
            [this.xCord + this.turn("left").x] != "x"){
                possibleMoves.push("left");
            }

            if (this.boardGrid.board[this.yCord + this.turn("right").y]
            [this.xCord + this.turn("right").x] != "x"){
                possibleMoves.push("right");
            }
        }
        return (possibleMoves[(this.randomNumber(0, possibleMoves.length - 1))])
    }

    randomNumber(min,max){
        return Math.floor(Math.random() * (max - (min) + 1)) + min
    }

    turn(direction){
        switch(direction){
            case "left":
                return({x: -1, y: 0});
            case "right":
                return({x: 1, y: 0});
            case "down":
                return({x: 0, y: 1});
            case "up":
                return({x: 0, y: -1});
            default:
                return({x: 0, y: 0});
        }
    }

    updatePosition(){
        this.clock += 400;
        if(this.clock >= 6000){
            this.dir = this.predictMove();
            let nextSquare = this.turn(this.dir);
            this.move(this.xCord + nextSquare.x, this.yCord + nextSquare.y);
            this.clock = 0
        }
    }

    flee(){
        this.color = "#66ff00";
        //gonna need a timeout
        setTimeout(() => {
            this.color = this.baseColor;
        }, 10000);
    }
}

An aside. As someone used to “functional” programming, I took a perverse glee in mutating state EVERYWHERE. It boggles my mind that I can pass an object as a parameter, change something about that object in the function, and have that change apply to the original object outside of that function. I can imagine this might have caused real trouble if the program were bigger, but I actually found it convenient not to have to return new instances of every object every loop. And while performance wasn’t really an issue, I can see how having to create brand new objects each loop could really affect performance in a real game. I was at least hygienic enough that objects didn’t just reach out and manipulate each other’s inner workings. The “check sprites” method doesn’t just reset the sprites’ x and y coordinates from within the gameboard class. It has to pretend it’s being safe by accepting the sprite as an argument and calling the move method on that sprite first.  

To wrap things up – what did I learn? Pretty much that it’s okay to do things the easy way. I’m not saying don’t bother learning anything beyond the basics. I wouldn’t have spent 23 years in school if I didn’t like learning. But I also have traditionally preferred to explore rather than exploit. I tend to fall into rabbit holes trying to find the perfect way to do something before I bother to, you know, do something in the way I already understand. I think in software engineering, this might be called “premature abstraction.” So using the tools you have at your disposal can get a surprising amount of things done. Confidence boosted.

Next steps could go in one of a couple directions. I could try to make a real game using something like Godot. That could be a fun way to kill a couple weeks, but, since I’m probably not going to get a job at Bethesda any time soon, maybe not the best avenue for growing my skillset. The other, which I’m leaning towards, is to double down on the JS side of things by learning a framework and making something in a way that actually follows best practices. My goal in either case isn’t to become a developer, but to show off skills that might come in handy in the career course I ultimately end up pursuing. I would like that to be UX research, so I’m thinking I could build something like the experimental software that I used for my dissertation (Thanks Chun Chan, for programming that!). The goal would be to have a very simple program for measuring what a subject (or, I guess, user) clicks and how long it takes them to do so.

Or I could do something completely different. I’ve wanted to do a “linguistics with cartoons” project for a long time. Fitting the theme of today’s post, that would make the maximum use of the skills I already have, but would do the least for building new hard skills. My cup runneth over with options, but I can only drink one sip at a time.

Code on GitHub