Collaborative ASCII Drawing With Telnet

by @bwasti


If the server isn't swamped, you can try it out (hold shift to erase, arrow keys to move):

telnet bram.town

If you're on a newer mac, you may need to brew install telnet. It doesn't come by default these days...

The full code listing can be found here. I run it with bun server.ts.


User Input

For details, check out the xterm docs: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html

We can tell xterm terminals to start sending mouse information by sending connected users an ANSI sequence like this:

socket.write("\x1b[?1003;1006;1015h");

This concatenates 3 different parameters, which we can find in the docs:

I found the EXT_MODE parameters to be particularly important, as they can cause the client to crash if not set correctly (when using a macOS version at least).

This was a nightmare to debug, because the errors looked like this:

and they only happened... sometimes! When moving the cursor beyond ~90 or so characters to the right, the client would crash but not disconnect.

But this wasn't consistent. And that was because I was playing with a really cool project for inspiration:

mapscii.me!

and every time I used mapscii, my terminal was correctly set to the extended mode.

Because terminals are state based and can be manipulated by the characters they print. Neat.

I guess we should clean up when people leave...

user.socket.write("\x1b[?1003;1006;1015l");

Rendering Output

But just getting user input isn't enough, we also need to "render" the output.

This happens to be a lot easier, and ChatGPT wrote that code for me:

First, move the cursor to the top left corner:

user.socket.write("\x1b[H");

Then, dump the correctly sized contents and hide the cursor:

user.socket.write(screenString + "\x1b[?25l");

Done.

Although I didn't explain "correctly sized contents" at all. This turns out to be another tricky mess of hard to understand control code sequences. Luckily for me, ChatGPT actually did understand this stuff, so I didn't have to write/debug that part of the code.

Getting the user window size is done with telnet's NAWS option. NAWS stands for "Negotiate About Window Size." The server requests NAWS by outputting a series of telnet protocol bytes:

socket.write(Buffer.from([IAC, DO, NAWS]));

and the client will respond if it can handle such a request as well as the data associated with it. I assume it can and just read the data:

if (data.length >= 5) {
  user.width = (data[0] << 8) + data[1];
  user.height = (data[2] << 8) + data[3];
  //scheduleRender(user);
}

You can read more about this stuff here: http://www.pcmicro.com/netfoss/telnet.html


Features

Once I got this working I added some random features, but I'm hoping to add more. Drawing and panning around a global canvas with multiple users is kind of a litmus test for these things, so I just did that.

You can move around with WASD or arrow keys.

You can click and drag to draw pixels. These can be "layered" by repeated drawing to get a typical "ASCII art" effect. If you hold shift while dragging around, you erase pixels.

Your cursor is represented by a little circle and everyone can see it.


if you'd like to follow me @bwasti :^}