Learn Hathora and Peasy-UI by building Pong

Posted 2022-07-12

Introduction to the tutorial

This tutorial was penned to provide a simple example of how the Hathora framework can be utilized to quickly create a multiplayer backend game server. With that, this tutorial showcases Peasy-UI framework to create a custom UI for the clients to connect to that server. The game being created for this example is a simple Pong game. This tutorial uses a state machine, simple physics and collision detection/resolution, and game state. The game state will be bound to UI components via Peasy-UI. Pong was selected as the game due to its tight scoping for scale, however, we get to exercise some great game dev concepts even with this limited scope that can be applied to larger scoped projects!

Table of Contents

End Objective

Back To Table of Contents

The end state for this tutorial is a deployed client on Netlify that runs the client code that connects to the Hathora backend server. It takes two players who can join the game, and each player has three lives. The ball bounces around until it leaves the screen on either side, and that player loses a life each time. When all the lives are gone, the game ends.

Tools

Hathora

Back To Table of Contents

Hathora is a multiplayer networking framework that manages much of the low-level duties that otherwise would have to be managed. Use cases for Hathora include turn-based games, real-time multiplayer games, and social applications such as chatting. Hathora manages the networking and remote procedure calls, provides a prototyping UI client, and provides a full API that abstracts away much of the low-level work.

Peasy-UI

Peasy-UI is a UI data binding Library. Peasy UI provides uncomplicated UI bindings for HTML via string templating. It’s intended to be used in vanilla JavaScript/Typescript projects where its power is in its simplicity and adding a complete SPA framework is complete overkill or simply not desired. Thanks to the small scope of the library, performance is snappy.

Development Environment

I developed this project and tutorial on a Windows 10 system, utilizing VS code, and executing all terminal commands in the node PowerShell terminal embedded in VS code. There maybe be nuanced differences between developing on a Mac or Linux system. Also, if using different shell applications or a different IDE, there may be subtle differences to take note of. Also, my source code is done in Typescript.

Assumptions on my part

Back To Table of Contents

With this tuturial, I have made a few assumptions. I am assuming you have SOME exposure to Javascript and/or Typescript. At no point do I explain my code, this isn’t a coding tutorial, it is an application tutorial. I explain the why/what I’m doing from an application perspective, but not explaining why I chose the method or function call that I did.

I assume that you have some inkling about networked games and clients and servers. We do not dive into the depths of those topics, as we do not need to, as our toolset does a TON of that work for us. For example, I understand enough about client/servers, networking, and websockets to be considered dangerous. Yet here I am, penning a tutuorial for multiplayer games. What a world we live in.

Probably most important assumption. I assume that you are using the tutorial source code in my GitHub as a reference. This tutuorial reviews most of the code, but specifically highlights of the code. There are support variables, utility functions, and constants that haven’t been covered in detail, but it will be all in the tutorial files.

GitHub Repo

I assume that these are all of my assumptions, with that, let’s dive into it.

Hathora Backend

Where to find:

GitHub

There is a comprehensive readme file that helps get you started, which this tutorial essentially holds your hand through.

Documentation

Back To Table of Contents

The API documentation for Hathora can be found here, which is a very nice, continually updated, site that outlines everything you need to know about using this framework.

Discord

The Hathora discord server gives you access to the team directly. This has been critical for me and my journey with Hathora, as the team has proven very open to ideas, very responsive to any issues encountered, and overall is a great group of individuals that I’ve enjoyed interacting with. I get updates on new features and guidance on any API breaking changes here as well, which is a plus.

Workflow

This is the true beginning of the tutorial, and we will start with the backend server using Hathora first.

Project Setup

Empty Project

Initial Project Folder

Back To Table of Contents

First, let’s start with a blank project. I will be using VS code editor for this tutorial. We will create a new folder; I am calling mine Pong HathoraPeasy. This tutorial also assumes you have node.js installed. If you don’t, you can go to https://nodejs.org/ and download and install node.js. Open the terminal window in the editor, CTRL + J, will work as the shortcut. Here I will type:

npm install -g hathora

This will install the Hathora NPM package from the internet. After that installation is complete, we will create a new file, hathora.yml, in the root of our new project folder. One thing we should verify here is that hathora has installed correctly.

Enter into the PowerShell terminal:

hathora --version

Should spit out whichever version was latest greatest. If an error comes up regarding hathora not being:

'hathora' is not recognized as an internal or external command,
operable program or batch file.

means that it is possible that the -g flag for installing hathora was left off. Hathora has to be installed globally for the Command Line Interface commands to work.

YML

Back To Table of Contents

The hathora.yml is a critically important file. A “yaml” file is a human-readable data-serialization language. Its used for configuration files similar as to a more well known, XML file is.

Before Hathora, I didn’t know what a YML file was, so… this was all new to me. We must outline out what our YML file needs to look like before we ask Hathora to build the project structure. Hathora parses the YML file, and builds the backend server template, the prototype UI, and provides the end path for your custom front end client when it builds the project. There are several sections that we need to cover in hathora.yml: types, methods, auth, userState, and error.

These sections will have to be addressed prior to asking Hathora to parse it.

Beginning YML file

I am going to link directly to the Hathora docs on YML, as they outline each section specifically on what it does and how it should be addressed.

Hathora Docs - YML

So… given that information, I’m outlining that our hathora.yml will be defined initially as follows:

types:
  GameStates:
    - Idle
    - PlayersJoining
    - WaitingToStartGame
    - WaitingToStartRound
    - InProgress
    - GameOver
  Vector:
    x: float
    y: float
  Ball:
    position: Vector
    velocity: Vector
    radius: int
    isColliding: boolean
  Player:
    id: UserId
    lives: int
    position: Vector
    size: Vector
    velocity: Vector
    isColliding: boolean
  PlayerState:
    player1position: Vector
    player2position: Vector
    ballposition: Vector
    player1Lives: int
    player2Lives: int

