Town Portals


About this tutorial

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.


We mentioned town portals in the design document, and it's becoming obvious how they would help: it's a real slog to travel back to town to sell your hard-earned loot (and possibly save up for upgrades to help against the itty-bitty draconic murderers!).

The basic idea of a town portal scroll is simple: you cast the spell, a portal opens and takes you back to town. You do your thing in town, and return to the portal - and it teleports you right back to where you were. Depending upon the game, it may heal the monsters on the level while they are gone. Generally, monsters don't follow you through the portal (if they did, you could kill the town with a well-placed portal!).

Spawning town portal scrolls

We should start by defining them in spawns.json as another item:

{
    "name" : "Town Portal Scroll",
    "renderable": {
        "glyph" : ")",
        "fg" : "#AAAAFF",
        "bg" : "#000000",
        "order" : 2
    },
    "consumable" : {
        "effects" : {
            "town_portal" : ""
        }
    },
    "weight_lbs" : 0.5,
    "base_value" : 20.0,
    "vendor_category" : "alchemy"
},

We should also make them reasonably common in the spawn table:

{ "name" : "Town Portal Scroll", "weight" : 4, "min_depth" : 0, "max_depth" : 100 },

That's enough to get them into the game: they spawn as drops, and are purchasable from the alchemist in town (admittedly that doesn't help you when you need one, but with some planning it can help!).

Implementing town portals

The next stage is to make town portals do something. We already added an "effects" tag, causing it to be consumed on use and look for that tag. The other effects use a component to indicate what happens; so we'll open components.rs and make a new component type (and register in main.rs and saveload_system.rs):


#![allow(unused_variables)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct TownPortal {}
}

We also need to open up rawmaster.rs, and edit spawn_named_item to add the tag:


#![allow(unused_variables)]
fn main() {
if let Some(consumable) = &item_template.consumable {
    eb = eb.with(crate::components::Consumable{});
    for effect in consumable.effects.iter() {
        let effect_name = effect.0.as_str();
        match effect_name {
            "provides_healing" => { 
                eb = eb.with(ProvidesHealing{ heal_amount: effect.1.parse::<i32>().unwrap() }) 
            }
            "ranged" => { eb = eb.with(Ranged{ range: effect.1.parse::<i32>().unwrap() }) },
            "damage" => { eb = eb.with(InflictsDamage{ damage : effect.1.parse::<i32>().unwrap() }) }
            "area_of_effect" => { eb = eb.with(AreaOfEffect{ radius: effect.1.parse::<i32>().unwrap() }) }
            "confusion" => { eb = eb.with(Confusion{ turns: effect.1.parse::<i32>().unwrap() }) }
            "magic_mapping" => { eb = eb.with(MagicMapper{}) }
            "town_portal" => { eb = eb.with(TownPortal{}) }
            "food" => { eb = eb.with(ProvidesFood{}) }
            _ => {
                rltk::console::log(format!("Warning: consumable effect {} not implemented.", effect_name));
            }
        }
    }
}
}

All of our level transitions thus far have occurred via RunState in main.rs. So in main.rs, we'll add a new state:


#![allow(unused_variables)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { 
    AwaitingInput, 
    PreRun, 
    Ticking, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    PreviousLevel,
    TownPortal,
    ShowRemoveItem,
    GameOver,
    MagicMapReveal { row : i32 },
    MapGeneration,
    ShowCheatMenu,
    ShowVendor { vendor: Entity, mode : VendorMode }
}
}

So that marks the effect. Now we need to make it function! Open up inventory_system.rs and we'll want to edit ItemUseSystem. After magic mapping, the following code simply logs an event, consumes the item and changes the game state:


#![allow(unused_variables)]
fn main() {
// If its a town portal...
if let Some(_townportal) = town_portal.get(useitem.item) {
    if map.depth == 1 {
        gamelog.entries.push("You are already in town, so the scroll does nothing.".to_string());
    } else {
        used_item = true;
        gamelog.entries.push("You are telported back to town!".to_string());
        *runstate = RunState::TownPortal;
    }
}
}

That leaves handling the state in main.rs:


