Creating a BBS in 2015

Although it fooled nobody, yesterday for April Fools' Day, Lobsters users that normally saw a boring list of story titles and links were greeted with a BBS-style interface to the site complete with story and comment browsing, private message reading and sending, and a multi-user chat area.

The BBS remains active at https://lobste.rs/bbs (you can login as "guest").

screenshot of lobsters BBS login terminal

The chat area was fairly active yesterday with Lobsters users and anonymous guests coming and going throughout the day, reminiscing about using BBSes some 20-30 years ago.

screenshot of lobsters BBS menu with various options
screenshot of lobsters BBS chat session with only myself chatting

Quite a few people asked me how the whole system worked, and since I am not currently planning on releasing the source code (as it was largely a creative effort), I thought I would write up the technical details of the system.

Background

Most of the BBSes that I dialed into in the 90s were DOS-based systems with ASCII or 16-color ANSI text interfaces, which is what I was trying to replicate in my implementation. True to form, the backend of this BBS only sends out ASCII text and ANSI escape sequences for all screen drawing, coloring, and cursor positioning and each client terminal maintains its own screen buffer. The output from the server will look the same communicating with a telnet client, a dialup user calling in with RIPterm (if I had actually hooked a modem up to it), or my custom JavaScript terminal for this BBS.

While the Lobsters BBS had to be created in 2 weeks to be ready by April 1st, much of the heavy lifting was done by things I had already created years ago: a JavaScript ANSI code interpreter and terminal, a TrueType font version of the 437 Codepage used by PCs (and DOS in particular), a Markdown-to-ANSI-Code module for Redcarpet, and an ANSI code utility library to simplify printing things like output ANSI[:clear_line, :fg_red, :bold] and word-wrapping text while taking into account embedded ANSI escape sequences.

I had written many of these things years ago for a terminal version of my personal website, which normally looks like this:

screenshot of my website showing colored squares of various content

The terminal version of my site looked like this (the background image over which the terminal element was displayed is an old laptop):

screenshot of terminal version of my website with similar colored squares
screenshot of terminal version of reading an article from my website

However, since most of the people that read my website are just family and friends that didn't appreciate understand the keyboard-driven site, they kept telling me my site was unusable. I even implemented wsmoused-style mouse tracking to be able to click on things instead of having to use a keyboard to navigate over and hit enter. Eventually I just switched back to the HTML version.

Since April Fools day 2015 was coming up, I resurrected those libraries and started on a new backend design in order to build the Lobsters BBS, which would hopefully have a much more technical audience to appreciate it.

Terminal Frontend

While BBSes in the 80s and 90s communicated over modems and serial ports, the Lobsters BBS would be accessed by a web browser and communicate over a WebSocket connection.

When the page is loaded, an 80x25 <pre> element is present on the page to act as the terminal, and my TrueType 437 font is loaded to accurately display each 8x16 character like DOS would. Early on I decided to go this route rather than doing all processing to a <canvas> element with a perfect-pixel font so that users would be able to highlight/copy text in the browser and use things like the browser's native "find in page" functionality.

The JavaScript terminal establishes a secure WebSocket connection back to the server and receives each WebSocket frame containing raw binary/ASCII input including ANSI codes for coloring text and positioning the cursor. The JavaScript then interprets each character and manipulates its internal 80x25 array accordingly, builds a big HTML buffer of <span>s with certain classes for text colors, underline, etc. and atomically swaps them into the <pre> terminal on screen by setting its innerHTML. It does this screen redrawing for each chunk of input from the server, which can be a single cursor position request or an entire screen of text.

To faithfully reproduce the slow modem speeds of yore, rather than just do all of that processing as fast as the client can, each line of input is stored in a buffer and a JavaScript timer processes one line at a time with a configurable delay in between (I settled on a 20ms delay). The slow screen redrawing seen on the BBS is there on purpose, and when the delay is set to zero, it will rebuild the <pre> buffer nearly instantly. (This delay is especially noticeable on complex ANSI art, which can be seen by pressing "a" at the main menu of the BBS.)

