Learning Rust By Building The Old Terminal Game Beast From 1984, Part 4
83min read
In the last post we added path-finding to our beasts and a game loop. In this post we will finish the game by moving through levels and add a help screen.
In the board module we setup our Board structure which is responsible to generate the terrain, render the inside of
the board and implements our own Index trait to make it easier for us to work with the board:
userand::seq::SliceRandom;usecrate::{ANSI_CYAN,ANSI_GREEN,ANSI_RED,ANSI_RESET,ANSI_YELLOW,BOARD_HEIGHT,BOARD_WIDTH,Coord,TILE_SIZE,Tile,beasts::{Beast,CommonBeast},level::{Level,LevelConfig},};usestd::ops::{Index,IndexMut};#[derive(Debug)]pubstructBoard{pubbuffer: [[Tile;BOARD_WIDTH];BOARD_HEIGHT],}implIndex<&Coord>forBoard{typeOutput=Tile;fnindex(&self,coord: &Coord) -> &Self::Output{&self.buffer[coord.row][coord.column]}}implIndexMut<&Coord>forBoard{fnindex_mut(&mutself,coord: &Coord) -> &mutSelf::Output{&mutself.buffer[coord.row][coord.column]}}implBoard{pubfnnew() -> (Self,Vec<CommonBeast>){letmutbuffer=[[Tile::Empty;BOARD_WIDTH];BOARD_HEIGHT];letmutall_coords=(0..BOARD_HEIGHT).flat_map(|row|(0..BOARD_WIDTH).map(move|column|Coord{column,row})).filter(|coord|!(coord.column==0&&coord.row==0)).collect::<Vec<Coord>>();letmutrng=rand::rng();all_coords.shuffle(&mutrng);buffer[0][0]=Tile::Player;letLevelConfig{block_count,static_block_count,common_beast_count,}=Level::One.get_level_config();for_in0..block_count{letcoord=all_coords.pop().expect("We tried to place more blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::Block;}for_in0..static_block_count{letcoord=all_coords.pop().expect("We tried to place more static blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::StaticBlock;}letmutbeasts=Vec::with_capacity(common_beast_count);for_in0..common_beast_count{letcoord=all_coords.pop().expect("We tried to place more common beasts than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::CommonBeast;beasts.push(CommonBeast::new(coord));}(Self{buffer},beasts)}pubfnrender(&self) -> String{letmutoutput=format!("{ANSI_YELLOW}▛{}▜{ANSI_RESET}\n","▀".repeat(BOARD_WIDTH*TILE_SIZE));forrowsinself.buffer{output.push_str(&format!("{ANSI_YELLOW}▌{ANSI_RESET}"));fortileinrows{matchtile{Tile::Empty=>output.push_str(" "),Tile::Player=>{output.push_str(&format!("{ANSI_CYAN}◀▶{ANSI_RESET}"))},Tile::Block=>{output.push_str(&format!("{ANSI_GREEN}░░{ANSI_RESET}"))},Tile::StaticBlock=>{output.push_str(&format!("{ANSI_YELLOW}▓▓{ANSI_RESET}"))},Tile::CommonBeast=>{output.push_str(&format!("{ANSI_RED}├┤{ANSI_RESET}"))},}}output.push_str(&format!("{ANSI_YELLOW}▐{ANSI_RESET}\n"));}output.push_str(&format!("{ANSI_YELLOW}▙{}▟{ANSI_RESET}","▄".repeat(BOARD_WIDTH*TILE_SIZE)));output}}
The level module contains our level enum which implements a way for us to get a config for each level:
usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Coord,Direction,Tile,board::Board};#[derive(Debug)]pubstructPlayer{pubposition: Coord,}implPlayer{pubfnnew() -> Self{Self{position: Coord{column: 0,row: 0},}}fnget_next_position(position: Coord,direction: &Direction,) -> Option<Coord>{letmutnext_position=position;matchdirection{Direction::Up=>{ifnext_position.row>0{next_position.row-=1}else{returnNone;}},Direction::Right=>{ifnext_position.column<BOARD_WIDTH-1{next_position.column+=1}else{returnNone;}},Direction::Down=>{ifnext_position.row<BOARD_HEIGHT-1{next_position.row+=1}else{returnNone;}},Direction::Left=>{ifnext_position.column>0{next_position.column-=1}else{returnNone;}},}Some(next_position)}pubfnadvance(&mutself,board: &mutBoard,direction: &Direction){ifletSome(first_position)=Self::get_next_position(self.position,direction){matchboard[&first_position]{Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;},Tile::Block=>{letmutcurrent_tile=Tile::Block;letmutcurrent_position=first_position;whilecurrent_tile==Tile::Block{ifletSome(next_position)=Self::get_next_position(current_position,direction){current_position=next_position;current_tile=board[¤t_position];matchcurrent_tile{Tile::Block=>{/* continue looking */},Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;board[¤t_position]=Tile::Block;},Tile::StaticBlock|Tile::Player|Tile::CommonBeast=>break,}}else{break;}}},Tile::Player|Tile::StaticBlock=>{},Tile::CommonBeast=>{todo!("The player ran into a beast and died");},}}}}
The beast trait module that contains the trait for all of our enemies:
When we play our game as we have built it so far, we notice that the beasts will follow us just as they’re suppoed to
but when they get close they never actually move in for the kill.
Even though we check in our game engine method if we walk into a tile with Player:
And indeed, we’re removing all coordinates from our possible_move Vec that don’t contain an Empty tile on our board.
Let’s fix that and check for two tile types we should allow the beast to move into:
We use the matches macro to allow both Empty and Player.
Now when we run the game and aloow the beasts to catch the player we get this:
cargo run
warning: variants `Two` and `Three` are never constructed--> src/level.rs:10:2
|8| pub enum Level {
|-----variants in this enum9| One,
10| Two,
|^^^11| Three,
|^^^^^|= note: `Level` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis
= note: `#[warn(dead_code)]` on by default
warning: `beast` (bin "beast") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/beast`
▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌▓▓├┤░░▐▌░░░░░░░░▐▌░░░░░░░░▐▌▓▓▐▌▐▌▐▌░░░░░░░░░░▐▌▓▓░░▐▌░░├┤▐▌░░░░░░▐▌░░░░▐▌▓▓▐▌░░░░░░▐▌░░▓▓▐▌░░░░▐▌▐▌├┤▐▌▐▌▐▌░░░░░░▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
Level: 1
thread 'main' panicked at src/game.rs:91:33:
not yet implemented: The beast just killed our player
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
We will get to that warning soon but for now the code path for our beast killing the player actually gets called and
results in a panic.
Before the player can be killed by the beast we have to define what death is.
Or more specifically: we have to give our player lives so it can come back from the dead until the game is over.
Does this make every video game player a zombie?
I don’t know but someone should go and find out!
To that end, let’s add a lives item to our Player struct:
usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Coord,Direction,Tile,board::Board};#[derive(Debug)]pubstructPlayer{pubposition: Coord,publives: usize,}implPlayer{pubfnnew() -> Self{Self{position: Coord{column: 0,row: 0},lives: 3,}}fnget_next_position(position: Coord,direction: &Direction,) -> Option<Coord>{letmutnext_position=position;matchdirection{Direction::Up=>{ifnext_position.row>0{next_position.row-=1}else{returnNone;}},Direction::Right=>{ifnext_position.column<BOARD_WIDTH-1{next_position.column+=1}else{returnNone;}},Direction::Down=>{ifnext_position.row<BOARD_HEIGHT-1{next_position.row+=1}else{returnNone;}},Direction::Left=>{ifnext_position.column>0{next_position.column-=1}else{returnNone;}},}Some(next_position)}pubfnadvance(&mutself,board: &mutBoard,direction: &Direction){ifletSome(first_position)=Self::get_next_position(self.position,direction){matchboard[&first_position]{Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;},Tile::Block=>{letmutcurrent_tile=Tile::Block;letmutcurrent_position=first_position;whilecurrent_tile==Tile::Block{ifletSome(next_position)=Self::get_next_position(current_position,direction){current_position=next_position;current_tile=board[¤t_position];matchcurrent_tile{Tile::Block=>{/* continue looking */},Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;board[¤t_position]=Tile::Block;},Tile::StaticBlock|Tile::Player|Tile::CommonBeast=>break,}}else{break;}}},Tile::Player|Tile::StaticBlock=>{},Tile::CommonBeast=>{todo!("The player ran into a beast and died");},}}}}
Let’s start the game of with 3 lives for now.
Then we should probably add the lives to our footer so that we know how many lives the player has left before the game
ends:
We previously used FOOTER_SIZE to calculate the width of the left padding but that’s not actually quite right.
So let’s add a new const called FOOTER_LENGTH that holds the size of anything that will come after the word Level:
so that we can pad the left space with the right amount of spaces.
This gives us a nicely right-aligned footer:
cargo run
warning: variants `Two` and `Three` are never constructed--> src/level.rs:10:2
|8| pub enum Level {
|-----variants in this enum9| One,
10| Two,
|^^^11| Three,
|^^^^^|= note: `Level` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis
= note: `#[warn(dead_code)]` on by default
warning: `beast` (bin "beast") generated 2 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/beast`
▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌◀▶░░▐▌░░░░▐▌░░░░░░▐▌▓▓░░▐▌░░░░▐▌▓▓░░▐▌░░▐▌░░░░▐▌░░░░░░░░▐▌░░▓▓▐▌░░▐▌├┤▐▌░░▐▌░░░░▐▌░░▐▌▐▌░░├┤░░░░▐▌░░▓▓├┤░░▐▌▓▓▐▌░░░░▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
Level: 1 Lives: 3
Now we can enable the the beast to properly kill the player.
Within our game after each beast move we need to check if the tile a beast moved into was of type Player and if it
was, make sure we deduct a life from our player.
We also need to check if the player has reached the end and kill the game.
Now when the player has been killed a third time, the game loop stops and the game finishes with a good bye message.
Or does it?
Well the game actually panics right when one move after a beast has killed the player.
That’s because once the player was killed, we don’t move it which means the player and the beast are on the same tile
and that’s the ONE thing we promised the beast module would never happen.
Because the beast module believed the game engine, it added an unreachable!() call which now panics.
But also, that’s not what should happen anyway.
Once the player gets killed, it should re-spawn to a new location as long as it has enough lives left.
pubfnrespawn(&mutself,board: &mutBoard){letempty_positions=board.buffer.iter().enumerate().flat_map(|(row_id,row)|{row.iter().enumerate().filter_map(move|(column_id,tile)|{if*tile==Tile::Empty{Some(Coord{column: column_id,row: row_id,})}else{None}})}).collect::<Vec<Coord>>();if!empty_positions.is_empty(){letmutrng=rand::rng();letindex=rng.random_range(0..empty_positions.len());letnew_position=empty_positions[index];self.position=new_position;board[&new_position]=Tile::Player;}else{panic!("No empty positions found to respawn the player");}}
We go over each tile of the board and collect all coordinates that contain an Empty tile.
Then we randomly pick one item of that collection and place the player there.
But unlike in part 2, where we
generate our terrain,
we only need a single empty space on the board to respawn into.
This operation would be O(BOARD_WIDTH × BOARD_HEIGHT) which is unnecessarily complex.
We’re scanning (39 * 20) 780 positions each and every time.
We’re also allocating a Vec on the heap.
So maybe this time around we try random sampling to find an empty position:
userand::Rng;usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Coord,Direction,Tile,board::Board};#[derive(Debug)]pubstructPlayer{pubposition: Coord,publives: usize,}implPlayer{pubfnnew() -> Self{Self{position: Coord{column: 0,row: 0},lives: 3,}}fnget_next_position(position: Coord,direction: &Direction,) -> Option<Coord>{letmutnext_position=position;matchdirection{Direction::Up=>{ifnext_position.row>0{next_position.row-=1}else{returnNone;}},Direction::Right=>{ifnext_position.column<BOARD_WIDTH-1{next_position.column+=1}else{returnNone;}},Direction::Down=>{ifnext_position.row<BOARD_HEIGHT-1{next_position.row+=1}else{returnNone;}},Direction::Left=>{ifnext_position.column>0{next_position.column-=1}else{returnNone;}},}Some(next_position)}pubfnadvance(&mutself,board: &mutBoard,direction: &Direction){ifletSome(first_position)=Self::get_next_position(self.position,direction){matchboard[&first_position]{Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;},Tile::Block=>{letmutcurrent_tile=Tile::Block;letmutcurrent_position=first_position;whilecurrent_tile==Tile::Block{ifletSome(next_position)=Self::get_next_position(current_position,direction){current_position=next_position;current_tile=board[¤t_position];matchcurrent_tile{Tile::Block=>{/* continue looking */},Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;board[¤t_position]=Tile::Block;},Tile::StaticBlock|Tile::Player|Tile::CommonBeast=>break,}}else{break;}}},Tile::Player|Tile::StaticBlock=>{},Tile::CommonBeast=>{todo!("The player ran into a beast and died");},}}}pubfnrespawn(&mutself,board: &mutBoard){letmutnew_position=self.position;letmutrng=rand::rng();whileboard[&new_position]!=Tile::Empty{new_position=Coord{column: rng.random_range(0..BOARD_WIDTH),row: rng.random_range(0..BOARD_HEIGHT),};}self.position=new_position;board[&new_position]=Tile::Player;}}
We start our loop with the position where the player is right now because we know it’s not Empty
(it’s indeed Player).
Then we start a loop in which we randomly generate coordinates until we find an empty tile.
We’re guaranteeing that there are Empty tiles in the board or the loop will never finish.
Because we’re controlling the board with our Level config, this is something we can opt into now.
Comparing our first version to this just to make sure we actually improved things:
The first version scanned 780 tiles each time while this version assumes we never have more than about 40 non-Empty
tiles on the board which means we have about 740 empty tiles to find which gives us a (740/780 ≈) 94.9%
probability for finding an empty tile randomly which would take about (1/0.949 ≈) 1.05 attempts on average.
In short, this means on average, our second version of the respawn method is about 700-800x faster for our use-case.
Ok with that respawn method now done, let’s make sure we call it:
Now when the player gets killed by a beast, the player respawns are a random spot on the board as long as there are
enough lives left.
One thing that notably isn’t addressed yet though is when the player walks into the beast.
We still get a panic when that happens:
cargo run
warning: variants `Two` and `Three` are never constructed--> src/level.rs:10:2
|8| pub enum Level {
|-----variants in this enum9| One,
10| Two,
|^^^11| Three,
|^^^^^|= note: `Level` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis
= note: `#[warn(dead_code)]` on by default
warning: `beast` (bin "beast") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
Running `target/debug/beast`
▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌░░░░▐▌▐▌░░▓▓░░▓▓▐▌░░▐▌░░▐▌▓▓░░▐▌░░▐▌░░▐▌░░▐▌▐▌◀▶░░░░░░▐▌├┤░░░░▐▌░░░░░░░░░░░░▐▌░░▓▓░░▐▌░░▐▌├┤▐▌├┤░░░░▐▌░░░░▐▌▓▓▐▌░░░░▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
Level: 1 Lives: 3
thread 'main' panicked at src/player.rs:96:21:
not yet implemented: The player ran into a beast and died
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
So we need to make sure our player dies when it walks into a beast.
Right now we left a todo!() macro in that code path:
userand::Rng;usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Coord,Direction,Tile,board::Board};#[derive(Debug)]pubstructPlayer{pubposition: Coord,publives: usize,}implPlayer{pubfnnew() -> Self{Self{position: Coord{column: 0,row: 0},lives: 3,}}fnget_next_position(position: Coord,direction: &Direction,) -> Option<Coord>{letmutnext_position=position;matchdirection{Direction::Up=>{ifnext_position.row>0{next_position.row-=1}else{returnNone;}},Direction::Right=>{ifnext_position.column<BOARD_WIDTH-1{next_position.column+=1}else{returnNone;}},Direction::Down=>{ifnext_position.row<BOARD_HEIGHT-1{next_position.row+=1}else{returnNone;}},Direction::Left=>{ifnext_position.column>0{next_position.column-=1}else{returnNone;}},}Some(next_position)}pubfnadvance(&mutself,board: &mutBoard,direction: &Direction){ifletSome(first_position)=Self::get_next_position(self.position,direction){matchboard[&first_position]{Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;},Tile::Block=>{letmutcurrent_tile=Tile::Block;letmutcurrent_position=first_position;whilecurrent_tile==Tile::Block{ifletSome(next_position)=Self::get_next_position(current_position,direction){current_position=next_position;current_tile=board[¤t_position];matchcurrent_tile{Tile::Block=>{/* continue looking */},Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;board[¤t_position]=Tile::Block;},Tile::StaticBlock|Tile::Player|Tile::CommonBeast=>break,}}else{break;}}},Tile::Player|Tile::StaticBlock=>{},Tile::CommonBeast=>{todo!("The player ran into a beast and died");},}}}pubfnrespawn(&mutself,board: &mutBoard){letmutnew_position=self.position;letmutrng=rand::rng();whileboard[&new_position]!=Tile::Empty{new_position=Coord{column: rng.random_range(0..BOARD_WIDTH),row: rng.random_range(0..BOARD_HEIGHT),};}self.position=new_position;board[&new_position]=Tile::Player;}}
In this code block we would have to subtract from our self.lives and call the respawn method.
But thinking about this presents a new challenge: what is responsible for what?
After we subtracted from lives, do we check if the player has enough lives left to continue?
If not, how do we stop the loop in the Game struct?
Maybe we return something to make it clear to the play method that the player is now dead and the game loop should
stop?
Let’s zoom out and take stock of all the interconnected bits we have created so far.
We have:
a Board module that keeps a buffer of our game state and handles rendering it
a Player struct that handles player movements and right now implements the changes to the player on our Board
buffer
a Beast struct that deals with pathfinding and returns a Coord telling the game where the beast would like to go
and last but not least a Game struct that implements the game engine, orchestrating all the above bits together
When I write software, I like make it clear what entity is responsible for what and then, crucially, stick with that and
be consistent.
Inconsistencies in those rules lead very quickly to bugs, technical debt and someone loosing a lot of their hair.
Looking at the above rough outline, we can see quickly that we’re inconsistent with where the buffer gets manipulated
and who is responsible for the correctness of those changes.
We’re now also running into the problem of calling side effects but not being in the right scope for it to do so easily.
This is a great red flag you should always notice and make you pause.
The Player struct takes a mutable reference of the Board and changes it in place after it figured out where the
player wants to go.
The Beast struct just takes responsibility of figuring out where to go and returns the Coord back to the play method
of the Game struct, leaving it to make sure the move is legal and to execute it in the buffer and ensure side effects
are controlled.
The later model sounds more reasonable because it divides responsibilities cleanly:
Beast struct responsibility: where to go
Game struct responsibility: orchestrate the game and decide on side effects
So the Player struct responsibility SHOULD just be figuring out where the player moved to and where the end of a
possible blockchain is that the player ended up pushing with the move.
Let’s fix this.
We need the advance method of the Player module not to make any changes to the board but instead return to us what
the effect of the move would be.
The player either doesn’t move, moves into an empty tile or pushes a blockchain with the move:
The Game struct now takes responsibility for checking if the tile, the player moves into, contains a beast and deal
with the consequences.
We were also able to collapse the match arm for Empty and CommonBeast because they now do the same thing.
Now we just pass &Board instead of &mut Beast and rust is here to make sure we don’t go beyond that scope.
Now let’s go into our engine and implement the things we used to do in the Player struct.
usestd::{io::{Read,stdin},sync::mpsc,thread,time::{Duration,Instant},};usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Direction,TILE_SIZE,Tile,beasts::{Beast,CommonBeast},board::Board,level::Level,player::{AdvanceEffect,Player},};#[derive(Debug)]pubstructGame{board: Board,player: Player,level: Level,beasts: Vec<CommonBeast>,input_receiver: mpsc::Receiver<u8>,}implGame{pubfnnew() -> Self{let(board,beasts)=Board::new();let(input_sender,input_receiver)=mpsc::channel::<u8>();letstdin=stdin();thread::spawn(move||{letmutlock=stdin.lock();letmutbuffer=[0_u8;1];whilelock.read_exact(&mutbuffer).is_ok(){ifinput_sender.send(buffer[0]).is_err(){break;}}});Self{board,player: Player::new(),level: Level::One,beasts,input_receiver,}}pubfnplay(&mutself){letmutlast_tick=Instant::now();println!("{}",self.render(false));'game_loop: loop{ifletOk(byte)=self.input_receiver.try_recv(){letadvance_effect=matchbyteaschar{'w'=>self.player.advance(&mutself.board,&Direction::Up),'d'=>self.player.advance(&mutself.board,&Direction::Right),'s'=>self.player.advance(&mutself.board,&Direction::Down),'a'=>self.player.advance(&mutself.board,&Direction::Left),'q'=>{println!("Good bye");return;},_=>AdvanceEffect::Stay,};matchadvance_effect{AdvanceEffect::Stay=>{},AdvanceEffect::MoveIntoTile(player_position)=>{ifself.board[&player_position]==Tile::CommonBeast{todo!("The player ran into a beast and died");}self.board[&self.player.position]=Tile::Empty;self.player.position=player_position;self.board[&self.player.position]=Tile::Player;},AdvanceEffect::MoveAndPushBlock{player_to,block_to,}=>{self.board[&self.player.position]=Tile::Empty;self.player.position=player_to;self.board[&self.player.position]=Tile::Player;self.board[&block_to]=Tile::Block;},}println!("{}",self.render(true));}iflast_tick.elapsed()>Duration::from_millis(1000){last_tick=Instant::now();forbeastinself.beasts.iter_mut(){ifletSome(new_position)=beast.advance(&self.board,&self.player.position){matchself.board[&new_position]{Tile::Empty=>{self.board[&beast.position]=Tile::Empty;beast.position=new_position;self.board[&new_position]=Tile::CommonBeast;},Tile::Player=>{self.board[&beast.position]=Tile::Empty;beast.position=new_position;self.board[&new_position]=Tile::CommonBeast;self.player.lives-=1;ifself.player.lives==0{println!("Game Over");break'game_loop;}else{self.player.respawn(&mutself.board);}},_=>{},}}}println!("{}",self.render(true));}}}fnrender(&self,reset: bool) -> String{constBORDER_SIZE: usize=1;constFOOTER_SIZE: usize=1;constFOOTER_LENGTH: usize=11;letmutboard=ifreset{format!("\x1B[{}F",BORDER_SIZE+BOARD_HEIGHT+BORDER_SIZE+FOOTER_SIZE)}else{String::new()};board.push_str(&format!("{board}\n{footer:>width$}{level} Lives: {lives}",board=self.board.render(),footer="Level: ",level=self.level,lives=self.player.lives,width=BORDER_SIZE+BOARD_WIDTH*TILE_SIZE+BORDER_SIZE-FOOTER_LENGTH,));board}}
We now use the match as an expression and return from it into a new variable called advance_effect.
We have to be careful with how we handle quitting now so we just return from the play method all together when the
user hits the q key.
The we match against the value of advance_effect and do nothing on Stay, move into a tile on MoveIntoTile and
execute a blockchain move on MoveAndPushBlock.
If we were to create a game engine for other developers to use, this is where we would now make sure the returned
actions are legal actions according to the game rules.
This is a good time to look through the rest of the code and find other inconsistencies where we might be adding side
effects and spreading responsibility all over the place.
A quick search for &mut Board will yield that we also make changes to the board in the respawn method we wrote
earlier.
Let’s fix that one up too:
When the player moves into a tile with a CommonBeast in it, we respawn a new Coord, set our old position to Empty,
store the new position from respawn in the player instance and set the tile on the board for that Coord to Player.
We also subtract from lives and check if we have enough lives left.
This all works and we can now freely walk into beasts and eventually end the game.
cargo run
[...some warnings removed] Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.17s
Running `target/debug/beast`
▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌░░▐▌░░░░▐▌▓▓▐▌░░░░▐▌░░▐▌░░░░░░░░▐▌░░░░░░░░▐▌░░▐▌░░▓▓░░▐▌░░▐▌▐▌░░░░▓▓░░▐▌░░◀▶├┤├┤▐▌░░▓▓▐▌░░░░▐▌├┤▐▌▓▓▐▌░░▐▌░░░░▐▌░░░░▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
Level: 1 Lives: 1
Game Over
This is what the end looks like.
The player is next to the best, one frame before the fetal last step and the lives in the footer show 1.
Let’s fix that.
We’re currently check if we have enough lives left in two places: the movement of the player and the movement of beasts.
Perhaps we move that check to the end of the game loop:
In both movements, player and beast, we check if we have enough lives left before respawning.
The only difference is in the player movement, we still have to remove our last position from the board even when we
just walked into a beast.
Now the end screen looks a little more like what really happened:
cargo run
[...some warnings removed] Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
Running `target/debug/beast`
▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜▌▓▓▐▌░░▓▓░░▐▌░░▐▌░░░░░░▐▌░░▓▓▐▌░░▐▌├┤░░░░░░▐▌▐▌├┤▐▌░░░░░░▐▌░░▐▌░░▐▌▐▌░░├┤▐▌░░░░░░▐▌▐▌░░░░▐▌░░░░▐▌░░▓▓░░▐▌░░▓▓░░░░░░▐▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟
Level: 1 Lives: 0
Game Over
Now it’s time to start squishing beasts to get to the next level.
We tell the engine where the player is moving to and where the beast was that we’re about to squish.
Let’s now make sure we return this new option only if we find a block or the end of the board behind the best while
looking through a blockchain:
userand::Rng;usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Coord,Direction,Tile,board::Board};pubenumAdvanceEffect{Stay,MoveIntoTile(Coord),MoveAndPushBlock{player_to: Coord,block_to: Coord},SquishBeast{player_to: Coord,beast_at: Coord},}#[derive(Debug)]pubstructPlayer{pubposition: Coord,publives: usize,}implPlayer{pubfnnew() -> Self{Self{position: Coord{column: 0,row: 0},lives: 3,}}fnget_next_position(position: Coord,direction: &Direction,) -> Option<Coord>{letmutnext_position=position;matchdirection{Direction::Up=>{ifnext_position.row>0{next_position.row-=1}else{returnNone;}},Direction::Right=>{ifnext_position.column<BOARD_WIDTH-1{next_position.column+=1}else{returnNone;}},Direction::Down=>{ifnext_position.row<BOARD_HEIGHT-1{next_position.row+=1}else{returnNone;}},Direction::Left=>{ifnext_position.column>0{next_position.column-=1}else{returnNone;}},}Some(next_position)}pubfnadvance(&mutself,board: &Board,direction: &Direction,) -> AdvanceEffect{ifletSome(first_position)=Self::get_next_position(self.position,direction){matchboard[&first_position]{Tile::Empty|Tile::CommonBeast=>{returnAdvanceEffect::MoveIntoTile(first_position);},Tile::Block=>{letmutcurrent_tile=Tile::Block;letmutcurrent_position=first_position;whilecurrent_tile==Tile::Block{ifletSome(next_position)=Self::get_next_position(current_position,direction){current_position=next_position;current_tile=board[¤t_position];matchcurrent_tile{Tile::Block=>{/* continue looking */},Tile::Empty=>{returnAdvanceEffect::MoveAndPushBlock{player_to: first_position,block_to: current_position,};},Tile::CommonBeast=>{ifletSome(behind_beast)=Self::get_next_position(current_position,direction){ifmatches!(board[&behind_beast],Tile::Block|Tile::StaticBlock){// squishing the beast between two blocks (static or normal)
// ◀▶░░├┤░░
returnAdvanceEffect::SquishBeast{player_to: first_position,beast_at: current_position,};}}else{// squishing the beast between a block and the edge of the board
// ◀▶░░├┤▐
returnAdvanceEffect::SquishBeast{player_to: first_position,beast_at: current_position,};}},Tile::StaticBlock|Tile::Player=>{returnAdvanceEffect::Stay;},}}else{returnAdvanceEffect::Stay;}}returnAdvanceEffect::Stay;},Tile::Player|Tile::StaticBlock=>{returnAdvanceEffect::Stay;},}}else{returnAdvanceEffect::Stay;}}pubfnrespawn(&mutself,board: &Board) -> Coord{letmutnew_position=self.position;letmutrng=rand::rng();whileboard[&new_position]!=Tile::Empty{new_position=Coord{column: rng.random_range(0..BOARD_WIDTH),row: rng.random_range(0..BOARD_HEIGHT),};}new_position}}
So when we find a beast within the blockchain the player is trying to push, we check if behind the beast we find either
a Block, a StaticBlock or the edge of the board.
If we do find those, we know the player is allowed to squish the beast and we return the new enum option SquishBeast
with the right values.
Now we need to implement the logic in our game engine that actually squishes the beast and removes it from the board.
We almost do the same thing we do when the player returns MoveAndPushBlock with one additional instruction: we remove
the beast we found at this location from the beast vec on the Game struct.
And our trusty compiler friend will let us know that Coord, as the custom data structure we built, doesn’t know how to
compare itself to another Coord.
cargo run
Compiling beast v0.1.0 (/Users/code/beast)
error[E0369]: binary operation `!=` cannot be applied to type `Coord`--> src/game.rs:102:53
|102| self.beasts.retain_mut(|beast| beast.position != beast_at);
|--------------^^--------Coord|||Coord|note: an implementation of `PartialEq` might be missing for `Coord`
--> src/main.rs:37:1
|37| pub struct Coord {
|^^^^^^^^^^^^^^^^must implement `PartialEq`help: consider annotating `Coord` with `#[derive(PartialEq)]`
--> src/main.rs:37:1
|37+ #[derive(PartialEq)]38| pub struct Coord {
|For more information about this error, try `rustc --explain E0369`.error: could not compile `beast` (bin "beast") due to 1 previous error
This we can fix quickly by deriving the PartialEq trait for
Coord:
And everything compiles and we can start squishing beasts!
If you’re good enough, you’ll quickly squish all three beasts we have setup in level one and notice nothing happens.
So let’s now check within our game loop if there are any beasts left and if there aren’t, move to the next level.