Games and Loops

Extreme Number Guess Challenge, Revisited

Think back to our Extreme Number Guess Challenge from last time. That game was not so great after all. Why not?

Well, one reason is that the chances of winning are pretty slim and there's no real way for a player to influence the outcome. In other words, there are only 10 possible strategies (pick 0, pick 1, pick 2, …) and none of them is any better than the rest. So it's not very interesting.

Let's consider two alternatives:

  1. The player can keep guessing until they get it right.
  2. The player gets 3 guesses, but the program gives them a hint after each guess.

Both of these are much more winnable, but which has less "strategy collapse"? There's not much you can do for the first one besides guessing each number in turn, but for the second there's room to use some more complex strategy: Guess 5, then if the hint says that the true value is higher then guess 8, then if the hint says the true value is lower you've got a 50/50 chance!

So there are two tweaks here that help promote our first little Rust program into a bona fide game that would be right at home on any graphing calculator:

  • Both variations introduce a game loop that gives the player opportunity to interact with the game over time
  • Variation 2 provides usable feedback that helps the player make more informed decisions

Along with our initial ingredient of user input, we now have all we need to make a real (if simple) game.

Number Guessing Game

Open a terminal in the parent directory of the extreme number guessing game and run cargo new --bin --edition 2021 number-guessing-game. Put rand into your Cargo.toml file…

[package]
name = "number-guessing-game"
version = "0.1.0"
authors = ["Joseph C. Osborn <joseph.osborn@pomona.edu>"]
edition = "2021"

[dependencies]
rand = "0.8.5"

And now we can implement our guessing game in src/main.rs:

// First we'll import a useful function and trait from rand...
use rand::{thread_rng, Rng};
// and the std::io module which will let us get user input.
use std::io;
// We need the Write trait so we can flush stdout
use std::io::Write;

// Every program needs an entry point:
fn main() {
    // Generate a random number.
    let number: usize = thread_rng().gen_range(0..10);
    // We could have written this and skipped the type annotation (why?):
    // let number = thread_rng().gen_range(0_usize..10);
    // Print out a prompt...
    println!("I'm thinking of a number between 0 and 10.");

    // We'll need an owned String to read input into.
    // Rust's Reader API tries to be efficient by reusing allocations.
    let mut input = String::new();

    // This part is new!
    let mut guesses_left = 3;
    while 0 != guesses_left {
        println!("You have {} guess(es) left...", guesses_left);
        print!("> ");
        // Flush so that the prompt is definitely printed
        io::stdout().flush().unwrap();

        // Don't forget to clear out our input string scratchpad!
        input.clear();

        // Actually read a line---call the stdin() function from the io namespace,
        // which gives us something on which we can call read_line.
        // Then unwrap it---can you think of better error handling here?
        io::stdin().read_line(&mut input).unwrap();

        // Trim whitespace off of input.
        // This shadows input for the rest of the block, but when we get back up to `input.clear()` above it's the String again.
        let input = input.trim();

        println!("You guessed {}...", input);

        // Can we do better error handling than "unwrap" here?
        let guess: usize = input.parse().unwrap();
        guesses_left -= 1;

        if guess == number {
            println!("You got it!");
            break;
        } else if guesses_left == 0 {
            println!("I was thinking {}!", number);
            println!("You didn't get it!");
            break;
        } else if guess < number {
            println!("Too low!");
        } else if number < guess {
            println!("Too high!");
        }
    }
}

What's next?

This is technically a game but it's quite bare-bones. Right now we have these game-y ingredients:

  • An input/output cycle
  • The player can learn about the game over time

We sort-of have one more, but it's quite simplistic:

  • The player can alter the state of the game

The idea of interesting mutable state is the missing piece we need to make "real" games. There are two main ways to address mutable state in a game:

  1. The game loop mutates game state in place
  2. The game loop determines a new game state from the previous state

The first approach is like working on a sheet of paper, erasing and writing updates as we go; the second is like having a stack of sheets of paper and writing a little new stuff on each new sheet while copying over the parts that stay the same. In fact, we only need two "sheets"—the current one and the next one. Each approach has its own trade-offs: we avoid copying for 1, but we can end up looking at "partially updated" states; we can also more easily decouple rendering from simulation with 2. The example above uses the first approach, but in this class we'll tend to prefer the second.

