projects

knucklebones

TUI multiplayer game of risk and reward that works over SSH

The game itself

Playing with AI

First of all, here is my game session against AI:

Playing with a real human being

Besides playing with soulless (and, to be honest, poorly engineered) AIs, it also supports multiplayer via creating and joining rooms:

The weird tech stack

There's something fascinating about working with terminal apps. It just feels cool.  
I recall feeling like a badass hacker after learning my first commands on navigating the Linux file system, and I also had about the same feeling after learning my nvim routines and getting used to lazygit.

Then, a couple of months ago (the time of writing is February 2025) I stumbled upon ThePrimeagen's terminal.shop:

My first thought: Holy shit it looks cool! And also supports vim mappings!
My second thought: I should build something like this

Terminal & SSH is not a common thing to work with when you're mostly doing React apps, but I knew for a fact there was a React renderer for terminal apps, because:

Side note: I'm not in any way making fun of the projects mentioned in the last point. They all exist for numerous reasons, so check out their docs

So, it took me a couple of minutes on npm to decide on my tech stack:

  • ssh2 to handle ssh connections;
  • blessed to work with boxes and lists instead of plain text;
  • react-blessed to work with <box>es and <list>s intead of boxes and lists.

The smol problem with the weird tech stack

This stack allowed me to get up & running in a couple of evenings. But those were not the most enjoyable evenings in my life.
Here's what I was getting on my screen after setting the project up:

Black screen. And that's it.
Interestingly enough, I had no problems with rendering the project using the node's stdin and stdout, but I was getting the black screen via SSH for two days straight.

I was not the only one dealing with this, so naturally blessed has this GitHub issue from 2016:

blessed-gh-issue

The most liked comment in that issue explains that the issue is caused by ssh stream having 1x1 dimensions for some reason. The solution looks like this:

let windowChange = () => {
      if(stream) {
        stream.rows = info.rows;
        stream.columns = info.cols;
        stream.emit('resize');
      }
    };

But Typescript was not happy with it:

typescript-issues

After quite long useless research, I tried to make typescript shut up with _.set and it worked like a charm:

import set from 'lodash/set';
import { type ServerChannel } from 'ssh2';

export const setStreamConfig = (
  stream: ServerChannel,
  options: Record<string, unknown>
) => {
  for (const [key, value] of Object.entries(options)) {
    set(stream, key, value);
  }
};
if (stream) {
  setStreamConfig(stream, {
    rows,
    columns,
  });
  stream.emit('resize');
}