methods:
  updatePlayerVelocity:
    velocity: Vector
  startRound:
  joinGame:
  startGame:

auth:
  anonymous: {}

userState: PlayerState

error: string

tick: 50

Let’s step through the ‘why’ on this. Under types, were outlining several types that we want the server to manage: Vector, Ball, Player, Game States,and Player State.

GameStates is a type that lets us use a simple state condition to track our progress through the server, we will use this to create ‘guard’ conditions so we can ensure random procedure calls only are listened to at the right time

Vector is a type that will have an (x,y) as integers being tracked. This will be used for the Ball entity type that we’ve outlined, as a ball type will have a vector signifying its position and velocity.

Also on the Ball type, we define a radius integer, which will be used for collision detection. The position and velocity are used for managing the movement of the ball, and there is a flag isColliding for collision detection.

Player type will outline all the characteristics of each player that connects, including an id, the number of lives remaining, and the position of the players paddle on the screen. Also velocity for each player will be managed as well as the isColliding flag

The Player State type is important, as we separate the collective state that the server monitors from what data is broadcast to each client on change. So, each client will understand and be able to monitor changes in the balls’ entities, and the players’ entities. When the Server state data changes, the data gets remapped into Player State prior to being pushed to clients. The reason we seperate the overall Server state from the Player state is that there ususually is data that the Server monitors for state changes that we don’t want each client to see. A good example is a game of Poker; the server needs to monitor ALL the players hands, but only needs to broadcast to each client their respective hand data. This took me awhile to understand, but there’s two aspects to this, we should ONLY send he minimum amount of data to the client that it needs to operate, and we shouldn’t burden the client with data that should be hidden, and then expect the client code to do the sorting.

There are four methods we’re defining for this, updatePlayerVelocity, joinGame, startRound and startGame.

These will generate remote procedure calls for the clients to execute and communicate events to the server.

We are setting our authentication to anonymous for this tutorial, and we are defining a tick event that will run every 50 milliseconds.

Generating Hathora Project

Let’s try generating our Hathora project off this YAML. In the PowerShell terminal, enter:

hathora init

Now your project in the explorer should look a bit like this:

Hathora Project Structure

As you can see, Hathora has generated our project structure for us. It includes all the API libraries automatically, as well as created our server directories and our client directories. A .gitignore is also initially included so you can create a repo at this time if you would like. For your reference, mine is at

IMPL.ts

Back To Table of Contents

Now we’re ready to start looking at our server backend code. Under the server directory you’ll find you implementation file, impl.ts.

Impl.ts

This is the main code that is used for your server. You can import and include other modules here too, if you want to break up your code, which we will do for this tutorial. Also generated is the prototype test client which we will look at next.

Prototype Test Client

I’m now going to introduce you to the prototype UI client tool which is provided by Hathora. We can use this tool to quickly iterate over the server logic in the impl.ts file. This section will show the UI and its interface but know that we will be using it to fill out the server code.

:warning: Important note on Prototype Test Client, it is crafted in React, which creates one limitation to consider for your hathora.yml file. All Types that will need to be rendered in the Test Client will have to start with a capital letter. Please… learn from my mistakes.

Player:
  name: string

not

player:
  name: string

From the PowerShell terminal, type:

hathora dev

When you run this command, Hathora will install a couple dependencies onto your system, and will use Vite utiltiy to run the test client.

Congratulations, you have a client/server setup running! Yay!

This will launch the prototype UI tool, built into Hathora, which allows you to quickly mockup your server methods. It also launches the server so that the prototype UI can connect to it and test it.

The client UI is running at http://localhost:3000/. You may need to open your default browser and navigate to this URL directly. It is automatically connecting to your server running out of VS code, and will look like this:

ProtoType UI

You will see the authentication login button has anonymous set in the text, this is because of our auth setting in the hathora.yml file.

If you click the login button, you will find something like this:

UI Logged In

You will notice in the top right, that Hathora, as an anonymous login, will assign you a random user ID, in this case its miniature-violet-tahr. This changes every time you login, anonymously. The UI gives you a few options here, you can create a new game instance, join and existing game, or use Hathora’s built in matchmaking functionality, which we will not get into during this tutorial.

Click the Create New button. The prototype UI client should now look like this:

UI Logged In

You get a view of the client state that’s pushed down to each client, and a button that gives you access to the defined methods from hathora.yml. Clicking the ‘methods’ button will expand a list of available remote procedure calls, ‘methods’ that you can send to the server, with the necessary data fields:

UI Game Created

This is how you’re going to rapidly create your server logic and test it iteratively

IMPORTANT NOTE: if you are updating your logic, you’ll have to close the server, save your updates, and restart it so it compiles your changes. I recommend using

hathora start

To compile and run any updates….

Just as an example, you can put a console.log() in your impl.ts file and recompile your server. My recommendation is to stick the console.log in the updatePlayerVelocity method.

console.log(`UpdatePlayerVelocity has been clicked with ${request.velocity.x},${request.velocity.y} , passed by ${userId}`);

And then click the updatePlayerVelocity button with some data in it and see what happens in your server console.

console shot

This demonstrates how these methods get called, and how you have access to the data objects being passed from each client.

NOTE: after you start a game instance, the url will append the game instance id onto the end of your URL. If you recompile or restart your server, you will get a connection error in this manner. Either hit your back button, or just restart a new browser connection to http://localhost/3000.

Filling in the rest of the server logic

Back To Table of Contents

Back into the impl.ts file, if you review the Impl class that’s created by Hathora, you can see there are pre-generated methods available, some of which, we specified in our hathora.yml file. The others are autogenerated and we will discuss each now.

