in which actors simulate a protocol


I've been on a bit of a yak shave recently on Bussard, my spaceflight programming adventure game. The game relies pretty heavily on simulating various computer systems, from your own craft to space stations, portals, rovers, and other craft. It naturally needs to simulate communications between all these.

I started with a pretty simple method of having each connection spin up its own coroutine running its own sandboxed session. Space station sessions run smash, a vaguely bash-like shell in a faux-unix, while connecting to a portal triggers a small lisp script to check for clearance and gradually activate the gateway sequence. The main loop would allow each session's coroutine a slice of time for each update tick, but a badly-behaved script could make the frame rate suffer. (Coroutines, you will remember, are a form of cooperative multitasking; not only do they not allow more than one thing to literally be running at the same time, but handing control off must be done explicitly.) Also input and output was handled in a pretty ad-hoc method where Lua tables were used as channels to send strings to and from these session coroutines. But most problematic of all was the fact that there wasn't any uniformity or regularity in the implementations of the various sessions.

Bussard shell session

The next big feature I wanted to add was the ability to deploy rovers from your ship and SSH into them to control their movements or reprogram them. But I really didn't want to add a third half-baked session type; I needed all the different implementations to conform to a single interface. This required some rethinking.

The codebase is written primarily in Lua, but not just any Lua—it uses the LÖVE framework. While Lua's concurrency options are very limited, LÖVE offers true OS threads which run independently of each other. Now of course LÖVE can't magically change the semantics of Lua—these threads are technically in the same process but cannot communicate directly. All communication happens over channels (aka queues) which allow copies of data to be shared, but not actual state.

While these limitations could be annoying in some cases, they turn out to be a perfect fit for simulating communications between separate computer systems. Moving to threads allows for much more complex programs to run on stations, portals, rovers, etc without adversely affecting performance of the game.

Each world has a server thread with a pair of input/output channels that gets started when you enter that world's star system. Upon a successful login, a thread is created for that specific session, which also gets its own stdin channel. Input from the main thread's SSH client gets routed from the server thread to the stdin channel of each specific session. Each OS implementation can provide its own implementation of what a session thread looks like, but they all exchange stdin and stdout messages over channels. Interactive sessions will typically run a shell like smash or a repl, and their thread parks on stdin:demand(), waiting until the main thread has some input to send along.

This works great for regular input and output, but sometimes it's necessary for the OS thread to make state changes to tables in the main thread, such as the cargo script for buying and selling. Time to build an RPC mechanism! I created a whitelist table of all functions which should be exposed to code running in a session thread over RPC. Each of these is exposed as a shim function in the session's sandbox:

local add_rpc = function(sandbox, name)
   sandbox[name] = function(...)
      local chan = love.thread.newChannel()
      output:push({op="rpc", fn=name, args={...}, chan=chan})
      local response = chan:demand()
      if(response[1] == "_error") then
         table.remove(response, 1)
         return unpack(response)

When the shim function is called it sends an op="rpc" table with a new throwaway channel (used only for communicating the return value), and sends it back over the output channel. The main thread picks this up, looks up the function in the rpcs table, and sends a message back over the response channel with the return value. This same RPC mechanism works equally well for scripts on space stations as it does for the portal control script, and a similar variation (but going the other direction) allows the SSH client to implement tab completion by making an RPC call to get completion targets.

They're not perfect, but the mechanisms LÖVE offers for concurrency have been a great fit in this particular case.

« older | 2017-05-14T21:01:31Z