#![allow(unused_variables)]
fn main() {
RunState::TownPortal => {
    // Spawn the portal
    spawner::spawn_town_portal(&mut self.ecs);

    // Transition
    let map_depth = self.ecs.fetch::<Map>().depth;
    let destination_offset = 0 - (map_depth-1);
    self.goto_level(destination_offset);
    self.mapgen_next_state = Some(RunState::PreRun);
    newrunstate = RunState::MapGeneration;
}
}

So this is relatively straight-forward: it calls the as-yet-unwritten spawn_town_portal function, retrieves the depth, and uses the same logic as NextLevel and PreviousLevel to switch to the town level (the offset calculated to result in a depth of 1).

We also need to modify the Ticking handler to allow TownPortal to escape from the loop:


#![allow(unused_variables)]
fn main() {
RunState::Ticking => {
    while newrunstate == RunState::Ticking {
        self.run_systems();
        self.ecs.maintain();
        match *self.ecs.fetch::<RunState>() {
            RunState::AwaitingInput => newrunstate = RunState::AwaitingInput,
            RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 },
            RunState::TownPortal => newrunstate = RunState::TownPortal,
            _ => newrunstate = RunState::Ticking
        }                
    }
}
}

The rabbit hole naturally leads us to spawner.rs, and the spawn_town_portal function. Let's write it:


#![allow(unused_variables)]
fn main() {
pub fn spawn_town_portal(ecs: &mut World) {
    // Get current position & depth
    let map = ecs.fetch::<Map>();
    let player_depth = map.depth;
    let player_pos = ecs.fetch::<rltk::Point>();
    let player_x = player_pos.x;
    let player_y = player_pos.y;
    std::mem::drop(player_pos);
    std::mem::drop(map);

    // Find part of the town for the portal
    let dm = ecs.fetch::<MasterDungeonMap>();
    let town_map = dm.get_map(1).unwrap();
    let mut stairs_idx = 0;
    for (idx, tt) in town_map.tiles.iter().enumerate() {
        if *tt == TileType::DownStairs {
            stairs_idx = idx;
        }
    }
    let portal_x = (stairs_idx as i32 % town_map.width)-2;
    let portal_y = stairs_idx as i32 / town_map.width;

    std::mem::drop(dm);

    // Spawn the portal itself
    ecs.create_entity()
        .with(OtherLevelPosition { x: portal_x, y: portal_y, depth: 1 })
        .with(Renderable {
            glyph: rltk::to_cp437('♥'),
            fg: RGB::named(rltk::CYAN),
            bg: RGB::named(rltk::BLACK),
            render_order: 0
        })
        .with(EntryTrigger{})
        .with(TeleportTo{ x: player_x, y: player_y, depth: player_depth, player_only: true })
        .with(Name{ name : "Town Portal".to_string() })
        .with(SingleActivation{})
        .build();
}
}

This is a busy function, so we'll step through it:

  1. We retrieve the player's depth and position, and then drop access to the resources (to prevent the borrow from continuing).
  2. We look up the town map in the MasterDungeonMap, and find the spawn point. We move two tiles to the west, and store that as portal_x and portal_y. We then drop access to the dungeon map, again to avoid keeping the borrow.
  3. We create an entity for the portal. We give it an OtherLevelPosition, indicating that it is in the town - at the coordinates we calculated. We give it a Renderable (a cyan heart), a Name (so it shows up in tooltips). We also give it an EntryTrigger - so entering it will trigger an effect. Finally, we give it a TeleportTo component; we haven't written that yet, but you can see we're specifying destination coordinates (back to where the player started). There's also a player_only setting - if the teleporter works for everyone, town drunks might walk into the portal by mistake leading to the (hilarious) situation where they teleport into dungeons and die horribly. To avoid that, we'll make this teleporter only affect the player!

Since we've used it, we better make TeleportTo in components.rs (and registered in main.rs and saveload_system.rs). It's pretty simple:


#![allow(unused_variables)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct TeleportTo {
    pub x: i32,
    pub y: i32,
    pub depth: i32,
    pub player_only : bool
}
}

We'll worry about making teleporters work in a moment.

To help test the systems, we'll start the player with a town portal scroll. In spawner.rs, we'll modify player:


#![allow(unused_variables)]
fn main() {
spawn_named_entity(&RAWS.lock().unwrap(), ecs, "Town Portal Scroll", SpawnType::Carried{by : player});
}