First, allow me to cover the variables and constants that the code below will need for this to be successful.

const screenHeight = 400;
const screenWidth = 600;
const firstPlayerX = 15;
const secondPlayerX = 575;
const ballSpeed = 100;
let ballspeedAdjustment = 0;
const paddlespeed = 20;
let vollies = 0;

:warning: And another reminder, that this tutorial does not cover the project line by line, so please reference the GitHub repo for the working source code, the impl.ts and index.ts code for both the server and client are fully completed and fully commented in the repo. Also, you will find that the impl.ts file imports a helper.ts file, which is provided in the GitHub repo, and the client index.ts file uses styles.css, which is provide in the repo as well.

GitHub Repo

InternalState:

Back To Table of Contents

type InternalState = {
  Players: Player[];
  Balls: Ball[];
  gameState: GameStates;
};

This designates the object that the server will monitor for changes. This doesn’t have to be what is sent to each client on changes, however. As you will see, we are going to modify a couple things here. Lets change it from PlayerState to our own defined object. Later on, this will allow us to remap the InternalState to the PlayerState prior to pushing to clients.

Initialize: autogenerated

Back To Table of Contents

initialize(ctx: Context, request: IInitializeRequest): InternalState {
        return {
            Players: [],
            Balls: [],
            gameState: GameStates.PlayersJoining,
        };
    }

The initialize function is the first function called when the createGame method is ran from the client. We will simply return the default condition of the InternalState that we defined previously. In other applications, you may want to setup some default values here for your game, but we have nothing too difficult to manage here.

joinGame:

Back To Table of Contents

joinGame(state: InternalState, userId: string, ctx: Context, request: IJoinGameRequest): Response {
        if (state.gameState != GameStates.PlayersJoining) return Response.error('Cannot allow players to join');
        if (state.Players.length >= 2) return Response.error('This game has maximum amount of players');
        let startingposition: number;

        if (state.Players.length == 1) startingposition = secondPlayerX;
        else startingposition = firstPlayerX;

        state.Players.push({
            id: userId,
            lives: 3,
            velocity: { x: 0, y: 0 },
            position: { x: startingposition, y: 0 },
            size: { x: 10, y: 48 },
            isColliding: false,
        });

        if (state.Players.length == 2) {
            state.gameState = GameStates.WaitingToStartGame;
            ctx.broadcastEvent('P2');
        } else if (state.Players.length == 1) {
            ctx.broadcastEvent('P1');
        }
        return Response.ok();
    }

The joinGame method primary job is to add the Player object to the array of state.Players array. Depending on the length of the array, you position it on the left or right. Also, if the 2nd player is joined, we modify game state to be ready to start the game.

startGame:

Back To Table of Contents

startGame(state: InternalState, userId: string, ctx: Context, request: IStartGameRequest): Response {
    if (state.Players.length != 2) return Response.error('Invalid number of players');
    if (state.gameState != GameStates.WaitingToStartGame) return Response.error('Not ready to start game');

    //create first ball
    const startPosition = { x: state.Players[0].position.x + 12, y: state.Players[0].position.y + 12 };
    state.Balls.push({
        position: startPosition,
        velocity: { x: 0, y: 0 },
        radius: 15,
        isColliding: false,
    });

    //update Gamestate
    state.gameState = GameStates.WaitingToStartRound;
    ctx.broadcastEvent('Ball');
    return Response.ok();
}

The startGame method primary function is to modify the game state after we add a ball into the game. The ball is automatically added next to the left player’s paddle.

startRound:

Back To Table of Contents

startRound(state: InternalState, userId: string, ctx: Context, request: IStartRoundRequest): Response {
    //gaurd conditions
    if (state.gameState != GameStates.WaitingToStartRound) return Response.error('Cannot start round');

    //set starting angle, by which side your on
    //if left side, angle will be between
    let startingAngle: number;
    if (state.Balls[0].position.x < 300) startingAngle = ctx.chance.integer({ min: -75, max: 75 });
    else startingAngle = ctx.chance.integer({ min: 115, max: 255 });
    let magnitude: number = ballSpeed;

    let xComponent = magnitude * Math.cos(toRads(startingAngle));
    let yComponent = magnitude * Math.sin(toRads(startingAngle));
    state.Balls[0].velocity = { x: xComponent, y: yComponent };
    state.gameState = GameStates.InProgress;
    return Response.ok();
}

The startRound method primary job is to set the initial velocity of the ball off the left player’s paddle, and to modify the game state to an active state.

updatePlayerPosition:

Back To Table of Contents

updatePlayerVelocity(state: InternalState, userId: string, ctx: Context, request: IUpdatePlayerVelocityRequest): Response {
    if (state.gameState != GameStates.InProgress && state.gameState != GameStates.WaitingToStartRound && state.gameState != GameStates.WaitingToStartGame) return Response.error('Cannot update velocity');

    let pIndex = 0;
    if (state.Players[1]) {
        if (userId == state.Players[1].id) pIndex = 1;
    }

    state.Players[pIndex].velocity = request.velocity;
    return Response.ok();
}

the updatePlayerPosition method will take the velocity vector from the client and update the global state.

getUserState:

Back To Table of Contents

getUserState(state: InternalState, userId: UserId): PlayerState {
    let clientState: PlayerState = {
        player1position: state.Players[0] ? state.Players[0].position : { x: 15, y: 10 },
        player2position: state.Players[1] ? state.Players[1].position : { x: 575, y: 10 },
        ballposition: state.Balls[0] ? state.Balls[0].position : { x: 25, y: 25 },
        player1Lives: state.Players[0] ? state.Players[0].lives : 3,
        player2Lives: state.Players[1] ? state.Players[1].lives : 3,
    };

    return clientState;
}

