MakeThingsWork.dev

Let's Build a Text Adventure - Intro

Next Post: Part 1 ->

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:

  1. 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).
  2. 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.).
  3. 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).
  4. 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.
  5. Host a library of adventures. - I do want to create a hub where people can upload, describe, and share different adventures they make.
  6. 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:

  1. 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.
  2. 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:

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!

01-cli-sta-sample

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:

full-sample

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

Next Post: Part 1 ->


  1. This will be the 5th rewrite I think...

  2. Schoolhouse Rock, anyone?

#coding #ruby #supertextadventure