DEV Community

Kevin Hoffman
Kevin Hoffman

Posted on

Invoking Functions on Distributed Game Objects

This is the second post in my series on developing a modern MUD using waSCC and WebAssembly; taking old-school monolithic concepts from the MUD worlds of yore and migrating them to the new, cloud-native, wasm-based future.

I've found that writing posts like this before coding helps me organize my thoughts and saves me some time when I get to the IDE. I like to call this method blog-ahead compilation.

LPC Function Calls

In LPC, there are countless reasons why a block of code might need to invoke a function on another game object. This could be in combat to reduce hitpoints, it could be the code that changes the state of a set of armor from unworn to worn, a sword from sheathed to wielded, etc. We need to make function calls to buy things from stores, to send text messages to players, to flip quest bits when a player places an ancient relic in the base of a fountain. The list goes on.

In the world of the single-process monolith, function invocation is pretty simple, and in LPC it looks something like this:

my_object->function(parameters);
call_other(my_object, "function", parameters);
Enter fullscreen mode Exit fullscreen mode

In most variants of LPC that I remember, you were able to make function calls in either an OOP-like (the "arrow") fashion or the regular C style syntax.

What's important to notice about this code is that the variable my_object is an instance variable just like any other, and is sitting in memory directly in the monolithic process.

If we need to invoke a function call on a game object in a world where game objects are spread across a cluster of driver processes, then we need to re-think things quite a bit.

Managing Distributed Game Objects

One possible way to deal with distributing game objects is to just simply distribute instances across a grid and then provide some kind of RPC plumbing to allow remote invocation. This means that for every single game object active in the virtual world, there is an instance variable consuming a stack pointer and heap memory somewhere in a process in the cluster.

This feels inefficient to me, like we're reaching for the safety blanket of old techniques because that's what we're familiar with. The only reason we want to manage game object instances like traditional language primitive objects is because these things have state exposed through simple fields or properties. In the cloud, we don't want stateful, long-running variables.

Further, if each of these long-running stateful variables is actually a WebAssembly interpreter (since our user code is all wasm actors), that's a ton of duplicated overhead per game object. If we get to hundreds of thousands of game objects, this will absolutely not scale well.

Instead, what we want to do is load the prototype of the game object, and then provide per-instance data as parameters to each function call. This means that the prototype remains stateless, and every function call is able to be executed by the prototype (the actor wasm code).

With this approach, we can have a single actor like std/sword handle all function invocations for all of the thousands of instances of swords in the game. If the path through /std/sword starts to get too "hot", then we can simply spin up more copies of this prototype on different nodes in the cluster and distribute load across them.

If the function call needs state for processing, it'll query it from a K/V store or cache or get it as part of the call (those are optimization details I can worry about later).

Making Distributed Game Object Function Calls

Recall that an instance of an object in an LPMud is a combination of its prototype and some form of unique instance identifier. Our distributed mudlib capability provider can keep track of the mapping between what the game calls a prototype (e.g. std/sword or areas/kevin/dragon1) and what waSCC calls an actor. waSCC uses URL-friendly encodings of ed25519 keys to uniquely and globally identify actors. Our mudlib will keep a mapping between the game prototype name and the public key of an actor, so that it can hide the complexity of those public keys from developers and convert a content-developer-friendly function call like this:

let sword: &dyn Sword = get_object("/std/sword#5672458").unwrap();
sword.wield();
Enter fullscreen mode Exit fullscreen mode

into code like the following that is safely hidden from game content creators:

fn get_object(obid: &str) -> &dyn GameObject {
    let (public_key, instance) = obid.pk(), obid.instance()); // a value like ("MB4OLDIC3TCZ4Q4TGGOVAZC43VXFE2JQVRAXQMQFXUCREOOFEKOKZTY2", "562478")
    &RootGameObject::new(public_key, instance)
// Could do &RootGameObject::from_objectid(obid) as a shortcut
}
. . .

sword.call(this_interactive(), "wield", []); // This is an RPC efun
Enter fullscreen mode Exit fullscreen mode

In the above code, the call function would then forward an invocation up to the mudlib capability provider (running in the waSCC host process) and in turn execute the appropriate network code to find the prototype, build context, invoke the function, and return the result.

A Note on Polymorphism

I don't want to support inheritance in this MUD because wasm modules (actors, game objects) are intended to be treated as freestanding, portable units of compute. Trying to build inheritance into them would be an affront to WebAssembly, in my opinion.

But, I do want to be able to treat things similarly and allow code to be shared. How do we deal with the fact that all weapons can be wielded? What if I want to make a scenario where a chair can be wielded or a cardboard box can be worn as armor? That's a topic for an upcoming blog post, because translating the flexibility of LPC's multiple inheritance hierarchies into freestanding WebAssembly modules without forcing a ton of code duplication is no small task.

