This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!

If you enjoy this and would like me to keep writing, please consider supporting my Patreon.

Hands-On Rust


Colonist Chat

One of the core ideas when I was imaging what SecBot would look like included the colonists saying useless - but flavorful - things to you as you encounter them. Seeing a green smiley face is great; having the smiley face tell you something adds a layer of immersion at little cost to the game.

I wanted colonists to have the option of saying more than one thing, to allow them to build a narrative. I also didn't want to overdo it; speech everywhere can get cumbersome.

Adding a Dialog Component

The first step was to make a Dialog component. (You may notice in the git source that I went with "dialogue" a few times at first; I sometimes forget whether I'm speaking UK or US English. Sorry!)

Make a new file: src/components/dialog.rs. It contains a vector of strings for things the colonist can say:


#![allow(unused)]
fn main() {
pub struct Dialog {
    pub lines : Vec<String>
}
}

As with all new components, you need to add mod dialog; pub use dialog::*; to your components/mod.rs file to tell Rust that it is part of your project.

Adding a Speech Component

I also wanted to make a second component, which I named Speech. Why have both a Speech and a Dialog component? My idea was that Dialog contains everything the entity can say, and Speech indicates that they are currently saying it.

Make another new component file, src/components/speech.rs:


#![allow(unused)]
fn main() {
pub struct Speech {
    pub lifetime: u32,
}
}

A lifetime field? My idea was that speech would appear when the colonist says it, and stay on the screen for a limited period of time (so as to not obscure the map). So when a colonist starts speaking, a new entity is made with a Description holding the current line of speech - and a Speech indicating how long it should remain on the screen.

Modify the First Colonist

Let's change the first colonist we encounter (who will always be in the first room) a bit. Open src/map/layerbuilder/colonists.rs and add a new function:


#![allow(unused)]
fn main() {
pub fn spawn_first_colonist(ecs: &mut World, location: Point, layer: u32) {
    ecs.push((
        Colonist{ path: None },
        Position::with_pt(location, layer),
        Glyph{ glyph: to_cp437('☺'), color: ColorPair::new( LIME_GREEN, BLACK ) },
        Description("A squishy friend. You are here to rescue your squishies.".to_string()),
        ColonistStatus::Unknown,
        Dialog{lines: vec![
            "Bracket Corp is going to save us?".to_string(),
            "I'll head to your ship.".to_string(),
            "Comms are down, power is iffy.".to_string(),
            "No idea where the others are.".to_string()
            ]
        }
    ));
}
}

This creates a new colonist. They are mostly the same as previous colonists, but they have a big list of dialog entries. Let's also modify spawn_random_colonist to make default colonists polite:


#![allow(unused)]
fn main() {
pub fn spawn_random_colonist(ecs: &mut World, location: Point, layer: u32) {
    ecs.push((
        Colonist{ path: None },
        Position::with_pt(location, layer),
        Glyph{ glyph: to_cp437('☺'), color: ColorPair::new( LIME_GREEN, BLACK ) },
        Description("A squishy friend. You are here to rescue your squishies.".to_string()),
        ColonistStatus::Unknown,
        Dialog{lines: vec![
            "Thanks, SecBot!".to_string()
            ]
        }
    ));
}
}

Again, the only change is adding a Dialog component - in this case with a single line of available text, "Thanks, SecBot!".

Now open up src/map/layerbuilder/entrance.rs and we'll modify it to spawn the first colonist in the first room. Add a use super::colonists::* entry to make it easy to pull in colonist spawn code. In populate_rooms, change the following code:


#![allow(unused)]
fn main() {
// The first room always contains a single colonist, who must be alive.
spawn_first_colonist(ecs, rooms[0].center(), 0);
}

Spawning Speech

Now that the colonists know what to say, we need to make them speak. Open src/game/colonists.rs. You want to add Dialog to the list of queried components - which means including it in the filter and for_each calls as well:


#![allow(unused)]
fn main() {
use crate::components::*;
use bracket_lib::prelude::{Algorithm2D, a_star_search};
use legion::{*, systems::CommandBuffer};
use crate::map::Map;

pub fn colonists_turn(ecs: &mut World, map: &mut Map) {
    let mut commands = CommandBuffer::new(ecs);

    let mut colonists = <(Entity, &mut Colonist, &mut ColonistStatus, &mut Position, &mut Dialog)>::query();
    colonists
        .iter_mut(ecs)
        .filter(|(_, _, status, _, _)| **status == ColonistStatus::Alive)
        .for_each(|(entity, colonist, status, pos, dialog)| {
}

The next section is unchanged:


#![allow(unused)]
fn main() {
            // Check basics like "am I dead?"

            // Am I at the exit? If so, I can change my status to "rescued"

            // Am I at a level boundary? If so, go up it!

            // Since I'm activated, I should move towards the exit
            let current_map = map.get_layer(pos.layer as usize);
            if let Some(path) = &mut colonist.path {
                if !path.is_empty() {
                    let next_step = path[0];
                    path.remove(0);
                    if !current_map.tiles[next_step].blocked {
                        pos.pt = current_map.index_to_point2d(next_step);
                    }
                } else {
                    // We've arrived - status update
                    if pos.layer == 0 {
                        *status = ColonistStatus::Rescued;
                        commands.remove_component::<Glyph>(*entity);
                        commands.remove_component::<Description>(*entity);
                    }
                }
            } else {
                let start = current_map.point2d_to_index(pos.pt);
                let end = current_map.point2d_to_index(current_map.colonist_exit);
                let finder = a_star_search(start, end, current_map);
                if finder.success {
                    colonist.path = Some(finder.steps);
                } else {
                    println!("Failed to find the path");
                }

            }
}

Immediately after path-finding, add a dialog spawner. It checks to see if there are any dialog lines remaining, grabs the first one and removes it from the list. It then creates a new entity with a Speech, Position (the location of the colonist) and Description component. The Description contains the line of speech to emit.


#![allow(unused)]
fn main() {
            // If the actor has dialogue, emit it
            if !dialog.lines.is_empty() {
                let line = dialog.lines[0].clone();
                dialog.lines.remove(0);
                commands.push((
                    Speech{lifetime: 20},
                    pos.clone(),
                    Description(line)
                ));
            }
        }
    );

    // Execute the command buffer
    commands.flush(ecs);
}
}

Rendering Speech

Now that we have Speech entities, we need to render them. In main.rs, add an import for the code we are about to write:


#![allow(unused)]
fn main() {
use render::speech::render_speech;
}

Now find your render code, and add a call to render_speech:


#![allow(unused)]
fn main() {
render::render_colonist_panel(ctx, &self.ecs, self.map.current_layer);
self.map.render(ctx);
render::render_glyphs(ctx, &self.ecs, &self.map);
render::speech::render_speech(ctx, &mut self.ecs, &self.map); // This is new
}

Open render/mod.rs and add an import for the speech module we're going to create:


#![allow(unused)]
fn main() {
pub mod speech;
}

Finally, we can write the render code! Create a new file, src/game/speech.rs and add the following to it:


#![allow(unused)]
fn main() {
use crate::{components::*, map::Map, map::WIDTH};
use bracket_lib::prelude::*;
use legion::*;

pub fn render_speech(ctx: &mut BTerm, ecs: &mut World, map: &Map) {
    let mut commands = legion::systems::CommandBuffer::new(ecs);
    let mut query = <(Entity, &mut Speech, &Position, &Description)>::query();
    query.for_each_mut(ecs, |(entity, speech, pos, desc)| {
        if pos.layer == map.current_layer as u32 {
            let x = if pos.pt.x < WIDTH as i32 / 2 {
                pos.pt.x - 1
            } else {
                pos.pt.x + 1
            };

            ctx.print_color(x, pos.pt.y - 2, GREEN, BLACK, &desc.0);

            speech.lifetime -= 1;
            if speech.lifetime == 0 {
                commands.remove(*entity);
            }
        }
    });
    commands.flush(ecs);
}
}

This creates a CommandBuffer and then queries all entities that have a Speech, Position and Description component. If they are on the current layer, it prints the speech to the screen. It then decrements the lifetime, and removes the entity if lifetime has reached zero.

Try it Out

You can find the source code for pathing_colonists_chat here.

Up Next

Next, we'll add a monster and let you target it.