Game, TypeScript, Cloudflare workers

Making a game on Cloudflare workers

Mizuenoe no Urashima riding a turtle with flowing tail (mino game)
Image by https://commons.wikimedia.org/wiki/File:The_Turtle_1898_Manhattan_Theatre_poster.jpg

Trying out technology is always fun, but you need a reason to do it. I made a game on Cloudflare workers based on a Polish board game that my girlfriend forces me to play.

You can head over to https://github.com/mewhhaha/jkot-turtles to read more about the project. This post will simply detail my experiences with building this application.

For this game I decided to go with React, Remix and Cloudflare workers (With Durable Objects and WebSockets). The reason I went with these technologies is because I've used them before, and this blog actually written using those technologies.

2022-07-16T15:41:49.635Z

This is the structure of my project. It's a monorepo with two main packages: durable_objects and web. Indurable_objects I have my one worker that is responsible for all the game logic and handles the WebSocket connections. Then in the web folder I keep the Remix app.

Now, the Remix app is all client-based and doesn't use the server-side of things at all (except for serving things blazingly fast). I could've used Vite or something similar, but I copied over a bunch of stuff from my other project and that was using Remix, so here we are.

If I could go back it'd be more optimal to use Vite, because it just gives a better DX experience if you're not using any server-side capabilities. But this is a small application and most of it is just client-side React that will look the same regardless of what hammer I use to solve this issue.

export type ClientMessage = ["start"] | ["latest"] | ["play", number, Turtle?];
export type ServerMessage =
  | ["error", string]
  | ["starting"]
  | [
      "started",
      { board: Tile[]; player: Player; turn: string; played: Card | undefined }
    ]
  | ["waiting", string[]]
  | ["reconnected", string]
  | ["joined", string]
  | ["cards", Card[]]
  | ["played", { board: Tile[]; card: Card; turn: string }]
  | ["done", { board: Tile[]; winners: Winner[] }];

I went with a very simple API between server and client. I typed up a bunch of tuples and since both the worker and the web application uses TypeScript I simply shared this type definition with the client to keep everything in line. Then it was just about deploying both the worker and web application at the same time.

Experience: smooth

There's a single endpoint that the worker listens to which is /:id/:name where the :id is used to identify a specific durable object and then the name is the name of the player connecting.

The players are just identified by their names, so you can easily impersonate somebody; and easily reconnect to your session. It's a give and take. Because the name was the only identifier then it was easy for me to open up a bunch of tabs and play test with myself.

router.get("/:id/:name", async (request: Request & RouterRequest, env: Env) => {
  const id = request.params?.id;
  const name = request.params?.name;

  if (!id) return new Response("expected id", { status: 422 });
  if (!name) return new Response("expected name", { status: 422 });

  if (request.headers.get("Upgrade") !== "websocket") {
    return new Response("expected websocket", { status: 400 });
  }

  const durableObject = init<TurtleGame>(env.TURTLE_GAME_DO).get(id);

  return await durableObject.call(request, "connect", decodeURIComponent(name));
});

One big issue I had, or self-made issue, was that I wanted to have a simple API to call my Durable Object. In the end I settled with the above, that I run an init function on a Durable Object Namespace that I can then use to get a specific instance from. The object that I get back exposes a function called "call" that I then use to call specific functions on a Durable Object.

The secret to this sauce here is that I can narrow down the strings that you can pass in to only the functions that return responses from the Durable Object. And if you look very closely you see that I'm able to pass more arguments after the function name.

 async connect(name: string) {
  // ...
}

But how is this possible, since the Durable Object uses fetch as its API to do requests?

export const init = <ClassDO extends Record<any, any>>(
  ns: DurableObjectNamespace
) => {
  return {
    get: (name: string | DurableObjectId) => {
      const stub =
        typeof name === "string" ? ns.get(ns.idFromName(name)) : ns.get(name);

      return {
        call: <Method extends External<ClassDO>>(
          request: Request,
          method: Method,
          ...args: Parameters<ClassDO[Method]>
        ) => {
          const encodedArgs = encodeURIComponent(JSON.stringify(args));
          return stub.fetch(
            new Request(`https://do/${method}/${encodedArgs}`, {
              method: request.method,
              headers: request.headers,
              body: request.body,
              formData: request.formData,
              redirect: request.redirect,
              bodyUsed: request.bodyUsed,
              cf: request.cf,
            })
          );
        },
      };
    },
  };
};

Short answer: I encode the arguments in the fetch URL (don't do this). This has obvious limitations with that the arguments have to be serializable (which I haven't enforced in any way) and that they aren't too long so that the URL goes above the character limit. However, it works "good enough".

I thought of passing the arguments in the body, but I can't control if the method is a GET, POST, or whatever. In hindsight maybe it's not so interesting what kind of method is being used and if you're calling functions directly maybe it should just always be a POST. Then I could pass it in the body, and then have the real body being passed as a property on it.

Then on the receiving side it looks like the following.

export class DurableObjectTemplate implements DurableObject {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const [method, encodedArgs] = url.pathname.split("/").slice(1);
    const args = JSON.parse(decodeURIComponent(encodedArgs));

    // @ts-ignore Here we go!
    return await this[method](...args);
  }
}

Just use https://github.com/kwhitley/itty-durable. Don't commit heresy like I did.

For the web application it is rather uninteresting. You can look it up in the repository but it mainly listens on the socket connection and updates the local state, then renders it.

Here are some screenshots!

2022-07-16T15:09:57.687Z

Login screen

2022-07-16T15:10:17.486Z

Starting field

2022-07-16T15:09:36.226Z

Turtles get darker the more you stack