getUserState is an automatically generated method from Hathora, that allows the server to modify the returned state to the client based off the userID…. For example, in a card game where you don’t want each client to know the card values of all the players, you can filter the provided client state so that you don’t expose that data to each client. This is how we will re-map player state, take note of using the ternary operator to set a default for undefined data:

onTick:

Back To Table of Contents

The onTick method will be the engine that runs the game. We have it set to a 50ms tick routine that runs when the game is started. It has several jobs in this game, and we will break them down.

Updating player paddle position
//player movement
for (const player of state.Players) {
  //check for players being at 'top' and 'bottom of screen
  const hittingTop = player.position.y < 0;
  const hittingBottom = player.position.y + player.size.y >= screenHeight;

  const pixelsToMove = paddlespeed * timeDelta;
  if (!hittingTop && player.velocity.y < 0) {
    player.position.y += player.velocity.y * pixelsToMove;
  } else if (!hittingBottom && player.velocity.y > 0) {
    player.position.y += player.velocity.y * pixelsToMove;
  }
  if (state.gameState == GameStates.WaitingToStartRound) {
    if (state.Balls[0].position.x < 300) {
      //left player
      if (player.id == state.Players[0].id) state.Balls[0].position.y += state.Players[0].velocity.y * pixelsToMove;
    } else {
      //right player
      if (player.id == state.Players[1].id) state.Balls[0].position.y += state.Players[1].velocity.y * pixelsToMove;
    }
  }
}

Based on the velocity vectors provided by each client, we will move the player paddles up or down, unless they hit the top/bottom of the screen. Also, as a note, if we are waiting to start the next round, the ball will move up/down with the paddles.

Ball physics:

Back To Table of Contents

Ball movement:

Based on the ball velocity vector, we reposition the ball for each tick…

//set each ball movement
for (const ball of state.Balls) {
  if (ball.velocity.x >= 0) ball.position.x += (ball.velocity.x + ballspeedAdjustment) * timeDelta;
  else ball.position.x += (ball.velocity.x - ballspeedAdjustment) * timeDelta;
  if (ball.velocity.y >= 0) ball.position.y += (ball.velocity.y + ballspeedAdjustment) * timeDelta;
  else ball.position.y += (ball.velocity.y - ballspeedAdjustment) * timeDelta;
}
Ball collision with player:

Back To Table of Contents

This block of code runs a helper routine that detects overlap between balls and player paddles, and set’s each entity’s isColliding property. If a ball is hitting, we change its velocity to send it back the other direction. The helper routine changeVelocity takes care of that.

//check for collisions with players
detectCollisions(state);

for (const player of state.Players) {
  if (player.isColliding) {
    vollies += 1;
    for (const ball of state.Balls) {
      if (ball.isColliding) {
        //depending on player, change balls velocity accordingly
        changeVelocity(ball, player);
      }
    }
  }
}
Ball collisions with top/bottom of screen:

Back To Table of Contents

This Block performs similar evaluation, checks for collision with top and bottom of screen, and depending on that changes the velocity of the ball to send it the opposite direction.

//check for balls being at 'top' and 'bottom of screen
for (const ball of state.Balls) {
  const hittingTop = ball.position.y <= 0;
  const hittingBottom = ball.position.y + ball.radius >= screenHeight;
  if (hittingTop) {
    //updateVelocity
    vollies += 1;
    changeVelocity(ball, "top");
  } else if (hittingBottom) {
    //updateVelocity
    vollies += 1;
    changeVelocity(ball, "bottom");
  }
}
Ball collisions with left/right of screen:

Back To Table of Contents

This Block performs similar evaluation, checks for collision with left and right of screen, and depending on that repositions the ball back on the player paddle and resets the round. Also, lives data is updated.

//check for ball leaving screen on left/right
for (const ball of state.Balls) {
  const hittingLeft = ball.position.x <= 0;
  const hittingRight = ball.position.x + ball.radius >= screenWidth;
  if (hittingLeft) {
    //player left decrement lives
    state.Players[0].lives -= 1;
    //if lives 0, game over
    if (state.Players[0].lives == 0) {
      ctx.broadcastEvent("Game Over");
      state.gameState = GameStates.GameOver;
    } else resetGame(state, "left");
  } else if (hittingRight) {
    //player right decrement lives
    state.Players[1].lives -= 1;
    if (state.Players[1].lives == 0) {
      ctx.broadcastEvent("Game Over");
      state.gameState = GameStates.GameOver;
    } else resetGame(state, "right");
  }
}

Final look at prototype UI When you open two clients, login, joinGame, then startGame, you will see this:

Proto UI Complete

As you can see in both client instances, they are separately logged in as two different id’s. But you can see both users in the ‘state’ data that is being pushed down to each client.

If the RPC (remote procedure call) is fired off to start the round, you will start seeing the onTick method updating the ball position, and the velocity will update if the ball collides with a wall or paddle. If the ball hits the edges of the screen, the position of the ball will be reset, and the game state will change.

Custom UI

Back To Table of Contents

So… now in theory, our backend server code is done. And we can exercise it with our prototype UI code, but what if we want to create our own custom front end UI that does this? Let’s start looking into that now.

Peasy-UI Front End

Why Peasy-UI?

I chose to use Peasy-UI for a few reasons. I liked that its pure Typescript, and that I can bundle it down to an html file and a JS file. Easy to deploy, as there are no complicated build steps required to generate it, as we are simply using the webpack bundler. It also doesn’t have as much overhead as say Angular, React, or other SPA frameworks.

Also, it provides a really clean way of abstracting UI code from the business logic and creates a bit of separation in the codebase.

Where to find

