Tic Tac Toe Game

Roman Balitsky
JavaScript developer
Mon Jul 04 2022

In this example we are looking at an online game of Tic Tac Toe for two players.

The game is available for three platforms: Web in a browser, and for iOS and Android as a native app downloadable from their stores. The code is cross platform and works the same on all three platforms.

Links for the app: Android, iOS, Web.

This document is describing the key parts of the platform used in the app, and the full source code with the comments throughout is available at the very bottom of this page.

The app incorporates two workflows: game play and chat. This documentat describes the key game play elements only, while the chat workflow is very similar to that of game play and is commented through in the source code.

The document assumes the reader’s existing knowledge of JavaScript and JSX.

Screens

The interface is structured using JSX made of Chatium cross platform blocks. And the logic is written in JavaScript.

The game has three screens:

  • Title – where the player chooses to start a new game or join an existing.
  • Game – actual screen where the game is happening.
  • Join – screen where the player is joining an existing game.

Screen handlers, Title screen as an example

In Chatium all screen data is prepared on the backend and displayed on the frontend (in the app). There is no logic on the frontend. The data on the backend is prepared in screen handlers.

Each screen handler is registered using the `app.screen` call, where the first parameter is the screen location pattern and the second is handler function.

The screen handler function is expected to return a JSX structure of UI elements.

With all styling and functionality removed, the bare bone Title screen of the game would look like this:

app.screen('', async (ctx, req) => {
  return (
    <screen title="Tic Tac Toe">
      <button>New game</button>
      <button>Join via Game ID</button>
      <text>Privacy policy</text>
      <text>Source code link</text>
    </screen>
  )
})

In the example above, you can seen all the elements of the Title screen. Except in that stripped example the elements look different to the actual Title screen, where they have specific positioning and respond to the user inputs.

The screen handler returns <screen> element as a root element to all other UI elements. There are two buttons: “New game” and “Join via Game ID”. And two text elements: “Privacy policy” and “Source code link”. In the actual screen, the later two are styled as clickable links.

To position the elements properly, the buttons need to move closer to the screen center and the text to the very bottom. To achieve that, we use <box> containers to wrap the controls around:

  1. All of the <screen> content is wrapped in <box> with a style property height set to 100%, to make it take the entirety of the space, from top to bottom. <box> is a Chatium element similar to <div> in HTML.
  2. Then the buttons and the text elements are wrapped in two other <box> containers, practically splitting the screen in two sections.
  3. The top <box> is set to take all of the available space with the style property flex = 1, while the bottom <box> is kept to a minimum size with no flex specified.
  4. The top <box> centers the elements vertically (along the main flex axis) with the style property justifyContent = 'center'.
  5. Both <box> center the elements horizontally (across the main flex axis) with the style property alignItems = 'center'.

Read more about flex in Chatium: https://docs.chatium.com/en/reference/app-ui/style/flex

app.screen('', async (ctx, req) => {
  return (
    <screen title="Tic Tac Toe">
	<box style={{ height: '100%' }}>
        <box
          style={{
            alignItems: 'center',
            justifyContent: 'center',
            flex: 1
          }}
        >
          <button>New game</button>
          <button>Join via Game ID</button>
        </box>
        <box style={{ alignItems: 'center' }}>
          <text>Privacy policy</text>
          <text>Source code link</text>
        </box>
      </box>
    </screen>
  )
})

With the positioning done, it is only down to the style of the buttons and the text. Styling of these elements is similar to that in HTML and the list of available properties can be found on their respective manual pages:

https://docs.chatium.com/en/reference/app-ui/blocks/button

https://docs.chatium.com/en/reference/app-ui/blocks/text

Unlike with the <box> elements, where we added the style property right in the element, here we want to reuse the same style between two buttons and the other one between two text elements. So we declare each style in a variable and then use them in the style property on the elements.

app.screen('', async (ctx, req) => {
  const buttonStyle = {
    paddingVertical: 30,
    marginVertical: 10,
    width: 200,
    border: [ 1, 'solid', '#445' ],
    color: '#445'
  }

  const linkStyle = {
    textDecorationLine: 'underline',
    marginBottom: 10
  }

  return (
    <screen title="Tic Tac Toe">
	<box style={{ height: '100%' }}>
        <box
          style={{
            alignItems: 'center',
            justifyContent: 'center',
            flex: 1
          }}
        >
          <button style={buttonStyle}>New game</button>
          <button style={buttonStyle}>Join via Game ID</button>
        </box>
        <box style={{ alignItems: 'center' }}>
          <text style={linkStyle}>Privacy policy</text>
          <text style={linkStyle}>Source code link</text>
        </box>
      </box>
    </screen>
  )
})

