DEV Community

Cover image for Writing a Modern MUSH Server with Typescript Part 1: Overview and Setup
Lem Canady
Lem Canady

Posted on • Updated on

Writing a Modern MUSH Server with Typescript Part 1: Overview and Setup

Introduction

For a while now, I've wanted to start writing tutorials and create my own MUSH server with one of my new favorite language (flavors), Typescript! In this project, we'll learn a little bit of everything. While it won't be a comprehensive guide to the technologies - We'll be working with things like Typescript, TS-Node, Express, a NoSQL database, and Socket.io. We'll also have to make a simple client to our server.

Disclaimer!

There has been a real renaissance of interest in the modernization of servers written in C/C++ out there that are now, twenty or thirty years old. That's amazing! I grew up playing on these platforms, and they will forever hold a special place in my heart. There are other platforms out there that have sprung up in as a new generation of MUSHING. Two of the most notable are AresMUSH written in Ruby, and Evennia written in Python. Both really approachable languages, and both really excellent options! There is, of course, always room for another project!

Update! (3/23/20)

UrsaMU has become my quarantine sanity project, so I've revised it's inner workings some. I'll be slowly moving through the articles I've written to update the code portions to reflect the current build. I flattened the API some - I might even put it into a facade if it starts feeling cumbersome. So, sorry to any of you that were coding along with me, and thanks for the patience! :)

Enter UrsaMU: Project Set up

First things first, we need to make a new folder and set up our project with a package.json. You'll need to make sure that you have node + npm installed in your development environment. For this project, I'll be using Ubuntu 18.04 (through the Windows 10 WSL2) and VS Code as my Typescript IDE.

What is a MUSH?

Let's take a moment to discuss what a MUSH actually is, or, what I interpret a MUSH to be. At its core, a MUSH is a chat server backed by a database of some sort the persistence of in-game objects traditionally consisting of rooms, things, players and exits. When a user types input into the game, it's piped through a series of middlewares to evaluate for in-game actions, evaluating them and returning output to all involved parties. Another key selling point for MUSH is its ability for a user to create custom in-game commands and functions that are saved to specially keyed attributes. This allows for things like creating rooms, objects, exits, etc, and have all of the changes persisted in the database.

A MUSH is a Chat Server At It's Core.

This is where Socket.io comes into play. It'll act as the backbone of our chat server, while also handling things like 'rooms' and 'channels'.

A MUSH Is Backed By A Database for Persistence

For my purposes, I'm going to use a file-based database, NeDB. However, I don't want to limit what kind of database a game uses, or how they choose to store their data so we will focus on creating an adapter for all of the basic commands we'll need for our model (Create, Find, Get, Update, and Delete). We also want to allow other implementations to easily add functionality on top of our initial needs.

A MUSH Evaluates User Input for In-Game Actions.

This will be the primary focus of UrsaMajor. When an input is sent it needs to be evaluated as 'just a message', or if it has in-game significance, like an expression to evaluate, or some special action to take to affect the in-game environment. We will create a middleware system to handle user input, passing responsibility for the input down the line, function by function.

A MUSH Allows For In-Game Creation On The Fly

During our evaluation process, we'll have to check for commands that have been set on in-game objects. This probably means writing a simple algorithm to handle how actions are parsed, and mushcode expressions evaluated.

Planned Features

The working list for basic server features. UrsaMU Is still under extreme development, so this list is subject to shrink (Or grow!):

  • Database: Handle your data however you want! UrsaMU allows you to bring your favorite database to the table with its database adapter API.
  • Commands: Enter your own custom commands through the command API.
  • Flags The game's flags are editable from either the Flag API or through stored JSON flat files - or both!
  • MushCode: Evaluate mushcode expressions!
  • Attributes Store and evaluate and register commands and functions through MUSH-Like attributes api.
  • Grid Build a grid in-game, load rooms or entire pre-built areas from flat JSON files or both!
  • Input Middleware: The server allows for registering middleware to handle how in-game input.

File Structure

Before we begin, we need to set up our basic file structure for the project. First, I'll list all of the commands needed, then we'll break them down to examine what all of this is doing.

mkdir ursamu
cd ursamu

mkdir src
mkdir src/api
mkdir src/config

touch src/ursamu.ts
touch src/api/mu.ts
touch src/config/config.json
touch .gitignore

