Sign in
Log inSign up
Game Dev From Zero - Part 1: Hello, Rust-Lang

Game Dev From Zero - Part 1: Hello, Rust-Lang

Marco Alka's photo
Marco Alka
·Oct 10, 2018

Game dev. The art of writing games. It's a little bit of a childhood dream of mine to write a game. A game, which is enjoyed by many people. A game, which reflects the world of my dreams. I can do whatever I want, and show other people what I think about about, what my 8th grader syndrome made me see and how everyone can be a distinctive hero there. My own little sandbox. I guess, it's this kind of motivation also driving other people into game dev.

However, game dev... there's so much going on, so many things to do and consider. Big dreams are one thing, but where to start is a completely different world. With this article, I want to introduce you to game development. I want to give you a little tour of where to start, how to explore possibilities and revive my journey a little bit at the same time. I will try to construct different games from the ground up while writing this article, so you can experience game-dev live. For now, I am no match for the likes of Casey Muratori, who streams his sessions, which is why I would like to stick to writing for now :) Also, don't expect regular updates, as I have quite a lot on my plate, so I will write whenever I find time. I hope the situation improves in the future, though.

This article was later released for Web-Dev, which is a high-level alternative for max-portability game dev!

Content

Requirements

Before you read on, if you plan on playing around with code yourself, please make yourself comfortable with Rust. This article series requires you to have basic knowledge of the language and the necessary tooling set up. I will not explain the finer details of the language to you, if not necessary for game development itself or understanding a complex block of code.

What is a game?

Ok, with that out of the way, let's start. Game dev. First of all, what we want to do is start simple. What exactly is a game? Taking a look around, there are many types of games. Card games, sport games, table-top games, pen-and-paper games, video games, and many more. They all have in common, that they serve the purpose of entertainment and that they have a set of rules, which define the structure in which the game should be conducted. Usually, it does not matter how complicated the rules are, as long as it's fun, anything can be a game! This realization gives us the freedom to realize our dreams, or at least find something to do over boredom.

In order to start out developing a game, we need to first settle on a few things:

  • What type of game do we want to create?
  • How can a player interact?
  • What should be the game rules?
  • What is the goal?
  • Why is it fun?

  • What language do we want to use for programming?

  • Which platforms do we want to support?
  • Which tools do we need?

What is our game?

Since we want to start at the very beginning, let's start with one of the simplest games we can come up with: a child's game called "Guess the number". It should be a text-only game, because text is very simple to handle. A player should just use the keyboard as input. It's standardized and easy to use from most programming languages. The rules for our first game should be, that a player has to guess a number thought up by the computer. The computer has to tell the player if the number is greater or smaller tan the thought-up number. The goal is to guess the correct number. It's fun... it's mostly for kids who want to brag about their new-learned math knowledge. For adults, it's fun to show off and play their first self-made game ;)

As for the technical aspects, we want to go with a modern setup. As such, I choose Rust, which is very good for concurrency (which will become important for bigger applications later on). We usually want to support as many platforms as possible for as little money as possible. Well, Rust is really great at cross-platform, so let's strive for the three main desktop platforms for now: Windows, macOS and Linux. As for tools, we need an IDE and a compiler. For my articles, I will use the JetBrains platform for the IDE.

Setting up the project

So, without further ado, let's jump into some Rust action. Let's create a new Rust project:

> cargo new guessthenumber --bin
    Created binary (application) `guessthenumber` project
> cd guessthenumber
> ls -r


    Directory: D:\projects\guessthenumber


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----       2018-10-10  12:55 PM                src
-a----       2018-10-10  12:55 PM             19 .gitignore
-a----       2018-10-10  12:55 PM            112 Cargo.toml


    Directory: D:\projects\guessthenumber\src


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2018-10-10  12:55 PM             45 main.rs

>

What you can see above is the typical structure of a Rust project. It includes a src folder, which contains the source code (which always starts with the main.rs file. Always.), a .gitignore file for our convenience, which ignores the build-directory, and a Cargo.toml , which is a configuration file containing all the necessary information for Cargo, the package manager and build program for Rust projects. Let's take a look at the config file for now:

[package]
name = "guessthenumber"
version = "0.1.0"
authors = ["Marco Alka <DEDUCTED>"]

[dependencies]

In this file, we can setup meta information and packages we depend on, as well as build configurations and other useful things. Personally, I usually add a few lines of configuration to all my Rust projects, which make sure the resulting binary is the best possible product for what it was build. We will use this configuration from here on for all projects:

[package]
name = "guessthenumber"
version = "0.1.0"
authors = ["Marco Alka <DEDUCTED>"]
publish = false

[profile.dev]
opt-level = 1
codegen-units = 5