Now the screen looks identical to the Title screen in the game, except it does nothing.

The functionality is added via onClick property of the elements. In the Title screen we utilise four types of onClick actions:

  • ctx.router.navigate to open a different screen, with a path relevant to the current router (eg. relevant to the request point of entry).
  • ctx.account.navigate to open a different screen, with a path relevant to the current account (eg. relevant to the absolute path, instead of the request point of entry).
  • ctx.router.apiCall to call a backend defined API handler, with a path relevant to the current router.
  • copyToClipboard to copy text to clipboard.

ctx is a short for context. It carries properties and functions specific for the currently processed request (preparing Title screen in our case). ctx.router is a set of properties and function helpers around the request point of entry (file index.tsx in our case) and ctx.router is for the application account (eg. root folder).

With the onClick actions added, the final code look like this. Note that ` copyToClipboard` action is imported from the `@app/ui` module.

import { copyToClipboard } from '@app/ui'

app.screen('', async (ctx, req) => {
  const buttonStyle = {
    paddingVertical: 30,
    marginVertical: 10,
    width: 200,
    border: [ 1, 'solid', '#445' ],
    color: '#445'
  }

  const linkStyle = {
    textDecorationLine: 'underline',
    marginBottom: 10
  }

  return (
    <screen title="Tic Tac Toe">
	<box style={{ height: '100%' }}>
        <box
          style={{
            alignItems: 'center',
            justifyContent: 'center',
            flex: 1
          }}
        >
          <button
            style={buttonStyle}
            onClick={ctx.router.apiCall('newGameApi')}
          >
            New game
          </button>
          <button
            style={buttonStyle}
            onClick={ctx.router.navigate('join')}
          >
            Join via Game ID
          </button>
        </box>
        <box style={{ alignItems: 'center' }}>
          <text
            style={linkStyle}
            onClick={ctx.account.navigate('privacy policy')}
          >
            Privacy policy
          </text>
          <text
            style={linkStyle}
            onClick={copyToClipboard('https://tic-tac-toe.chatium.com/s/ide/embed-folder/index')}
          >
            Source code link
          </text>
        </box>
      </box>
    </screen>
  )
})

Heap, built-in NoSQL database

Heap is a Chatium built-in NoSQL database engine. It allows runtime in-code table definitions with type checking. Each table is defined using the `Heap.Table` call, where the first parameter is the table name and the second is it’s definition.

Let’s have a look at the fields of the table storing game boards in this app:

const boardTable = Heap.Table('ttt.boards', {
  /**
   * gameId - 5 letter Game ID. Used to invite a guest player
   * into the game by the creator.
   */
  gameId: Heap.NonEmptyString(),

  /**
   * creatorId - iternal identifier of the game creator.
   * Either a Session ID or the Chatium registered User ID.
   */
  creatorId: Heap.NonEmptyString(),

  /**
   * guestId - similar to creatorId, is an identifier of a
   * guest player. Unlike the creatorId, guestId is not known
   * upon initiation of the game by the creator.
   * Therefore we must allow `null` as a possible value.
   * For that we use Heap.Union to create a new type allowing
   * either Heap.NonEmptyString or Heap.Null stored as a value.
   */
  guestId: Heap.Union(
    [
      Heap.NonEmptyString(),
      Heap.Null()
    ]
  ),

  /**
   * nextMoveBy is a filed describing who is making the next move
   * in the game. It is an enum of type `NextMoveBy` declared
   * earlier in the code.
   */
  nextMoveBy: Heap.Enum(
    NextMoveBy,
    { default: NextMoveBy.Creator }
  ),

  /**
   * xo - actual data for the current board with Xs and Os.
   * The data is an array of nine cells (3 x 3).
   * Xs are represented by integer values of 1, and Os by 0.
   * An unoccupied cell has a value of `null`. 
   * Here again we are using Heap.Union to represent a cell value.
   * Apart from allowing only either Heap.Integer or Heap.Null,
   * we further restrict Heap.Integer to the range of 0 to 1 only.
   */
  xo: Heap.Array(
    Heap.Union(
      [
        Heap.Integer({ minimum: 0, maximum: 1 }),
        Heap.Null()
      ]
    )
  )
})