npm init -y

Then we create stubs for some files that we're going to be working with. Finally, we add our package.json with all of the defaults, which will do for now!

Dependencies

Next, we install the dependencies for our project. We will install the base packages first, before adding our development only npm calls.

npm install express socket.io nedb @ts-stack/markdown shortid
npm install -D typescript @types/node @types/socket.io @types/express @types/nedb  @types/shortid

While we're working on development, we're going to want a way to reload the server automatically and save us a few keystrokes in the process. For this, we're going to be using nodemon. And, as it's a tool I use on MULTIPLE projects, I tend to install it globally. On Linux, it means you need to elevate your permissions with sudo:

sudo npm install -g nodemon

And then set up our tsconfig.json file:

npx tsc -init

npx will call our local install of typescript, instead of installing typescript as a global command.

Our folder structure should now look something like this:

/ursamu
    /src
        /api
            - mu.ts
        /config
            - config.json
        - ursamu.ts
        - tsconfig.json
    - .gitignore
    - package.json

Now! We need to edit our package.json. First, we'll add our start scripts:


"scripts": {
    "prestart": "npx tsc -p ./src/tsconfig.json ./src/ursamu.ts", 
    "start": "node ./dist/ursamu.js",
    "start:watch": "nodemon"
  },

Finally, we need to add a nodemon config section to our package:

"nodemonConfig": {
    "ignore": [
      "**/*.test.ts",
      "**/*.spec.ts",
      ".git",
      "node_modules"
    ],
    "watch": [
      "src"
    ],
    "exec": "npm start",
    "ext": "ts"
  }

And last, we're going to add our config file into src/config/config.json.

{
  "game": {
    "port": 8090
  }
}

That was a bit of setup and exposition! Now let's move onto some code!

Our first step is going to set the MU class, which will handle a lot of the socket.io book-keeping and game startup methods. Notice the constructor is private, and instance is a static property. We're going to make the MU class into a Singleton. I could probably just get away with exporting an instance of the class, but this way future me (or you!) doesn't try to instantiate the class again!

import { EventEmitter } from "events";
import { Server, Socket } from "socket.io";
import { game } from "../config/config.json";

export class MU extends EventEmitter {
  io: Server | undefined;
  private static instance: MU;
  connMap: Map<string, DBObj>;

  private constructor() {
    super();
    this.io;
    this.connMap = new Map();
  }

  /**
   * Get an instance of the MU Class.
   */
  static getInstance() {
    if (!this.instance) {
      MU.instance = new MU();
    }

    return MU.instance;
  }

In order to keep track of our socket server from any other piece of the server we decide to call mu.ts from, it will have a stored instance of the server, using mu.attach().

 /**
   * Attach to a Socket.io  server implementation.
   * @param io The Socket.io server to attach too.
   */
  attach(io: Server) {
    this.io = io;
    return this;
  }

This is where we'll handle things like listeners for new data from
sockets, checking to make sure the starting room is built, etc. A nice facade for implementation details! :)

  /**
   * Start the game engine.
   * @param callback An optional function to execute when the
   * MU startup process ends
   */
  async start(callback?: () => void) {
    if (typeof callback === "function") callback();
  }
}

Then, I make a call to getInstance() for a new instance of the MU class, and share the object as the file's default export. Now, whenever it's imported from another file it'll be working with the same instance, and can't create another.

const mu = MU.getInstance();
export default mu;

Finally, we'll start our ursamu.ts main file, to see the code in action!

import express, { Request, Response } from "express";
import { Server } from "http";
import socketio from "socket.io";
import config from "./config/config.json";
import { resolve } from "path";
import ursamu from "./api/mu";

// Define the various communication channels.
const app = express();
const server = new Server(app);
const io = socketio(server);
const mu = ursamu.attach(io);

app.use(express.static("public"));

app.get("/", (req: Request, res: Response) =>
  res.sendFile(resolve(__dirname, "../public/index.html"))
);

mu.start(() =>
  server.listen(config.game.port, () => {
    console.log(`Server started on port: ${config.game.port}`);
  })
);

That should about do it for part one! Next installment we'll setup Socket.io and the parser! Feel free to follow to get updates on my articles and new posts! Also, feel free to leave a comment or post a question! :)

Top comments (0)