XState

| View comments on Hacker News

My current side project, Discord Plays Pokémon, has a lot of dependencies. The application streams video with Discord, which presents a challenge. Discord does not provide APIs for streaming video, and I didn’t want to have to reverse-engineer the client. I chose to automate interactions with Discord’s web application using Selenium, which has yielded great results. It can programmatically stream video to a specific voice channel. This has worked very well so far — users are able to play real-time games of Pokémon with each other just using Discord’s text chat!

A screenshot of Discord text chat with two users inputting commands

Two users can input commands at the same time. D means to simulate a down-button press once, and 10r means to simulate a right-button press ten times.

If the command is valid, the bot will react to the message with a 👍 once the command is applied to the game.

I brute-forced a lot of this code to get the bot working quickly. While it works well, adding new features was not easy. I wanted only to have the bot stream if a user was in the voice chat. This requires tracking the state of the bot. Was the bot able to log in to Discord? Is the bot streaming or not? Has there been any error? How do I switch between browser tabs since there is one tab for Discord and another for the browser-based emulator?

Switching between tabs was an easy problem to fix. I created two instances of Firefox — one for the stream and another for the video. This eliminated a whole class of errors at a slight performance cost.

A screenshot of Discord streaming Pokémon
Video is streamed in real-time with instant feedback for the applied inputs.

Tracking the state, however, was not something I wanted to do. There are a lot of subtle edge cases that I didn’t want to deal with. I felt state machines would be applicable, but I had never used them in TypeScript.

I found the XState project and immediately fell in love. The project is incredibly polished and has excellent support for VS Code and TypeScript. I ported over my old code to a state machine, although understanding the concepts that XState introduced took some time.

A screenshot of VS Code with a code pane to the left and a state machine diagram to the right.
XState integrates well with VS Code.

I was surprised at how helpful the XState VS Code plugin was. It allows me to see a diagram of my state machine. You can simulate state transitions to understand what your state machine will do.

The XState VS Code extension lets you step through your state transitions.

Aside from the coolness of the extension, the library itself is quite polished and well-documented. Porting over my old code was simple because of how well XState integrates with promises.

Here’s an example of the state machine’s state for starting a Discord video stream. The method in src is invoked when the starting_stream state is reached. Once the promise is complete, onDone is called, which transitions the machine to the streaming state.

starting_stream: {
    invoke: {
    src: async ({ driver: anydriver }, _event: any_event) => {
        await joinVoiceChat(driver: anydriver);
        return await shareScreen(driver: anydriver);
    },
    onDone: {
        target: "streaming",
    },
    onError: {
        target: "is_error",
        actions: (_context: any_context, event: anyevent) => {
            var console: Console
The `console` module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers. The module exports two specific components: * A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream. * A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and [`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module. _**Warning**_: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for more information. Example using the global `console`: ```js console.log('hello world'); // Prints: hello world, to stdout console.log('hello %s', 'world'); // Prints: hello world, to stdout console.error(new Error('Whoops, something bad happened')); // Prints error message and stack trace to stderr: // Error: Whoops, something bad happened // at [eval]:5:15 // at Script.runInThisContext (node:vm:132:18) // at Object.runInThisContext (node:vm:309:38) // at node:internal/process/execution:77:19 // at [eval]-wrapper:6:22 // at evalScript (node:internal/process/execution:76:60) // at node:internal/main/eval_string:23:3 const name = 'Will Robinson'; console.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to stderr ``` Example using the `Console` class: ```js const out = getStreamSomehow(); const err = getStreamSomehow(); const myConsole = new console.Console(out, err); myConsole.log('hello world'); // Prints: hello world, to out myConsole.log('hello %s', 'world'); // Prints: hello world, to out myConsole.error(new Error('Whoops, something bad happened')); // Prints: [Error: Whoops, something bad happened], to err const name = 'Will Robinson'; myConsole.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to err ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html) (the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)). ```js const code = 5; console.error('error #%d', code); // Prints: error #5, to stderr console.error('error', code); // Prints: error 5, to stderr ``` If formatting elements (e.g. `%d`) are not found in the first string then [`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args) for more information.
@sincev0.1.100
error
(event: anyevent);
}, }, }, },
The state for starting a Discord stream.

I even wrote some quick unit tests to ensure it works properly. This test was much easier to write than tests without a state machine.

test("able to reach the streaming state", (done: anydone) => {
  const const actor: anyactor = interpret(streamMachine)
    .onTransition((state: anystate) => {
      if (state: anystate.matches("is_ready")) {
        const actor: anyactor.send({ type: stringtype: "start_stream" });
      }
      if (state: anystate.matches("is_streaming")) {
        done: anydone();
      }
    });
  const actor: anyactor.start();
});
Unit testing a state machine is straightforward. This would’ve been a lot more code without XState!

Hooking the entire thing up to the application wasn’t hard, either. This allows the bot to enter the voice channel and stream only when people are in the channel.

const const stream: anystream = interpret(streamMachine);

const stream: anystream.start();

handleChannelUpdate(async (channel_count: anychannel_count) => {
    if (channel_count: anychannel_count > 0) {
        const stream: anystream.send({ type: stringtype: "start_stream" });
    } else {
        const stream: anystream.send({ type: stringtype: "end_stream" });
    }
}

A complex set of interactions become so easy.

Overall, using XState feels like a huge win. I can be more confident about how I interact with Selenium and Discord. I hope to move more of my application to XState, which will significantly help when implementing new input methods and notification systems.

Recent posts from blogs that I like

Prompts.js

via Simon Willison

Paintings of the Coast of California 1

From Albert Bierstadt's visit to the Farallon Islands in 1872, to George Bellows in 1917, with paintings from Mannheim, Granville Redmond and others in between.

via The Eclectic Light Company

How I write essays

Notes on process

via Henrik Karlsson