Keyboard input from the user is sent back to the server character-by-character through the WebSocket connection by JavaScript binding to particular keydown/keypress events. The server maintains all input buffers for each connection, allowing it to do things like handle text input longer than a field's size and scroll the text side to side as the left and right arrow keys are pressed. There are specific keyboard bindings in the JavaScript to send keys like PageUp (^[[5~), Control+A (ASCII 1), etc.

Since the Lobsters website sees a lot of traffic from users on mobile devices, I wanted to make sure the new BBS was at least usable for them as well. While most of the JavaScript, CSS fonts, and WebSockets were also usable on iOS/Android web browsers, there was the problem of keyboard input. Since there would be no input field present to cause the mobile browser's on-screen keyboard to appear, a hidden <textarea> is placed at the top of the terminal, and focus is locked to it by re-focusing it on its blur event. Since Mobile Safari won't automatically focus an input field on page load and show the keyboard, a "Plug-In Keyboard" button is shown to mobile devices using a CSS media query, which provides the necessary touch event that allows the programmatic focus of the <textarea>. Using a <textarea> for receiving keyboard input also allowed me to receive a paste event, so users could just hit Control+V or Command+V on the page and it could paste in a password when logging in.

Now I had an ANSI-capable JavaScript terminal, a secure WebSocket connection established to the server which can receive ASCII/ANSI, and keyboard input from the user is being sent to the server.

Backend

The BBS server backend is a single Ruby process that uses EventMachine to handle input and output among all of the connected user sockets.

nginx, which normally proxies connections to the Lobsters Rails processes, was setup to proxy requests to the particular WebSocket path (requested via the frontend JavaScript terminal) to the EventMachine app. EventMachine handles the negotiation and upgrading which then presents the BBS server with plaintext input from the user.

Normally in an EventMachine app, as each message comes in from a different socket, one would just pass that message to the associated socket/user/session object and have it perform some action. With the BBS, there would be a ton of back-and-forth between the user and server, as each keystroke of input would require action on the server to manipulate the current input buffer in memory, redraw the text field, and output it all to the user's connection with proper ANSI codes to manipulate the cursor.

This model of entering the session object at a particular function was not suited well for this style of back-and-forth, since it would require re-building the state every time (or enter callback hell) and make it difficult for me to write. I wanted to be able to write code as if it was a single-threaded, single-user script running at a terminal blocking while reading from the TTY:

def field_input
  buf = ""
  
  while char = read_input
    break if char == "\n"
    buf << char
    ...
  end
  
  buf
end

def read_input
  STDIN.read(1)
end

def login
  render "welcome.ans"
  output ANSI[:reset, :cursor_15_61]
  username = field_input :length => 12
  
  if username.strip == ""
  	return
  end
  
  output ANSI[:cursor_17_61]
  password = field_input :length => 12, :password => true
  ...

To accomplish this in EventMachine, I turned to Ruby's Fibers. By creating a Fiber for each EventMachine connection/session, the session can pause in a blocking fashion (only to the session, not the EventMachine loop) until the main EventMachine loop wakes it up with new input which gets returned right where the input was requested rather than using callbacks.

The guts of the EventMachine manager look like this (with extraneous stuff removed):

EM.start_server(127.0.0.1, 8080, EM::WebSocket::Connection, {}) do |conn|
  conn.onopen do |handshake|
    ...
    n = BBSNodeManager.new_node
    n.session = WebsocketSession.new(n)
    n.fiber = Fiber.new
      n.session.post_init
      # we are now in the main loop of the bbs session,
      # so we will never get here until it returns which
      # only happens when the session has broken out of its
      # main menu loop
    end
    
    n.fiber.resume
  end
  
  conn.onmessage do |msg|
    n = BBSNodeManager.find_node(conn)
    if n.alive?
      n.fiber.resume(msg)
    end
  end
end

The WebSocket version of the BBS session class has a read_input method which just looks like this:

def read_input
  # this will block until EM wakes us up with Fiber.resume,
  # passing the keyboard input to us as the result of Fiber.yield
  return Fiber.yield
end

Now with an efficient way to get input from the user, the rest of the system can be written in a very top-down fashion.

