Purpose
This document is an attempt at putting a complete Bevy game and Design Document in a single file.
We will be creating a document based on this tutorial
Why?
The reason for this workflow is to keep all our documentation and code in a single place. It’s practice for creating a living org doc that will be my standard for creating projects in the future.
That is, the code for this project is pulled directly from this document using org-babel
Final Program
<script type="module">
import init from './literate-snake.js'
init()
</script>
Specifications
Use Bevy
In our Cargo File, we define Bevy 0.7 as a dependency. Bevy allows us to create a game using ECS and built in 2D and 3D renderers and compile it to wasm32 for web games. In our case, we will be making a 2D snake game and embedding it in this file.
Cargo File Code
[package]
name = "literate-snake"
version = "0.1.0"
edition = "2021"
[profile.release]
opt-level = "z"
lto = "thin"
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
[dependencies]
bevy = "0.7"
rand = "0.7.3"
iyes_loopless = { version = "0.6", features = ["fixedtimestep", "states"] }
Start The Game Code
use bevy::core::FixedTimestep;
use bevy::prelude::*;
use rand::prelude::*;
fn main() {
App::new()
.insert_resource(WindowDescriptor {
title: "Snake!".to_string(),
width: 500.,
height: 500.,
..default()
})
.insert_resource(ClearColor(Color::rgb(0.04, 0.04, 0.04)))
.insert_resource(TailSegments::default())
.add_startup_system(setup_camera)
.add_startup_system(spawn_snake)
.add_system(snake_input)
.add_system(eat_food)
.add_system(add_tail_segment)
.add_system_set(
SystemSet::new()
.with_run_criteria(FixedTimestep::steps_per_second(1.0))
.with_system(food_spawner),
)
.add_system_set(
SystemSet::new()
.with_run_criteria(FixedTimestep::steps_per_second(5.0))
.with_system(snake_movement),
)
.add_system_set_to_stage(
CoreStage::PostUpdate,
SystemSet::new()
.with_system(size_scaling)
.with_system(position_translation),
)
.add_event::<EatFoodEvent>()
.add_plugins(DefaultPlugins)
.run();
}
Setup Camera Code
fn setup_camera(mut commands: Commands){
commands.spawn_bundle(OrthographicCameraBundle::new_2d());
}
Show a snake
We will use the SnakeHead
struct to hold the head of the body.
Create Snake Code
#[derive(Component)]
struct SnakeHead;
#[derive(Component)]
enum SnakeDirection{
UP,
DOWN,
LEFT,
RIGHT,
}
const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7);
fn spawn_snake(mut commands: Commands) {
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: SNAKE_HEAD_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(SnakeHead)
.insert(Position { x: 3, y: 3 })
.insert(Size::square(0.8))
.insert(SnakeDirection::UP);
}
Move the snake
The snake will always move forward, and pressing a key changes the direction.
You will only be able to choose from the 2 directions to the left and right of the current heading
Control Snake Code
fn snake_input(
keyboard_input: Res<Input<KeyCode>>,
mut head_positions: Query<&mut SnakeDirection, With<SnakeHead>>,
) {
let mut dir = head_positions.get_single_mut().expect("No Snake Direction");
for key in keyboard_input.get_pressed() {
match key {
KeyCode::Left => match *dir {
SnakeDirection::UP | SnakeDirection::DOWN => *dir = SnakeDirection::LEFT,
_ => {}
},
KeyCode::Right => match *dir {
SnakeDirection::UP | SnakeDirection::DOWN => *dir = SnakeDirection::RIGHT,
_ => {}
},
KeyCode::Up => match *dir {
SnakeDirection::LEFT | SnakeDirection::RIGHT => *dir = SnakeDirection::UP,
_ => {}
},
KeyCode::Down => match *dir {
SnakeDirection::LEFT | SnakeDirection::RIGHT => *dir = SnakeDirection::DOWN,
_ => {}
},
_ => {}
}
}
}
fn snake_movement(mut head_positions: Query<(&mut Position, &SnakeDirection), With<SnakeHead>>) {
for (mut pos, dir) in head_positions.iter_mut() {
match dir {
SnakeDirection::UP => pos.y += 1,
SnakeDirection::DOWN => pos.y -= 1,
SnakeDirection::LEFT => pos.x -= 1,
SnakeDirection::RIGHT => pos.x += 1,
}
}
}
Put the snake on a grid
The snake needs to be confined to a grid for movement to replicate the original snake game
The Position
is used to place it on the grid, and movement is done in integers.
Grid Code
const ARENA_WIDTH: u32 = 10;
const ARENA_HEIGHT: u32 = 10;
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component)]
struct Size {
width: f32,
height: f32,
}
impl Size {
pub fn square(x: f32) -> Self {
Self {
width: x,
height: x,
}
}
}
fn size_scaling(windows: Res<Windows>, mut q: Query<(&Size, &mut Transform)>) {
let window = windows.get_primary().unwrap();
for (sprite_size, mut transform) in q.iter_mut() {
transform.scale = Vec3::new(
sprite_size.width / ARENA_WIDTH as f32 * window.width() as f32,
sprite_size.height / ARENA_HEIGHT as f32 * window.height() as f32,
1.0,
);
}
}
fn position_translation(windows: Res<Windows>, mut q: Query<(&Position, &mut Transform)>) {
fn convert(pos: f32, bound_window: f32, bound_game: f32) -> f32 {
let tile_size = bound_window / bound_game;
pos / bound_game * bound_window - (bound_window / 2.) + (tile_size / 2.)
}
let window = windows.get_primary().unwrap();
for (pos, mut transform) in q.iter_mut() {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width() as f32, ARENA_WIDTH as f32),
convert(pos.y as f32, window.height() as f32, ARENA_HEIGHT as f32),
0.0,
);
}
}
Accept User input
The only user input we need for now is the arrow keys.
The Player can only turn the snake left or right.
Make the Snake’s tail follow the path the snake does
Add a struct called TailSegment
that holds a single position
Grow the snake when it collects pellets
Pellets
spawn in random locations around the grid and add 1 to the length of the snake’s tail when picked up.
End the game if the snake hits a wall or its tail
If the snake enters a square that is either out of bounds or occupied by another tail segment, the game ends
OPTIONAL Keep a score
Code
Food Code
const FOOD_COLOR: Color = Color::rgb(1.0, 1.0, 1.0);
struct EatFoodEvent;
#[derive(Component)]
struct Food;
fn food_spawner(mut commands: Commands, food_query: Query<&Food>) {
//If we already have a food on the screen, don't create another.
if let Ok(_) = food_query.get_single() { return; }
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
})
.insert(Food)
.insert(Position {
x: (random::<f32>() * ARENA_WIDTH as f32) as i32,
y: (random::<f32>() * ARENA_HEIGHT as f32) as i32,
})
.insert(Size::square(0.8));
}
fn eat_food(
mut commands: Commands,
mut ev_eat_food: EventWriter<EatFoodEvent>,
positions_query: Query<(Entity, &Position, Option<&SnakeHead>, Option<&Food>)>,
) {
let mut iter = positions_query.iter_combinations();
//Check the positions of the head with every food object
while let Some([(ent1, pos1, Some(head), None), (ent2, pos2, None, Some(food))]) = iter.fetch_next() {
if pos1 == pos2 {
ev_eat_food.send(EatFoodEvent);
commands.entity(ent2).despawn_recursive();
}
}
}
Tail Code
#[derive(Default, Deref, DerefMut)]
struct TailSegments(Vec<Entity>);
const TAIL_COLOR: Color = Color::rgb(0.4, 0.4, 0.4);
fn add_tail_segment(
mut commands: Commands,
mut er_eat_food: EventReader<EatFoodEvent>,
mut tail_segments: ResMut<TailSegments>,
player_query: Query<(&Position, &SnakeDirection), With<SnakeHead>>,
) {
let (head_position, direction) = player_query.get_single().expect("No Snakehead");
for ev in er_eat_food.iter() {
let tail_position = match *direction {
SnakeDirection::UP => Position { x: head_position.x, y: head_position.y - 1 },
SnakeDirection::DOWN => Position { x: head_position.x, y: head_position.y + 1 },
SnakeDirection::LEFT => Position { x: head_position.x + 1, y: head_position.y },
SnakeDirection::RIGHT => Position { x: head_position.x - 1, y: head_position.y },
};
(*tail_segments).append(&mut vec![commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: TAIL_COLOR,
..default()
},
..default()
})
.insert(Size::square(0.6))
.insert(tail_position)
.id()]);
}
}