Kevin Hoffman's Blog

Blathering about development, dragons, and all that lies between


Making a MUD out of Monad Stacks

An experiment to isolate mud side effects from pure functions

Putting the Fun in Functor

For those of you not old enough to have experienced them, MUDs are “Multi-User Dungeons” (or Dimensions, depending on your preference). The main thing you need to know about them is that there was a single server that played host to large (for their time) numbers of online players via the telnet protocol.

The way these servers work is pretty simple: they accept incoming TCP connections on a known port like 3000. Each one of these new connections spins up a loop that sends messages to the player and accepts commands from them. Players then type things like kill tomato to attack the sketchy looking tomato standing nearby.

In a MUD written in an imperative language, side-effects and business logic would be impossibly entangled. Take the following code snippet:

target->set_hp(target->get_hp() - 100);
write("You dealt 100 points of damage!");

Here, the code is mutating the hitpoints of the target and then using a function like write to send text directly to the current player’s socket.

A couple nights ago, I was still feeling the effects of anesthesia from an outpatient procedure and I was bizarrely motivated to see if I could set up the framework for an elegant way to code a MUD by capturing side effects with Monad transformer stacks in Haskell. I know, some people make TikTok videos of themselves making poor decisions but I go back to MUD building. It’s sad and I accept that.

In idiomatic Haskell, you’ll often see a runXXX function that accepts the necessary initial state and parameters and accepts code in a do block as input. These “monad runners” make all of the functions of the monad type available to any code running “inside” the monad, even though the function chains don’t actually see the input parameters. Much of the “implicit” magic here relies on partial application/currying, but it’s fine if you want to just think of it as magic/sorcery.

My theory was that I could write a runMUDCommand monad runner every time we accept a new connection from a client. This tail-recursive function (no loops here!) would be running inside the monad, so any of the code it calls can, in theory, write to the player’s socket, mutate local or global state, etc.

The first thing I needed was some state to weave through the monad runner:

data CommandState = CommandState
  { clientSocket :: Socket
  , playerName :: String
  , playerList :: TVar (Map String Socket)
  }

This state will be available to anything running inside the monad. So let’s take a look at my monad:

newtype MUDCommand = MUDCommand { unMUD :: StateT CommandState IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadState CommandState)

Here I’m getting a lot of work done by deriving the monad hierarchy (Functor -> Applicative -> Monad). The short version of what’s going on here is that the unMUD field is storing a StateT monad transformer. With the monad type in hand, I created the runMUDCommand function:

runMUDCommand :: CommandState -> MUDCommand a -> IO a
runMUDCommand st action = evalStateT (unMUD action) st

With these in place, I can “simply” start writing functions that are available to any function that runs within the monad. For example, writing to the player’s socket:

rawWrite :: String -> MUDCommand ()
rawWrite msg = do
  sock <- gets clientSocket
  liftIO $ NSB.sendAll sock (B.pack msg)

writeLine :: String -> MUDCommand ()
writeLine s = rawWrite (s ++ "\r\n")

Now we can hopefully reap the benefits of this architecture. We should be able to write pure functions that call functions like writeLine as side-effects. The prototype that I built the other night lets players log in (no storage), get commands echoed back to them, and even use /tell to send a message to another connected player.

Let’s take a look at my main:

main :: IO ()
main = withSocketsDo $ do
  hSetBuffering stdout NoBuffering
  playerMap <- newTVarIO Map.empty
  addr <- resolve "3000"
  sock <- open addr
  putStrLn "Server running on port 3000"
  acceptLoop sock playerMap

The <- left arrows are (I’m taking huge liberties here to avoid getting into complex details) “monadic assignments”. It’s pulling a value from a monadic function call and storing it in a variable and then moving on to the next statement in the do list without propagating it. This is syntactic sugar for chaining Haskell’s oh-so-fun >> and >>= operators.

The acceptLoop function is where the magic happens. I won’t dump the whole thing here, but I’ll show where this function uses runMUDCommand to illustrate my entire goal for writing this sample.

After a player successfully connects inside the accept loop, I create a new instance of CommandState and then use runMUDCommand to add the player to the global player list. This is subtle but powerful - the command state is local to the function running inside the monad, but because we’re using a TVar, it’s basically a local pointer to a global atomically wrapped value.

let st = CommandState conn name playersTVar
runMUDCommand st $ do
  pl <- gets playerList
  liftIO $ atomically $ modifyTVar' pl (Map.insert name conn)

And now the guts of the input handling loop:

-- this gets run before the loop
runMUDCommand st $ do
  writeLine $ "Welcome, " ++ name ++ " to Kevin's delusional universe!"

let loop = do
  msg <- recvLine conn
  if null msg
    then disconnect
    else do
      keepGoing <- runMUDcommand $ st handleCommand msg
      if keepGoing then loop else disconnect
  
  disconnect = do
    putStrLn (name ++ " disconnected.")
    runMUDCommand st $ do
      pl <- gets playerList
      liftIO $ atomically $ modifyTVar' pl (Map.delete name)
    close conn

loop

Hopefully you’re seeing the pattern now. Any time we want to run a function and give it the ability to interact with the game and with the player, we just run that function “inside” the MUDCommand monad via runMUDCommand. In the last code sample, you can see me using runMUDCommand to add and remove players from the global connection list.

The handleCommand function is essentially where the rabbit hole starts. This is where we (hopefully) will have functions that handle player commands like "wield can opener" and "attack mouse".

handleCommand does a split and then invokes a separate handleSlashCommand function. This is where commands like /tell, /who, and /quit are defined.

handleSlashCommand :: String -> MUDCommand Bool
handleSlashCommand input = case words input of
  ["who"] -> do
    pl <- gets playerList
    players <- liftIO $ atomically $ readTVar pl
    writeLine "Connected players:"
    mapM_ writeLine (Map.keys players)
    return True

Another pattern that is emerging is that the function’s type signature says it’s returning a type of MUDCommand Bool (remember MUDCommand actually takes a type parameter a, similar to generics in other languages) but you don’t actually see any code that constructs a new MUDCommand. Here we have return True and that somehow remains within the MUDCommand monad.

This is the key to creating a (theoretically) elegant and extensible library of MUD functions. Any function that you want to run inside the MUDCommand monad just needs to indicate that in its type signature because the monad itself isn’t represented as a variable as it is “higher” than the function itself.

Assuming I decide to spend more time on this between now and my next outpatient procedure, my plan for the next step is:

  • Optionally add wizard commands to the monad stack so that the type system enforces access to those commands. A player without the wizard transformer in the stack will fall through to the bottom error handler while a player with that wizard transformer will support commands like /summon or /boot etc. Core goal: support wizard and non-wizard commands without using an if or case expression.
  • Add a logger to the stack that adds the current player’s name to the log emission, e.g. [bob] booted user 'alice' from the game.

Stay tuned, I’ll either continue with this or I’ll drop it like I do all my other side projects!