we are creating a game for programmers on Bevy

we are creating a game for programmers on Bevy

Hello, Habre! IN

previous article

I talked about my transition to gamedev and my concept of a “hacker” game. Right there he focused on development, also on Bevy and Rust tools, which he used for the game engine. Interested in learning firsthand how domestic indie games are created? Then welcome under the executioner.

Use the navigation if you don’t want to read the entire text:

→ What did he start with?
→ UI
→ Gameloop
→ IDE
→ Results

What did he start with?


Before starting the development, I will tell a little about the project itself. HackeRPG is an action game with RPG elements. In it, the user controls a character using code to fight viruses, bugs, trojans and other pests.

Since the mechanics require knowledge of programming, the game is designed for a narrow audience, or, in other words, “on their own”, especially for them I added easter eggs and features familiar to developers.

Tools

To simplify the further understanding of the solutions I implemented in the project, consider the Bevy engine. Its main feature is ECS (Entity component system), where

  • Component — any game entity, such as a sprite, light, or text,
  • Entity – the container where we put all the components,
  • System – actions with components that are performed in an infinite loop.

Visualization of ECS.

Additionally implemented in the engine state to limit the operation of systems, events for a one-time call to systems and resource data storage. The latter is not associated with any entities and has only one instance. Formally, it can be increased with the help of components, but in some situations this option is more convenient.


UI


At the beginning of the work, I needed to decide on the development of a visual style. For user input, I chose a mouseless approach: each menu in the game is a terminal simulation, and the main gameloop (game loop) is text input into the console. And, of course, the entire text must be green. Where to go without hacker aesthetics?

And don’t tell me what your work day looks like.

The task itself is not difficult, but ECS makes its organization inconvenient. It is necessary to create additional abstractions that other approaches do not need. And this is a large number of lines of code, nuances and errors. However, after some optimizations, separation of functionality and modules, a completely tolerable architecture can be achieved.

To do this, I created systems that run once and create the necessary components:

fn setup_menu_system(
	mut commands: Commands, 
	game_assets: Res<GameAssets>
) {
	commands.spawn(
		NodeBundle {
			style: Style {
				width: Val::Percent(100.),
				height: Val::Percent(100.),
				flex_direction: FlexDirection::Column,
				justify_content: JustifyContent::Center,
				align_items: AlignItems::Center,
				..default()
			},
			..default()	
		}	
	).with_children(|parent| {
		spawn_console(
			&game_assets,
			parent,	
		);
	});
}

Structure

commands

adds a NodeBundle element to the screen with child elements created by my own spawn_console function.

I would like to emphasize the convenience of working with the HUD layer: in Bevy, it is implemented based on the FlexBox concept, which will be familiar to anyone who is more or less familiar with web and front-end development.

Another important nuance is that most often the system displays the same component

input

and

output

. For example, there is a program:

Say hello
>hello█

where the first line is

output

and the second (except for the first character and caret) –

input

. If there is such a menu, it uses one text element that displays both lines. Therefore, the user’s input is stored in a separate component, and the system that displays the text on the screen “pastes” it on the fly. This approach allows you to conveniently configure the carriage logic and input history.

To output text in our “terminal” I use the following systems:

fn menu_output_system(
	mut console_query: Query<(&mut Text, &TextInput)>
) {
	if let Ok((mut output, input)) = console_query.get_single_mut() {
		let output_text = get_menu_output();
		output.sections[0].value = output_text + text_input.text.as_str();
	}
}

where

Query

gets entities that have the specified components.

TextInput

– custom component – stores input and autocompletion data, and the get_menu_output function generates a string for output.

All that remains is to add input processing and the terminal MVP is ready! Below are the two systems I used:

fn menu_char_input_system(
	mut ev_char: EventReader<ReceivedChar>,
	mut console_query: Query<(&mut TextInput, &mut TextCaret)>
) {
	for ev in ev_char.read() {
		if ev.char.is_control() {
			return;
		}
		if let Ok((mut input, mut caret)) = console_query.get_single_mut() {
			insert_char_at_caret_position(
				&mut input,
				ev.char.to_string(),
				&caret	
			);
			update_caret_on_input(&mut caret);
		}	
	}
}

fn menu_control_input_system(
	keyboard: Res<Input<KeyCode>>,
	mut console_query: Query<(&mut TextInput, &mut TextCaret)>
	mut ev_exit: EventWriter<AppExit>
) {
	if let Ok((mut input, mut caret)) = console_query.get_single_mut() {
		if keyboard.just_pressed(KeyCode::Return) {
			match input.text.as_str() {
				"0" => ev_exit.send(AppExit),
				_ => {}	
			}
			input.text = "".to_string();
			reset_caret(&mut caret);
		}
	}
}

The first function handles the event

ReceivedChar

and sends the received symbol

TextInput

by the position of the carriage and then updates its position. The second reads button presses

Enter

and exits the program if zero is entered. With the help of additional states characterizing the current screen, any navigation, input processing and information display can be implemented.

The result of the work done.