If you cargo run now, you start with a Town Portal Scroll. Trying to use it in town gives you a "does nothing" message. Going to another level and then using it teleports you right back to town, with a portal present - exactly what we had in mind (but with no way back, yet):

Screenshot

Implementing teleporters

Now we need to make the portal go back to your point-of-origin in the dungeon. Since we've implemented triggers that can have TeleportTo, it's worth taking the time to make teleport triggers more general (so you could have teleport traps, for example - or inter-room teleporters, or even a portal to the final level). There's actually a lot to consider here:

  • Teleporters can affect anyone who enters the tile, unless you've flagged them as "player only".
  • Teleporting could happen across the current level, in which case it's like a regular move.
  • Teleporting could also happen across levels, in which case there are two possibilities:
    • The player is teleporting, and we need to adjust game state like other level transitions.
    • Another entity is teleporting, in which case we need to remove its Position component and add an OtherLevelPosition component so they are in-place when the player goes there.

Cleaning up movement in general

We're seeing more and more places implement the same basic movement code: clear blocked, move, restore blocked. You can find this all over the place, and adding in teleporting is just going to make it more complicated (as will other systems as we make a bigger game). This makes it far too easy to forget to update something, and also convolutes lots of systems with mutable position and map access - when movement is the only reason they need write access.

We've used intent based components for most other actions - movement should be no different. Open up components.rs, and we'll make some new components (and register them in main.rs and saveload_system.rs):


#![allow(unused_variables)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct ApplyMove {
    pub dest_idx : usize
}

#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct ApplyTeleport {
    pub dest_x : i32,
    pub dest_y : i32,
    pub dest_depth : i32
}
}

To handle these, let's make a new system file - movement_system.rs:


#![allow(unused_variables)]
fn main() {
use specs::prelude::*;
use super::{Map, Position, BlocksTile, ApplyMove, ApplyTeleport, OtherLevelPosition, EntityMoved,
    Viewshed};

pub struct MovementSystem {}

impl<'a> System<'a> for MovementSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        WriteStorage<'a, Position>,
                        ReadStorage<'a, BlocksTile>,
                        Entities<'a>,
                        WriteStorage<'a, ApplyMove>,
                        WriteStorage<'a, ApplyTeleport>,
                        WriteStorage<'a, OtherLevelPosition>,
                        WriteStorage<'a, EntityMoved>,
                        WriteStorage<'a, Viewshed>,
                        ReadExpect<'a, Entity>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, mut position, blockers, entities, mut apply_move, 
            mut apply_teleport, mut other_level, mut moved,
            mut viewsheds, player_entity) = data;

        // Apply teleports
        for (entity, teleport) in (&entities, &apply_teleport).join() {
            if teleport.dest_depth == map.depth {
                apply_move.insert(entity, ApplyMove{ dest_idx: map.xy_idx(teleport.dest_x, teleport.dest_y) })
                    .expect("Unable to insert");
            } else if entity == *player_entity {
                // It's the player - we have a mess
                rltk::console::log(format!("Not implemented yet."));
            } else if let Some(pos) = position.get(entity) {
                let idx = map.xy_idx(pos.x, pos.y);
                let dest_idx = map.xy_idx(teleport.dest_x, teleport.dest_y);
                crate::spatial::move_entity(entity, idx, dest_idx);
                other_level.insert(entity, OtherLevelPosition{ 
                    x: teleport.dest_x, 
                    y: teleport.dest_y, 
                    depth: teleport.dest_depth })
                    .expect("Unable to insert");
                position.remove(entity);
            }
        }
        apply_teleport.clear();

        // Apply broad movement
        for (entity, movement, mut pos) in (&entities, &apply_move, &mut position).join() {
            let start_idx = map.xy_idx(pos.x, pos.y);
            let dest_idx = movement.dest_idx as usize;
            crate::spatial::move_entity(entity, start_idx, dest_idx);
            pos.x = movement.dest_idx as i32 % map.width;
            pos.y = movement.dest_idx as i32 / map.width;
            if let Some(vs) = viewsheds.get_mut(entity) {
                vs.dirty = true;
            }
            moved.insert(entity, EntityMoved{}).expect("Unable to insert");
        }
        apply_move.clear();
    }
}
}

