Project 8: Bare Metal Game

Classic early video games such as Space Invaders, Asteroids, and Pac-man were implemented without an operating system. The games mediated all hardware interaction by themselves.

In this project, you will use the Pluggable Interrupt OS to develop a bare-metal video game. The graphical aspect of the game will be rendered in the VGA buffer. You are encouraged to be creative in using VGA characters to represent your game graphics.

You are welcome to develop an original game or to create your own interpretation of a classic game. As an example, feel free to reference Ghost Hunter, my own interpretation of a well-known classic.

Unlike our other projects, for this project you are welcome to work in a team of up to three students.

Interrupts

The primary purpose of this project is for you to gain experience writing software that handles hardware interrupts. When an event pertinent to the hardware occurs, it notifies the CPU by signaling an interrupt. The CPU responds to the interrupt by suspending execution of whatever code it is running and running the code designated for handling the interrupt. Naturally, interrupt-handling code should complete as quickly as possible so that the CPU can resume normal execution.

Setup

The Pluggable Interrupt OS provides a convenient framework for specifying to the CPU the code to be executed to handle each interrupt.

I have created a template for you to use as a starting point for your projects. To start your project, clone the Pluggable Interrupt Template project. In order to build the project, you’ll also need to install:

  • Qemu
  • Nightly Rust:
    • rustup override set nightly
  • llvm-tools-preview:
    • rustup component add llvm-tools-preview
  • The bootimage tool:
    • cargo install bootimage
  • On Windows:
    • rustup component add rust-src --toolchain nightly-x86_64-pc-windows-msvc

Once the template is up and running, you will be ready to implement your own interrupt handlers! Of course, you’ll want to change the project name and authors in Cargo.toml, and you’ll also want to set up your own GitHub repository for it.

The template project demonstrates a simple interactive program that uses both keyboard and timer interrupts. When the user types a viewable key, it is added to a string in the middle of the screen. When the user types an arrow key, the string begins moving in the indicated direction. Here is its main.rs:

#![no_std]
#![no_main]

use pc_keyboard::DecodedKey;
use pluggable_interrupt_os::HandlerTable;
use pluggable_interrupt_os::vga_buffer::clear_screen;
use pluggable_interrupt_template::LetterMover;
use crossbeam::atomic::AtomicCell;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    HandlerTable::new()
        .keyboard(key)
        .timer(tick)
        .startup(startup)
        .cpu_loop(cpu_loop)
        .start()
}

static LAST_KEY: AtomicCell<Option<DecodedKey>> = AtomicCell::new(None);
static TICKS: AtomicCell<usize> = AtomicCell::new(0);

fn cpu_loop() -> ! {
    let mut kernel = LetterMover::new();
    let mut last_tick = 0;
    loop {
        if let Some(key) = LAST_KEY.load() {
            LAST_KEY.store(None);
            kernel.key(key);
        }
        let current_tick = TICKS.load();
        if current_tick > last_tick {
            last_tick = current_tick;
            kernel.tick();
        }
    }
}

fn tick() {
    TICKS.fetch_add(1);
}

fn key(key: DecodedKey) {
    LAST_KEY.store(Some(key));
}

fn startup() {
    clear_screen();
}

The _start() function kicks everything off by placing references to our interrupt handling functions in a HandlerTable object. Invoking .start() on the HandlerTable sets up the interrupt handlers, then starts execution of the cpu_loop() function.

I created the LetterMover struct to represent the application state. It is maintained inside of cpu_loop(). It gets updated in response to messages from the interrupt handlers. Those messages are sent by updating shared AtomicCell objects.

Whenever a timer interrupt occurs, tick() increases TICKS by one. Whenever a keyboard interrupt occurs, key() updates LAST_KEY to contain the most recent keypress.

The cpu_loop() checks TICKS constantly. Whenever a new tick occurs, it instructs the LetterMover to update its state accordingly. Similarly, whenever a new keypress appears, cpu_loop() relays that keystroke to LetterMover.

This shows the basic design that all of these projects should employ:

  • Create a main.rs that sets up the interrupt handlers.
  • Write a cpu_loop() function that contains the main data structures and monitors concurrent state for updates from interrupts.
  • Write one-line handlers for the timer and keyboard that reference shared state that cpu_loop() can employ.
  • Place all of the game functionality within the game-state object, defined in lib.rs.

Here is the rest of its code, found in its lib.rs file:

#![cfg_attr(not(test), no_std)]