GitHub https://github.com/peasy-ui/peasy-ui
NPM: https://www.npmjs.com/package/peasy-ui

Data Bindings

Back To Table of Contents

The data bindings work through the string templating code. We will provide the library a string template of the HTML directly and embed that template with ‘bindings’ that will be parsed out and monitored for change of state.

For Peasy-UI to work, you must pass the library a template and a data model object, or ‘state’. The template will make references to the state object passed, and that is what you modify to update your UI. A very quick example to outline this:

const template = `
    <input type="radio" ${'red' ==> color} ${change @=> changedColor}> Red
    <input type="radio" ${'green' ==> color} ${change @=> changedColor}> Green
    `;
const model = {
    color: 'red';
    changedColor: (event, model) => alert(`Changed color to ${model.color}.`),
};

The template string being used has two input radio buttons. You can see a data binding on the color property of the model object, and a binding on the onChange event of the radio button.

In this binding patter, the radio button sends the radio button value attribute of either ‘red’ or ‘green’ to the color property when active. When the onChange event is fired, you see the method changedColor() is ran.

This is just a small slice of the binding patters. Here is an outline of the available patterns from the Peasy-UI readme.

${attr <== prop}    Binding from model property to element attribute
${attr <=| prop}    One-time binding from model property to element attribute
${attr ==> prop}    Binding from element attribute to model property
${attr <=> prop}    Two-way binding between element attribute and model property

${prop}             Binding from model property to attribute or text
${|prop}            One-time binding from model property to attribute or text

${event @=> method} Event binding from element attribute to model method

${'value' ==> prop} Binding from element to model property, used to bind values of radio buttons and select inputs to a model property

${ ==> prop}        One-time binding that stores the element in model property

${ === prop}        Binding that renders the element if model property is true
${ !== prop}        Binding that renders the element if model property is false

${alias <=* list}   Binding from model list property to view template alias for each item in the list

UI.update()

Back To Table of Contents

The primary method that is required to trigger the UI engine to conduct comparisons on your model data object, and to push updates to the UI binding templates is UI.update().

There are a couple ways to implement this. You can force a UI.update() after an event, like a button press, this will be an immediate UI update.

Also, for games that use a RequestAnimationFrame method, you can place the UI.update() inside that, and can be especially effective with a Fixed Step Engine pattern implemented in your RAF recursive call.

For the sake of this demo, I simply have placed it inside a setInterval method. That way i don’t have to even think about managing the UI.

intervalID = setInterval(() => {
  UI.update();
}, 1000 / 60);
UI.destroy()

This isn’t needed for this tutorial, but if you are routing to different UI models, like mimicing a SPA application that has routing, you have to unload your UI model before switching.

This can simply be done by calling UI.destroy(). Here i use it in context of leaving one scene and transitioning to another. I run ui.destroy(), then null out UI, then stop my interval from updating UI.update().

leaving() {
    ui.destroy();
    ui = null;
    clearInterval(this.intervalID);
}

Custom UI overview

Back To Table of Contents

To simplify this as much as possible, I will create simple template.html file that renders all my index.ts code.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Pong: hathora & peasy-UI</title>
  </head>
  <body>
    <div id="myApp"></div>
  </body>
</html>

I will have a login button, a create Game button and connect to Game button, and then the playing field will be available with buttons to allow for joining the game, starting the game, and keyboard bindings for updating velocity, and spacebar for starting the round. Kind of something like this:

Client custom UI

Tutorial Workflow

Back To Table of Contents

Creating custom UI project in Hathora framework

Step one in creating a custom UI for Hathora is to create the web directory under the Hathora client folder.

Client custom UI

This folder will house the entire client project, so we can treat that as our new root directory for our client. I use a .bat script to setup new projects, its available for you to use as well. It can be found at:

and I just place the webpack starter file (webpackstarter.bat) in the web directory and run it from the node PowerShell terminal. Make sure you change your directory appropriately.

Again, this is an optional path I use as a shortcut, you are welcome to create your client project as you see fit, I don’t believe it should matter with regards to the tutorial. After I run the bat file and set it to T for typescript, my directory looks like this.

client web project file structure

The batch file will automatically start up the dev server to start running the client. We need to make sure we update our tsconfig.json and webpack.config.js files.

tsconfig.json:

{
    "compilerOptions": {
        "outDir": "./build/",
        "sourceMap": true,
        "noImplicitAny": false,
        "module": "es6",
        "target": "es2020",
        "jsx": "react",
        "allowJs": true,
        "moduleResolution": "node",
        "allowSyntheticDefaultImports": true
    }
}

webpack.config.json

const path = require("path");
const mode = process.env.NODE_ENV == "production" ? "production" : "development";
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");

module.exports = {
  entry: {
    index: path.resolve(__dirname, "./src/index.ts"),
  },
  output: {
    path: path.resolve(__dirname, "./build"),
    filename: "[name].bundle.js",
  },
  mode: mode,
  module: {
    rules: [
      {
        test: /.css$/,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /.(?:ico|gif|png|jpg|jpeg)$/i,
        type: "asset/resource",
      },
      {
        test: /.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
  devtool: "inline-source-map",
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
  },
  devtool: "inline-source-map",
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "./src/template.html"),
    }),
    new webpack.EnvironmentPlugin({
      COORDINATOR_HOST: "coordinator.hathora.dev",
      MATCHMAKER_HOST: "matchmaker.hathora.com",
    }),
  ],
};

Installing and importing Peasy-UI

Back To Table of Contents

Cancel out of the dev server running (Ctrl+C), and let’s install Peasy-UI.

npm i peasy-ui

Let’s import Peasy-UI, open index.ts file in your client project.

import { UI, UIView } from "peasy-ui";

Creating string template and data model

Back To Table of Contents

