DEV Community

Askarbek Zadauly
Askarbek Zadauly

Posted on

Building Tetris using WebSocket and Svelte Stores

In this article I wanted to share my experience of building a Web Application that uses WebSocket connection instead of HTTP request/response. As a sample Web Application we'll be building Tetris game, because... why not?

You can checkout the full source code right away on GitHub

Key Features

  • Full Duplex. Both the client and the server can send messages at the same time. So when we send a request from frontend to backend we will not wait for a response. Instead we will subscribe to an event (Subject) that will be triggered by backend request (which can be considered as a response).
  • Synchronized Svelte Stores. When backend sends a request to frontend, all we have to do is to update a corresponding Svelte Store. Which in turn will trigger a UI update.

Tech Stack

Starting the uWebSockets (backend) server

Here's how I start our WebSocket server:

import { App, WebSocket } from 'uWebSockets.js';

const SocketServer = App({});

const startServer = () => {
  console.log('starting backend server...');

  SocketServer.ws<{ socket_id: string }>('/tetris', {
    open: () => {
      console.log('opening connection');
    },
    close: () => {
      console.log('closing connection');
    },
    message: (ws: WebSocket<{ socket_id: string }>, message: ArrayBuffer, isBinary: boolean) => {
      console.log('message received')
    },
  }).listen(8080, () => {
    console.log('backend server is running on: ', 8080);
  });
};

startServer();
Enter fullscreen mode Exit fullscreen mode

uWebSockets socket server

uWebSockets is web server written in C++. In our case we'll be using uWebSockets.js which has bindings to NodeJS. It can be used as a usual web server but the main feature is its WebSocket server. It is 10x faster than Socket.IO and 8.5x faster than Fastify. I have to say I haven't benchmarked it myself. I decided to use uWebSockets just because it feels more pure to my taste. For example it doesn't require Express it runs on it's own, also it doesn't have any additional wrappers or helper functions that "babysit" you. You just get a message and you handle it whatever the way you want it. So if you're ok with that approach and you need a faster WebSocket server then you should use uWebSockets.

Messages

Frontend and Backend will be sending each other a data which we'll be calling "messages". A Message is a JSON object like this:

export type TMessage = {
  type: MessageType;
  body: any;
};

export enum MessageType {
  MOVE_LEFT,
  MOVE_RIGHT,
}
Enter fullscreen mode Exit fullscreen mode

Depending on a Message Type our backend will be executing a corresponding handler with parameters supplied in "body" field. The function that executes handler depending on a Message Type we'll call the "MessageBroker" and it can look like this:

import { MessageType } from './message.js';

export const messageBroker = (type: MessageType, body: any) => {
  switch (type) {
    case MessageType.MOVE_LEFT:
      {
        moveLeftHandler(body);
      }
      break;
    case MessageType.MOVE_RIGHT:
      {
        moveRightHandler(body);
      }
      break;
  }
};

export const moveLeftHandler = (params: any) => {
  console.log('moving left');
};

export const moveRightHandler = (params: any) => {
  console.log('moving right');
};
Enter fullscreen mode Exit fullscreen mode

Now we would like to send a request from backend to frontend. In our case we need to send the blocks of the tetris game. If user clicks left or right button we will redraw all blocks by sending an array, like this:

export const blocks: number[][] =[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1],
[0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1],
[1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1],
]
Enter fullscreen mode Exit fullscreen mode

This array will be "synchronized" with a Svelte Store which has same type and will be used in UI to show this:

Image description

here's the UI source:

export const Rows = writable<number[][]>([]);
Enter fullscreen mode Exit fullscreen mode
<div class="flex flex-col items-center border-l border-r border-b border-neutral-500">
  {#each $Rows as row, indexRow}
    <div class="flex items-center border-b border-neutral-200 last:border-b-0">
      {#each row as column, indexColumn}
        <Cell value="{column}" />
      {/each}
    </div>
  {/each}
</div>
Enter fullscreen mode Exit fullscreen mode

In order to understand how all "other stuff" work you're welcome to checkout the full source code.

Here are some main features of the GitHub repo you need to keep in mind.

Monorepo

The project is a monorepo, containing both backend and frontend in "modules" folder.

packages:
  - 'modules/*'
Enter fullscreen mode Exit fullscreen mode

There are some advantages and disadvantages to using monorepo, the reason I used monorepo is that I share Typescript types and Zod shapes (more on this later) across both projects. So I declare all types in backend project and import them in frontend project, that way I have one source for all types.

Validating data using Zod

Zod is a TypeScript-first schema validation with static type inference.
So let's say for example we have this basic type for a User:

type TUser = {
  email: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Now we want to know if the provided email field is a valid email and password field is at least 6 character length. Instead of writing a check function we can use a special library that does it for us. So I've chosen Zod to do that. Here's how:

const userShape = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});

const data = {
  email: 'some@example.com',
  password: '123456',
};

const user = userShape.parse(data);
Enter fullscreen mode Exit fullscreen mode

Now if the data is incorrect the userShape.parse function will throw an exception.
But the best feature of Zod is that it can infer types:

type TUser = z.infer<typeof userShape>
Enter fullscreen mode Exit fullscreen mode

Now TUser is a proper Typescript type.
I'm using Zod validation and infered types for all data that goes between frontend and backend.

Final thoughts

Some of my decisions on building this WebApp might not be the best, I'm aware of that, so consider this article more like a conversation starter rather than a How-To guide.

Cheers!

Top comments (0)