Introduction

Imagine you’re writing a book in English for English readers. Now, imagine being told that all the connecting words—the “ands,” “buts,” and “ors”—must be written in Latin. Sounds absurd, right? Yet, this is often what web development feels like: you write your frontend in one language, only to switch to another for the backend.

Today, we’re changing that narrative. We’re building a multiplayer version of Infinite Craft, a captivating browser game where players combine elements to create an ever-expanding universe of objects and concepts. But the game isn’t the real story here. The real story is how we’re building it—all in TypeScript, from start to finish.

We’re using Astro and React, powerful tools you might know. But we’re pairing them with something revolutionary: Freestyle. Freestyle isn’t just another framework. It’s a fundamental shift in how we approach web development. With Freestyle, you write your entire application—frontend and backend—in TypeScript. No context switching. No mental gymnastics. Just pure, consistent TypeScript throughout.

In this tutorial, you’ll handle data persistence and user interactions. You’ll create shared rooms for collaborative noun discoveries. And you’ll do it all without leaving the comfort of TypeScript. No SQL. No Python. No Java. Just the language you already know and love.

No time to code? To skip the tutorial and deploy the final game right away:

  1. Clone the final project from https://github.com/kevgug/multiplayer-infinite-craft.
  2. Follow the setup instructions below, but use this cloned repo instead of the starter project.
  3. Skip ahead to deploy your version to the cloud.

Setup

Let’s get the grunt work out of the way first. We’ll get our Anthropic API key for the noun-crafting magic, set up our environment, and clone the starter repository.

1. Anthropic API Key

With an infinite gameplay, it shouldn’t come at surprise that the original Infinite Craft game uses generative AI under the hood to generate new words. In this tutorial we’ll be using one of Anthropic’s large language models (LLMs) to generate new words. Don’t worry, this won’t become a prompt engineering tutorial, since I have already written the prompt for generating words.

Note to Prompt Engineers: Feel free to use a foundation model of your choice, when we get to that stage. Just make sure you add the API key environment variable for your chosen provider - as I’ll demonstrate for Anthropic.

Creating an API key

You’ll need to create an Anthropic account; when I signed up in March 2024, I received $5 free credit. Once you have your account, head over to the Settings tab and navigate to API keys.

alt

Click on the Create Key button. Give your key a name (‘secret-key’ or ‘sk’ are both fine if you’re unsure). Finally, click on the second Create Key button (the one in the modal) and copy it to your clipboard. Don’t forget to copy it!

alt

You’ll want your computer environment to remember your Anthropic API key in one source of truth, so that you can (1) use generative AI across all your locally run projects without copy/pasting around the API key and (2) without exposing your secret key in a git repository.

macOS

Open Terminal and enter the following command.

open ~/.zshrc

A .txt editor will open. Insert the following line anywhere in the file, replacing the placeholder with your own Anthropic API key (starting "sk-ant").

export ANTHROPIC_API_KEY="<YOUR-API-KEY>"

Save the text file. Return to your Terminal window and enter the following command.

source ~/.zshrc

To verify that the environment variable has been set correctly, you can enter the following command in your Terminal:

echo $ANTHROPIC_API_KEY

This should display your API key. We’re all set to use Anthropic’s official NPM packages on your Mac.

Windows

  1. Open the Start menu and search for “Environment Variables” or “Edit the system environment variables”.

  2. Click on “Edit the system environment variables”.

  3. In the System Properties window that opens, click on the “Environment Variables” button.

  4. In the Environment Variables window, under the “User variables” section, click “New”.

  5. Set the Variable name as ANTHROPIC_API_KEY.

  6. Set the Variable value as your Anthropic API key (starting "sk-ant").

  7. Click “OK” to close each window.

  8. Restart any open command prompt windows or applications for the changes to take effect.

To verify that the environment variable has been set correctly, you can open a new Command Prompt window and type:

echo %ANTHROPIC_API_KEY%

This should display your API key. We’re all set to use Anthropic’s official NPM packages on your Windows machine.

2. Clone Starter Repository

We’ll be starting with a pre-built project that includes the basic setup for our multiplayer Infinite Craft game. You can clone the starter repo directly from https://github.com/kevgug/multiplayer-infinite-craft-starter.

Alternatively, navigate to the directory where you want to store the project and run one of the following commands:

SSH (Recommended)
git clone [email protected]:kevgug/multiplayer-infinite-craft-starter.git
HTTPS
git clone https://github.com/kevgug/multiplayer-infinite-craft-starter.git

3. NPM Packages

Install the packages we’ll be using with the npm install command.