def post_init
  reset
  splash
  
  if !user
    return close
  end
  
  welcome
  
  while true
    menu_loop
  end
  
  close
end

When the BBS server starts up, it loads in all of the ActiveRecord models from the Rails framework that existed for the normal Lobsters website to get easy access to user authentication, story lists, comments, etc., as well as share the same logic for external user notifications of new private messages sent through the BBS. Since we are basically single-threaded and only acting on one key input or screen output at a time, we only need one database connection (of course, if any queries are running slow, they will slow down everything).

There are actually two EventMachine modules loaded, one for WebSockets (which all of the web-based users use) and another for telnet. Since all of the output from the server is done using ANSI codes, the same code that works in our JavaScript terminal should work in an ANSI-capable terminal like xterm or Terminal.app which would be talking to the server over a direct TCP connection. However, because the telnet interface still had some negotiation-related bugs on April 1st, it was not enabled.

Chat Server

With all connections being handled by a single process, implementing the chat server was fairly simple since it was just copying input from one connection and storing it in each other chatting user's session buffer to eventually be sent to their terminal (like an old-school ircd).

While I was able to get away with directly sending text to a user's connection for things like BBSNodeManager.wall (which spammed a message to every logged-in user such as "The server is shutting down for maintenance, please call back later."), the chat component needed a bit more finesse. Because the main chat routine would be blocking in read_input waiting for a user to type something, new chat events from other users would not be processed until the Fiber woke up from input and was able to process its local buffer of new chat text and precisely print each to the screen at the proper location.

To solve this, when new chat input is available, the EventMachine manager just wakes up each Fiber that is chatting and sends it a particular type of message.

class BBSNodeManager
  ...
  def self.chat_input(msg)
    nodes.select{|n| n.chatting? }.each do |n|
      n.session.chat_input.push(msg)
      n.fiber.resume(ChatNotification.new)
    end
  end

Since all of those Fibers were each blocking in read_input, they will get that chat-indicator message back as the result of Fiber.yield, which it can then be passed back up to field_input instead of a keystroke. field_input then looks for that type of message, saves its input state, and returns early to the chat routine which is looking for input. Then new chat text is processed and it again runs field_input which restores its previously saved state until a keystroke or more chat input.

def chat
  node.chatting = true
  
  chat_input.push({ :msg => "*** Welcome to Multi-User Chat" })
  
  BBSNodeManager.chat_input({
  	:msg => "*** " << ANSI.escape(user.username) <<
    " has joined chat" })

  while true
    if chat_input.any?
      # output the new line of text in a particular place on the
      # screen, moving all other chat text up the screen
      ...
    end

    output ANSI[:cursor_23_1, :reset, :bg_red] <<
      (status bar ansi code here)

    if chat_input.any?
      next
    end

    input = field_input(:length => 77)
    if input.is_a?(ChatNotification)
      # chat_input has new stuff in it
      next
    else
      # operate on input, parsing commands like /whois or
      # sending it as chatter to BBSNodeManager.chat_input
      # which will put it in each other chatting user's chat_input
      # buffer and wake up those fibers for processing
      ...
    end
   end

I was pleasantly surprised at the lack of latency in this entire system even with dozens of users logged in and doing things like reading stories with pages full of text and chatting. Typed keys showed up nearly instantly (aside from the intentional JavaScript output buffering) and there was very little CPU usage in the single Ruby process.

Outro

So far there have been over 15,000 "calls" to the BBS in the past two days, although from the console it seemed like a lot of anonymous visitors could not figure out to try logging in as "guest" (though many tried to login as "jcs" for some reason).

Since April 1st is over, the BBS has moved off of the Lobsters home page but remains accessible at https://lobste.rs/bbs. I'm not sure what to do with it now, especially since a lot of the excitement from users died off within the first day. I suppose it is easier to thumb through a webpage on your phone while doing a dozen other things than to login to a system by hand and have to poke around at every menu option each time.

Update: I've since expanded on this code and created a full-featured BBS that I am continuing to operate.

Questions or comments?
Please feel free to contact me.