PITVYPR Dev Blog #5: Actions, Actions, Actions!

Hello!

PITVYPR Demo Development has come along really well recently, especially in regards to yet ANOTHER feature Roadmap that I developed for getting the Demo Released (I truly can't help myself). Said roadmap has much more design-focused goals, as opposed to the completely technical goals of the initial Roadmap.

Speaking of the old roadmap, I am happy to announce that it is finally completed!! It only took, what, a year and some change longer than I'd hoped? Not that I intend on losing sleep over that fact. As mentioned before, this is a liquid project with no real deadlines as of yet, so there is no pressure on me to produce things so long as it eventually comes out looking good.

Considering the almost finalized "Technical Bedrock" of the game, I think it's as good a time as any to take a tour through the many technical implementations that make up the project. Some of which are, in my opinion, rather elegant, and some of which are… less so.

The worst of my impulses oft come out in this source code… some truly horrific stuff is buried in it… Demons that I grow more afraid of excorcising by the day.

Today's Technical Tourstop is, as foreshadowed in the previous article, the Action System! To ease my attempt at writing about this, we will split this into a handful of sections: The Concept, The Implementation, and The Consequences of the Action System. All of which, I find, are things that will help to put everything about this system into perspective.

The Concept

To start with, we need to go over the conceived plan for implementing Actions. So, what are Actions?

Actions are just as they sound: they are the things you can do! They are also the things NPCs and Enemies can also do. So, they are the Verbs of the game, at least in a moment-to-moment sense: Moving, Attacking, Opening and Closing doors, Drinking Potions, Casting Spells, Shooting Arrows (or, to translate those last few to PITVYPR terms: Channeling Power, Chanting Mantras, and Shooting 9mm Rounds), etc.

To give a little more of a technical perspective on these: Actions can be performed by all Actors in a given Map - and they influence the Map in some way: changing the layout of Actors in the scene, or the state of the Actors in the scene, for the most part. There may also be special cases that affect more global Map functions, or even Game-Wide variables. Actors are the only objects that can take Turns, but Actions should also be performable by non-Actors, to allow for cascading consequences of Actions being performed.

The Game Developers among you readers may be familiar with the term Entity Component System. Well, we are specifically NOT using ECS in this game - well, not fully. At least, not in the Action System. I will give a small explanation of everything here, but truthfully, I can't do it better than Bob Nystrom in his talk on Non-ECS Approaches to Roguelike Design. If you are planning to develop a roguelike, I implore you to watch this video. Even in my non-roguelike projects, I've found the concepts talked about here infinitely useful.

To summarize the part of the talk that applies to this blog: we aren't treating Actions like Functions or Methods contained within different Actor subclasses, but instead as Classes themselves, that each serve to be Performed. We turn the verb of acting (a function to be run) into a noun (an action to be stored and retrieved) and then treat this noun as a verb again (a stored Perform function in the Action class).

On the surface, this seems rather unnecessary - why treat is as a noun just to turn run a function anyways? The key appeal to this approach is it's linear scaling complexity. If we treated Actions as simple functions contained in each item, each door, each actor, etc, then finding them in the file-structure would get rather unweildly, and calling them would be even more unweildly. Having Actions treated as their own, independent Classes, means we can keep them contained, and write them to be general without needing to add much in the way of special variables or functions to entities for running specific actions. It also keeps us from needing to re-write functions in similar, but not necessarily related Classes.

Basically, we can implement each Action as a general Class, which can then be constructed/retrieved by the Actor on their turn. Our Staggered Turn system, as discussed previously, really helps facilitate this implementation. Speaking of this Implementation:

The Implementation

A pseudocode for the Turn system in PITVYPR is as follows:

Turn currentTurn <-- TimeQueue.Pop()              //Get the next Turn from the StaggeredTurn Queue.

Action action <-- currentTurn.Actor.TakeTurn(Map) //The Actor uses the current state of the Map to decide on an Action to perform: Moving, Attacking, e.t.c.

action.Perform(Map)                               //The action runs its Perform method, where it affects the caster, or the map, or some other Actor in the Map.

Turn newTurn <-- CreateNewTurn(currentTurn.Actor, action.Time + currentTurn.Time) //Create a new Turn for the Actor with an offset-time based on the Action retrieved by TakeTurn.

TimeQueue.Enqueue(newTurn)                        //Queue the Actor back in the StaggeredTurn Queue at the correct time value.

Pretty simple, right? You may see how the extra step of getting an Action from the TakeTurn call allows us to abstract Action Definitions away from the behaviour of Actors, keeping code much more organized and compartmentalized.

Not the prettiest file structure (especially in Actions) - but note that Actors and Actions are entirely separate! This really does make creation of everything easier, especially Items. Though, their implementation will come in a future article.

Something fun to note, as well, is that the real C# code ends up looking a lot like our pseudocode in practice:

if (!playerTurn)
{
    Actor? actor = (Actor?)this.GetObject(id);

    if (actor != null)
    {
        Action? action = actor.TakeTurn(this);
        int offset = GameSettings.TURN_TIME; //Default offset unless specified otherwise

        if (action != null)
        {
            action.Time = item.Item1;
            action.Perform(this).Process(this);

            offset = action.energy;
        }

        TimeEventQueue.InsertActor(id, offset + time);
    }
}

This is all performed within the Map class, so replace all “this” with “Map” and it looks pretty similar.

The only thing extra here, besides some null-checking, is the function Process(Map). What is being run there? And what is Perform() returning so as to then also cast another function. Well, to see that, we can look at an actual implementation of a Action's Perform event. Let's use ActionSummon as an example:

internal class ActionSummon : Action
{

...

	public override ActionResult Perform(Map map)
	{
    	if (MonsterRegistry.ContainsKey(monsterKey))
    	{
        	if (target == Point.None)
        	{
            	target = obj.Position;
        	}

	
        	//spawn in area closest to caster (maximum summon range of 3cheb, currently. Tile MUST be visible.
        	Point spawn = FOVFinder.GetNearestFree(target, map, 3, true);

	
        	if (spawn == Point.None)
            	return new ARFailure("Nowhere visible to summon!", false);

	
        	map.CreateMonster(monsterKey, spawn);

	
        	return new ARSuccess("Creature Spawned!", false);
    	}
    	return new ARFailure("Monster not found in registry", false);
	}

ActionSummon does as it sounds: It summons an entity as defined in the MonsterRegistry stored statically in the game at runtime, somewhere in an unoccupied space near the target.

ActionSummon is fairly simple, and it somewhat cleanly illustrates the ActionResult in its simplest form, Log Messages.

Actions return an ActionResult from being Performed. This is, most typically, an ARSuccess or ARFailure, for if Perform was successful or not. They then have a text string in their definition, which is added to the MessageLog when the ActionResult's Process() method is executed. There are additional ActionResult classes that add Highlights to the map tiles, or even contain multiple ActionResults to be processed in sequence for more complicated Actions.

The ActionResult is returned for the sake of clarity and communication for the Player, so that they understand what happened as a result of pressing buttons - especially in the cases where it is less obviuos (such as Attacking, where there are very minimal pieces of feedback on the UI currently).

The Consequences

Consequently, the Action system has been one the most effective and easy-to-extend elements of PITVYPR's engine!

I currently have several dozen actions that the Player and Enemies can perform alike, and I was able to implement each one independently without needing to change code in any of the Turn Taking or Map Handling, just their own internal code. I should probably organize them into sub-folders soon to help more with organization, but I'm not having much difficulty yet, nor have I figured out any good ways to categorize the Actions besides “Damage” and “Not Damage”. There will be many-dozens more Actions added as the game continues to grow, but I don't foresee any rising complexity as a result of this. Having many wild and varied things to do is one of the appeals of games like this, in my opinion. Being able to add so many is something I look forward to.

Any questions about the system, or more technical insights, please leave a comment of DM me somewhere. Additionally, I am considering creating a website on neocities so that I can post my dev blogs quicker than the once-per-week that is enforced on gamedev (not that I have a problem with that, I just like writing more often sometimes). If there is interest in this I will trying to get it looking good and running well within a month.

Also, PITVYPR now has an itch page!! You can find it on my page at https://berneytd.itch.io/pitvypr . Please consider checking it out and leaving a comment or adding it to any lists to watch!

With regards to the next devlog, the next Technical Tourstop, it will be on Items and their implementation: my favourite part of the technical systems to-date!

Many thanks for reading,

Liam (BerneyTD)