This is a meaty system, but should be quite familiar to you - it doesn't do very much that we haven't done before, it just centralizes it in one place. Let's walk through it:

  1. We iterate all entities that are marked as teleporting.
    1. If its a teleport on the current depth, we add an apply_move component to indicate that we're moving across the map.
    2. If it isn't a local teleport:
      1. If its the player, we give up for now (the code is later in this chapter).
      2. If it isn't the player, we remove their Position component and add an OtherLevelPosition component to move the entity to the teleport destination.
  2. We remove all teleport intentions, since we've processed them.
  3. We iterate all entities with an ApplyMove component.
    1. We obtain the start and destination indices for the move.
    2. If the entity blocks the tile, we clear the blocking in the source tile, and set the blocking status in the destination tile.
    3. We move the entity to the destination.
    4. If the entity has a viewshed, we mark it as dirty.
    5. We apply an EntityMoved component.

You'll notice that this is almost exactly what we've been doing in other systems - but it is a little more conditional: an entity without a viewshed can move, an entity that doesn't block tiles won't.

We can then update ai/approach_system.rs, ai/chase_ai_system.rs, ai/default_move_system.rs, and ai/flee_ai_system.rs to no longer calculate movement, but instead set an ApplyMove component to the entity they are considering. This greatly simplifies the systems, removing a lot of write access and several entire component accesses! The systems haven't changed their logic - just their functionality. Rather than copy/pasting them all here, you can check the source - otherwise this will be a chapter of record length!

Finally, we need to add movement into run_systems in main.rs. Add it after defaultmove and before triggers:


#![allow(unused_variables)]
fn main() {
defaultmove.run_now(&self.ecs);
let mut moving = movement_system::MovementSystem{};
moving.run_now(&self.ecs);
let mut triggers = trigger_system::TriggerSystem{};
}

Once those changes are made, you can cargo run - and see that things behave as they did before.

Making player teleports work

Instead of just printing "Not Supported Yet!" when the player enters a teleporter, we should actually teleport them! The reason this was special-cased in movement_system.rs is that we've always handled level transitions in the main loop (because they touch a lot of game state). So to make this function, we're going to need another state in main.rs:


#![allow(unused_variables)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { 
    AwaitingInput, 
    PreRun, 
    Ticking, 
    ShowInventory, 
    ShowDropItem, 
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    PreviousLevel,
    TownPortal,
    ShowRemoveItem,
    GameOver,
    MagicMapReveal { row : i32 },
    MapGeneration,
    ShowCheatMenu,
    ShowVendor { vendor: Entity, mode : VendorMode },
    TeleportingToOtherLevel { x: i32, y: i32, depth: i32 }
}
}

Now we can open up movement_system.rs and make some simple changes to have the system send out a RunState change:


#![allow(unused_variables)]
fn main() {
impl<'a> System<'a> for MovementSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteExpect<'a, Map>,
                        WriteStorage<'a, Position>,
                        ReadStorage<'a, BlocksTile>,
                        Entities<'a>,
                        WriteStorage<'a, ApplyMove>,
                        WriteStorage<'a, ApplyTeleport>,
                        WriteStorage<'a, OtherLevelPosition>,
                        WriteStorage<'a, EntityMoved>,
                        WriteStorage<'a, Viewshed>,
                        ReadExpect<'a, Entity>,
                        WriteExpect<'a, RunState>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, mut position, blockers, entities, mut apply_move, 
            mut apply_teleport, mut other_level, mut moved,
            mut viewsheds, player_entity, mut runstate) = data;

        // Apply teleports
        for (entity, teleport) in (&entities, &apply_teleport).join() {
            if teleport.dest_depth == map.depth {
                apply_move.insert(entity, ApplyMove{ dest_idx: map.xy_idx(teleport.dest_x, teleport.dest_y) })
                    .expect("Unable to insert");
            } else if entity == *player_entity {
                *runstate = RunState::TeleportingToOtherLevel{ x: teleport.dest_x, y: teleport.dest_y, depth: teleport.dest_depth };
                ...
}

Over in main.rs, lets modify the Ticking state to also accept TeleportingToOtherLevel as an exit condition:


#![allow(unused_variables)]
fn main() {
RunState::Ticking => {
    while newrunstate == RunState::Ticking {
        self.run_systems();
        self.ecs.maintain();
        match *self.ecs.fetch::<RunState>() {
            RunState::AwaitingInput => newrunstate = RunState::AwaitingInput,
            RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 },
            RunState::TownPortal => newrunstate = RunState::TownPortal,
            RunState::TeleportingToOtherLevel{ x, y, depth } => newrunstate = RunState::TeleportingToOtherLevel{ x, y, depth },
            _ => newrunstate = RunState::Ticking
        }                
    }
}
}