Now we have `boardTable` variable, handler for the table storing the games. In the app, we use the following methods to manipulate the data in that table:

  • boardTable.create – adds a new record to the table; used to add a new board/game to the table.
  • boardTable.findOneBy – returns a record from the table; used to find a board by the Game ID.
  • boardTable.update – updates a record in the table; used to makes changes to the existing board or game state.

Backend defined API and boardTable.create

Similar to `app.screen` call binding a screen handler, `app.apiCall` binds an API handler. It takes the first parameter as the handler location pattern and the second is the handler function.

Lets have a look at a very simple `newGameApi` handler and `boardTable.create` it calls to create a new board.

/**
 * newGameApi creates a new game. The user who called the API
 * becomes the Creator. In the end, it navigates the user to
 * open a screen for the newly created game.
 */
app.apiCall('newGameApi', async (ctx, req) => {
  /**
   * New random Game ID made if 5 capital letters
   */
  const gameId = makeId()

  const board = await boardTable.create(ctx, {
    gameId,
    creatorId: myId(ctx),
    guestId: null,
    nextMoveBy: NextMoveBy.Creator,
    xo: [
      null, null, null,
      null, null, null,
      null, null, null
    ]
  })

  /**
   * Navigate the user to the Game screen. While the
   * `board.guestId` is null, the screen will display a
   * Guest player invitation blok. Read about navigation:
   * https://docs.chatium.com/en/handbook/intro/navigation
   */
  return ctx.router.navigate(`${board.gameId}`)
})

So it pretty much does just two things – creates a new game, and then commands back to the frontend (the app) to navigate the user to a screen for the newly created game.

Note how the board is initialized with nine null values and the next move is set to the game creator.

Path templates, boardTable.findOneBy and boardTable.update

A different API handler `boardClickApi` is called when a player clicks on an empty cell on the board to place an X or O.

Unlike previously looked at `newGameApi` API call, `boardClickApi` path is defined using a path template and accepts two parameters gameId and xoIdx. The handler registration looks like that:

app.apiCall('boardClickApi/:gameId/:xoIdx', async (ctx, req) => {
  // handler’s code here
})

Note how the parameters are prefixed with a colon. These parameters are accessible in the handler as req.params.gameId and req.params.xoIdx.

At the very start of the handler, it loads the game data from the boardTable using gameId from the template as the search parameter:

const board = await boardTable.findOneBy(ctx, {
  gameId: req.params.gameId
})

If the board with the supplied gameId is not found, boardTable.findOneBy then returns null.

Fast forward past the parameters and the access check, the handler makes the changes to the game status, placing and X or O on the board and changing which player is expected to make the next move. All of this is done on the abject previously loaded via boardTable.findOneBy.

const xoIdx = parseInt(req.params.xoIdx)

board.xo[xoIdx] = mySymbol(ctx, board)

board.nextMoveBy = (
  board.nextMoveBy === NextMoveBy.Creator ?
  NextMoveBy.Guest :
  NextMoveBy.Creator
)

And finally the changes are saved back to the boardTable:

await boardTable.update(ctx, board)

The actual table record (board in our case) to make changes to is identified by the id value of the record (board.id for us). That value is immutable.

The last step in the ` boardClickApi` is a call to update the Game screens.

Screen update sockets

In this app, two online players are taking part in one game. Whenever one player makes a move, the Game screens for both players need to be updated to display the current situation in the game.

The Game screen is listening for a signal issued upon a move made by either of the players taking part in the game. That is done using Chatium sockets, whose sole purpose is delivering screen update signals.

When such signal is received, the entire screen that is waiting for it gets updated: the initial request that has opened the screen gets resent and the data for the entire screen is returned. However only the section of the screen which data that has changed will be redrawn for the performance sake.

Looking at the Game <screen> block, inside <BoardScreenBlock> component function, we see how it is bound to an update socket:

<screen title="Game" socketIds={[await gameSocketId(board.gameId)]}>

Here the `socketIds` parameter is what binds the screen. Note how the game socket is specified by `board.gameId`.

Now, looking at the `boardClickApi` handler again, at the very end of the function we see a call to issue an update signal for the Game screen:

await updateGameSocket(board.gameId)

And again note how the game socket is specified by `board.gameId`. Only a socket for the particular game is triggered.

Both `gameSocketId` and `updateGameSocket` are simple wrapper functions around Chatium’s `genSocketId` and `updateSocket` functions from the `@app/socket` module:

function gameSocketId(gameId) {
  return genSocketId(`tic-tac-toe-update-${gameId}`)
}