Now that we have access to UI, and UIView, we can define our string template and model object.

const template = `
        <div>
          <div class="instructions">Pong <span ${===showID}> -> Game ID: ${gameID}</span> <span ${===showUser}> -> User: ${username}</span></div>
          
          <div class="flex small_width">
            <button id="btnLogin" class="button" ${click@=>login} ${disabled <== loginButtonDisable}>Login</button>
          </div>

          <div class="flex startLeft large_width">
            <button id="btnCreateGame" class="button" ${click@=>create} ${disabled <== createButtonDisable}>Create Game</button>
            <button id="btnConnectGame" class="button" ${click@=>connect} ${disabled <== connectButtonDisable}>Connect Game</button>
            <label for="gameJoinID">Game ID</label>
            <input id="gameJoinID" type="text" ${value <=> gameID}></input>
            <button id="btnCopy" class="button" ${click@=>copy} }>Copy</button>
          </div>

          <div class="flex startLeft large_width">
            <button id="btnJoinGame" class="button" ${click@=>join} ${disabled <== joinButtonDisable}>Join Game</button>
            <button id="btnStartGame"  class="button" ${click@=>start} ${disabled <== startButtonDisable}>Start Game</button>
          </div>

          <div class="instructions">Up/Down arrows move paddle, spacebar launches ball</div>

          <div id='playArea' class="gameArea">
            <div class="p1score" ${ === player1Joined} >P1: Lives: ${p1Lives}</div>
            <div class="p2score" ${ === player2Joined}>P2: Lives: ${p2Lives}</div>
            <div id="p1" ${ === player1Joined} class="p1" style="transform: translate(${player1pos.x}px,${player1pos.y}px)"></div>
            <div id="p2" ${ === player2Joined} class="p2" style="transform: translate(${player2pos.x}px,${player2pos.y}px)"></div>
            <div id="ball" ${ === ballvisible} class="ball" style="transform: translate(${ball.x}px,${ball.y}px)"></div>
          </div>
        </div>
      `;

The string template will create several sections of the HTML elements, very simplistic, and I pass the HTML straight to the UI.create() method, with the parent element, and the data model, which for now is empty, but that will change. The myApp parameter is defined and captured by using getElementByID for the parent div in the template HTML.

Peasy-UI executes its state and compare feature by calling UI.update() method. To have this run in the background and monitor and react to changes in state, we will create a Interval to call UI.update() periodically.

Creating data bindings

Back To Table of Contents

If you’re running a dev server, and using the styles.css file provided in the GitHub repo, the client should be looking like the screenshot shown earlier at the beginning of this section: So our data binding to do list included:

  • Data Fields in the main title div, two for rendering, and two for the data
`<div class="instructions">Pong <span ${===showID}> -> Game ID: ${gameID}</span> <span ${===showUser}> -> User: ${username}</span></div>`;
  • Login button, click event binding, and one for the disabled property for the button
`<button id="btnLogin" class="button" ${click@=>login} ${disabled <== loginButtonDisable}>Login</button>`;
  • Create Game and Connect game buttons, one binding each for the click event and one binding each for the disabled property
`<button id="btnCreateGame" class="button" ${click@=>create} ${disabled <== createButtonDisable}>Create Game</button>
<button id="btnConnectGame" class="button" ${click@=>connect} ${disabled <== connectButtonDisable}>Connect Game</button>`;
  • The Game ID field data will have a data binding, and the Copy button will have an click event binding
`<input id="gameJoinID" type="text" ${value <=> gameID}></input>
<button id="btnCopy" class="button" ${click@=>copy} }>Copy</button>`;
  • The Join Game and Start Game buttons will both have an click event binding and their disabled properties tied to bindings
`<div class="flex startLeft large_width">
<button id="btnJoinGame" class="button" ${click@=>join} ${disabled <== joinButtonDisable}>Join Game</button>
<button id="btnStartGame"  class="button" ${click@=>start} ${disabled <== startButtonDisable}>Start Game</button>
</div>`;
  • The Game area will have several bindings itself:
`<div id='playArea' class="gameArea">
<div class="p1score" ${ === player1Joined} >P1: Lives: ${p1Lives}</div>
<div class="p2score" ${ === player2Joined}>P2: Lives: ${p2Lives}</div>
<div id="p1" ${ === player1Joined} class="p1" style="transform: translate(${player1pos.x}px,${player1pos.y}px)"></div>
<div id="p2" ${ === player2Joined} class="p2" style="transform: translate(${player2pos.x}px,${player2pos.y}px)"></div>
<div id="ball" ${ === ballvisible} class="ball" style="transform: translate(${ball.x}px,${ball.y}px)"></div>
</div>`;
  • The fields for how many lives each player possess, will have a rendering binding and the data field will have a binding, 1 for each player
  • The player paddles will each have their rendering bindings, and their CSS transform data field bindings
  • The ball will have its own rendering binding, and its CSS transform data binding

The different bindings are represented by these patterns:

Event bindings: ${event @=> method}

Data bindings: ${data}

Rendering bindings: ${===property}

One-way attribute binding: ${disabled<==getDisabledButton}

Two-way attribute binding: ${value<=>data}

Let’s add the rest of the event bindings, we can update the code logic when ready.

const model = {
    login: () => {},
    create: () => {},
    connect: () => {},
    join: () => {},
    start: () => {},
    copy: () => {},

We now have our events mocked up. Once we connect the client to the Hathora server, we’ll fill in the events will the remote procedure calls. We can add the remaining data bindings now. Let’s update the data model object:

    title: '',
    gameID: '',
    username: '',
    player1pos: { x: 15, y: 10 },
    player2pos: { x: 575, y: 10 },
    ball: { x: 25, y: 25 },
    p1Lives: 3,
    p2Lives: 3,
    get loginButtonDisable() {
        return this.username.length > 0;
    },
    get showID() {
        return this.gameID.length > 0;
    },
    get showUser() {
        return this.username.length > 0;
    },
    createButtonDisable: true,
    connectButtonDisable: true,
    joinButtonDisable: true,
    startButtonDisable: true,
    player1Joined: false,
    player2Joined: false,
    ballvisible: false,
};