use bare_metal_modulo::{ModNumC, MNum, ModNumIterator};
use pluggable_interrupt_os::vga_buffer::{BUFFER_WIDTH, BUFFER_HEIGHT, plot, ColorCode, Color, is_drawable};
use pc_keyboard::{DecodedKey, KeyCode};
use num::traits::SaturatingAdd;

#[derive(Copy,Debug,Clone,Eq,PartialEq)]
pub struct LetterMover {
    letters: [char; BUFFER_WIDTH],
    num_letters: ModNumC<usize, BUFFER_WIDTH>,
    next_letter: ModNumC<usize, BUFFER_WIDTH>,
    col: ModNumC<usize, BUFFER_WIDTH>,
    row: ModNumC<usize, BUFFER_HEIGHT>,
    dx: ModNumC<usize, BUFFER_WIDTH>,
    dy: ModNumC<usize, BUFFER_HEIGHT>
}

impl LetterMover {
    pub fn new() -> Self {
        LetterMover {
            letters: ['A'; BUFFER_WIDTH],
            num_letters: ModNumC::new(1),
            next_letter: ModNumC::new(1),
            col: ModNumC::new(BUFFER_WIDTH / 2),
            row: ModNumC::new(BUFFER_HEIGHT / 2),
            dx: ModNumC::new(0),
            dy: ModNumC::new(0)
        }
    }

    fn letter_columns(&self) -> impl Iterator<Item=usize> {
        ModNumIterator::new(self.col)
            .take(self.num_letters.a())
            .map(|m| m.a())
    }

    pub fn tick(&mut self) {
        self.clear_current();
        self.update_location();
        self.draw_current();
    }

    fn clear_current(&self) {
        for x in self.letter_columns() {
            plot(' ', x, self.row.a(), ColorCode::new(Color::Black, Color::Black));
        }
    }

    fn update_location(&mut self) {
        self.col += self.dx;
        self.row += self.dy;
    }

    fn draw_current(&self) {
        for (i, x) in self.letter_columns().enumerate() {
            plot(self.letters[i], x, self.row.a(), ColorCode::new(Color::Cyan, Color::Black));
        }
    }

    pub fn key(&mut self, key: DecodedKey) {
        match key {
            DecodedKey::RawKey(code) => self.handle_raw(code),
            DecodedKey::Unicode(c) => self.handle_unicode(c)
        }
    }

    fn handle_raw(&mut self, key: KeyCode) {
        match key {
            KeyCode::ArrowLeft => {
                self.dx -= 1;
            }
            KeyCode::ArrowRight => {
                self.dx += 1;
            }
            KeyCode::ArrowUp => {
                self.dy -= 1;
            }
            KeyCode::ArrowDown => {
                self.dy += 1;
            }
            _ => {}
        }
    }

    fn handle_unicode(&mut self, key: char) {
        if is_drawable(key) {
            self.letters[self.next_letter.a()] = key;
            self.next_letter += 1;
            self.num_letters = self.num_letters.saturating_add(&ModNumC::new(1));
        }
    }
}

The keyboard handler receives each character as it is typed. Keys representable as a char are added to the moving string. The arrow keys change how the string is moving.

Requirements

This project is very flexible in its requirements. A successful submission will:

  1. Run in the Qemu x86 virtual machine without any supporting operating system.
  2. Be built upon the Pluggable Interrupt OS.
  3. Only use #[no_std] Rust features and crates.
  4. Enable the player to control a graphically depicted avatar using the keyboard.
  5. Include gameplay elements that advance based on clock ticks and cause the game to eventually end if the player does not react to them appropriately.
  6. Run indefinitely without any panics.
  7. Allow the user to restart the game when it ends.
  8. Display the user’s current score.
  9. If multiple students collaborate, the features of the game should be proportionate to the number of contributors.

Submissions

  • Create a public GitHub repository for your bare metal game.
  • Post the repository URL to the General channel in the CSCI 320 Teams page by 5pm on Thursday, March 16.
  • Pick four games. Download and play them. Then post reviews of each game in your private Teams channel by Friday, March 17 at 5 pm.

Assessment

  • Partial: Meets requirements 1, 2, and 3, and either requirement 4 or requirement 5.
  • Complete: Meets all requirements given above, including the submitted reviews of other games.
  • Bonus Token: If the program meets all requirements, and additionally demonstrates creativity or innovation in a striking way, each contributor will earn a bonus token.

Acknowledgement

This assignment was adapted from materials developed by Philipp Oppermann.