async function updateGameSocket(gameId) {
  return updateSocket(`tic-tac-toe-update-${gameId}`)
}

Game screen

The Game screen handler is bound to a path template in a similar way to ` boardClickApi` API. It has no path and takes one template parameter `gameId` only:

app.screen(':gameId', async (ctx, req) => {
  // Game screen code here
})

The handler doesn’t return a <screen> block directly, but rather returns either of three compoents defining the specific screen situations:

  • <InviteScreenBlock> – a screen displaying invitation code for the Guest player.
  • <BoardScreenBlock> – a screen for the actual game.
  • <AccessDeniedScreenBlock> – if the user tries to acces a game they are neither a creator of, nor were the original guest player.

Both <InviteScreenBlock> and <BoardScreenBlock> are screen bound to the same socket.

Once a Guest player joins the game, the update signal on the socket that <InviteScreenBlock> is bound to redraws the Game screen, making the <BoardScreenBlock> replace the <InviteScreenBlock>.

All consequent updates on that socket simply redraw <BoardScreenBlock> to display the actual game situation.

<InviteScreenBlock>

Is a very simple screen block. Practically does two things: displays the invite code for the Guest player and binds to the game update socket. Upon the socket update, the parent function handler for the Game screen is called, and returns the <BoardScreenBlock> instead of the <InviteScreenBlock>.

The screen itself doesn’t have any user actionable actions, other that copying the code to the clip board. It practically is a waiting screen for the game Creator.

Join screen and API

Join screen allows the Guest player to join a new game started by a Creator. The screen is made of a very simply form displaying a Game ID input field and the submit button.

Both input field and the submit button are bound to the same submit action:

const submitFormAction = {
  type:   'submitForm',
  formId: 'formJoin',
  url:    ctx.router.url('joinGameApi')
}

The input field executes submitFormAction upon the Done press on the virtual keyboard or the Enter button on the laptop.

The bare bone version of the form, stripped of elements styling and positioning, looks like this:

<text-input
  name='gameId'
  formId='formJoin'
  onReturnKeyPress={submitFormAction}
/>

<button onClick={submitFormAction}>Join!</button>

Note how formId value in submitFormAction matches that in the input. It is required for the action to know which fields to submit, if there were multiple inputs on the screen.

The API handler receiving the form data is `joinGameApi`. It is specified in the `submitFormAction` as `url`.

In the `joinGameApi`, the form data is accessible via `req.body`. The only field our form has is named gameId and is accessed this way:

const gameId = req.body.gameId?.trim().toUpperCase()

The only thing this API does is the basic checks, before it redirects the Guest player to the Game screen:

return ctx.router.navigate(`${board.gameId}`)

So we see, how the Join game form, submits the data to API, which in return redirects the player to a different screen.

<BoardScreenBlock>

<BoardScreenBlock> is a <screen> block with the Game board, where the actual game play occurs. The block is returned by the Game screen handler – the same handler that returns <InviteScreenBlock>. Both players end up on the screen after the Creator starts a new game, and the Guest joins it via an invitation. The <screen> is bound to an update socket: see “Screen update sockets” section earlier.

The screen features a number of components. The component of the most interest for us is <Board>.

The boards is made of 3 x 3 cells: 3 horizontally oriented <box> containers each including another 3 <box> containers. Each in turn including an <icon> with either X, O or an empty space.

<icon> element uses icons from FontAwesome version 5. For Xs and Os it uses icons “fas times” and “far circle” respectively. For an empty cell an <icon> with no specified name is used. This makes it appear empty but still hold the space and ensure the containing cell doesn’t collapse.

See https://docs.chatium.com/en/reference/app-ui/blocks/icon for more information on <icon>.

If the current player is expected to make a move, then all empty cells become clickable. In this case instead of displaying empty <icon> in cells on their own, <button> elements are displayed with `leftIcon` property set to empty <icon>.

Even though <icon> elements have `onClick` property, using <button> is more preferable as it displays progress animation once clicked, unlike <icon> if used on it’s own.

At the very bottom of the <BoardScreenBlock> is <footer> section. This element is pinned to the bottom of the physical screen and overlays the content, if there is any underneath. In this screen it is used for chat <MessageInput> component.

Full source code

Below is the full source code for the app and the emulator. Feel free to experiment and change the code. However Android and iOS apps currently deployed to Play Google and Apps Store do work with the original unmodified project only. You will need to deploy you own builds to the stores to work with the modified code. However this is a task that goes beyond the scope of this document.