Now in trigger_system.rs we need to make a few changes to actually call the teleport when triggered:


#![allow(unused_variables)]
fn main() {
impl<'a> System<'a> for TriggerSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Map>,
                        WriteStorage<'a, EntityMoved>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, EntryTrigger>,
                        WriteStorage<'a, Hidden>,
                        ReadStorage<'a, Name>,
                        Entities<'a>,
                        WriteExpect<'a, GameLog>,
                        ReadStorage<'a, InflictsDamage>,
                        WriteExpect<'a, ParticleBuilder>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, SingleActivation>,
                        ReadStorage<'a, TeleportTo>,
                        WriteStorage<'a, ApplyTeleport>,
                        ReadExpect<'a, Entity>);

    fn run(&mut self, data : Self::SystemData) {
        let (map, mut entity_moved, position, entry_trigger, mut hidden, 
            names, entities, mut log, inflicts_damage, mut particle_builder,
            mut inflict_damage, single_activation, teleporters, 
            mut apply_teleport, player_entity) = data;

        ...

        // If its a teleporter, then do that
        if let Some(teleport) = teleporters.get(*entity_id) {
            if (teleport.player_only && entity == *player_entity) || !teleport.player_only {
                apply_teleport.insert(entity, ApplyTeleport{
                    dest_x : teleport.x,
                    dest_y : teleport.y,
                    dest_depth : teleport.depth
                }).expect("Unable to insert");
            }
        }
}

With that in place, we need to finish up main.rs and add TeleportingToOtherLevel to the main loop:


#![allow(unused_variables)]
fn main() {
RunState::TeleportingToOtherLevel{x, y, depth} => {
    self.goto_level(depth-1);
    let player_entity = self.ecs.fetch::<Entity>();
    if let Some(pos) = self.ecs.write_storage::<Position>().get_mut(*player_entity) {
        pos.x = x;
        pos.y = y;
    }
    let mut ppos = self.ecs.fetch_mut::<rltk::Point>();
    ppos.x = x;
    ppos.y = y;
    self.mapgen_next_state = Some(RunState::PreRun);
    newrunstate = RunState::MapGeneration;
}
}

So this sends the player to the specified level, updates their Position component, and updates the stored player position (overriding stair case finding).

If you cargo run now, you have a working town portal!

Screenshot

Humorous Aside

Let's see what happens when we remove the player_only and SingleActivation safeguards from a town portal. In spawner.rs:


#![allow(unused_variables)]
fn main() {
ecs.create_entity()
    .with(OtherLevelPosition { x: portal_x, y: portal_y, depth: 1 })
    .with(Renderable {
        glyph: rltk::to_cp437('♥'),
        fg: RGB::named(rltk::CYAN),
        bg: RGB::named(rltk::BLACK),
        render_order: 0
    })
    .with(EntryTrigger{})
    .with(TeleportTo{ x: player_x, y: player_y, depth: player_depth, player_only: false })
    // .with(SingleActivation{})
    .with(Name{ name : "Town Portal".to_string() })
    .build();
}

Now cargo run, find a dangerous spot, and town portal home. Sit around for a while, until a few innocent townsfolk have fallen into the portal. Then follow the portal back, and the bewildered townspeople suffer horrible deaths!

Screenshot

I included this as an illustration as to why we put the safeguards in!

Make sure you remove these comment tags when you're done watching what happens!

Wrap-Up

In this chapter, we started creating town portals - and wound up with a generic teleport system and a cleaned up movement system. This gives a lot more tactical options for the player, and enables "grab loot, return and sell it" play mechanics (as seen in Diablo). We're getting much closer to the game described in the design document!

...

The source code for this chapter may be found here

Run this chapter's example with web assembly, in your browser (WebGL2 required)

Copyright (C) 2019, Herbert Wolverson.