Connecting the client

Back To Table of Contents

Now our data bindings are complete, and now we can connect Hathora to our client.

Connecting the custom client to the server

This section is going to import the Hathora Client module, as well as fill in the logic for the Login, Create Game, and Connect Game buttons.

Let’s import the Hathora Client code into our index.ts file.

import { HathoraClient, HathoraConnection, UpdateArgs } from "../../.hathora/client";

This will give us access to the methods needed to connect our client. Let’s prepare our necessary app scoped variables :

/**********************************************************
 * Hathora Client variables
 *********************************************************/
const client = new HathoraClient();
let token: string;
let user: AnonymousUserData;
let myConnection: HathoraConnection;
let updateState = () => {};

This gives us the key items that are required for connecting to the back-end server. Take note of the updateState method, as we have it empty for now, we will fill it in later.

This first thing our code will do is look to see if there is a session token saved locally, in session storage. If not, we create a new authorization token. This will be a part of our Login method for the button, so let’s fill that out down in the data model that we created earlier:

login: async (event, model) => {
    if (sessionStorage.getItem('token') === null) {
        sessionStorage.setItem('token', await client.loginAnonymous());
    }
    token = sessionStorage.getItem('token');
    user = HathoraClient.getUserFromToken(token);
    model.username = user.name;
    model.createButtonDisable = false;
    model.connectButtonDisable = false;
},

So after a token is established with the loginAnanymous() method, we can retrieve our user data from that token. We can set our model.username to our new ID, and the UI will automatically update, thanks Peasy!!!!

So now that we’re logged in, let’s work on creating a new game instance, or joining an existing one.

    create: async (event, model) => {
        model.gameID = await client.create(token, {});
        model.title = model.gameID;
        history.pushState({}, '', `/${model.gameID}`);
        myConnection = await client.connect(token, model.gameID);

        myConnection.onUpdate(updateState);
        myConnection.onError(console.error);
        //manage UI access
        model.joinButtonDisable = false;
        model.createButtonDisable = true;
        model.connectButtonDisable = true;
    },
    connect: async (event, model) => {
        myConnection = await client.connect(token, model.gameID);

        model.title = `-> Game ID: ${model.gameID}`;
        history.pushState({}, '', `/${model.gameID}`);
        myConnection.onUpdate(updateState);
        myConnection.onError(console.error);
        //manage UI access
        model.joinButtonDisable = false;
        model.createButtonDisable = true;
        model.connectButtonDisable = true;
    },

Each code is similar, the only difference being that the create game method uses the client.create() method, which allows for a gameID to be created, and yes, we tie it into our UI model, and the UI will update automatically. The important final piece of these is the establishment of myConnection, which will be used for remote procedure calls.

Remote Procedure Calls

Back To Table of Contents

Using myConnection, we now have access to the methods that are created on the server, in the impl.ts file. This lets us fill out the two other buttons, Join Game and Start Game.

join: (event, model) => {
    myConnection.joinGame({});
    bindKeyboardEvents();
    //manage UI access
    model.joinButtonDisable = true;
},
start: (event, model) => {
    myConnection.startGame({});
    //manage UI access
    model.startButtonDisable = true;
},

We do have a quality of life function left for copy(), let’s finish that up.

//copies input text to clipboard
copy: () => {
    navigator.clipboard.writeText(model.gameID);
},

We still need to figure out how we are going to access the updatePlayerVelocity() method. Let’s tie some keyboard presses to it.

const bindKeyboardEvents = () => {
  document.addEventListener("keydown", (e) => {
    switch (e.key) {
      case "ArrowUp":
        myConnection.updatePlayerVelocity({ velocity: { x: 0, y: -15 } });
        break;
      case "ArrowDown":
        //ditto
        myConnection.updatePlayerVelocity({ velocity: { x: 0, y: 15 } });
        break;
      case " ":
        myConnection.startRound({});
        break;
      default:
        break;
    }
  });
  document.addEventListener("keyup", (e) => {
    switch (e.key) {
      case "ArrowUp":
        //ditto
        myConnection.updatePlayerVelocity({ velocity: { x: 0, y: 0 } });
        break;
      case "ArrowDown":
        //ditto
        myConnection.updatePlayerVelocity({ velocity: { x: 0, y: 0 } });
        break;

      default:
        break;
    }
  });
};

This method can be called after the joinGame method is called. This connects the last two RPCs from our server into the client. The up and down arrows will update the players paddle velocity. The spacebar will fire off the startRound() method.

Adding logic to the game elements

Back To Table of Contents

Okay, that’s quite a bit of stuff. We have our UI bindings, the HTML framework, the RPC’s, our client is connected to the server, all that’s left is now getting the information OUT of the server so our UI can use it.

This is the client state that the impl.ts file calls out. Remember that updateState() method we defined a long time ago? That’s the connection for how the data is going to come out of the server when the data changes.

Tying in the server data via updateState()

Earlier we created a method call updateState() that we left empty. Now its time to fill it out and review what is going on.

