In my previous post I described how to build out a very basic plugin framework for our MUD engine. In this post we will go over the implementation of our first plugin, a Telnet portal.

Telnet

First lets talk a little about Telnet, quoting Wikipedia:

Telnet is an application protocol used on the Internet or local area network to provide a bidirectional interactive text-oriented communication facility using a virtual terminal connection.

In our case, our virtual terminal is going to be our MUD client. You can connect to a telnet server using the telnet command on most *nix systems, but for MUDding you probably want to use a proper MUD client. There are a few really great clients out there, including Mudlet or Mushclient among a host of others, personally Mudlet is my favorite.

I won’t go into the gritty details of the various facets of the Telnet protocol in this post, for our purposes it is enough to know that at its most basic level a telnet client is simply a tcp server that ships and receives text.

Engine Architecture

One thing that will set our MUD implementation apart from many others is the separation of the “portal”, which is what a MUD client connects to, from the “world” which is our actual game world. This separation allows us to recompile and restart the game world without actually interrupting the player connection, so from the player perspective, a MUD update would be entirely seamless.

Let’s take a look at what this would look like:

Breaking this down we have 3 major components:

  1. The portal, which is handles the client connection.
  2. The message broker, which passes message back and forth between the portal and the world.
  3. The world, which is the actual game world.

As you can see, this in addition to allowing ups to change the game world without having to restart the portal, this architecture also allows us to run multiple instances of the world in parallel, adding a level of scalability to our MUD. While it is unlikely that you will ever need to do this, it is a nice bonus to have.

Some Groundwork

Before we build our portal plugin, lets lay some additional groundwork.

Making Our Engine State Static

A static variable is a variable whose lifetime is tied to the lifetime of the program. This means that the variable will only be destroyed when the program exits.

First lets modify our engine to have a single static state variable. As our engine only needs to maintain one state variable per running process, a static variable makes sense, this also simplifies how we interact with the engine, so we do not have to pass the state around to various components that need to interact with it, the can simply call functions defined within the engine package.

To make a static within the engine we simply declare and instantiate it:

package engine

// ...

var state = &State{}

An ampersand (&)is used to obtain a pointer. A pointer to a variable isn’t in fact the variable, but a reference to that variable in memory. It could be said that a pointer is the real entity within the machine that makes up the variable.

Note that this variable is private. And cannot be accessed externally outside the package. Instead, elements will interact with the engine by calling functions that will access various parts of the engine state.

Go manages function and variable visibility the variable or functions name case. A function or variable with an upper case name is public, and a lower case name is private.

Now we will modify our State struct to make our Name string and Plugins array private, as we will only ever interact with them using functions, to do this we simply lowercase the names like so:

package engine

// State represents the state of the game.
type State struct {
  name        string
  plugins     []Plugin
}

Go manages function and variable visibility using the variable or functions name case. A function or variable with an upper case name is public, and a lower case name is private.

Initializing Our Engine State

Now lets make an engine.Init function that can be called by our example game. This function will initialize the engine, configuration and state. While we’re at it, we can add our LoadPlugins function that we created in the previous post. Before we do that, lets make the LoadPlugins function private by lower casing the name, and making it work with our static state variable:

func loadPlugins() error {
  for _, plugin := range state.plugins {
      if err := plugin.Init(state); err != nil {
		  return err
      }
    }
  return nil
}

And then:

func Init(name string, plugins []Plugin) {
	state.name = name
	state.plugins = plugins
	
	err := loadPlugins()

	if err != nil {
		fmt.Print(fmt.Errorf("error loading plugins: %s", err))
		os.Exit(1)
	}
}

Note that we made it if a function fails to load, we exit the program.

Error handling in Go is a bit different from in other languages. In Go, functions can may return an error, but it doesn’t throw an error like in other languages. If In functions that may return an error, nil is returned if the error did not occur.

The CLI

I envision that running the portal will be done by simply calling the compiled game executable with the portal argument. To accomplish this, we need to build out some sort of command line interface. Working with the command line interface in many languages can be a bit daunting, the excellent cobra library makes this part easy.

First we need a base command to operate from. Let’s set this up in our game state:

package engine

import "cmd/go/internal/base"

// State represents the state of the game.
type State struct {
  name    string
  plugins []Plugin
  baseCommand *base.Command
}

We can now update our engine.Init function to initialize our base command:

func Init(name string, plugins []Plugin) {
    state.name = name
    state.plugins = plugins
    state.baseCommand = &base.Command{
        Use:   name,
        Short: "A MUD in Go",
        Long:  "A MUD in Go",
    }
    
    err := loadPlugins()

    if err != nil {
        fmt.Print(fmt.Errorf("error loading plugins: %s", err))
        os.Exit(1)
    }
}

Next, lets add an AddCLICommand that a plugin can call that will add a command to our base command:

func AddCLICommand(c *cobra.Command) {
    state.baseCommand.AddCommand(c)
}

Finally, we can add an ExecCommand function that will call our base command’s Execute function, thereby starting the command line interface.

func ExecCommand() {
    state.baseCommand.Execute()
}

The Example Game

Lastly for our groundwork items we need to create an example game (which will also be a plugin) and have it initialize the engine. To start we simply create the directory: mkdir example and initialize it with go mod init github.com/mjolnir-mud/example. This creates a new Go managed package in the exampledirectory we created.

Next we install the engine package. Because we are developing locally we can point our example game at our local package using go mod edit -replace github.com/mjolnir-mud/engine=../engine. This will make sure that our example game is pointing to our local package. Now we can call go get github.com/mjolnir-mud/engine to pull it into our example game.

Now we can create a file called main.go:

package main

import "github.com/mjolnir-mud/engine/pkg/engine"

func main() {
	engine.Init("example", []engine.Plugin{})

	engine.ExecCommand()
}

This will be the entry point for our game.

In go the main package and function have a special purpose. The main package is the entry point for the program, and any executable will call the main function within that package when it is run.

The Telnet Portal Plugin

With all of that out of the way, we can now create our portal plugin. We’ll create an empty directory for the plugin called telnet_portal and initialize it just as we did our example directory.

The Server

Now we can jump right in and build out a basic TCP server. We’ll start by creating a server struct in the telnet_portal/internal directory:

package server

import "net"

type server struct {
	listener net.Listener
}

// New creates a new Telnet server.
func New() *server {
  return &server{}
}

The internal directory is a convention for packages that are only used by the package itself.

Now we create a Start function that executes the main server loop:

func (s *server) Start() {
	listener, err := net.Listen("tcp", "localhost:2323")

	s.listener = listener

	if err != nil {
		fmt.Print(fmt.Errorf("error listening: %v", err))
		os.Exit(2)
	}

	for {
		conn, err := listener.Accept()

		if err != nil {
			fmt.Println("Error accepting: ", err.Error())
			os.Exit(1)
		}
		go s.handleConnection(conn)
	}
}

Breaking this down, we have a listener, which is the actual socket server. If for some reason we cannot create the listener, we exit the program. Finally, we have the actual server loop, which will accept an incoming connection and execute handle connection inside a goroutine.

A goroutine is a lightweight thread of execution, and the basic unit of concurrency in Go. Goroutines are started by calls to the go keyword and passing in a function to execute.

We can now create a handleConnection function that will handle the actual telnet connection:

func (*s server) handleConection(conn net.Conn) {
  for {
    buf := make([]byte, 1024)

    _, err := c.conn.Read(buf)

    if err != nil {
      fmt.Println("Error reading:", err.Error())
      c.Stop()

      break
    }
    // Send a response back to person contacting us.
    _, err = c.conn.Write([]byte(fmt.Sprintf("Received %s", string(buf))))

    if err != nil {
      fmt.Println("Error writing:", err.Error())
      c.Stop()

      break
    }
  }
}
  

To handle the connection data we create a new loop (which will be executed within a goroutine) that will read the data from the connection. It stores this data inside a byte array and echos it back to the connection.

Connecting the Dots

Wrapping this up, we can now combine all the work we have done to create a single executable application. Within our example/main.go file we can update our Init function to load our portal plugin:

package main

import (
  "github.com/mjolnir-mud/engine/pkg/engine"
  "github.com/mjolnir-mud/plugins/telnet_portal"
)

func main() {
  engine.Init("example", []engine.Plugin{
    telnet_portal.TelnetPortal{},
  })

  engine.ExecCommand()
}

And we can run our portal server with go run main.go telnet_portal. Now when we connect to our server with our favorite MUD client, we can enter in any text and have it echoed back to us.

In my next post, I will detail how to start sending data to the message broker, so that our world can consume it.

Previous Post In This Series <> Next Post In This Series