[profile.release]
panic = "abort"
lto = true
codegen-units = 1


[dependencies]

First of all, I added package.publish and set it to false. This configuration prevents us from accidentally uploading the project as a new package to the global cargo repository. Then I added to profile overwrites. The different profiles are used for different compilation scenarios. dev is used for development and focuses on debugging. release is for the final program, which should be shipped, and is geared towards performance. There are default settings for the profiles, but overwriting the defaults, we achieve better results.

In the dev profile, we change the opt-level from 0 to 1, which results in slightly better performance without impairing the debugging capabilities. It's a small thing, but will be very visible in larger projects later on. As for the codegen-units, they define the number of threads which should be used for parallel compilation. My computer has an i5 3570K with four cores, so, as a rule of thumb, I inserted core-count plus one, which is five. The default value is 16, so I can achieve better results on my smaller CPU with a lower number.

For the release profile, we want runtime performance above all and no debugging at all. A secondary goal is to reduce the executable size, so we can ship a small game in the end. In order to get runtime performance, we may decide to forgo some of rustc's developer candies, and have a slow compilation as a result. Which does not matter, since we won't use the release profile too often and when we do, we actually want to have the most performance we can get. In order to achieve that, we let the program crash hard without it giving a reason for its hard crash (that's what's logging for anyway), which reduces file size and the amount of information stored about the source code inside the executable by setting panic to abort. Usually, when calling panic!() in Rust, the program would start producing a stack trace and try to output as much info as possible about what caused the crash. That's not relevant for our players. Next is lto, which is short for "link time optimization". What it means is that when several packages are used, they have to be connected. The way they are linked can be optimized in a way which yields size benefits. Without lto, the whole packages are just glued together, however the optimization actually removes unused code, which yields a smaller executable. This option is deactivated by default, however we activate it, since it's a simple gain for size benefits. Last but not least, it's codegen-units, again. The problem with parallel compilation is, that the compiler cannot use its full potential to optimize a program (see docs). So we have to set it to 1 thread, only. Slow compilation, speedy runtime performance.

Hello World!

Now that the project is set up, let's take a look at some source code! At the moment, we only have the main file, which is src/main.rs:

fn main() {
    println!("Hello, world!");
}

Wow, that's it. We already have a Hello World program in the project template. Nothing to do here, so let's see what happens when we run as dev and release:

