CS 3410: MUD

Introduction

In this assignment, you will write a simple Multi-User Dungeon, or MUD, which is a kind of multi-player text role-playing game that eventually evolved into the MMORPGs of today. The goals of this assignment include learning:

The assignment is in three parts.

Part 1

In the first part you will write a simple loop to accept commands from the keyboard and process them. A big part of this step is to get a working Go programming environment and learn to use the associated tools.

Getting started

Start by installing Go on the system where you intend to work. Start at the Go documentation page:

The “Getting Started” page talks about how to install and test a Go build system. You should read the following document to learn how to start a project, structure your directories, and use the build tools:

The project

The purpose of this part is to write a little bit of Go code in the environment you have set up, and to explore the standard library a bit.

Write a command processing loop. It should:

Start by writing code to print the prompt (which can be anything) and accept a line of input. You will need help from the standard libary, which is documented here:

The fmt package contains code for printing formatted text. To use it, you will need to import it near the top of your source file:

import "fmt"

or if it is one of multiple imports you can use:

import (
    "fmt"
    "log"
)

Then to print something you would reference the package and the function:

fmt.Println("Hello, world")

To read lines of input, I suggest starting with the bufio package and using the Scanner API. Note that the bufio package documentation has a complete example that does exactly what you need to do: it reads lines of input and processes each one.

Once you can read lines of input, you need to parse them to recognize commands. A command in a MUD always starts with the command itself, and then has additional text to support it if appropriate. Example commands:

In each case the command is the first part of the line (separated by one or more space characters from the rest of the line). Look at the strings package to find helpful functions to parse and process strings. There are more complex parsing functions in the standard library, but strings will let you do simple tasks like splitting a string on whitespace into words (the Fields function), etc.

Next, you should implement a way to dispatch different commands in a way that lets you easily add to the list of supported commands. You can do this in many different ways, but the most straightforward way is to create a map from a command name to a function that processes the command. For example, if the function that processes the look command looks like:

func doLook(line string) {
    // process the look command
}

You might have a map of this type:

var commands map[string]func(string)

When the user types a command, you would look it up in the map. If it is present, it is a valid command and you can call the associated function. If not, tell the user you did not understand their request.

There are other ways to implement a command dispatcher, and you are welcome to use whatever makes sense to you. However:

In addition, MUD users end up typing a lot and expect to be able to use shortcuts. You should be able to recognize a prefix of a command as being the same as the command. So these should all be recognized as the “north” command:

If you are using a map, you could add duplicate entries for all prefixes of the command. There may be multiple commands with some of the same prefixes, in which case you should be able to prioritize which one wins. More important/command commands should be able to use the shortest abbreviates. So “e” and “ea” are short for “east”, not “eat”, because moving around is more common than eating.

The examples above all show the command processing function as taking a single string parameter. This is just for demonstration purposes: you should figure out what your command processor actually needs, and you may find that the requirements will change as the project progresses.

Part 2

In this part you will add a simple SQL database to your MUD to store the rooms and zones that make up the world.

Using sqlite

Go has a standard SQL database driver (database/sql), but to use it you must include a driver for the specific database you are using. We will use sqlite3 in this project, which is an embedded database. To install the driver, use:

go get github.com/mattn/go-sqlite3

In one of your source files (technically it does not matter which), add the driver to your imports using:

_ "github.com/mattn/go-sqlite3"

The underscore means that you will not be referring to the package directly in your code (you will work with the database/sql package of the standard library), but you need it to load so it can register itself.

To see the basics of how to use the driver, see the simple example from the driver package:

Sqlite keeps all of its data in a single file; when you open the database, you supply the name of that file. At the time you open the database, you can set various configuration options. I suggest something like the following:

// the path to the database--this could be an absolute path
path := "world.db"
options :=
    "?" + "_busy_timeout=10000" +
        "&" + "_foreign_keys=ON"
db, err := sql.Open("sqlite3", path+options)
if err != nil {
    // handle the error here
}   

The world database

The world is organized into zones, and each zone has some number of rooms. I have extracted the basic world information from the classic MERC mud distribution so that you do not have to start from scratch. That data is supplied here:

This is a sqlite database dump. It is a text file containing a series of commands that, when executed in order, reproduce an entire database. Search through it and look at the CREATE statements to see the schema. The INSERT statements provide the data. Note that there are three tables: zones, rooms, and exits (the connections between rooms).

You can create the database from this file using the following command:

sqlite3 world.db < world.sql

This assumes that you have sqlite3 installed on your system (you should install it if you do not already have it).

The starting point of this world is room 3001, The Temple of Midgaard. The original data included items, mobs (short for mobile objects, another name for monsters), shops, etc., but we will only be using the basic room descriptions and connections.

An example

A Linux binary demonstrating the functionality is available here:

Download it, untar it, and run it in the same directory as the world.db file. The supported commands include:

To quit, type ctrl-d to end the stream of input.

Queries

To retrieve a kind of object from the database, you will need to run a query. You can run these by hand within the sqlite3 tool:

sqlite3 world.db

and then type a query, such as:

select id, zone_id, name, description from rooms where id = 3001;

You can change the way the data is presented, see the database schema, and various other operations as well. Type .help for details.

To run a query within your Go program, you need to use the database driver. See the example cited above for the basics of how to run commands. As an example, you could retrieve all of the zones using:

SELECT id, name FROM zones ORDER BY id

An example in the standard library documentation shows how to issue a query and read in the results using a Rows object:

I suggest the following datatypes in Go:

type Zone struct {
    ID    int 
    Name  string
    Rooms []*Room
}

type Room struct {
    ID          int 
    Zone        *Zone
    Name        string
    Description string
    Exits       [6]Exit
}

type Exit struct {
    To          *Room
    Description string
}

To get started, I suggest the following:

Part 3

In this part you will make your MUD into a multi-player envornment. You will introduce the following:

Goroutines

A goroutine is Go’s name for a lightweight thread. Each goroutine runs independent of the others, so multiple things can be happening at the same time. A few important notes:

Your mud should include the following goroutines:

Concurrency allows you to think about different parts of the code that are logically independent of each other as independent entities. Your program can run in many places at once. This is often a natural way to think about a program, since it may involve many different moving parts whose actions do not follow an obvious sequence.

An important danger in concurrent code is that of data races. A data race is a fault where multiple concurrent goroutines access the same data, and one or more of them may change that data. When this happens, the outcome may depend on luck of timing between the goroutines, including incorrect results when the timing works out poorly. Correct code makes such data races impossible.

To avoid data races, we will institute a simple rule: no two goroutines may access the same data. This means you cannot have any variables or objects that can be used by multiple goroutines at the same time. You should be able to point clearly at any piece of data in your program and identify which goroutine owns it. This includes local and global data.

When goroutines need to share data, they will do so by communicating explicitly. In Go we achieve this using channels. A channel is a special data type that allows us to safely communicate between goroutines. A goroutine can write a data item to a channel, and another goroutine can read that value from the channel. It is safe to have many goroutines writeing to a channel and/or many goroutines reading from a channel, and the system will ensure that no conflicts occur.

Networking

Start by adding networking. The examples in the net package show how to listen on a TCP port and accept connections, and also how to dispatch a goroutine to handle an individual connection. Recall that your main goroutine should launch a new goroutine dedicated to accepting connections. To launch a goroutine you can either have an named function and call it will a go in front:

go foo(a, b)

or you may find it more convenient to create the function and call it at the same time:

go func() {
   // do stuff
}()

This code defines an anonymous function (also called a function literal) and calls it in a new goroutine. It can access the local variables that existed when it was defined and called, so be careful to follow the single-owner rule discussed above (every variable should have a single clear owner, and no other goroutine should access it). The exception is channels, which may be safely accessed by any number of goroutines.

Start by accepting connections and writing simple messages back to the user. You can use fmt.Fprintf to write to a connection as you would any io.Writer objects:

fmt.Fprintf(conn, "Hello, world!\n")

Confirm that you can in fact connect to your server using telnet. If you are listening on port 9001, then from the same machine you can use:

telnet localhost 9001

and it should connect. You may have to install telnet, as it is not always installed by default on modern operating systems.

Whenever you create a network connection or a goroutine, you should think about its complete lifecycle. When is it created? Which goroutine creates it? When does its lifetime end? Can you ensure that every connection and every goroutine associated with it always closes cleanly? With concurrent code, there is no substitute for thinking through a problem.

When you can successfully accept connections and dispatch them to new goroutines, start processing commands from the player that made the connection. A TCP connection is an io.Reader, so you can use the same bufio package from part 1 to read a line at a time.

The world data is global data, and so it must have a single owner. The owner cannot be the goroutine associated with a single player connection, because there can be many player connections. Instead, it should be owned by the main goroutine (the one that launched all of the others), which will do most of the work in the MUD. It should:

This will be simpler if the Player object itself is owned by the main goroutine. You will create it in the connection-specific goroutine, but then hand over control to the main goroutine after everything is set up. Create the player object in the connection goroutine, and start out by having the player type a name as the first line of input (before tying in to the rest of the system).

Note: there is no formal notion of “ownership” or “control” in Go as we are using the terms here, so handing control from one goroutine to another is just something that you should keep track of mentally (and in the comments) to avoid making errors.

Finally, you need a way for messages from the goroutine to be sent back to the player:

Note that there is only one channel that all players share to send events to the MUD, but each player connection has its own channel to accept events going in the other direction.

Let us review the goroutines and what each one does:

An important detail is how to close a connection safely and make sure that both connection goroutines close down safely with it. Here is one approach:

The main MUD goroutine has the most subtle rules:

Here are a few common sequences of events that might happen:

Since the main goroutine always initiates the actual disconnect, it can do so in response to events other than a player request if it needs to. For example, if a player has not typed a command for a long time and the MUD decides to timeout and disconnect, or if an administrator kicks the player off, or if the same player logs in with a fresh connection and the old connection needs to be terminated.

Player names

When a player connection first starts and before the main loops begin, ask the player to log in. For simplicity:

This should happen in the connection goroutine before starting the main commands loops. You should introduce the player to the main event loop with a special event (add a field to the event struct to signal when this happens) so the main MUD goroutine can add the player to global data structures that it owns and possibly terminate an existing connection for the same player.

Multiplayer features

Once you have the ability to connect multiple players, you should add a few interactive features. This is the fun part and is much easier than the infrastructure we have focused on so far. A few suggestions:

An example

A Linux binary demonstrating the functionality is available here:

Download it, untar it, and run it in the same directory as the world.db file. You should also create the players table before running it. The supported commands include:

To connect, use telnet localhost 9001 from the same machine, or put the IP address/hostname in place of “localhost” to connect from a different machine.

Note that the demo asks you to create a password and it adds players to the database. You do not need to implement this functionality. Your code should just prompt for the player’s name and create the new player on the fly each time.

Last Updated 03/03/2021