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()]);
    }
}