let updateState = (update: UpdateArgs) => {
  //updating state
  model.player1pos = update.state.player1position;
  model.player2pos = update.state.player2position;
  model.ball = update.state.ballposition;
  model.p1Lives = update.state.player1Lives;
  model.p2Lives = update.state.player2Lives;
  //process events
  if (update.events.length) {
    update.events.forEach((event) => {
      switch (event) {
        case "P2":
          model.player2Joined = true;
          model.player1Joined = true;
          model.startButtonDisable = false;
          break;
        case "P1":
          model.player1Joined = true;
          break;
        case "Ball":
          model.ballvisible = true;
          model.startButtonDisable = true;
          break;
        case "Game Over":
          model.ballvisible = false;
          model.player2Joined = false;
          model.player1Joined = false;
          alert("Game Over");
          break;
      }
    });
  }
};

This routine runs whenever the server state changes, and that creates a remapping of the client state on the server side, and that state gets pushed, or broadcasted, out to each connected client.

It arrives via UpdateArgs parameter, called update, in this routine. There are two aspects of the update object that we are going to leverage, state and events.

Update.state is the client state being pushed from the server, and we simply take the important properties of that object and store it into our data model. The act of doing this will force Peasy-UI to respond to any changes in the data and update our UI automatically.

Update.events is the Hathora event system, and this array holds a list of the events that have been fired off from the server. We have four events outlined in our server code, Players joining, P1 and P2, the Ball being ready to display, and Game Over.

Wrapping up the project

We’ve reviewed about 96% of the code, please refer to the project repo for the miscellaneous lines of code not reviewed, as well as the helper functions that are imported. This completes the project at a local level. Finally, let’s talk deployment.

Deployment

Back To Table of Contents

So how do we push our local project out to the world for others to see? There are many, many different paths to take.

For this tutorial, I am using Netlify to push the front end out into the world, and I am self-hosting my server on a dedicated machine. However, there are cloud-based options for deploying the backend.

Building

For the front end, I simply changed directory in /client/web/ in my project, and ran the webpack build:

npm run build

This creates a /build/ directory under our web client. There will be an html file and a bundled JavaScript file. These files can be pushed to a GitHub repo project now. We will be using our GitHub accounts to push our projects to a webhosting service, Netlify.

Many of these service providers create plugins for their tools to easily connect to GitHub repo’s. A special note for this is that the server data is being built into the client.ts and base.ts files from where you are building. Steps should be taken to make sure the appID called out for the coordinator matches the server target.

.gitignore

Back To Table of Contents

#.hathora
/server/.hathora/*

/client/.hathora/*
!/client/.hathora/client.ts

node_modules
#dist
!/tutorial/*

.env
/api/*
!/api/base.ts
/data/*
!/data/saves
/client/prototype-ui/*
!/client/prototype-ui/plugins
/client/web/webpackstarter.bat
/client/web/node_modules
!/client/web/build/*

I had to modify the .gitignore file so that the Hathora client dependencies can get pushed to the repo. These files are imported into your index.ts file, so you have to give access to the hosting service.

Netlify - Frontend

Please refer to the expansive amount of Netlify documentation regarding creating an account with their service. There is a free level of service provided with Netlify.

Once your account is created you can create a new site to your account. They have a one-click deploy feature that lets the Netlify build tools clone your GitHub repo, then package it up and launch the site live automatically. There was a little bit of configuration in the build step to be successful.

First, I recommend changing the domain settings to a site name that makes more sense, I used Hathor-peasy-pong.netlify.app.

The final step is setting up the deployment settings. If you’ve tied your GitHub repo to this site, then you will fill out the build settings as such. We have two static files, so there are no necessary build steps to spell out.

Netlify Config

After this is setup, you can go to the deploy page for your site and trigger a deployment. The site should go live after that.

Real life website!

Congratulations, you just made a real-life website with a multiplayer game on it!!!!

Self-Hosted Backend

Back To Table of Contents

To get this running on my dedicated machine at home, I simply recreated the project locally, and ran this command:

hathora build

This command will compile all your server code into a ‘dist’ directory under the server folder. In that folder you will find index.mjs. There are some environmental variables that need set, but once you do that and you can call from the command line.

node .server/dist/index.mjs

I highly recommend reviewing the Hathora Documentation on deploying.

Hathora Deploy Documentation

To run that service Now the service is running on my dedicated machine, and it can connect to the Hathora Coordinator via the internet.

3rd party hosting service

To push to a hosting cloud service like , change your directory back to the project root. Here you’ll be able to run the Hathora build command:

This will run a vite script that bundles and packages up your server into a index.mjs file that’s located at /server/dist/. This file, can be pushed to a hosting service. From the Hathora Docs:

Cloud Hosting

Hathora is positioning themselves to be able to host your backend application as well. You can have an account created with Hathora, and then use the ‘hathora deploy’ command and your application will be automatically pushed to the cloud and running. Please see the Hathora Docs and reach out to the team directly for more information.

Summary

Back To Table of Contents

This was a lot!

Let’s review what we accomplished.

From scratch: - we created a backend, multiplayer server using Hathora - we created a stand alone client using Peasy-UI, that connects to our server - we deployed both into the wild!

Questions, Comments, or Concerns

That’s it for today, I hope you liked the tools, and enjoyed this application of some neat concepts.

I’d love to hear feedback on how I can make this easier to understand. The easiest way to reach me is on the Discord server for which I assist in moderating.

Game Dev Shift My handle is Mookie, and you can give me a holler over there, we have a whole channel for #code-help and everyone there is super friendly and willing to jump in and assist.

At the top of this post, is my twitter handle, you can dm me there as well. We’d love for you to share your game dev creations with us on the discord Server.

Good Luck, and Good Coding.

Justin

About Justin Young (Mookie)

Hi, I'm Justin Young, a self-taught, hobbyist developer who dabbles in JavaScript, TypeScript, React, Godot (GDscript), and Python. I'm from the midwest US, and I spend my free time working on different types of games and learning new technologies. I can be found on Twitter @jyoung424242.