Whatever approach we pick, we generally want to organize the different aspects of our game simulation in some way. Common breakdowns for action games include "input", "collision", "physics", "animations", and "resources"—these functional or task groupings tend to be called systems, although that word can suggest everything from the low-level collision system up to high-level concepts like a fluid dynamics system or crafting system.

Game Logics and Game Systems

In this class, we'll separate out the idea of technical game systems from the underlying game design concept of systems of interactions—we'll build the latter out of operational logics, and from those logics we'll construct game mechanics and other higher level concepts:

  • Control logics: Button and axis inputs are mapped onto abstract game actions, often located in particular characters.
  • Collision logics: Stuff can happen when things touch.
  • Physics logics: Things move in continuous space, things have physical properties like mass and can influence each other.
  • Resource logics: There are pools of resources that can be exchanged and converted between forms. Things can happen when resources cross certain thresholds.
  • Pattern matching logics: When objects enter a certain arrangement in space or time, something can happen.
  • Game state logics: The game can be in different modes at different times.
  • And so on (for a full set of logics, see the list).

Lots of mechanics can be built out of combinations of these logics:

  • When the player spaceship touches an asteroid, the player loses some health resource
  • When the ball hits the paddle, its velocity's x and y components are each flipped
  • If the player's health drops below 0, the game is over
  • If the player holds the grab button while touching a box, they can pick up the box; then if they're moving the box will move too; but while they're holding it their stamina goes down, and if it's empty the player will drop the box.

Let's look at how we can build a few different genres of games by putting together different sets of logics.

First, we'll examine arcade games, sometimes called "graphical-logic games". These games (like Pac-Man, Pong, Asteroids, or Balloon Fight) combine collision and physics logics with simple resource, character-state, and control logics. Let's look at a few screenshots of Balloon Fight and try to figure out what's going on:

2023-04-27_10-04-55_Balloon Fight (USA)-230425-134410.png
2023-04-27_10-07-18_Balloon Fight (USA)-230425-134413.png

In Balloon Fight, you tap a button to flap your character's arms and rise above your opponents; if you descend onto their balloons they pop and you earn points, but if your balloons are popped you lose. You also lose a life if you fall into the water or are struck by lightning. Interestingly, the map wraps around horizontally—so you can exit the right side of the screen to appear on the left side.

While these constitute something like the "rules" of the game, we can see they're made up of a few pieces: flapping (control, character-state), rising and falling (physics), stuff happening when things touch (collision), points going up and lives going down (resource). If the rules are tweaked slightly and we add in camera logics, we end up with Balloon Fight's second mode, "Balloon Trip", which does away with the humanoid enemies and has the player float leftwards forever, in 1985's chillwave version of the endless runner genre.

2023-04-27_10-09-14_Balloon Fight (USA)-230425-134428.png

These two are clearly both variations on the same foundational rules and logics. What if we were to add a new logic? Incorporating links between discrete spaces, we could obtain distinct levels or stages; developing the character-state logics a bit further yields something like this:

2023-04-27_10-11-37_Super Mario Bros. (World)-230425-133702.png

Mario can jump, get fire powers, and do some other things, but his main advantage over Balloon Fight Guy is the ability to go down pipes and flagpoles to get to new areas. The use of linking logics here gives us a whole world of stages to play in, and a progression based on seeing more of that world. Tweaks on this formula give you the platformer genre.

Emphasizing resources a little bit more and adding a progression scheme takes us to adventure games like Metroid:

2023-04-27_10-19-07_Metroid (USA)-230427-092521.png
2023-04-27_10-19-07_Metroid (USA)-230427-092529.png
2023-04-27_10-19-07_Metroid (USA)-230427-092553.png

We can branch off in a couple directions from here. If we add selection logics and supplement our numeric resources into character inventories, we get to adventure games like The Legend of Zelda.

2023-04-27_10-19-39_Legend of Zelda, The (USA)-230425-133800.png
2023-04-27_10-19-39_Legend of Zelda, The (USA)-230425-133808.png

If we removed physics and made the resource and chance logics more sophisticated, we'd move towards role-playing games like Final Fantasy:

2023-04-27_10-14-40_Final Fantasy Restored (Tweaked)-230425-134314.png

These games are split into a number of different modes, have strong progression mechanics, and make extensive use of randomization or recombination of smaller pieces. Moreover, selection logics are vital for choosing among the many moves and abilities and items used in these kinds of games:

2023-04-27_10-15-04_Final Fantasy Restored (Tweaked)-230425-134327.png
2023-04-27_10-14-46_Final Fantasy Restored (Tweaked)-230425-134235.png

Hopefully this progression of examples shows that genres are just conventional arrangements of logics, and within a genre we can get a lot of variety by changing the emphasis we give to one logic or another in their composition.

Games vs Engines

Looking at Balloon Fight and its Balloon Trip mode, it should be clear that the rules are substantially the same—they're two different games built of the same pieces, but arranged in different ways. Modern "game engines" are designed to be much more flexible still. Engines like Godot, Unity, or Unreal serve as programming platforms on which a multitude of different specific games can be made. More specialized engines like PuzzleScript, Inform 7, or Bitsy trade off some flexibility for ease of use.

In our class, we're going to making two small game engines starting the week after next. Their flexilibity will be more like the Balloon Fight example; we'll make two games in each engine which share many of the same underlying systems and rules, but have some distinctive properties that make them feel like different games. For today, we'll think about to recombine bits and pieces of games to make new ones.

Mini-lab: Game Bits and Pieces

Start with a genre composition like arcade games or platformers. Then, looking at the operational logics catalog, think about adding one logic and removing another and imagine two different games you could make with that combination of operations. You can sketch out drawings on paper or just describe the rules. It can help to look at the catalog and check out the abstract processes and operations to see what your "atoms" are.

If you're having trouble, instead take three games you've played in different genres and try to break them down into logics. One way to do this is to think about the rules—the "if then" sort of things that happen—and think about the underlying ideas from which the conditions and effects are drawn. For example, combos in a fighting game are a new idea—pushing buttons in a particular pattern over time is substantively different from pressing a button to have something specific happen. Similarly, a line disappearing in Tetris is not something that makes sense to think of from a collision standpoint.

Unit 1: Text-Based Game

Here are a few helpful excerpts from the syllabus about the unit 1 text-based game, which is due at the end of week 3 (demo on 9/14, turn-ins on 9/15).

For Unit 1 you're still learning a brand new programming language, so it might be a good idea to start from a given game design and implement it as best you can. I'm suggesting a Pac-Man like game where you have a character moving around in a maze, with enemies that can defeat the player character on contact (or, to go full Pac-Man, have a special pickup that gives the player the temporary ability to defeat the enemies). If the player collects all of the items placed around the stage, they win! You could also make it so that collecting the items unlocks a door which the player must exit to win, or fill the stage with keys and locked doors. It would be great to have two kinds of enemies: one that moves towards the player, and one that moves in a different way (e.g. away from the player, or towards a point ten steps ahead of the player, or…). You can make this a turn-taking game or a real-time game. You could give the player a number of lives, with a game over triggered when they're exhausted.

Here are a few more ideas to stimulate your imagination. Each team will meet with me during week 2 to make a grading contract.

  • At least two entities that move in different ways from the player
  • Some kind of animation or movement over time
  • At least two things the player can interact with, one of which is moving
  • Some kind of interactive interface element
  • A UI with two visible areas that are always visible, and one screen area that's only visible sometimes (e.g. a menu)
    • Consider the ratatui crate
  • Using rust's async features
  • Real-time movement or animation
  • Procedural generation of text or assets
  • Music/sfx; music should loop well. Consider the kira or CPAL crates.
  • Title screen and polish

Grading for a unit is based on completeness and features. As a team, each project group and I will come to an agreement on what constitutes an adequate feature set.

Completing a unit means completing all of its games (the text-based unit just needs one game; the other units need two games each). Completing a game means sending me the following during the course of the unit:

  • In the first two weeks of each unit (this time around, within the next week):
    • A feature plan and description of your game(s)
  • At the time of the mid-unit playtest (we're skipping this for unit 1):
    • The code of the engine and games, ready for me to cargo run --release on my linux machine. If it doesn't run, I can't grade it!
    • A brief summary of how the games changed (or didn't) since the original plan, and the main things you learned so far
  • Just before the end of the unit (9/14):
    • A demo of the game ready to playtest in class
  • At the end of the unit (9/15):
    • The code of the engine and games
    • A trailer for your game: a 1-2 minute edited video showing off the features you hit.
    • A postmortem for your game: What features you hit, a brief description of the game, what went well, what went poorly, what you learned from playtesting, what you learned from coding it up (e.g. for your second game, how easily could you reuse code meant for the first game?).