A more elegant way to make MUD
Rainboots is super barebones, providing some important core functionality without getting in the way of making any sort of MUD experience you can imagine. It aims to be flexible and extensible, so that any number of features can be plugged in and shared, but is unopinionated about the ultimate experience. Almost every aspect---from low-level input handling to user authentication---can be replaced and redesigned.
Rainboots doesn't even initially provide a room or navigation system, or even any basic commands---though it does provide some excellent tools for developing them!
Rainboots is implemented in Clojure for flexibility and rapid development. New commands (or updated versions of existing commands!) can easily be swapped in with a REPL on the fly, without needing to restart the server.
At its core, Rainboots is a fancy telnet server, capable of sending and receiving raw telnet signals, so if you want to make a telnet server for whatever reason, Rainboots can be used for that, as well. Some (but not all) telnet commands will be parsed into a nice, friendly Keyword, but it will fall back to the integer value for any it doesn't know---feel free to send a PR with any missing signals!
A sample is included with Rainboots that shows some basic usage, which we will describe in more detail here:
;; you can refer specific functions if you like, but this is
;; the easiest way to get started:
(ns your.awesome.game
(:require [rainboots
[command :refer :all]
[core :refer :all]]))
(defn start-sample
[]
(def svr
;; Handlers and configuration are provided via keyword args.
;; Rainboots provides some sane defaults, but there are a few
;; handlers you must provide yourself
(start-server
;; Since rainboots doesn't provide its own auth mechanism,
;; you must install your own. The handler is just a function,
;; called with a client object and the user's input line (see below)
:on-auth on-auth
;; The :on-connect handler is called as soon as a client connects,
;; with a client object as its arg. Sure, we could provide a default
;; for this, as well, but you'll want to customize the experience anyway
:on-connect on-connect)))
That's it! Your basic MUD server is running. Before any commands can be
accepted, however, you'll need to implement that on-auth
handler.
Before we talk about auth, let's learn about client objects. A client object
is simply a clojure map wrapped in an atom. There are a few "reserved"
keywords (enumerated below) which you should not overwrite (rainboots won't
stop you; do so at your own peril!), and any keywords in the rainboots.core
namespace should probably also be left alone, but otherwise feel free to store
any transient data in the client object/atom.
"Reserved" keywords:
:stream
holds the connection object, used by (send!)
:ch
holds your character data, once auth'd:input-stack
is used by the default command handler for command sets:term-types
is a set of strings reported by the clientOther keywords:
:rainboots.core/closed?
is true
if an only if the client is disconnected:rainboots.core/remote
is a map describing the remote connection (includes:remote-user
)Character data management and formatting is totally up to you. As long as
there is some non-nil
value stored in :ch
, it will assume you are logged
in.
Now that we know what a client object is (typically abbreviated cli
in handlers), let's look at how to implement auth. Here is a minimal
example:
(defn on-auth
[cli username]
(swap! cli assoc :ch username)
(send! cli "Welcome back, " username "!")
This will be a very simple MUD, with no stats or attributes storable in
the user, but rainboots doesn't mind. As stated above, so long as :ch
is non-nil
, the user is "logged in," and the on-cmd
handler will be
called (see below) instead of on-auth
.
A more interesting on-auth
will probably want to use the client atom
as temporary storage for checking credentials. Here's a more complete
example, where each character has their own username and password.
Persistence is left to the user.
(defn on-auth
[cli line]
(if-let [u (:user @cli)]
;; they've already provided a username
(do
;; probably load the character by username from a db or flat file,
;; then compare the (hashed) password
(if-let [ch (validate-and-load u line)]
(do
(swap! cli assoc :ch ch)
;; note the {W escape sequence; this is for colors! (see below)
(send! cli "Logged in as {W" (-> ch :name) "{n"))))
(do
(swap! cli dissoc :user)
(send! cli "Invalid username/password combo"))
;; first input; store as a username and prompt for a password
(do
(swap! cli assoc :user line)
(send! cli "Password:"))))
Because on-auth
is plug-and-play, you can implement it however you
like. You could have a single login with multiple characters, or even
support two-factor auth! The sky is the limit.
You've seen some examples of the send!
function up there, but it can do
a lot more than you might have thought. For example, instead of embedding
colors in the string with escape sequences, why not use a hiccup-inspired
syntax?
(send! cli "Logged in as " [:W (-> ch :name)])
All the same colors are supported as keyword tags, but with hiccup they automatically close themselves. They even support nesting:
; these two lines are functionally identical:
(send! cli "{YCaptain {rMal{Y Reynolds{n")
(send! cli [:Y "Captain " [:r "Mal"] " Reynolds"])
Notice how with hiccup syntax, we didn't need to remember to re-set the yellow color after the inner red color block!
Of course, in addition to the colors built-in, you can provide your own hiccup handlers. A hiccup handler in Rainboots is just a function that takes the client receiving the message and whatever arguments you passed to it. For example:
(defn upper [cli text]
(str/upper-case text))
(send! cli "You yell, " [upper "Where'd you go?"])
If you'd like to declare your handler as a keyword, you can do that too:
(rainboots.hiccup/defhandler :upper
[cli text]
(str/upper-case text))
(send! cli "You yell, " [:upper "Where'd you go?"])
Since you're given the receiving client, you can do neat things like customize the output depending on who's receiving it:
(rainboots.hiccup/defhandler :name
[cli person]
(if (ch-knows? (:ch @cli) person)
(:name person)
"Someone"))
(send! cli [:name sender] " yells, " [:upper "I'm over here!"])
Rainboots comes with a pretty powerful on-cmd
handler installed by
default. You should rarely need to replace the default on-cmd
handler, but you are more than welcome to. As with on-auth
, the
on-cmd
handler is called with a client object, and a line of input.
Since there are many ways to handle commands, and most of them annoying
to manage, rainboots provides a convenient way to get started:
(defcmd broadcast
"Send a message to everybody online"
[cli ^:rest text]
(send-all! (-> @cli :ch :name) " broadcasts: " text))
This demonstrates a couple things. First, the (defcmd)
macro, which
installs a command into the default set with a familiar syntax, and
"argument types." Any logged-in user will be able to type broadcast Hi!
to greet the whole mud. In fact, we automatically build out
shortcuts, so with no other commands def'd, you could just type b Hi!
. The :rest
argument type that annotates the text
argument to
get the entire rest of the input does not come with rainboots, but
would be easy enough to implement. See below for more on "argument
types."
By default, user input is destructured into arguments based on whitespace. For example, imagine this command:
(defcmd put
"Put something somewhere"
[cli what where]
; this part is up to you!
)
A user-input line "put gun holster" will be automatically destructured for you.
Commands don't always follow a single format, however, so defcmd
supports multi-arity out of the box:
(defcmd look
"Look around you, or at something"
([cli]
(send! cli "You're on a run-down old ship (But don't call it that!)"))
([cli thing]
(send! cli thing " isn't very interesting to look at")))
To make this really convenient, however, you'll want to make some argument types.
A powerful feature built into rainboots is the notion of "argument types." These are basically keyword annotations for command arguments which transform the user's input into the appropriate object, so commands can just declare what they expect, and rainboots can handle validating the user's input and providing the objects.
Here's how to implement that ^:rest
type shown above:
(defargtype :rest
"A string of text"
[cli input]
[input nil])
What? That's it!?
defargtype
installs the argument type handler globally, and also uses
(defn)
-like syntax. Every handler MUST return a vector, whose first
item is the resulting object, and whose second item is the remaining
part of the input to parse. Handlers are provided a client object and
the remaining input line at that point. This lets you do fancy things
like supporting "sword in stone" as a single ^:item
argument.
For more specificity per-command, you may use the map annotation style
to provide a parameter to your argtype. For example, you could annotate
a param as ^{:item :on-ground}
if you only want the item if it is on
the ground. The argtype def should then look something like:
(defargtype :item
"An item somewhere"
[cli input & [param]]
(cond
(= :on-ground param)
;; .. etc
:else ;; ...
))
Argtypes may not always "work," however. Even if the user provided
parse-able input, it might be invalid. Having to handle that everywhere
you use an argtype is problematic, so you may return a Throwable
instead of a value. If any argument is parsed to a Throwable
, the
message in the first Throwable
found will be sent to the user, and
your command handler will not be called. For example:
(defargtype :item
"An item somewhere"
[cli input]
(if-let [[item etc] (find-item cli input)]
;; found it!
[item etc]
;; no such item found:
[(Exception. (str "I don't see any " input)), etc]))
Sometimes, you might want an argtype that isn't directly supplied by
the user's input, for example if you have combat commands that require
a previously-specified target. Using an argtype is a convenient way to
access this, and have the logic to verify the existence of that target
be unified in a single place. For such an argtype, the input is
generally not necessary, so you can mark it as :nilable
, meaning that
it's okay if it's nil
—normally, an argtype is only called if there's
some input left to handle. For example:
(defargtype :target
"Your current target"
[cli ^:nilable input]
[(if-let [target (:target @cli)]
target
(Exception. "You must target something first"))
input]) ; return the input unchanged
Normally, all commands are added to a default "command set." Sometimes,
however, you may wish to put the user into a special mode where they
have access to only specific commands. You can do this via
(push-cmds!)
and (pop-cmds!)
.
(push-cmds!)
takes a client object, and the command set. You can
define a command set using, you guessed it:
(defcmdset combat-commands
(defcmd punch
[cli]
(send! cli "You swing a punch!")))
Any defcmd
s inside a defcmdset
will be bound to that set, and only
visible after a call to (push-cmds! cli combat-commands)
.
In fact, a cmdset is just a function, which looks like (fn [on-404 cli input])
. on-404
is the registered "unknown command" function
installed on the server, and the rest is as you expect. So, if you want
full control over the input and don't wish to use defcmd
or
defcmdset
, you can just (push-cmds!)
your own function!
Hooks allow you to provide information throughout the system without having any direct dependencies. For example, you might have a hook for "wearing" an item. There might be different types of effects associated with an item, such as armor points, or magical properties. You could store these properties as keywords on the item, and apply them by hooking into the "wear" event.
For example:
;;
;; Register hooks
;;
; magic-items.clj:
(hook! :wear-item
; Note the use of a named fn here: It's not
; required, but your repl experience will be
; better if you do.
(fn wear-magic-item [{:keys [cli item] :as arg}]
(when-let [magic (:magic item)]
(apply-magic! cli magic))
arg))
; armor.clj:
(hook! :wear-item
(fn wear-armor [{:keys [cli item] :as arg}]
(when-let [armor (:armor item)]
(apply-armor! cli armor))
arg))
;;
;; Execute hooks
;;
(defcmd wear
"Wear an item"
[cli ^:item item]
(when (trigger! :wear-item {:cli cli :item item})
;; You could potentially support returning nil
;; to indicate that the item couldn't be worn;
;; the semantics of each hook is up to you!
(add-equip! cli item))
By default, the order in which hooks are triggered is undefined, since the order in which they are added depends on when you load the namespace they're declared in, but you can provide a specific priority if you need to make sure that some hook is executed before another:
(hook! :wear-item
{:priority 10}
(fn wear-magic-item [{:keys [cli item] :as arg}]
; etc
)
If unspecified, a hook's priority will be 0
. Higher-priority hooks
will be executed before lower-priority hooks.
If the semantics of your hook are that not every registered hook fn
needs to see the input, you may wrap the returned value with reduced
to indicate that the given value is the result, and that no other
hook fn needs to run:
(hook! :wear-item
{:priority 10}
(fn prevent-wearing-unwearables [{:keys [cli item] :as arg}]
(if (wearable? item)
arg ; proceed
(do
(send! cli "You can't wear that!")
(reduced {})))))
Sometimes you want to provide a "fallback" hook that only gets called when nobody else was interested. Rainboots has a couple options here:
:when-only
With this option, the hook fn will only be called if:when-no-result
With this option, the hook fn will only beIf you're only providing one of these options and not priority (they are essentially mutually exclusive, so this should be the normal case) you can use a set instead of a map, for example:
(hook! :perform-action
#{:when-no-result}
(fn default-perform-action [arg]
; etc
))
Rainboots uses hooks internally to provide ways for you to extend or replace default behaviors.
Hook | Description |
---|---|
:process-send! |
All strings sent with (send!) are processed by triggering the :process-send! hook with a map containing the recipient as :cli and the text to send as :text . This is how the built-in colorization is applied, which means you can fully disable it by doing (unhook! :process-send! rainbooks.comms/default-colorize-hook) .In fact, (send!) , (send-if!) , and (send-all!) all support an optional first parameter process-extras which is a map whose keys and values will be included in the argument to the :process-send! hook, so you can provide extra information to your own custom output processing functions. |
When using the built-in (send!)
method, strings will automatically be colorized
with ansi using some built-in escape sequences. The foreground color can be
changed using the {
character, followed by a color. Here's a table:
Symbol | Color | Symbol | Color |
---|---|---|---|
{d |
{D |
||
{r |
{R |
||
{g |
{G |
||
{y |
{Y |
||
{b |
{B |
||
{p |
{P |
||
{c |
{C |
||
{w |
{W |
||
{n |
(Reset) |
Copyright © 2016-2019 Daniel Leong
Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.