Let's Build a Text Adventure - Intro
I love text adventures. I'm far from a connoisseur and there are many classics I've never played let alone completed. I spent a lot of time playing the first Zork! and loved every second, though I never did get to the end.
This love of text-based adventuring stems from my love of D&D and the belief that the best visuals are created from one's own imagination. One of my pet projects is SuperTextAdventure and over the years I've written and re-written the application. It's not really a text-adventure, rather it looks like a text adventure but is just a chat app.
I've been mulling over the next iteration of SuperTextAdventure and decided that it's time for another re-write1. I want to continue to leverage Rails and focus on server-side rendering, as these are my passions, but I want to set some new goals that I think embody the original goal of the app: create an asynchronous multiplayer game that can be put down and picked up at any time. And I want you to join me!
My Goals
So here's what I want to accomplish:
- Create an actual text-adventure engine. - This would be a structured format that people could use to create their own adventures with rooms, items, people, triggers, timers, etc. It will have a UI that allows for easy editing and a way to save "worlds" in a simple file format (probably JSON but SQLite might be better).
- Create an actual text-parser. - This would be a traditional text-adventure parser, but it would be customizable. Part of the fun of text-adventures was the ability to create a complicated environment, but only give the player a limited number of ways to interact, thus forcing them to use their brain and get creative. For this system, I'd like to give creators the power to modify what "verbs" they want their game to allow (i.e. JUMP, LOOK, SAY, etc.).
- Make it mulitplayer. - I want players to be able to join together to experience an adventure. As a core-mechanic I think all interactions will need to be somewhat linear and turn-based so that that state can sit for an indefinite period of time (e.g. no backend or scheduled events that happen outside of a player's interaction with the adventure).
- Make it easy to host. - I'd like this to built with self-hosting as the primary way of distribution. An official hub that I build and support one day might be cool but for now I'd like it to live on people's Raspberry Pis.
- Host a library of adventures. - I do want to create a hub where people can upload, describe, and share different adventures they make.
- ASCII ART!!! - We're going to create a library of cool ASCII art that we can use!
How it's getting done
One step at a time. If anyone is reading this and wants to contribute, I'll be doing all of this work in a public Github organization. I'm going to stick with the SuperTextAdventure name as I have the domain and like the stupid simplicity.
For the tech stack, I'm building it with things I love:
- Rails - I want to leverage all of the latest 8.0 features like Solid Cable, Solid Cache, Propshaft, and SQLite support. I want to stick to Rails fundamentals.
- TailwindCSS - I love TailwindCSS and think it'll be a great way to build the app. I'm likely going to create a very customized "theme" to support the look of an older terminal to start with. But it'd be fun to build different themes.
That's pretty much it - we're going to keep it simple. I'd love for this to be easy to use on a mobile device - this might be a great opportunity to use Hotwire Native but that'll be something we add much later.
"But Wait! X Already Exists"
This idea is far from unique. This is essentially a simpler version of a MUD but I'm more interested in creating something that can be set down and picked up at will. The original inspiration for this came from playing chess over Google Hangouts with my brother while we worked. We'd just paste in an ASCII chess board and move our pieces, then paste the whole board in. It was fantastic because we could go to our meetings and then come back and continue. I want to expand that experience.
Let's get started
Here's the GitHub Organization: SuperTextAdventure
Here's the repo for what I'm calling supertextadventure-app
that will contain the actual deployable Rails app: supertextadventure-app
If you want to get involved feel free to reach out there or shoot me an email directly:
rick [at] makethingswork [dot] dev
I also created an email address for the project:
hub [at] supertextadventure [dot] com
Surprise!
This intro isn't going to just be an intro!! Let's write some dang code! Here's a repo called (supertextadventure-core) which is where a lot of our initial work is going to take place as I want to build a pure ruby implementation of the core engine that can be separately tested and tinkered with as this will help shake out details that I haven't considered (doubtless, there are many...).
Scratch File
Let's build as much as we can in a simple scratch ruby file. I'd like to start with a single-file implementation that runs in the terminal. That sounds like fun, doesn't it?
To start I think I'll use:
- tty-prompt - this is an amazing gem that supports all manner of terminal interactions with tons of great helpers.
- colorize - this is a bit of an obtrusive gem that extends the
String
class but adds a lot of great helpers for adding color to text easily.
We can build a little program that asks the user for their name, then asks them if they want to play a game or build a world. We'll start with the world builder.
The Intro
Let's setup our supertextadventure-cli.rb
file:
#!/usr/bin/env ruby
require 'tty-prompt'
require 'colorize'
DATA = {
player: {
name: nil,
},
game: {
rooms: []
}
}
This simply requires the gems and sets up a naive data-structure.
Let's have some fun and create some intro text:
intro = <<~'INTRO'
Welcome To
_____________________________________________________
| ____ _____ _ |
| / ___| _ _ _ __ ___ _ _|_ _|____ _| |_ |
| \___ \| | | | '_ \ / _ \ '__|| |/ _ \ \/ / __| |
| ___) | |_| | |_) | __/ | | | __/> <| |_ |
| |____/ \__,_| .__/ \___|_| |_|\___/_/\_\\__| |
| |_| |
| _ _ _ |
| / \ __| |_ _____ _ __ | |_ _ _ _ __ ___ |
| / _ \ / _` \ \ / / _ \ '_ \| __| | | | '__/ _ \ |
| / ___ \ (_| |\ V / __/ | | | |_| |_| | | | __/ |
| /_/ \_\__,_| \_/ \___|_| |_|\__|\__,_|_| \___| |
| |
-----------------------------------------------------
Your Adventure Awaits
INTRO
That's pretty fun. I used figlet via brew
to generate the text then copy it into the file.
Did you know that you can define a HEREDOC
(the thing with the <<~
) to be a literal and ignore escape characters? I didn't until my initial output looked like this:
Welcome To
_____________________________________________________
| ____ _____ _ |
| / ___| _ _ _ __ ___ _ _|_ _|____ _| |_ |
| ___ | | | | '_ / _ '__|| |/ _ / / __| |
| ___) | |_| | |_) | __/ | | | __/> <| |_ |
| |____/ __,_| .__/ ___|_| |_|___/_/_\__| |
| |_| |
| _ _ _ |
| / __| |_ _____ _ __ | |_ _ _ _ __ ___ |
| / _ / _` / / _ '_ | __| | | | '__/ _ |
| / ___ (_| | V / __/ | | | |_| |_| | | | __/ |
| /_/ ___,_| _/ ___|_| |_|__|__,_|_| ___| |
| |
-----------------------------------------------------
Your Adventure Awaits
Turns out all those \
can cause problems. But simply adding single quotes around the name of your HEREDOC ignores any interpolation or escape characters. Knowledge is Power!2
Asking for Our First Input
Now let's ask the user for their name and store it in the JSON:
prompt = TTY::Prompt.new
prompt.ok intro # the "ok" makes the text green
player_name = prompt.ask("What is your name, adventurer?".colorize(:light_blue))
DATA[:player][:name] = player_name
Cool. (This is why I chose colorize
- you can setup colors for some of the tty-prompt
helpers but not ask
.) Let's spit the user's name back out to them and ask them what they want to do:
prompt.ok("Welcome, #{DATA[:player][:name]}!")
selection = prompt.select("What would you like to do?") do |menu|
menu.choice name: "Build an Adventure", value: 'build'
menu.choice name: "Play a Game", value: 'play'
end
prompt.say("You chose to #{selection}.", color: :yellow)
Nice!
Some Helpers
That looks a bit squished and if you do this live it moves rather quickly. As a fan of the dramatic pause, I decided to write a couple of helper methods to add x number of newlines and wait x number of seconds:
def newline(x=1)
x.times { puts }
end
def wait(x=2)
sleep(x)
end
Now we can do the following:
prompt = TTY::Prompt.new
prompt.ok intro
player_name = prompt.ask("What is your name, adventurer?".colorize(:light_blue))
DATA[:player][:name] = player_name
newline(2)
prompt.ok("Welcome, #{DATA[:player][:name]}!")
newline(2)
wait
selection = prompt.select("What would you like to do?") do |menu|
menu.choice name: "Build an Adventure", value: 'build'
menu.choice name: "Play a Game", value: 'play'
end
prompt.say("You chose to #{selection}.", color: :yellow)
Which makes it run a bit better:
What's Next?
That was a fun little start.
Coming up, we'll build a class to easily access our DATA
object and save it to a file. We can then have the app load that JSON file if it exists and modify the start of the program to pick up where the user left off.
This file is up on the repo if you'd like to check it out.
See you all in the next one!
-- Rick
This will be the 5th rewrite I think...↩