To make sure everything was installed correctly, run the npx freestyle dev command at your project path (e.g., VSCode’s integrated terminal). Then open the URL (most likely http://localhost:8910/) in your browser:

alt

Awesome—our local development setup is ready!

You’ll notice the buttons don’t do anything yet. So let’s dive right into coding, starting with multiplayer room management.

Room management

In our multiplayer adaptation of the original game, we want players to be able to create rooms and collaborate with their friends to craft nouns in existing rooms. To enable multi-room gameplay, we’ll employ the following plan:

  • Room class

    • Purpose

      • Modify, store, and retrieve game state for each room
    • Properties

      • Unique ID
      • List of crafted nouns
    • Methods

      • Get nouns

      • Craft noun, given two nouns to combine

  • Room Manager class

    • Purpose

      • Create and retrieve uniquely identifiable rooms
    • Properties

      • Map of rooms, keyed by their unique room ID
    • Methods

      • Check room exists, given a room ID
      • Create room with a unique ID

All cloud-based functionality in this project will be stored in the cloudstate folder.

Note: No need to panic. We’ll stay in TypeScript land for our entire backend, from database to RPC functionality, thanks to Freestyle. You’ll see in just a moment.

Basic room management

Room class

Let’s start with the room class in src/cloudstate/room.ts. We’ll name it RoomCS by Freestyle convention, where CS is the abbreviation of CloudState, reflecting that we’ll be running instances of this class in the cloud.

src/cloudstate/room.ts
import { EmojiNoun } from './noun';
 
export class RoomCS {
	nouns: EmojiNoun[] = EmojiNoun.STARTING_NOUNS;
	getNouns() {
		return this.nouns;
	}
}

The EmojiNoun class is a simple class that holds a noun (text), its emoji representation (emoji), and whether the room that crafted this noun were first to discover it (discovered). The list of initial nouns for each room is EmojiNoun.STARTING_NOUNS: 💧 Water, 🔥 Fire, 🌬️ Wind, and 🌍 Earth.

Importantly, the RoomCS class is not cloud-stateful yet. To make data persist in a database, and functions like getNouns available as an RPC (client calls method from the cloud), we simply add the @cloudstate decorator from Freestyle and an identifier to the class.

src/cloudstate/room.ts
import { cloudstate } from "freestyle-sh";
import { EmojiNoun } from "./noun";
 
@cloudstate
export class RoomCS {
   id: string;
	constructor(id: string) {
		this.id = id;
	}

Wasn’t that easy?

Room Manager class

Next, let’s create the RoomManagerCS class in src/cloudstate/room-manager.ts. We’ll add a roomsMap property to store all rooms, and a roomExists method to check if a room with a given ID exists.

src/cloudstate/room-manager.ts
import { RoomCS } from './room';
 
export class RoomManagerCS {
	roomsMap: Map<string, RoomCS> = new Map();
	roomExists(roomId: string): boolean {
		return this.roomsMap.has(roomId);
	}
}

Again, to make the RoomManagerCS class cloud-stateful, we simply add the @cloudstate decorator and an identifier. In this case, there will only ever be one RoomManagerCS instance. So, we use a static constant identifier to let Freestyle know that this class is a singleton.

src/cloudstate/room-manager.ts
import { cloudstate } from 'freestyle-sh';
import { RoomCS } from './room';
 
@cloudstate
export class RoomManagerCS {
	static id = 'room-manager' as const;
 
	roomsMap: Map<string, RoomCS> = new Map();
	roomExists(roomId: string): boolean {
		return this.roomsMap.has(roomId);
	}
}

Important Note: Cloudstate classes seamlessly integrate with custom classes as properties and return types, but those classes must also be cloudstate classes.

What good is a room manager if we can’t create rooms? Let’s add a createRoom method to the RoomManagerCS class, directly below the roomExists method.

src/cloudstate/room-manager.ts
createRoom(): string {
   const roomId = crypto.randomUUID();
   const room = new RoomCS(roomId);
   this.roomsMap.set(roomId, room);
   return roomId;
}

Important Note: To ensure a dynamically created cloudstate instance such as RoomCS persists, it must be stored in a data structure property of another cloudstate class. While any collection (e.g., list, set, map) works, choose the most appropriate for your use case. In RoomManagerCS, a map is ideal for storing both RoomCS instances and their unique IDs.

The createRoom method generates a new room ID, creates a new room instance, and stores it in the roomsMap.

Creating and joining rooms

Now that we have the basic room management set up, let’s create a new room when the user clicks the “Create Room” button in the Room Manager screen on the frontend.

In src/components/RoomManager.tsx, there are unimplemented createRoom and joinRoom methods. We’ll implement the createRoom method first.

With a traditional backend, we’d need to send an HTTP request to the server to create a new room, using a RESTful API or GraphQL. But with Freestyle, we can call the createRoom method directly from the frontend, as if it were a local function.

Create Room

src/components/RoomManager.tsx
import { useCloud } from 'freestyle-sh';
import { RoomManagerCS } from '../cloudstate/roomManager';
export default function RoomManager() {
	const [textInput, setTextInput] = useState('');
 
	const roomManager = useCloud<typeof RoomManagerCS>(RoomManagerCS.id);
 
	const joinRoom = (roomId: string) => {};
	const createRoom = async () => {
		const roomId = await roomManager.createRoom();
}
To enable TypeScript code completion, it's convention to type the useCloud hook with the class you're using (e.g., typeof RoomManagerCS).

Here we’ve created an instance of the RoomManagerCS class and call the createRoom method on it. Since we’ve set up the RoomManagerCS class as a singleton, we can use the RoomManagerCS.id constant to identify the cloud instance, but 'room-manager' would work just as well.

Note that all cloud functions are asynchronous, so we need to use async and await when calling them.

Once the room is created, we’ll want to navigate to the game screen for that room.

src/components/RoomManager.tsx
import { useState } from 'react';
import { navigate } from 'astro:transitions/client';
const createRoom = async () => {
	const roomId = await roomManager.createRoom();
	navigate(`/room/${roomId}`);
};

Astro’s client side navigate method will redirect the user to the game screen for the newly created room.

alt

You can should see that the URL changes to /room/ followed by a random UUID. This is the ID for the room you just created.

We’ll use it to join the room in the next section, so copy it to your clipboard or save it somewhere else temporarily. Mine is 7d8eac6d-b689-48ee-bc56-f66b2ff87d85.

Join Room

To join a room on the client side, we’ll need to implement the joinRoom method in src/components/RoomManager.tsx. Joining a room is as simple as navigating to the game screen for that room, but we need to ensure the room exists before we do so.

src/components/RoomManager.tsx
const joinRoom = async (roomId: string) => {
	if (!(await roomManager.roomExists(roomId))) {
		return;
	}
 
	// Go to room page
	navigate(`/room/${roomId}`);
};

Since we know that an empty string will never be a valid room ID, we can add a check to the joinRoom method to avoid unnecessarily calling the roomExists cloudstate method.

src/components/RoomManager.tsx
const joinRoom = async (roomId: string) => {
	if (roomId.length == 0) {
		return;
	}
	if (!(await roomManager.roomExists(roomId))) {
		return;
	}
 
	// Go to room page
	navigate(`/room/${roomId}`);
};

Since we know that an empty string will never be a valid room ID, we can add a check to the joinRoom method to avoid unnecessarily calling the roomExists cloudstate method.

Using Freestyle, it’s easy to forget that we’re making cloud calls, because we’re (1) writing all code in TypeScript and (2) running a zero-latency local development server via npx freestyle dev. But remember that every method call on a cloudstate class is a network request in production - just very well abstracted away for the developer.

src/components/RoomManager.tsx
const joinRoom = async (roomId: string) => {
	if (roomId.length == 0) {
		return;
	}
	if (!(await roomManager.roomExists(roomId))) {
		return;
	}
 
	// Go to room page
	navigate(`/room/${roomId}`);
};

Since we know that an empty string will never be a valid room ID, we can add a check to the joinRoom method to avoid unnecessarily calling the roomExists cloudstate method.

src/components/RoomManager.tsx
const joinRoom = async (roomId: string) => {
	if (roomId.length == 0) {
		return;
	}
	if (!(await roomManager.roomExists(roomId))) {
		return;
	}
 
	// Go to room page
	navigate(`/room/${roomId}`);
};

Important Note: Freestyle’s zero-latency local development server (via npx freestyle dev) and TypeScript integration make cloud calls feel seamless. However, in production, each cloudstate method invocation is an actual network request. Optimize your code to minimize these calls, just as you would with any API—as we’ve done here.

To let the player know when a room doesn’t exist, we can present a clean toast notification with error styling using the sonner NPM package, which we’ve already installed and imported.

src/components/RoomManager.tsx
import { useState } from 'react';
import { Toaster, toast } from 'sonner';
if (!(await roomManager.roomExists(roomId))) {
	toast.error(`Room "${roomId}" doesn't exist`, {
		duration: 2000,
	});
	return;
}

Try joining a room that doesn’t exist. You should now see an error toast notification animate into the bottom right of the screen.

alt

Now let’s test joining a room that we know exists. Try submitting the room ID you set aside earlier. For me, that’s 7d8eac6d-b689-48ee-bc56-f66b2ff87d85.

alt

You should see the URL change to /room/<your-room-id>. If you see the (unimplemented) game screen, you’ve successfully joined the room!

Notice we didn’t need to use fetch or Axios to make HTTP requests to the server even once! Freestyle turned our RoomManagerCS class into a cloud-stateful class, so we could call its createRoom and roomExists methods directly from the frontend.

alt

Long gone are the old days of context switching between languages with an awkward REST API communication layer in between. We’re building a full-stack application in TypeScript, from the database to the frontend.

Let’s move on to the game screen, where we’ll implement the infinite crafting gameplay.

Gameplay

In our starter project, we’ve already set up dynamic routing to handle URLs with the pathname /room/<room-id>. In Astro, this is done by creating a src/pages/room/[roomId].astro file, where [roomId] is a dynamic parameter. The roomId parameter is conveniently available in Astro’s params object.

Loading the Game component

Let’s take a look at the game screen in src/pages/room/[roomId].astro. Currently, the game screen has a navigation bar at the top and footer at the bottom. What’s missing is obviously the game itself.

We will display the game in the center of the screen, below the navigation bar. The game and its logic is in a React component, which we’ll import from src/components/Game.

The Game component takes two props:

  • roomId—the ID of the room the player is in.
  • nouns—the list of nouns the room has crafted so far.

For now we’ll hardcode the nouns prop to EmojiNoun.STARTING_NOUNS.

src/pages/room/[roomId].astro
---
import Layout from '../../layouts/Layout.astro';
import Game from '../../components/Game';
import { EmojiNoun } from '../../cloudstate/noun';
---
 
<Layout title="Multiplayer Infinite Craft - Freestyle Demo">
	<main class="flex justify-center text-white">
		<div class="flex h-screen w-screen flex-col items-center justify-between">
			<div class="flex flex-col items-center">
				<div class="relative mt-8 w-screen px-6">...</div>
				<Game client:load roomId="" nouns={EmojiNoun.STARTING_NOUNS} />
			</div>
			// ...
		</div>
	</main>
</Layout>
// ...

Astro Newcomers: The client:load directive tells Astro, our meta-framework, to load the React Game component on the client side. Without this directive, Astro by default renders UI components on the server side with just HTML and CSS, meaning no interactivity. This default behavior is great for SEO and performance, but we want our game to be interactive, so we load the Game component with JavaScript on the client side.

Save your code and navigate to http://localhost:8910/room/any-room-id.

alt

Note: You could replace any-room-id with any string you like, as long as it’s not empty, since we aren’t yet validating room IDs.

It looks like our hardcoded list of nouns is rendering correctly as chips, with some guiding text above. But we’re not able to interact with the game just yet.

Let’s connect the Game component to the cloudstate data for the room.

Getting initially stored nouns

All TypeScript written in .astro files runs on the server side (once compiled to JavaScript), which is typically far more performant than running on the client side. So, our approach is to quickly retrieve the initial list of nouns from the cloudstate server on the server side, and pass it to the Game component as a prop. That explains why we’re not just passing the roomId prop to the Game component, but also the nouns prop.

We’ll get the room ID from the URL by accessing Astro.params.roomId (Astro is not an import, but a global object provided by Astro). We’ll then use the useCloud hook to get the RoomCS instance and call the getNouns method on it.

src/pages/room/[roomId].astro
---
import Layout from '../../layouts/Layout.astro';
import Game from '../../components/Game';
import { useCloud } from 'freestyle-sh';
import { RoomCS } from '../../cloudstate/room';
import { EmojiNoun } from '../../cloudstate/noun';
 
// Get the room ID from the URL
let roomId = Astro.params.roomId ?? '';
 
const room = useCloud<typeof RoomCS>(roomId);
const roomNouns: EmojiNoun[] = await room.getNouns();
---

We can now pass the roomId and roomNouns props to the Game component.

src/pages/room/[roomId].astro
<Game client:load roomId={roomId} nouns={roomNouns} />

If you’re still at http://localhost:8910/room/any-room-id, you should see a cloudstate error meesage, because there is no instance of RoomCS by the ID of any-room-id.

alt

Let’s make sure invalid room IDs are handled gracefully. We’ll check the room exists before proceeding with fetching the nouns, by calling the roomExists method on the RoomManagerCS instance.

src/pages/room/[roomId].astro
---
import Layout from '../../layouts/Layout.astro';
import Game from '../../components/Game';
import { useCloud } from 'freestyle-sh';
import { RoomCS } from '../../cloudstate/room';
import { RoomManagerCS } from '../../cloudstate/roomManager';
import { EmojiNoun } from '../../cloudstate/noun';
 
// Get the room ID from the URL
let roomId = Astro.params.roomId ?? '';
 
const roomManager = useCloud<typeof RoomManagerCS>(RoomManagerCS.id);
if (!(await roomManager.roomExists(roomId))) {
	// Visit index page to create a new room
	return Astro.redirect('/');
}
 
// Continue with fetching the room
const room = useCloud<typeof RoomCS>(roomId);
const roomNouns: EmojiNoun[] = await room.getNouns();
---

Now, if a player navigates to a room ID that doesn’t exist, they’ll be redirected to the room manager screen at the index page. We’re now fully done with the initial game loading.

Let’s move on to adding cloudstate functionality in the Game component to get the list of nouns.

Fetching nouns in the Game component

In the starter code, the Game component takes in the initial game state (the roomID and the list of nouns) as props. We then pass those same props to the ChipList component, which will render the list of nouns as chips.

src/components/Game.tsx
export default function Game(props: InitialState) {
	return <ChipList {...props} />;
}
 
function ChipList(props: InitialState) {
	const nouns = props.nouns;
	// ...
}

Unlike previous direct uses of the useCloud hook for cloudstate classes, we’ll want to use TanStack Query for managing game state.

TanStack Query Newcomers: TanStack Query is a data-fetching library for React that provides a set of hooks for fetching, caching, and updating data. Freestyle works seamlessly with TanStack Query, so you can use it to fetch data from your cloudstate classes.

To use TanStack Query, we need to first wrap the ChipList component in a QueryClientProvider.

src/components/Game.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ...
export default function Game(props: InitialState) {
	const queryClient = new QueryClient();
	return (
		<QueryClientProvider client={queryClient}>
			<ChipList {...props} />
		</QueryClientProvider>
	);
}

Next, we’ll use the useQuery hook to fetch the list of nouns from the cloudstate server on demand with the refetch function, which we’ll give a custom name of refetchNouns. We’ll store the data in the nouns variable.

src/components/Game.tsx
import { useCloud } from 'freestyle-sh';
import { RoomCS } from '../cloudstate/room';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// ...
function ChipList(props: InitialState) {
	const room = useCloud<typeof RoomCS>(props.roomId);
 
	const nouns = props.nouns;
	const { data: nouns, refetch: refetchNouns } = useQuery({
		queryKey: [props.roomId, 'getNouns'],
		queryFn: () => room.getNouns(),
		initialData: props.nouns,
	});
	// ...
}

The queryKey is an array that uniquely identifies the query. In this case, we’re using the room ID and the string 'getNouns'. The queryFn is a function that fetches the data, in this case calling the getNouns method on the RoomCS instance. The initialData is the initial data to use while the query is fetching.

If you create or join a room, you should see the same initial list of nouns as before, because the list of nouns in the RoomCS class is initalized with EmojiNoun.STARTING_NOUNS. Notably, data is now being fetched from the cloudstate server!

alt

We’re now ready to implement the core gameplay logic: combining two nouns to create a new noun.

Crafting nouns

In the original Infinite Craft game, players combine two nouns to create a new noun. For example, combining “Fire” and “Water” might create “Steam”.

Rules

Here are the rules for combining nouns in our game:

  1. Combinations are deterministic

    • Example: If A + B = C, then A + B will always equal C.
    • Consequence: The same two nouns will always combine to the same noun, across all rooms. We’ll want to cache generated nouns by the pair of nouns that created them.
  2. Combinations are commutative

    • Example: If A + B = C, then B + A = C.
    • Consequence: The order in which nouns are combined doesn’t matter. When caching each generated noun, we should set the key as an alphabetically sorted pair of the nouns that created it.
  3. Two distinct pairs of nouns can result in the same output noun

    • Example: A + B = C, X + Y = C.
    • Consequence: The same noun can be generated by combining two different pairs of nouns. Just because a noun combination hasn’t been cached doesn’t mean the generated noun hasn’t been discovered before; the same noun may have been generated in a different room via a different combination.
  4. Combinations can result in one of the original nouns

    • Example: A + B = A.
    • Consequence: Combining two nouns can result in one of the original nouns.
  5. Nouns can combine with themselves

    • Example: A + A = B.
    • Consequence: A noun can combine with itself to create a new noun.

Noun management

Before we write any crafting logic, we’ll want to set up a caching system for generated nouns, according to the above rules.

Let’s create a new cloudstate class, NounManagerCS, in src/cloudstate/noun-manager.ts.

src/cloudstate/noun-manager.ts
import type { EmojiNoun } from './noun';
 
export class NounManagerCS {
	comboKeysMap: Map<string, EmojiNoun> = new Map();
	addKeyAndNoun(comboKey: string, noun: EmojiNoun) {
		this.comboKeysMap.set(comboKey, noun);
	}
	didTryCombo(comboKey: string): boolean {
		return this.comboKeysMap.has(comboKey);
	}
	getNoun(comboKey: string): EmojiNoun {
		return this.comboKeysMap.get(comboKey)!;
	}
}

The comboKeysMap property is a map that stores the generated nouns by the key of the pair of nouns that created them. The addKeyAndNoun method adds a new key-value pair to the map. The didTryCombo method checks if a noun combination has been tried before. The getNoun method retrieves the noun for a given key (with a forced non-null assertion that assumes the key exists in the map).

Note: If you look at the EmojiNoun class in src/cloudstate/noun.ts, you’ll see we’ve already written a createKey method to ensure consistent and commutative keying of noun pairs. We’ll use this later in the crafting logic in RoomCS.

src/cloudstate/noun.ts
static createKey(a: EmojiNoun, b: EmojiNoun): string {
	// Order nouns alphabetically, for consistent keying
	if (a.text > b.text) {
		[a, b] = [b, a];
	}
	return `${a.text};${b.text}`;
}

Unfortunately, getNoun currently returns the same instance of the noun, which makes it vulnerable to mutation across all rooms that try the same combination and call getNoun. To prevent this, we’ll need to clone the noun before returning it. We can do this by spreading the noun’s properties into a new object.

src/cloudstate/noun-manager.ts
getNoun(comboKey: string): EmojiNoun {
	return this.comboKeysMap.get(comboKey)!;
	return { ...this.comboKeysMap.get(comboKey)! };
}

The NounManagerCS class is still one final method for checking if a noun has been discovered before, regardless of the combination that created it. We’ll add a hasNoun method that checks if a noun appears as a value in the map at least once (remember, the same noun can be generated by different combinations).

src/cloudstate/noun-manager.ts
hasNoun(noun: EmojiNoun): boolean {
	if (this.comboKeysMap.size === 0) {
		return false;
	}
	return Array.from(this.comboKeysMap.values()).some(n => n.text === noun.text);
}

We’re almost set with the NounManagerCS class. We just need to make it cloud-stateful as a singleton, like we did with the RoomManagerCS class, so that all rooms can access the same noun cache.

src/cloudstate/noun-manager.ts
import { cloudstate } from 'freestyle-sh';
import type { EmojiNoun } from './noun';
 
@cloudstate
export class NounManagerCS {
	static id = 'noun-manager' as const;
 
	comboKeysMap: Map<string, EmojiNoun> = new Map();
	addKeyAndNoun(comboKey: string, noun: EmojiNoun) {
		this.comboKeysMap.set(comboKey, noun);
	}
	didTryCombo(comboKey: string): boolean {
		return this.comboKeysMap.has(comboKey);
	}
	getNoun(comboKey: string): EmojiNoun {
		// Return a copy to prevent mutation
		return { ...this.comboKeysMap.get(comboKey)! };
	}
	hasNoun(noun: EmojiNoun): boolean {
		if (this.comboKeysMap.size === 0) {
			return false;
		}
		return Array.from(this.comboKeysMap.values()).some((n) => n.text === noun.text);
	}
}

Perfect! We’re now ready to implement the noun crafting logic in the RoomCS class.

RPC method for crafting nouns

In the RoomCS class, we’ll add a craftNoun RPC method that combines two nouns to create a new noun. Inside the method, we’ll check if the combination has been tried before, and if not, we’ll generate a new noun and cache it in the NounManagerCS instance.

Since we want to access the methods of NounManagerCS from another cloudstate class, RoomCS, we’ll use the useLocal hook. This hook is similar to the useCloud hook, but it’s used to access cloudstate instances from within other cloudstate instances.

src/cloudstate/room.ts
import { cloudstate, useLocal } from 'freestyle-sh';
import { EmojiNoun } from './noun';
import { NounManagerCS } from './nounManager';
 
@cloudstate
export class RoomCS {
	id: string;
	constructor(id: string) {
		this.id = id;
	}
 
	nouns: EmojiNoun[] = EmojiNoun.STARTING_NOUNS;
	getNouns(): EmojiNoun[] {
		return this.nouns;
	}
	craftNoun(a: EmojiNoun, b: EmojiNoun) {
		let outputNoun: EmojiNoun;
		let isNewToRoom: boolean;
 
		const comboKey = EmojiNoun.createKey(a, b);
		const nounManager = useLocal(NounManagerCS);
	}
}

Important Note: We’re using the useLocal hook instead of useCloud because we’re already within a cloudstate context. useLocal allows us to access and modify cloudstate data without making additional network requests. It also ensures that any changes we make are part of the same transaction as the parent method, maintaining data consistency.

The first condition we’ll check is if the combination has been tried before. If it has, we’ll retrieve the cached noun from the NounManagerCS singleton, and because the noun is cached we know it’s not a discovery. If the combination hasn’t been tried before, we’ll generate a new noun, determine if it’s a discovery to all rooms, and cache it in the NounManagerCS singleton.

src/cloudstate/room.ts
craftNoun(a: EmojiNoun, b: EmojiNoun) {
		let outputNoun: EmojiNoun;
		let isNewToRoom: boolean;
 
		const comboKey = EmojiNoun.createKey(a, b);
		const nounManager = useLocal(NounManagerCS);
 
		if (nounManager.didTryCombo(comboKey)) {
			// Retrieve cached noun
			outputNoun = nounManager.getNoun(comboKey);
			outputNoun.discovered = false;
		} else {
			// Generate a new noun
			outputNoun = RoomCS._generateNoun(comboKey);
 
			// Check if noun is a discovery to all rooms
			outputNoun.discovered = !nounManager.hasNoun(outputNoun);
 
			// Add noun to global cache
			nounManager.addKeyAndNoun(comboKey, outputNoun);
		}
	}
	static _generateNoun(comboKey: string): EmojiNoun {
		// TODO: Generate a new noun from the combination
		return new EmojiNoun();
	}

When implemented, the _generateNoun method will generate a new noun from the combination of two nouns passed as a key. The method is static because it doesn’t need access to the instance properties of RoomCS, and the underscore prefix lets Freestyle know that the method should not be exposed as an RPC to the client.

But first, let’s finish the craftNoun method. We’ll add the logic to update the room’s list of nouns with the new noun, only if it’s new to the room represented by the RoomCS instance.

src/cloudstate/room.ts
	// Add noun to global cache
	nounManager.addKeyAndNoun(comboKey, outputNoun);
}
 
// Check if noun is new to room
isNewToRoom = !this.nouns.some((noun) => noun.text === outputNoun.text);
if (isNewToRoom) {
	// Add new noun to room
	this.nouns.push(outputNoun);
}

Finally, we’ll return the noun that was either generated or retrieved from the globally cached nouns. We’ll extend the EmojiNoun class with an isNewToRoom property that lets the client know, without recomputing what’s already been computed on the server, whether the noun is new to the room.

Let’s create the new EmojiNoun response payload class, EmojiNounRes, at the bottom of src/cloudstate/noun.ts.

src/cloudstate/noun.ts
export class EmojiNounRes extends EmojiNoun {
	isNewToRoom: boolean = false;
}

Back in the RoomCS class, we can now return an instance of EmojiNounRes from the craftNoun method. We’ll type the method as returning EmojiNounRes and set the isNewToRoom property based on the isNewToRoom variable.

src/cloudstate/room.ts
import { cloudstate, useLocal } from 'freestyle-sh';
import { EmojiNoun, EmojiNounRes } from './noun';
import { NounManagerCS } from './nounManager';
	craftNoun(a: EmojiNoun, b: EmojiNoun): EmojiNounRes {
// Check if noun is new to room
isNewToRoom = !this.nouns.some((noun) => noun.text === outputNoun.text);
if (isNewToRoom) {
	// Add new noun to room
	this.nouns.push(outputNoun);
}
 
// Response payload
return { ...outputNoun, isNewToRoom: isNewToRoom };

Onto the _generateNoun method, after a quick overview of our generative AI approach.

Generative AI for crafting nouns

For crafting nouns that are not cached, we’ll use generative AI, technically known as Large Language Models (LLMs).

Prompt Engineering Newcomers: If you’re new to LLMs, you can think of them as advanced text completion models. Simply, they’re sophisticated functions that take text as input and output the predicted best completion of that text.

Imagine your friend starts “the quick brown fox jumps over the”. You would be thrown off if they continued with “moon”. It’s not impossible, just unlikely. The primary task of LLMs is to output the most probable completion of the inputted text, with variance set by a parameter called temperature, which gives less probable completions more of a chance as it is dialed up. If you want the LLM to be deterministically stuck with the most likely completion, “lazy dog”, you set temperature to 0. If you want to allow less likely completions like “moon”, you need to increase the variance and thus set temperature closer to 1.

So far, we input some text, known as the user prompt, and the LLM best completes it. But in most cases, we don’t simply want the model to complete a sentence. Should the text completion be creative or succinct? Should it focus on a specific topic? In our game, we want the completion to be in valid JSON format with same properties each time. We put such expectations into the system prompt.

The process of writing a system prompt that best guides the LLM to output the desired completion given an inputted user prompt is known as prompt engineering. We can think of prompt engineering as a function f(x) -> y, where f is the system prompt, x is the user prompt, and y is the generated text output (hence the term ‘generative AI’). Closer to reality, we have an LLM trying to predict the best text completion of the combined system prompt and user prompt text.

The system prompt

If you look in the prompts folder, you’ll see we’ve already set up a prompt that instructs an LLM to generate a new noun.

prompts/prompts.ts
export default class Prompts {
	static GENERATE_NEW_NOUN: string = `
You are a highly creative and witty noun combiner.
Given two inputted nouns, you will output all possible new nouns that creatively and logically combine the two inputted nouns. Every noun option MUST be one to three words separated by a single space. An inputted noun can be an option, if it is a logical combination of the two inputted nouns. Duplicates are not allowed: NEVER output a noun that is semantically equal but lexically different from an inputted noun.
Available categories: natural thing, animal, appliance, product, brand, occupation, famous icon, color, food, music, sport, place, landmark, concept, language, movie, book, etc.
 
# Examples
Earth;Water
{"obvious_choice":{"text":"Plant","emoji":"🌱"},"witty_choice":{"text":"Mud","emoji":"💩"}}
Engineer;Money
{"obvious_choice":{"text":"Entrepreneur","emoji":"💼"},"witty_choice":{"text":"Bill Gates","emoji":"💸"}}
Mars;Steam
{"obvious_choice":{"text":"Olympus Mons","emoji":"🌋"},"witty_choice":{"text":"Life","emoji":"🌍"}}
Ash;Tree
{"obvious_choice":{"text":"Pencil","emoji":"✏️"},"witty_choice":{"text":"Paper","emoji":"📄"}}
Mud;Water
{"obvious_choice":{"text":"Pig","emoji":"🐷"},"witty_choice":{"text":"Mudbath","emoji":"🛀"}}
Computer;Plant
{"obvious_choice":{"text":"Renewable Energy","emoji":"🌞"},"witty_choice":{"text":"Apple","emoji":"🍏"}}
iPhone;Steam
{"obvious_choice":{"text":"iCloud","emoji":"☁️"},"witty_choice":{"text":"iSteam","emoji":"🚿"}}
Rain;Rainbow
{"obvious_choice":{"text":"Color","emoji":"🎨"},"witty_choice":{"text":"Hope","emoji":"✨"}}
 
You must output just the JSONL.
`.trim();
}

Note to Prompt Engineers: This prompt is by no means perfect, but in our testing it makes for good gameplay. If you’re up for a challenge, try creating a shorter prompt that achieves similar results while cutting down on input token cost (e.g., more concise instructions, fewer examples).

We’re using an effective prompt engineering technique known as few-shot prompting: we provide a small but diverse set of input-output example pairs that demonstrate the desired behavior of the LLM. Here, few-shot prompting complements the explicit instructions on how to generate new nouns.

Let’s look at the first example.

Input
Earth;Water
Output
{
	"obvious_choice": { "text": "Plant", "emoji": "🌱" },
	"witty_choice": { "text": "Mud", "emoji": "💩" }
}

Input

The input will be the key of the pair of nouns to combine. We’ve already written the createKey method in the EmojiNoun class to ensure consistent and commutative keying of noun pairs.

Remember, if nouns A + B = C, then B + A = C.

src/cloudstate/noun.ts
static createKey(a: EmojiNoun, b: EmojiNoun): string {
	// Order nouns alphabetically, for consistent keying
	if (a.text > b.text) {
		[a, b] = [b, a];
	}
	return `${a.text};${b.text}`;
}

Output

The output will be a JSON object with two properties: obvious_choice and witty_choice. The obvious_choice property will contain the most logical noun to generate, while the witty_choice property will contain a more creative noun.

Calling Anthropic’s API

In our _generateNoun method, we’ll call Anthropic’s API to generate a new noun from the combination of two inputted nouns, using Anthropic’s official NPM package. We’re passing in the prompt we just looked at as the system prompt and the key of the pair of nouns comboKey as the user prompt.

Then, we’ll randomly select between the obvious and witty choice, with a bias towards the obvious choice. This bias ensures that the majority of nouns discovered are purely logical, while keeping the game interesting with the occasional witty noun.

src/cloudstate/room.ts
static _generateNoun(comboKey: string): EmojiNoun {
	// TODO: Generate a new noun from the combination
	// Prompt Anthropic for noun choices
	const nounChoicesMsg = await new Anthropic().messages.create({
		model: 'claude-3-haiku-20240307',
		max_tokens: 200,
		temperature: 0.5,
		system: Prompts.GENERATE_NEW_NOUN,
		messages: [{'role': 'user','content': [{'type': 'text','text': comboKey}]}],
	});
 
	// Randomly choose between obvious and witty noun
	const nounChoices = EmojiNounChoices.fromJson(JSON.parse(getFirstText(nounChoicesMsg)));
	const noun = Math.random() < EmojiNounChoices.WITTY_THRESHOLD ? nounChoices.obvious : nounChoices.witty;
 
	// Ensure a single emoji
	noun.emoji = getFirstEmoji(noun.emoji);
 
	return new EmojiNoun();
	return noun;
}

Since Anthropic’s create function is asynchronous, we’ll need to make the _generateNoun method asynchronous and return a Promise of the generated noun.

src/cloudstate/room.ts
static async _generateNoun(comboKey: string): Promise<EmojiNoun> {

As a consequence, we’ll need to make the craftNoun method asynchronous as well, since it calls the _generateNoun method.

src/cloudstate/room.ts
async craftNoun(a: EmojiNoun, b: EmojiNoun): Promise<EmojiNounRes> {
// Generate a new noun
outputNoun = await RoomCS._generateNoun(comboKey);

And that’s it! We’ve implemented the cloudstate logic for crafting nouns using generative AI and caching. Using Freestyle, it was super easy to integrate generative AI without exposing our system prompt to the client.

Let’s make our game interactive by making calls to the craftNoun method on the client side.

Crafting nouns in the Game component

In the Game component, we’ll use the useMutation hook from TanStack Query to call the craftNoun method on the RoomCS instance. We’ll pass in the two nouns to combine as arguments to the mutation function.

We’ll also provide an onSuccess callback that refetches the list of nouns from the cloudstate server after a successful mutation, and an onError callback that logs any server side errors to the console.

src/components/Game.tsx
import { QueryClient, QueryClientProvider, useQuery, useMutation } from '@tanstack/react-query';
const { data: nouns, refetch: refetchNouns } = useQuery({
	queryKey: [props.roomId, 'getNouns'],
	queryFn: () => room.getNouns(),
	initialData: props.nouns,
});
const { mutate: craftNoun } = useMutation({
	mutationFn: ({ a, b }: { a: EmojiNoun; b: EmojiNoun }) => room.craftNoun(a, b),
	onSuccess: () => refetchNouns(),
	onError: (error) => console.error(error),
});

So that the player can interact with the game, we’ll keep a list of selected nouns in the ChipList component. Once two nouns are selected, we’ll call the craftNoun mutation function with the two selected nouns.

src/components/Game.tsx
import { useRef, useState } from 'react';
const [selectedIdxs, setSelectedIdxs] = useState<number[]>([]);
const selectChip = (idx: number) => {
	const newSelectedIdxs = [...selectedIdxs, idx];
	if (newSelectedIdxs.length === 2) {
		// Two chips selected: craft noun
		const [a, b] = newSelectedIdxs.map((selectedIdx) => nouns[selectedIdx]);
		craftNoun({ a, b });
	}
	setSelectedIdxs(newSelectedIdxs);
};

Note: In React, stateful variables don’t update as soon as their setters are called. We want read the length and values of the selected nouns array directly after updating it, but cannot rely on setSelectedIdxs to update selectedIdxs synchronously. Therefore, we store the new array state in a temporary newSelectedIdxs variable.

<Chip
	text={`${noun.emoji} ${noun.text}`}
	isStarred={noun.discovered}
	isSelected={selectedIdxs.includes(idx)}
	onClick={() => selectChip(idx)}
/>

Once the mutation is successful, we’ll not only refetch the list of nouns as we’ve set up, but also clear the selectedIdxs to allow the player to select two new nouns.

src/components/Game.tsx
const { mutate: craftNoun } = useMutation({
	mutationFn: ({ a, b }: { a: EmojiNoun; b: EmojiNoun }) => room.craftNoun(a, b),
	onSuccess: () => refetchNouns(),
	onSuccess: () => {
		// Reset selected chips
		setSelectedIdxs([]);
 
		// Refetch nouns
		refetchNouns();
	},
	onError: (error) => console.error(error),
});

Let’s test the game by selecting any two nouns and combining them.

alt

Success! Nouns that are new to the room are correctly added to the list of nouns.

No luck? If you’re having trouble with generating nouns, make sure you’ve stored a valid Anthropic API key as an environment variable, as outlined in the Setup section.

Alternatively, you can set your API key directly in the Anthropic constructor in the _generateNoun method in src/cloudstate/room.ts, but remember to remove it from your code before pushing to a public repository.

alt

src/cloudstate/room.ts
const nounChoicesMsg = await new Anthropic({apiKey: '<YOUR_API_KEY>' }).messages.create({

Testing the noun crafting logic

Here are some possible tests you can run to ensure the crafting logic is working as expected:

  • If you try the same combination twice, the cached noun is retrieved instead of generating a new one, so the noun list should remain unchanged.
  • Try the same combination in a different order to test the commutative nature of the noun combinations.
  • Green chip backgrounds indicate that this room discovered the noun; if you only play in one room, then all generated nouns should be green. Try playing the same combination in different rooms to test the discovery logic.

Final UX Improvements

While the game is now fully functional with rooms that support single and multiplayer gameplay, we can polish the user experience (UX) in three notable ways.

  1. Pending crafting state
    • Disable all chips to prevent the player from selecting new nouns.
    • Indicate that a noun is being crafted.
  2. Animate prevent adding the same noun twice
    • Shake the chip.
    • Turn the chip red.
  3. Improved multiplayer gameplay
    • Generate a memorable room ID for each room, so players can easily share their room with friends.
    • Automatically refresh the list of nouns in the room every second, to keep the game visually in sync across all players.

Pending crafting state

When a player selects two nouns to combine, we should disable all chips to prevent the player from selecting new nouns.

We’ll add an isCrafting variable to the TanStack Query useMutation hook, which will be set to true when the mutation is in progress. While isCrafting is true, we’ll (1) disable all chips and (2) display some text that says “Crafting…” below the chip list.

src/components/Game.tsx
const { mutate: craftNoun, isPending: isCrafting } = useMutation({
	<div className="chip-list mx-6 my-4">
		<TransitionGroup>
			{nouns.map((noun, idx) => (
				<CSSTransition key={idx} timeout={500} classNames="chip-container">
					<Chip
						text={`${noun.emoji} ${noun.text}`}
						isStarred={noun.discovered}
						isSelected={selectedIdxs.includes(idx)}
						disabled={isCrafting}
						onClick={() => selectChip(idx)}
					/>
				</CSSTransition>
			))}
		</TransitionGroup>
	</div>
	{isCrafting && <div className="text-md mt-8 text-center text-gray-300">Crafting...</div>}
	<Toaster />;
</div>

alt

Animate prevent adding the same noun twice

When a player tries to add a noun that already exists in the room, our cloudstate logic prevents the noun from being added. We should make this clear to the player by shaking the chip of the noun that already exists and turning it red.

Let’s add a shakingIdx state variable to the Game component to store the index of the chip that should shake.

src/components/Game.tsx
const [shakingIdx, setShakingIdx] = useState<number | null>(null);
const [selectedIdxs, setSelectedIdxs] = useState<number[]>([]);

We’ll set this variable when the mutation succeeds and returns a response payload with the isNewToRoom property set to false. We’ll also set a timeout to reset the shakingIdx after 500ms.

src/components/Game.tsx
import { EmojiNoun, EmojiNounRes } from '../cloudstate/noun';
onSuccess: (res: EmojiNounRes) => {
	// Reset selected chips
	setSelectedIdxs([]);
 
	if (!res.isNewToRoom) {
		// Noun already exists: shake existing chip
		const chipIdx = nouns.findIndex(noun => noun.text === res.text)
		setShakingIdx(chipIdx);
		setTimeout(() => setShakingIdx(null), 500);
	}
 
	// Refetch nouns
	refetchNouns();
},

Finally, we’ll set the isShaking prop on the Chip component to shake the chip when the shakingIdx matches the current index.

src/components/Game.tsx
<Chip
	text={`${noun.emoji} ${noun.text}`}
	isStarred={noun.discovered}
	isSelected={selectedIdxs.includes(idx)}
	disabled={isCrafting}
	isShaking={shakingIdx === idx}
	onClick={() => selectChip(idx)}
/>

Try adding the same noun twice to see the chip shake and turn red.

alt

Note: In case you’re wondering how the isShaking prop is working: we simply add the shake CSS class to the chip when isShaking is true, which triggers the shake CSS animation as defined in src/styles/app.css.

Improved multiplayer gameplay

Memorable room IDs

Currently, the room ID is far from memorable, making it difficult and uninviting for players to share their room with friends. We’ll generate a memorable room ID for each room, using the friendly-username-generator NPM package.

Over in the RoomManagerCS class, we’ll add a private _generateRoomId method that generates a memorable room ID. Unlike the current crypto.randomUUID() function, it is also far more likely that our shorter word-based room IDs are already taken, so we’ll need to check if the generated room ID is unique.

src/cloudstate/room-manager.ts
import { cloudstate } from 'freestyle-sh';
import { RoomCS } from './room';
import { generateUsername } from 'friendly-username-generator';
createRoom(): string {
	const roomId = this._generateRoomId();
	const room = new RoomCS(roomId);
	this.roomsMap.set(roomId, room);
	return roomId;
}
_generateRoomId(): string {
	let roomId;
	do {
		roomId = generateUsername({useHyphen: true, useRandomNumber: false});
	} while (this.roomExists(roomId));
	return roomId;
}

Create a new room and you should see a more memorable room ID, such as “fearless-lapwing” in the example below. That’s undeniably easier to remember and share with friends!

alt

Automatic noun list refresh

Currently, the list of nouns in the room is only refreshed when the player crafts a new noun. To keep the game visually in sync across all players, we’ll automatically refresh the list of nouns in the room every second.

This is every easy to implement with TanStack Query. We just need to set the refetchInterval option in the useQuery hook to 1000 ms (1 second).

src/components/Game.tsx
const { data: nouns, refetch: refetchNouns } = useQuery({
	queryKey: [props.roomId, 'getNouns'],
	queryFn: () => room.getNouns(),
	initialData: props.nouns,
	refetchInterval: 1000,
});

If you open the same room in two different windows, you should see that adding a noun in one window automatically updates the list of nouns in the other window, due to the automatic refetching.

alt

… and that’s a wrap! 🎉

You can find the completed tutorial code at https://github.com/kevgug/multiplayer-infinite-craft.

Deploying to the Cloud

Now that we’ve built a fully functional multiplayer version of Infinite Craft, it’s time to share it with the world. We’ll deploy our game to the cloud using Freestyle, the web development platform that allows you to code your cloud fluently in TypeScript.

Bringing environment variables onto the server

Before hosting our game, we need to ensure that the environment variables we’re using in our project find their way onto the server. Currently, the Anthropic API key is only stored on your local machine, which your server won’t have access to.

Importantly, developers should never write out secret keys in their codebase, because once pushed to a public git repository they’re no longer, well, secret. Instead, we use a nifty package called dotenv to load environment variables from a .env file, which is by default ignored by git.

Create the following .env file in the root of your project with your Anthropic API key.

.env
ANTHROPIC_API_KEY=<YOUR_ANTHROPIC_API_KEY>

Can’t find your API key? On macOS, open your terminal and run echo $ANTHROPIC_API_KEY. On Windows, open your command prompt and run echo %ANTHROPIC_API_KEY%.

Finally, in your freestyle.config.ts file, import the dotenv package and call config to load the environment variables from the .env file. This way, the environment variables will be available on the server.

freestyle.config.ts
import { defineConfig } from 'freestyle-sh';
import dotenv from 'dotenv';
 
dotenv.config();
 
export default defineConfig({
	deploy: {
		cloudstate: {
			envVars: {
				ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
			},
		},
	},
});

Freestyle Hosting

With the environment variables set up, we’re ready to host our project. The Freestyle CLI makes it easy to deploy your project to the cloud with just a few commands.

  1. Login
npx freestyle login
  1. Build
npx freestyle build
  1. Deploy
npx freestyle deploy

Note: Freestyle’s CLI will let you decide your own subdomain for the freestyle.dev domain, where your game will be hosted.

Easy as that—your game is now live! 🚀

We’ve hosted our own version of the game at infinitecraft.freestyle.dev. Try getting your friends to join the same room and play the game together, in either our or your own hosted version!

Conclusion

You’ve just created a multiplayer version of Infinite Craft, and more importantly, experienced a new approach to web development that doesn’t force you to be multilingual in your own project.

With Astro, React, and Freestyle, you’ve accomplished three key things:

  1. Built a full-stack application entirely in TypeScript.
  2. Implemented cloud-based data persistence without configuring a database.
  3. Integrated generative AI that runs server side for dynamic content generation.

This project demonstrates the power of staying in one language throughout the development process. You’ve seen how quickly ideas can become reality when you’re not constantly context-switching between languages.

You would push back against a publisher demanding you write half of your book in Latin. Now we can do the same in software development using Freestyle. The future of web development is here, and it speaks fluent TypeScript, from frontend to backend and everything in between.

Ad astra! We’re excited to see what you’ll build next, now that you can express your entire web application in TypeScript.

Credits: Thanks to Neal Agarwal for creating the original game and inspiring this tutorial.

Get Updates on our Unified TypeScript Stack

Subscribe to the latest product updates, articles, and tutorials from Freestyle, a YC-backed company. Be among the first to build full-stack in just TypeScript.