> cargo run
   Compiling guessthenumber v0.1.0 (file:///D:/projects/guessthenumber)
    Finished dev [unoptimized + debuginfo] target(s) in 2.12s
     Running `target\debug\guessthenumber.exe`
Hello, world!
>
> cargo run --release
   Compiling guessthenumber v0.1.0 (file:///D:/projects/guessthenumber)
    Finished release [optimized] target(s) in 0.67s
     Running `target\release\guessthenumber.exe`
Hello, world!

Great! Hello world compiles and outputs what we expect. That's something we can build on.

A question of size

For your information, the resulting executable is ca. 3.9MB in size. That's because the rust compiler has to put a ton of things into the executable, which might be needed. The alternative, which is used for example by Microsoft, is to distribute these things in an extra installer file. That would mean that everyone has to install several installers on every platform just to run one executable. It's error prone and bothersome, so instead everything is packed into one executable. Today's internet is fast enough to handle a few MB in general, so it does not matter that much. For those interested, though, there is a number of ways to reduce the executable's size. The easiest is to strip the executable of all the unused things. Let's see how that changes things:

> ls ./* -Include *.exe


    Directory: D:\projects\guessthenumber\target\release


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2018-10-10   1:49 PM        3973552 guessthenumber.exe


> strip guessthenumber.exe
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `.refptr._gnu_exception_handler'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `.refptr._matherr'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `__imp__set_invalid_parameter_handler'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `__imp__get_invalid_parameter_handler'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `__native_dllmain_reason'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `GetStartupInfoA'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `SetUnhandledExceptionFilter'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `_onexit'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `RtlVirtualUnwind'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `RtlLookupFunctionEntry'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `__native_vcclrit_reason'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `__lconv_init'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `RtlAddFunctionTable'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `GetTickCount'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `VirtualProtect'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `UnhandledExceptionFilter'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `_charmax'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `_errno'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `GetCurrentThreadId'
BFD: .\guessthenumber.exe: Unrecognized storage class 106 for *UND* symbol `VirtualQuery'
> ls ./* -Include *.exe


    Directory: D:\projects\guessthenumber\target\release


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2018-10-10   1:49 PM        689152 guessthenumber.exe


>

WOW! 3.9MB down to 673KB. Personally, that's what I recommend doing before shipping. There is one more simple way to reduce the binary size at the expense of startup-time AND the danger of being flagged by anti-virus: UPX. UPX is a packer, which basically compresses the executable. It has to be uncompressed on execution, so the startup time might be slightly increased, however it usually yields another big size improvement. Since many malware tools are compressed using such a technique, anti-virus programs might flag your resulting executable as malware, though, so I do not recommend using UPX for your final release.

> upx guessthenumber.exe
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2018
UPX 3.95w       Markus Oberhumer, Laszlo Molnar & John Reiser   Aug 26th 2018

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
    689152 ->    237056   34.40%    win64/pe     guessthenumber.exe

Packed 1 file.
> ls ./* -Include *.exe


    Directory: D:\projects\guessthenumber\target\release


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----       2018-10-10   1:49 PM        237056 guessthenumber.exe


>

Down to 232KB. If size matters, that's how you can create a small binary size without impairing runtime speed. There are more tricks to get even smaller executables (like removing jemalloc or replacing gnu/glibc with musl), however they usually involve using nightly features, might cause performance loss or are not cross-platform. As such, they are nice tricks for embedded development, but are not very relevant for game dev. We want a straight-forward, quick-win way.

To summarize: Simply strip your game executable, and get ready for some sexy perf+size.

Time for interaction

Enough playing around with stuff which will still not yield a game. We have output, we want input. Let's ask the player for their name! In order to do so, we need to access the stdio streams, so let's include them into our program.

use std::io::{stdin, stdout, Write};

Then, let's output the question the user, get their input and make sure it is correct.

print!("Please enter your name: ");

let player_name = String::new();
stdin().read_line(&mut player_name);

When working with the console, there is one thing we have to be careful about. The console uses buffers for performance reasons. If we want the text to appear before the user input, we first have to make sure that the ouput is not buffered. The process of writing the buffer to the stream is called "flushing" the buffer.

In addition to that, we are using Rust, and read_line() returns a Result of reading the input into the variable - it might fail, especially for things like numbers. In Rust, we always have to make sure that we handle Results. Since strings work about all the time in a simple game like this, we can just let the game abort if the input fails and not bother handling edge cases. The same applies for the flushing, too, by the way. But we handle it by assigning the Result to a variable. We don't care about the result of a flushing operation.

// ...
let _ = stdout().flush();

// ...
stdin().read_line(&mut player_name).expect("Did not enter a correct string");

Once the input is read, we usually also have the newline characters in the string and maybe even white-spaces. So, we also have to remove those:

let player_name: String = player_name.trim().into();

Last but not least, let's output the player's name to see if everything worked fine. The final code looks like this:

use std::io::{stdin, stdout, Write};

fn main() {
    println!("Welcome to this number guessing game!");
    print!("Please enter your name: ");
    let _ = stdout().flush();

    let mut player_name = String::new();
    stdin().read_line(&mut player_name).expect("Did not enter a correct string");
    let player_name: String = player_name.trim().into();

    println!("Your name is {}", player_name);
}

Let's run it and see what happens...

> cargo run
   Compiling guessthenumber v0.1.0 (file:///D:/projects/guessthenumber)
    Finished dev [unoptimized + debuginfo] target(s) in 1.04s
     Running `target\debug\guessthenumber.exe`
Welcome to this number guessing game!
Please enter your name: Marco
Your name is Marco!
>

Game logic time

Let's take a look at how our game should actually unfold. The computer has to think of a number. Let's use a number between 1 and 100, so that the game is nice and easy. Then the user has to guess the number and the computer should give tips.

OK. First the random number. The rust stdlib was created to be lean. As such, there is no random number generator in it. Instead, a crate (rust library package) alled "rand" is maintained. We have to pull it in by adding a line to the dependencies field in the Cargo.toml:

rand = "0.5"

The version used is SemVer based and means that we want release version 0, feature version 5 with the highest patch version available, or in another notation 0.5.*. Next, we have to tell the Rust compiler that we want to use the external crate and, in addition to that, which parts of it. In order to do so, we have to add an external-instruction and more use-instructions:

extern crate rand;
use rand::random;

With that, we can generate a number an make sure it is only in the range of 0..100 by using the remainder operator. We specifically ask for a u32 (32bit unsigned integer) variable type here, which can only contain 0 and positive integer values. Since we want numbers between 1 and 100 ([1, 100]), we have to add one, so there can never be a 0 as number.

let number = random::<u32>() % 100 + 1;

Phil_No noted, that there is another, more abstract, way to create a random number in a range, which might be better for everyone who wants to keep code more verbose:

let number = rand::thread_rng().gen_range(1, 101);

Let's summarize what we have so far:

extern crate rand;

use std::io::{stdin, stdout, Write};
use rand::random;

fn main() {
    // Write to console
    println!("Welcome to this number guessing game!");
    print!("Please enter your name: ");

    // Make sure the out-buffer is empty
    let _ = stdout().flush();

    // Create mutable (writeable) string variable
    let mut input = String::new();

    // Read user input
    stdin().read_line(&mut input).expect("Did not enter a correct string");

    // Prevent us from accidentally overwriting the username by making it immutable
    let player_name: String = input.trim().into();

    // Create random number
    let number = random::<u32>() % 100 + 1;

    // Output what we have so far
    println!("Your name is {} and my random number is {}!", player_name, number);
}

So now, we want to keep asking the player for a number, until they finally guessed right. As such we need a loop. Inside the loop, we first have to read the player input, clean it, convert it to a number and then compare the guess to the random number. Based on the comparison, we can then print different messages. The whole loop looks like this in the end:

    loop {
        let mut input = String::new();

        print!("Take a guess: ");
        // Make sure the out-buffer is empty
        let _ = stdout().flush();

        // Read player input
        if stdin().read_line(&mut input).is_err() {
            println!("Not a valid input, try again!");
            continue;
        }

        // Clean it and convert it to a number
        let guess = input.trim().to_string().parse::<u32>();

        if guess.is_err() {
            println!("Not a valid number, try again!");
            continue;
        }

        let guess = guess.unwrap();

        // Compare it to the random number and give a tip, or end the game
        if guess == number { break; }
        if guess > number { println!("My number is smaller!"); }
        if guess < number { println!("My number is greater!"); }
    }

Here's the full code:

extern crate rand;

use std::io::{stdin, stdout, Write};
use rand::random;


fn main() {
    // Write to console
    println!("Welcome to this number guessing game!");
    print!("Please enter your name: ");

    // Make sure the out-buffer is empty
    let _ = stdout().flush();

    // Create mutable (writeable) string variable
    let mut input = String::new();

    // Read user input
    stdin().read_line(&mut input).expect("Did not enter a correct string");

    // Prevent us from accidentally overwriting the username by making it immutable
    let player_name: String = input.trim().into();

    // Create random number between 0 and 100
    let number = random::<u32>() % 100 + 1;

    println!("I thought of a number between 1 and 100.");

    loop {
        let mut input = String::new();

        print!("Take a guess: ");
        // Make sure the out-buffer is empty
        let _ = stdout().flush();

        // Read player input
        if stdin().read_line(&mut input).is_err() {
            println!("Not a valid input, try again!");
            continue;
        }

        // Clean it and convert it to a number
        let guess = input.trim().to_string().parse::<u32>();

        if guess.is_err() {
            println!("Not a valid number, try again!");
            continue;
        }

        let guess = guess.unwrap();

        // Compare it to the random number and give a tip, or end the game
        if guess == number { break; }
        if guess > number { println!("My number is smaller!"); }
        if guess < number { println!("My number is greater!"); }
    }

    println!("Congratulations, {}! My random number is {}!", player_name, number);
}

Running the program makes Cargo download our dependency and compile the game. Then we can have a test run:

> cargo run
    Updating registry `https:github.com/rust-lang/crates.io-index`
 Downloading rand_core v0.2.2
 Downloading rand_core v0.3.0
   Compiling winapi-x86_64-pc-windows-gnu v0.4.0
   Compiling winapi v0.3.6
   Compiling rand_core v0.3.0
   Compiling rand_core v0.2.2
   Compiling rand v0.5.5
   Compiling guessthenumber v0.1.0 (file:///D:/projects/guessthenumber)
    Finished dev [optimized + debuginfo] target(s) in 0.03s
     Running `target\debug\guessthenumber.exe`
Welcome to this number guessing game!
Please enter your name: Marco
I thought of a number between 1 and 100.
Take a guess: 50
My number is smaller!
Take a guess: 30
My number is greater!
Take a guess: 40
My number is greater!
Take a guess: 45
Congratulations, Marco! My random number is 45!
>

Simple enough. It's an activity with a rule-set, which is fun (the first three times you play it). It's a game. Congratulations! We just created our very first game in Rust. It might not seem like much, but it's a first step into the right direction.

For your pleasure, I also built a release version, stripped it and then run it through UPX. Here are the numbers:

  • vanilla: 989KB
  • stripped: 167KB
  • UPXed: 354KB
  • stripped+UPXed: 74KB

In the next part, we will use this simple game in order to understand more complex games, and then create something a little more practical than just guessing numbers. I really hope you like this article and learned a few things from it. Leave your opinion and any questions below and I will try to answer anything which comes up :)


Unbezahlte Werbung durch Nennung und Verlinkung von Personen, Organisationen oder Unternehmen.