Managing Per-Call Context

When we invoke the wield function on a sword, we need some context in order to properly deal with that call. This context is especially important because the actor in which this function resides is stateless by design.

We need to know who wielded the sword. We also needed to know which sword this is, and the values of all of the variables currently set on that sword.

An absolutely crucial aspect of LPC that I enjoyed so long ago is that wizards were able to be productive and create programmatic content without needing to know the underlying details of how the MUD and game driver worked. In my case, I spent months building an entire area with no knowledge of the intricate interplay between the mudlib and game driver, or what the difference was between library functions and external functions.

If this actor-based wasm MUD is going to be successful, we need to be able to hide the irrelevant details so content creators can easily do their jobs.

To keep the API clean, I don't want wizards to have to code things like context.get_something("foo") over and over again, nor do I want them to know the details of how we're representing context internally. Instead, the context will be transparently set in a variable hidden in the mudlib actor API prior to the invocation of a function. If a function call needs context, then it can obtain it from the underlying variables.

Let's take a look at some sample Rust wasm code that might be found in /std/sword.wasm:

public_functions! { wield };

fn wield() -> Result<()> {
    let me: &dyn Sword = this_object();
    let ti = this_interactive();
    write(ti, "You wield the sword. Is it glowing??")?;
    say(ti.third_person() + 
      " wields the sword. It begins to glow!\n")?;
    me.set_wielded(true);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This looks like super simple code, but there's a "metric crap-ton" of complicated things happening, and that's as the world should be.

Feature developers should write simple logic, and boilerplate and plumbing belongs elsewhere.

Let's take it apart step by step:

  • this_interactive() returns a &dyn Interactive, a game object reference that supports core functionality, plus interactive functionality (it can send and receive text). All of this is done inside the wasm module boundary through the mudlib API by accessing the thread-safe (actors are single-threaded) context that was hydrated prior to invocation.
  • write() sends a text message to the interactive thing (which could be a player or a game-controlled NPC). This creates a remote invocation request that will bubble out across the wasm boundary, through the mudlib capability provider, across the network to the prototype for the current interactive object (e.g. /std/player). Context is built remotely prior to invoking the say function in the player prototype (which is a waSCC-hosted actor).
  • say sends a text message to every object listed in the inventory of the room the wielder is standing in
  • this_object() is essentially the "self" reference, and is a &dyn GameObject which is in turn automatically cast to &dyn Sword via supertrait casting.
  • set_wielded makes a call to the host's key-value capability to set the wielded value of actor Mxxxxx....x instance 12345 in the distributed cache.

All of these functions could fail, which can in turn cause wield to fail, which would eventually send a message to the player that typed wield sword, which would ultimately be delivered via socket over the telnet connection (or web socket to the browser client).

Summary

Building an elastically scalable cloud-native online multiplayer world is a difficult thing. However, my hypothesis is that as developers building business functionality day in and day out in the cloud, we deal with a lot of the same problems.

If we remain vigilant about hiding complexity from feature developers (in the case of our MUD, wizards writing actors), then we can make the act of creating clean, crisp, secure, functional code an actual joy, and maybe writing code won't suck as much as it does today.

p.s. all of the code in this post is purely hypothetical, and will likely change radically once I sit down to code some of this up.

Top comments (1)

Collapse
 
deadwisdom profile image
Brantley Harris • Edited

Yeah, alright. Let's get to it then.

I have been thinking of a similar architecture, layering things like Engine | Servers | Web Clients.

The Engine, I imagined as an Entity-Component-System that held all of the game state. It would shard per "map", a zone/group of rooms. And it would run like a data-source, sort of like redis. You could send it commands and queries, and it would return state information. Systems would tick the world, as it were: move objects, spawn things, apply effects, etc.

Servers could be many, and this is where the WASM would come into play. I don't fully understand your WASM actors, but I am reading about them now. Servers would manage configuration, worlds, and libraries. Each "Library", would be a versioned set of types and scripts. So you could branch off of a library to work on your own Ogre, put this new guy into a demo room, work on him, and then upgrade all Ogres to your new version. The servers would be able to work on their own, running many actors as AI, or they could connect to the web clients.

I'm more of a front-of-house full-stack guy, so I'm exceedingly comfortable in the web-client. I have lots of designs, and have prototyped, a web interface that feels like a modern book, with SVG maps that give you a sense of where you are. (No images except for these maps, this is a MUD after all.)

Anyway, those were my thoughts. I have much more, and I would love to work with you on this.