Currently, states in Bevy do not store information, instead representing a primitive enumeration. This forces them to be associated with the resources or components that store their data. As far as I know, that’s not going to change anytime soon, so we’ll just have to put up with it.

There is nothing complicated in the rest: text display, sprite animation, movement are typical content for tutorials. Therefore, I will immediately move on to the “Proger” features.

Gameloop


The main feature of the game is character management using commands. Let’s consider in more detail how to do this.

To enter text, use the menu_char_input_system command. We remove the word menu from the name and make it work not only on the main screen, but also in the game itself. Unfortunately, this will not work with control_input_system, you need to write another one. Let’s omit the code template (boilerplate) and go straight to the point:

...
if let Some(command) = command_from_string(input.text.to_string()) {
	player_commands.queue.push(command);
}
...

Now when we click

Enter

, we start a parse command based on our input and, if successful, add it to the queue. It looks like this: we perform the parsing inside the command_from_string function. The system starts one command at a time from the queue, if the current one is not in process.

fn handle_command_queue_system(
	mut commands_query: Query<&mut PlayerCommands>
) {
	for (mut commands) in commands_query.iter_mut() {
		if check_in_progress(&commands)	{
			continue;
		}
		set_current_command(&mut commands_query);
	}
}

You can see that we use for and iter_mut() instead of if let Ok and get_single_mut(). In this way, we process the components, which may be several in the game.

This snippet is a programmatic embodiment of my optimism that the project will find interest and recognition, and demand for multiplayer will appear. Otherwise, a check is made to see if the current command is active using check_in_progress, and if it is not, transfers the queue vector command to the current variable.

Further, the management accepts a large number of systems that process a specific command. Initially, this entire construction was one big match, because of which it was mercilessly cut into many systems of rolling refactoring. Here is one of them:

fn handle_current_move_command_system(
	mut commands_query: Query<(&mut PlayerCommands, &mut Movable)>
) {
	for (mut commands, mut movable) in command_query.iter_mut() {
			match &commands.current {
				Move(x,y) => handle_move_command(x,y,&mut movable),
				_ => {}
			}	
	}
}

In the function we check if move is the current command. If the answer is positive, then we start processing it. Template code helps us in this: it removes the “hell” from the huge number of Queries that are needed to process all the commands in one function. Practice has shown that this solution is quite convenient.

Result.

As a result, I managed to implement the control of the character using commands. At the same time, you can add new ones using keywords and elements in Enum when parsing, as well as the system that will process this command. Pretty good and almost clean.

IDE


Now it’s time to jump down the rabbit hole – add an in-game IDE.
Above, I described the main features that I used in the development of the terminal. Next, the task is quite trivial — to set up recursive parsing. But the problem is: Rust is not friendly with the use of recursion in regular expressions, so there is only one solution.

For this, it was necessary to combine parsing and use regular expressions with data-based recursion. This helped me to place if commands within if and for commands within for. As a result, the CodeBlock structure came out of the line:

#[derive(PartialEq, Debug, Clone)]
pub struct CodeBlock {
    pub name: String,
    pub block_type: CodeBlockType,
    pub content: Vec<CodeBlockContent>,
}

#[derive(PartialEq, Debug, Clone)]
pub enum CodeBlockType {
    Function(Vec<String>),
    Daemon,
    Virus,
}

#[derive(PartialEq, Debug, Clone)]
pub enum CodeBlockContent {
    Block(InnerCodeBlock),
    Lines(Vec<String>),
}

#[derive(PartialEq, Debug, Clone)]
pub struct InnerCodeBlock {
    pub block_type: InnerCodeBlockType,
    pub content: Vec<CodeBlockContent>,
}

#[derive(PartialEq, Debug, Clone)]
pub enum InnerCodeBlockType {
    If(String),
    For(ForLoopInnerCodeBlock),
    While(String),
}

#[derive(PartialEq, Debug, Clone)]
pub struct ForLoopInnerCodeBlock {
    pub variable_name: String,
    pub from: PlayerCommandInput,
    pub to: PlayerCommandInput,
}

Each block of code has one of three types: Function, Daemon, or Virus. They store the “body” content in the CodeBlockContent vector. At the same time, the internal content can be strings that will be parsed by command_from_string, or InnerCodeBlock structures. The latter are similar to CodeBlock, but have If, ​​For or While types.

If the current entities are not enough for me or I want to connect new ones, then I will add elements to the list, and keywords to the parsing logic.

This is what the IDE looks like in the game.

Results


The goal of this game is to help people improve their programming skills and instill a love for it. To some extent, the goal was achieved: during development, I developed my skills quite well and really fell in love with Rust. Now, when it is possible and makes sense, I try to give preference.

What about Bevy? He showed himself well. Of course, there were also unpleasant bugs that I fixed in the next build, not entirely convenient solutions, as well as migration when switching to a new version, but all this overlaps:

  • ease of development,
  • quality of the final product,
  • small assembly size.

The entire text is just a small slice of the work done. In addition, there are other interesting solutions that I have created as part of this project. For example, the nuances of parsing, the use of resources for running code, a Git-style pumping tree, learning logic, and more. If you are interested in this topic, I will tell you more about them in the following materials.

Related posts