Building a text-based game in Ruby, part 1
Simultaneous, real-time input and output
August 19, 2023 Ā· Felipe Vogel Ā·Not long ago I resolved to make a game in Rubyāspecifically a text-based game, because procuring sprites is tedious.
(The long version: instead of worrying about how the game looks on-screen, Iād rather focus on how the game works and be content with how it looks in my imagination.)
The simplest possible game loop
To start us off, hereās a loop that gets input from the player, then does something with it.
loop do
input = gets
puts "You said: #{input}"
end
Of course, weāll have to add actual content in order for this to become a game. But first letās add a more fundamental element of fun: things happening in real time.
To see why, letās imagine that weāve just begun our adventure, and our aspiring hero is in the newbie area, ready to take on a wolf or rat. Suddenly, one of the basic beasties appears out of nowhere and lunges! At this point, it would be pretty strange if the hero could sit there calmly contemplating what to do next while their aggressor is frozen mid-leap. And yet our game loop currently blocks output while itās waiting for input. Letās change that.
Hereās a demonstration (slightly exaggerated) of the simultaneous input/output weāre aiming for:
Getting some output
Before we tackle simultaneous input, letās set up some output. Below are the Runner
class containing our simple game loop from above but now slightly modified, and the Updater
class which reacts to input (echoing it) and updates game state (a timer that generates a message every second).
class Runner
def self.io_loop
loop do
input = nil # We'll implement this in the next section.
if output = Updater.tick(input)
puts output
end
end
end
end
class Updater
def self.tick(input)
return "You said: #{input}" if input
# On why not Time.now, see https://blog.dnsimple.com/2018/03/elapsed-time-with-ruby-the-right-way
@time_start ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
time_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
time_elapsed = time_now - @time_start
if time_elapsed >= 1
outputs = []
@time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
return "One second has passed!"
end
end
end
# Run the game.
Runner.io_loop
The result:
Now letās bring back input, this time in a way that doesnāt block output.
Simultaneous input
First, some helper methods:
class Helper
# These methods call system commands setting the terminal to raw or normal mode.
# Raw mode means each keystroke is sent straight from the terminal, so that we
# can work with input directly rather than having to wait for a line of input.
#
# Note: The `stty` command only works on MacOS and Linux, so if you're on Windows
# I suggest using WSL (https://learn.microsoft.com/en-us/windows/wsl) to have
# Linux within Windows. That will also make your life as a Ruby dev on Windows
# more pleasant in general.
def self.io_mode_raw! = `stty raw`
def self.io_mode_normal! = `stty -raw`
# If the cursor weren't hidden, it would appear at the beginning of the line
# due to ::io_mode_raw!
def self.hide_cursor! = print "\033[?25l"
def self.show_cursor! = print "\033[?25h"
# Reads newly inputted characters in a way that doesn't block output,
# to allow output above the input line.
def self.read_nonblock
line = ''
while char = STDIN.read_nonblock(1, exception: false)
return line if char == :wait_readable
line << char
end
end
# To allow output above the input line, wraps `puts` in a change to the
# terminal mode. Also right-pads the output with spaces to prevent the input
# from "bleeding over" into output wherever an output line is shorter than a
# line being inputted.
def self.puts(str)
terminal_width = `tput cols`.to_i
io_mode_normal!
Kernel.puts str.ljust(terminal_width, ' ')
io_mode_raw!
end
end
Now letās expand the Runner
class from the previous section. (The Updater
class stays the same as before.)
class Runner
CURSOR = 'ā'
INTERRUPT = "\x03" # Ctrl+C
def self.io_loop
loop do
# We need our own input buffer here because the terminal input buffer is
# disabled due to Helper::io_mode_raw!
@input_buffer ||= ''
new_input = Helper.read_nonblock
if new_input
return if new_input.include?(INTERRUPT)
# Handle Enter.
new_input_has_newline = new_input.include?("\n") || new_input.include?("\r")
new_input = new_input.split(/[\n\r]/).first if new_input_has_newline
# Add new input to buffer (or add nothing, if no new input).
@input_buffer << (new_input || '')
# Echo input. The \r is to make the line replaceable by new output,
# while the input line will re-appear below the new output; in effect,
# to allow output above the input line.
print "#{@input_buffer}#{CURSOR}\r"
end
# Empty the input buffer if Enter was pressed.
if new_input_has_newline
input = @input_buffer.strip
@input_buffer = ''
end
# Allow the game to loop, and print output if any.
if output = Updater.tick(input)
Helper.puts output
end
# Reset input, to remain empty until next time Enter is pressed.
input = nil if input
end
end
end
Running the game now involves a few extra lines:
# Initial setup.
Helper.io_mode_raw!
Helper.hide_cursor!
# Run the game.
begin
Runner.io_loop
ensure # when exiting back to the terminal.
Helper.show_cursor!
Helper.io_mode_normal!
end
And, violĆ ! There we have the essentials for building a real-time text-based game. Hereās what the above code looks like when run:
Pros, cons, and future plans
There are other ways to build a real-time text-based game, including Curses, Scarpe, and DragonRuby Game Toolkit.
So why take the approach Iāve outlined in this post? What I like about it is that itās simple. Thereās nothing more straightforward than writing to standard output, line by line, and that simplicity will speed up the development of the āgutsā of the game.
This approach has its downsides, of course:
- Itās visually unappealing.
- Itās not very user-friendly, especially since hacking the terminal input means all the terminalās native text-editing features are gone.
- To somewhat make up for this and the visual blandness, Iām adding colors and backspacing (to delete input), which you can see in the GitHub repo. (See the 0.1.0 release if you want to see just the features related to this post.)
- It doesnāt work with screen readers.
- It requires installing a Ruby gem, which is not convenient for most people.
Later on Iād like to build another interface thatās easily accessible via the Web, but for now Iāll stick with my minimalist terminal-hacking approach because of its convenience to me as I work on the gameās back end.
What next? Now that our real-time input/output system is in place, we can start thinking about how to organize the game world. Thatāll be the topic of the next post in this series.