The Making of Session21
I've been working on this one for a while now. Not continuously, but ever since I wrapped up A Night in Algiers it's been itching at me to explore what I could really do with the namespace. The core of the idea was to make a blackjack mini-game within a larger story. That spiraled into a little swashbuckling space-noir adventure shamelessly inspired by Cowboy Bebop (I discovered the show just as I was diving into the non-blackjack parts) that I think might be worth the five to ten minutes it takes to play. A lot of the final features of the game were accomplished just by being more creative with class structure and built-in C# tools, but I also developed a bunch of new goodies for the Algiers namespace. Yeah, I also renamed the first game about Meursault “A Night in Algiers” and renamed the C# tool just “Algiers”. Try to keep up.
Algiers 2.0
The main goal with this project was to develop more functionality for Algiers and I think I added some pretty interesting stuff to the namespace:
States
Probably the biggest structural addition was the idea of Player State. This was instrumental to the initial spark for the game, implementing a blackjack mini-game within the story. Playing blackjack clearly requires a different set of commands then your standard text-adventure. The player's State lets the Parser select between different sets of commands for validating input based on the current context. If you type “hit” when you're not playing blackjack, the game won't recognize it as a valid action. Type “hit” while your at the table, though, and you get dealt another card. State can also be used to change the behavior of a command. Typing “help” at different points in the game, for instance, will show different advice based on what the player is doing.
Internally, States are bitfields with some methods for comparing and composing themselves. Initializing a new state will create a “prime” field, i.e. one with exactly one bit set to 1. To create commands that can be used across different states you can compose multiple states, yielding the logical OR of the fields. Trying to create two commands with the same name that are valid in overlapping states will throw an error. One special case is the field with all 0's, State.Default. Commands that are assigned State.Default are available in all states that opt-in to default behavior, but since it doesn't overlap with any other state, these commands can be overwritten at will. To really enforce that a command is available all the time and executed exactly the same way, you can use State.All, where every bit is 1.
Parser Modes
Sometimes, you need to break from the main command-based input loop and deal directly with what the user types.
Examples in this game include taking a bet in blackjack or entering the code for the locked door. In Algiers version 1, the only to
do this was to exit the main game-loop and handle the logic directly in Program.cs. Now, there are Parser Modes. To handle user input
directly, you can call Parser.GoRaw() and pass a Func
Afterword
Generally in the game, you enter a command and receive a response in a one-for-one interaction. Sometimes, though, you want to tell the player more than just one thing. Say a wizard NPC sends the player on a quest to collect three artifacts. Once the player finds the last one, the wizard reappears to congratulate them. The act of taking any one of the three items will not necessarily trigger that dialogue, so it can't be included in any of their “take” responses. The solution is to add an afterword, a piece of text that the parser will append to its usual response, if its been given one during the current cycle.
Hoards, NumHoards, and InventoryStacks
These are some more tangible features, all related to grouping multiple GameObjects together. An Inventory stack is exactly what it sounds like. If the player has a GameObject marked as stackable in their inventory and they add a second, instead of appearing twice in the inventory it will appear as a stack of two. Peanuts and drinks work like this in the game. A NumHoard is like a stack that exists outside of the player's inventory. The player can interact with the “exposed” object, but can only take or otherwise remove it a limited number of times. A Hoard is the same thing, but it never runs out. You might notice that you can take an unlimited number of peanuts from the bowl on the bar.
StartKit
I decided to neatly bundle up a good amount of the commands I created for A Night in Algiers in the namespace Algiers.Startkit. I figure that commands like talk, look, go, and take are going to be needed for most Algiers projects. Keeping with the spirit of total customization and ground-up-edness, though, I didn't think they deserved a place in the main namespace. I also put model game loops for both Console and Web versions in here, though Blazor specific stuff like @bind statements couldn't be included.
Elements
Elements was my rudimentary attempt at managing memory. An Element has a parent and children, and a delete function. Every object in Algiers – worlds, rooms, players, included – now inherits from Element. In theory, this means that anything can be deleted when you're done with it, by which I mean that I think Element.Delete() should clear all its references and C#'s garbage collector should come for it. In theory. If I wanted to get serious about memory, I would have to move away from creating all the rooms at the beginning of the game. There would need to be a save file, and any action that affects any sort of permanent state would be recorded in the file. On exiting a room, it would be deleted, and the new room would be loaded from the file. I honestly doubt I'll ever implement something like that though. Certainly not until I see performance issues, and I'm guessing those would only come with a truly massive text-adventure.
Modularity
A goal that formed later on in the development process was modularity. As the game grew and as time passed between periods of working on it, I became frustrated at the amount of backtracking and cross-referencing I was having to do each time I added new functionality. The Game class was functioning more and more like a Main class, and working in Unity and Godot had weaned me off of Main classes. I decided to make the World and Parser globally accessible as Singletons, and as long as I passed around a reference to the Player, I was suddenly able to pack self-contained code into a single class and lob it onto the project with only minimal knowledge of what was going on anywhere else. I think this modular approach has significant advantages when it comes to game development and I'm glad Algiers allows for it.
Redundancies, Good and Bad
As the project spread across more and more classes, I began to notice that I was organizing the code in a very silly way. Back when I was making A Night in Algiers, almost all the functionality was mushed together in one huge function that created all the rooms, objects, and responses for the game. For this game, I mindlessly copied that structure, even as I spread things out across multiple classes. The result was a bunch of classes whose only function was a big messy initializer. I was using my own SetCondition and GetCondition functions rather than class variables, and all the methods were defined within the initializer, which I didn't even know was possible. I went through and cleaned everything up, and now the classes behave much more like classes, which I think is a much better way of doing things.
I did leave the option to do things the old way, though. Sometimes if an object is simple enough and doesn't need to copied anywhere else a cluster of lambda expressions is good enough. And if that object has no more specific type than the standard GameObject, (or if the method in question is only written to handle the general case) then SetCondition, GetCondition, and the rest need to stay. I'm not sure why, but I feel like having multiple ways of doing things is a good thing for a tool like Algiers.
Backwards Compatibility and Hiding from the User
This project was in no way close to developing a real tool that many real people use and rely on. However, I did sort of try and split my work into Eli the Algiers developer and Eli the Session21 developer who was using Algiers, and the experience gave me some lessons I would definitely keep in mind if I ever did work on a library or utility that other people were using in their own projects.
The main one was that backwards compatibility is important. Changing the name of a function, changing a function to a property, or really any change at all is a big pain in the butt for whoever is using those functions. You should pick the names of things with some consideration, and then stick to them. The best way to keep things consistent, I found, was to hide from the user behind a layer of abstraction. If you look at the code for A Night in Algiers, you'll find lots of dictionary square bracket notation. For Algiers 2.0, I ended up removing or changing the key types of most of the dictionaries, making all of that notation obsolete. This could have been avoided with a more abstract way of accessing the data. The user doesn't need to know that there's a dictionary behind the scenes. Just give them a GetValue method, and then you can change how exactly the value gets got as much as you want and no one needs to rewrite a line of code. A bit of foresight goes a long way.
Having a Plan
I've written about having plans before, I think, but clearly I've yet to learn my lesson. It's hard for me at the beginning of a project to step back, sit down, and plan things out. I want to dive in and get my hands dirty right away. I do it every time, and I always regret it. The two biggest time sinks for this project were all the backtracking and revising I had to do each time I changed how a fundamental part of Algiers worked and the huge breaks I took when I didn't know how to proceed. It's a lot easier to build something when you have a concrete list of features you have to implement, when you can picture the finished project in your mind. Coding blackjack was the first thing I did and as soon as I was done I had no idea what to do next. I knew the story was in a sort of saloon where you play blackjack, but beyond that I hadn't a clue. When I hit that wall I got bored, and it took a few months for me to come back and actually plan the story. I did a half-baked job though, and after making everything up to opening the door, I took another long break since I had no idea how to end it. It would have been a lot easier for me if I had just sat down at the beginning and drafted everything out, the technical goals and the story. Especially as my projects get bigger, I think that's the skill I need to work on most.