In my last post, I talked about the importance of defining goals for your project. This helps us stay focused and determine when we’ve reached success, but it’s not the whole story. Today I’ll describe some strategies that I used to help me stay aligned with my goals, avoid dithering on things that didn’t matter, and get my game shipped quickly.
Software engineering presents lots of problems, and each one is likely to have multiple solutions. Should I use a database? What kind? How do I organize my project? Testing frameworks. UI technology. Dependency injection. On and on it goes.
It's important to recognize when these decisions are just details that don't help us stay aligned with our goals. When we find these details, we can put them off for later, or sometimes indefinitely! This allows us to keep moving decisively, save engineering time, and even improve our software design.
File and Folder Structure
How much time has your team spent arguing about this? Have you found the right solution yet? I contend that it's never perfect, and it doesn't necessarily help encourage loosely coupled design, which is far more valuable. I took Dan Abramov's advice on this one and just moved stuff around when I felt the need. I focused instead on separating concerns within my code, making sure that the game logic and UI logic stayed completely separate. In the end, I wound up with only a few files, so I'm glad I didn't frontload this project with a lot of subfolders to represent various abstractions that I'd never need.
While this might seem obviously true for personal projects, I'd challenge you to consider it on a team setting as well. Ask yourself if the structures you've been using are helping you to write loosely coupled code, and consider letting your structure evolve organically based on the abstractions that are actually needed in your app, which may be different than the last one you wrote!
Even the UI is a Detail
There's no reason why this game has to be played on the canvas. It could just as easily be text based like the original version. So you shouldn't write your game with the assumption that your game state will be rendered using any specific UI technology. My dreams of writing a cross platform game may have fallen flat, but at least I can still support the ability to write multiple clients for my game if I treat things like input and output as the abstract details that they are.
In practice, doing this usually means creating some kind of interface, which is just a predefined means of communication between two entities. You determine what the interface needs to support by keeping your problem in the abstract. So, let's look at a picture.
There are some things you can determine about the interface just by asking yourself questions about this picture. Some questions you might ask:
- What if a word doesn't fit on the game screen? How can I make sure they fit, in order to prevent words that are impossible to capture?
- How does the game logic know what the user has typed in, so that it can remove any matching words from the game state?
- How does the UI logic know what to draw?
It turns out that answering those questions reveals most of what we need to support. For the third point, the game loop needs to return a description of the game state that the UI can read and draw. But for the other two questions, we actually need the UI to be able to answer a couple of questions, which are all represented in this record type:
The dimensions might be obvious, and a function to return the user input might be as well, but the calculateWidth function is perhaps less so. It is needed because the game logic really doesn't know how much space a word will occupy, because it knows neither what font the client is using nor how the client renders text in any given font. So we leave it to the ui object to answer these questions.
Treating my UI as a detail and abstracting it away to an interface actually led to a loosely coupled design that helped me achieve my goal of creating a game that could be consumed by multiple UI implementations.
Solving Tough Problems
In programming, there is often a problem that turns out to be more difficult to solve than you anticipated. While I was confident that the mechanics of this game would be easy enough to implement, I had no idea that blowing some holes in a flat, orange line would give me so much trouble. I had two requirements of my implementation.
- It shows where words have crashed.
- When the line is gone, it's game over.
The first problem by itself gave me some difficulty. I tried to break the base into smaller chunks every time a word crashed, but I couldn't make the code work from that angle, and started to suspect that the mental model was wrong. Rather than assume that I was too stupid to implement the solution, I concluded that a good mental model should lead to an implementation that doesn't require a genius to code it.
I decided to invert the way that I was thinking about the problem. Instead of breaking the base into chunks, I started by keeping a record of where the crash occurred, a concept which I've since heard described as capturing negative space. Collecting this information allowed me to paint over the orange line so it looked like it had holes in it. That achieved my first requirement.
My second requirement was still unsolved, because I had duplicate/overlapping crash information in my game state, making it hard for the program to determine if the whole line was gone. But I could tell that I was closer to the right answer, giving me the confidence that I'd picked a better mental model. I could then iterate towards a better solution.
That solution was straightforward conceptually, but with a fair number of cases to sort out. At a high level, it involved merging a new crash record with existing crash records at the time of the crash. Here's the implementation:
This function is part of a data structure that manages a list of ordered crash records. It partitions that list into crashes that occurred to the left of the new crash site, and all other crashes. It looks for overlapping crash records in both of these lists and makes connections if they exist. A connection is formed by replacing an overlapping pair of crash records with a single crash record that represents the leftmost and rightmost points of the pair. At the end, the leftside list and the rightside list are put back together. It may go unnoticed, but the function also makes sure that the list of crash records stays ordered from left to right, since it always inserts new records in between the partitions.
With this data structure, I can easily ask if a certain area is completely covered in damage. All I need to do is ask if there is a single crash record that covers those boundaries.
If I call this function with x, y arguments that specify the outsides of my base, and the function returns true, that means the game is over! As a bonus, I can even calculate the percentage of my base that's covered, allowing me to display a health meter somewhere on the screen. The function below sums up the covered area from x to y and divides by the total area to give the percent covered.
The conclusion from all of this is that you don't need to have everything figured out up front. When you encounter a tough problem, start by finding a mental model that allows you to make progress. Trust your instinct if it seems like it's not getting you anywhere, go with something that gets you somewhere, and iterate from that point.
Saying NO to Enhancements
My solution for representing damage to the base was not an act of genius, but I was happy with it. It worked reliably, it performed well, and I felt rewarded for engaging in some marginally creative thinking. I even felt proud enough to brag about my implementation to a friend, who's much smarter than I am and unsurprisingly had a really awesome suggestion based on the classic game Gorillas.
He explained that to show damage to the buildings, this game used an alpha mask. If you don't know what that is, don't worry. Neither did I! Quick explanation: when you apply an alpha mask to part of an image, it means that the applied area will show something that's behind the image instead of the image itself.
Here's how it works in this context: to paint the picture above, you'd keep a two dimensional array of booleans that represents the space occupied by the buildings. You can flip one of the booleans in that map to indicate that a cell is damaged. When you paint the building, you loop over this map and apply the alpha mask when you encounter a damaged cell, so that the location of that cell will show the background behind the image instead of the image itself.
This sounds similar to what I did with representing negative space, but it works in 2D space, and the alpha mask is a more sophisticated method than my rudimentary technique of simply painting black over orange. In fact, this is probably the solution that I would need in order to implement the base the way the original game did it. This is awesome! I should totally do this, right?
Nah, settle down. I already had something working. Doing anything else means waiting longer to ship, and this solution sounded complex enough to keep me from shipping for quite a while. And for all I know, it doesn't make my game more fun to play! It was perfectly enjoyable how it already was, and this improvement could be added later if I determined that users actually wanted it. In fact, the same friend who had this great suggestion said something else to me about my game:
This game makes no sense, but I think I'd still play it
And that's good enough for me. If this is the average sentiment towards my game, let's ship it!
That's a Wrap
Today my game lives at lavish-balloon.surge.sh. I think it's simple and even rudimentary. And I also think that it's fun! In my view, this has been my most successful side project to date, and I accomplished it by defining sensible goals, sticking to them, allowing myself to be happy with small wins, and maintaining some balance in my life. Maybe someday I'll add a system for collecting feedback and start adding features that users want. But then, only if it makes me happy.