Mobile
Article

Horizon: A Scalable Backend Perfect for JavaScript Mobile Apps

By Wern Ancheta

Horizon is a scalable backend for cross-platform, JavaScript based mobile apps, especially those needing realtime functionality. It was built by the awesome people at RethinkDB and so uses RethinkDB as the default database. If you’re not familiar with RethinkDB, it’s an open-source database with realtime capabilities.

Horizon exposes a client-side API that allows you to interact with the underlying database. This means you don’t have to write any backend code. All you have to do is spin up a new server, run it and Horizon will take care of the rest. Data is synced between the server and the connected clients in realtime.

If you want to learn more about Horizon, you can check out their faq page.

In this tutorial you’re going to build a Tic-Tac-Toe app with Ionic and Horizon. I’ll assume that you’re not new to Ionic and Cordova so I’m not going to explain Ionic-specific code in depth. I recommend you go check out the Getting Started Guide on the Ionic Website if you want a bit of background. If you want to follow along, you can clone the app repo on Github. Here’s how the final app will look:

tic-tac-toe app

Installing Horizon

RethinkDB serves as Horizon’s database so you’ll have to install RethinkDB first before installing Horizon. You can find out how to install RethinkDB here.

Once RethinkDB is installed, you can install Horizon through npm by executing the following in your terminal:

npm install -g horizon

Horizon Server

The Horizon server serves as the backend of the app. It talks to the database whenever the app executes code.

You can create a new Horizon server by executing the following in your terminal:

hz init tictactoe-server

This creates the RethinkDB database and server files that Horizon uses.

Once the server is created, you can run it by executing:

hz serve --dev

In the command above, you specified --dev as an option. This means that you want to run a development server. The following options are set with the development server:

  • --secure no: This means that websockets and files aren’t served over an encrypted connection.
  • --permissions no: Disables permission constraints. This means that any client can perform whatever operation they want in the database. Horizon’s permissions system is based on whitelisting. This means that by default, all users doesn’t have permission to do anything. You have to explicitly specify what operations are allowed.
  • --auto-create-collection yes: Auto-creates a collection on first use. In Horizon, collections are the equivalent of tables in a relational database. Setting this option to yes means that every time a client uses a new collection, it’s automatically created.
  • --auto-create-index yes: Auto-creates an index on first use.
  • --start-rethinkdb yes: Automatically starts a new instance of RethinkDB in the current directory.
  • --allow-unauthenticated yes: Allows unauthenticated users to perform database operations.
  • --allow-anonymous yes: Allows anonymous users to perform database operations.
  • --serve-static ./dist: Enables serving of static files. This is useful if you want to test interactions with the Horizon API in the browser. The horizon server runs on port 8181 by default so you can access the server by visiting http://localhost:8181.

Note: The --dev option should never be used in production because it opens a lot of holes for attackers to exploit.

Building the App

Now you’re ready to build the app. Start by creating a new Ionic app:

ionic start tictactoe blank

Installing Chance.js

Next you need to install chance.js, a JavaScript utility library for generating random data. For this app you use it to generate a unique ID for the players. You can install chance.js through bower using the following command:

bower install chance

index.html

Open the www/index.html file and add the following code:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
    <title></title>

    <link href="lib/ionic/css/ionic.css" rel="stylesheet">
    <link href="css/style.css" rel="stylesheet">

    <!-- IF using Sass (run gulp sass first), then uncomment below and remove the CSS includes above
    <link href="css/ionic.app.css" rel="stylesheet">
    -->
    <!-- chance.js -->
    <script src="lib/chance/dist/chance.min.js"></script>
    <!-- ionic/angularjs js -->
    <script src="lib/ionic/js/ionic.bundle.js"></script>

    <!-- cordova script (this will be a 404 during development) -->
    <script src="cordova.js"></script>

    <!-- horizon script -->
    <script src="http://127.0.0.1:8181/horizon/horizon.js"></script>

    <!-- your app's js -->
    <script src="js/app.js"></script>

    <!--main app logic -->
    <script src="js/controllers/HomeController.js"></script>
  </head>
  <body ng-app="starter">
    <ion-nav-view></ion-nav-view>
  </body>
</html>

Most of the above code is boilerplate code from the Ionic blank starter template, only adding the chance.js script:

<script src="lib/chance/dist/chance.min.js"></script>

The horizon script is served by the horizon server.

Note: You have to change the URL if you’re going to deploy this later.

<script src="http://127.0.0.1:8181/horizon/horizon.js"></script>

The main app logic resides in this JavaScript file:

<script src="js/controllers/HomeController.js"></script>

app.js

The app.js file is where you run the code for initializing the app. Open www/js/app.js and add the following right below the run function:

.config(function($stateProvider, $urlRouterProvider) {
  $stateProvider

  .state('home', {
    cache: false,
    url: '/home',
    templateUrl: 'templates/home.html'
  });
  // if none of the above states are matched, use this as the fallback
  $urlRouterProvider.otherwise('/home');
});

This sets up a route for the default app page. The route specifies the template used for the page as well as the URL where it can be accessed.

HomeController.Js

Create a HomeController.js file under the the www/js/controllers directory and add the following:

(function(){
  angular.module('starter')
  .controller('HomeController', ['$scope', HomeController]);

  function HomeController($scope){

    var me = this;
    $scope.has_joined = false;
    $scope.ready = false;

    const horizon = Horizon({host: 'localhost:8181'});
    horizon.onReady(function(){
      $scope.$apply(function(){
        $scope.ready = true;
      });
    });

    horizon.connect();

    $scope.join = function(username, room){

      me.room = horizon('tictactoe');

      var id = chance.integer({min: 10000, max: 999999});
      me.id = id;

      $scope.player = username;
      $scope.player_score = 0;

      me.room.findAll({room: room, type: 'user'}).fetch().subscribe(function(row){
        var user_count = row.length;

        if(user_count == 2){
          alert('Sorry, room is already full.');
        }else{
          me.piece = 'X';
          if(user_count == 1){
            me.piece = 'O';
          }

          me.room.store({
            id: id,
            room: room,
            type: 'user',
            name: username,
            piece: me.piece
          });

          $scope.has_joined = true;

          me.room.findAll({room: room, type: 'user'}).watch().subscribe(
            function(users){

              users.forEach(function(user){

                if(user.id != me.id){

                  $scope.$apply(function(){
                    $scope.opponent = user.name;
                    $scope.opponent_piece = user.piece;
                    $scope.opponent_score = 0;
                  });

                }

              });

            },
            function(err){
              console.log(err);
            }
          );


          me.room.findAll({room: room, type: 'move'}).watch().subscribe(
            function(moves){
              moves.forEach(function(item){

                var block = document.getElementById(item.block);
                block.innerHTML = item.piece;
                block.className = "col done";

              });

              me.updateScores();

            },
            function(err){
              console.log(err);
            }
          );
        }

      });

    }


    $scope.placePiece = function(id){

      var block = document.getElementById(id);

      if(!angular.element(block).hasClass('done')){
        me.room.store({
          type: 'move',
          room: me.room_name,
          block: id,
          piece: me.piece
        });
      }

    };


    me.updateScores = function(){

      const possible_combinations = [
        [1, 4, 7],
        [2, 5, 8],
        [3, 2, 1],
        [4, 5, 6],
        [3, 6, 9],
        [7, 8, 9],
        [1, 5, 9],
        [3, 5, 7]
      ];

      var scores = {'X': 0, 'O': 0};
      possible_combinations.forEach(function(row, row_index){
        var pieces = {'X' : 0, 'O': 0};
        row.forEach(function(id, item_index){
          var block = document.getElementById(id);
          if(angular.element(block).hasClass('done')){
            var piece = block.innerHTML;
            pieces[piece] += 1;
          }
        });

        if(pieces['X'] == 3){
          scores['X'] += 1;
        }else if(pieces['O'] == 3){
          scores['O'] += 1;
        }
      });

      $scope.$apply(function(){
        $scope.player_score = scores[me.piece];
        $scope.opponent_score = scores[$scope.opponent_piece];
      });
    }

  }

})();

Breaking down the code above, first set the default state. has_joined determines whether the user has already joined a room. ready determines whether the user is already connected to the Horizon server. You do not show the UI of the app to the user while this is set to false.

$scope.has_joined = false;
$scope.ready = false;

Connect to the Horizon server:

const horizon = Horizon({host: 'localhost:8181'});
horizon.onReady(function(){
  $scope.$apply(function(){
    $scope.ready = true;
  });
});

horizon.connect(); //connect to the server

As I said earlier, Horizon runs on port 8181 by default, that’s why you’ve specified localhost:8181 as the port. If you’re connecting to a remote server, this should be the IP or the domain name assigned to the server. When the user is connected to the server, the onReady event is triggered. This is where you set ready to true so you can show the UI to the user.

horizon.onReady(function(){
  $scope.$apply(function(){
    $scope.ready = true;
  });
});

Joining a Room

Next, is the join function executed once a user clicks on the Join button:

$scope.join = function(username, room){
    ...
};

Inside the function, connect to a collection called tictactoe.

Note: Since you’re in development mode, this collection is automatically created if it doesn’t already exists.

me.room = horizon('tictactoe');

Generate an ID and set it as the ID for the current user:

var id = chance.integer({min: 10000, max: 999999});
me.id = id;

Set the username and the default player score.

Note: These variables are bound to the template so you can show and update them at any time.

$scope.player = username;
$scope.player_score = 0;

Query the table for documents that has a room set to the current room and the type of user. Don’t get confused by the use of the subscribe function, you’re not actually listening for changes. You’ve used the fetch function which means it will only execute once the user joins a room.

me.room.findAll({room: room, type: 'user'}).fetch().subscribe(function(row){
    ...
});

Once a result is returned, check for the number of users. Of course tic-tac-toe can only be played by two players so alert the user if they try to join a room which has already two players in it.

var user_count = row.length;

if(user_count == 2){
  alert('Sorry, room is already full.');
}else{
    ...
}

Otherwise, proceed with the logic for accepting a user, determining the piece that will be assigned to the user depending on the current number of users. The first person to join the room gets the “X” piece, the second person gets the “O” piece.

me.piece = 'X';
if(user_count == 1){
    me.piece = 'O';
}

Once you’ve determined the piece, store the new user to the collection and flip the has_joined switch so that the tic-tac-toe board shows up.

me.room.store({
    id: id,
    room: room,
    type: 'user',
    name: username,
    piece: me.piece
});

$scope.has_joined = true;

Next, listen for changes in the collection. This time instead of fetch, use watch. This executes the callback function every time a new document is added or an existing document is updated (or deleted) that matches the query supplied. When the callback function is executed, loop through all the results and set the opponent details if the user ID of the document doesn’t match the user ID of current user. This is how you show the current user who their opponent is.

me.room.findAll({room: room, type: 'user'}).watch().subscribe(
  function(users){

    users.forEach(function(user){

      if(user.id != me.id){

        $scope.$apply(function(){
          $scope.opponent = user.name;
          $scope.opponent_piece = user.piece;
          $scope.opponent_score = 0;
        });

      }

    });

  },
  function(err){
    console.log(err);
  }
);

Next subscribe to the changes made to documents of type move executed every time either of the players places their piece on the board. If that happens, loop through all the moves and add text to the corresponding block. I’ll be using the word “block” to refer to each individual cells in the board from now on.

The text added is the piece used by each of the users, also replacing the class name with col done. col is the class used for Ionic’s grid implementation and done is the class used to show that a specific block already has a piece on it. You use this for checking if the user can still place their piece on the block. After updating the UI for the board, update the scores by calling the updateScores function (which you will add later).

me.room.findAll({room: room, type: 'move'}).watch().subscribe(
  function(moves){
    moves.forEach(function(item){

      var block = document.getElementById(item.block);
      block.innerHTML = item.piece;
      block.className = "col done";

    });

    me.updateScores();

  },
  function(err){
    console.log(err);
  }
);

Placing a Piece

You call the placePiece function every time a user taps on any of the blocks on the board, supplying the ID of the block as an argument to this function. This allows you to manipulate it any way you want. In this case, you use it to check if the block doesn’t have the class of done. If it doesn’t, create a new move indicating the room, the ID of the block and the piece.

$scope.placePiece = function(id){

  var block = document.getElementById(id);

  if(!angular.element(block).hasClass('done')){
    me.room.store({
      type: 'move',
      room: me.room_name,
      block: id,
      piece: me.piece
    });
  }

};

Updating the Scores

To update the scores, construct an array containing the possible winning combinations.

const possible_combinations = [
  [1, 4, 7],
  [2, 5, 8],
  [3, 2, 1],
  [4, 5, 6],
  [3, 6, 9],
  [7, 8, 9],
  [1, 5, 9],
  [3, 5, 7]
];

[1, 4, 7] being the first row, [1, 2, 3] the first column and so on. The order doesn’t really matter as long as the corresponding numbers are there. Here’s a visual to help you understand better:

tic-tac-toe combinations

You can also do diagonally ([1, 5, 9] and [3, 5, 7]) but the editing tool that I used does’t allow me, so bear with me.

Next, initialize the scores for each individual piece and loop through each possible combination. For each iteration of the loop, initialize the total number of pieces for each piece already placed in the board. Then loop through the items for the possible combinations. Using the id, check if the corresponding block already has a piece attached to it. If there is, get the actual piece and increment the total number of pieces. Once the loop is complete, check if the totals for each piece equals 3. If they do, increment the score for the piece until you’ve iterated through all the possible combinations. Once complete, update the score of the current player and the opponent.

var scores = {'X': 0, 'O': 0};
possible_combinations.forEach(function(row, row_index){
  var pieces = {'X' : 0, 'O': 0};
  row.forEach(function(id, item_index){
    var block = document.getElementById(id);
    if(angular.element(block).hasClass('done')){ //check if there's already a piece
      var piece = block.innerHTML;
      pieces[piece] += 1;
    }
  });

  if(pieces['X'] == 3){
    scores['X'] += 1;
  }else if(pieces['O'] == 3){
    scores['O'] += 1;
  }
});

//update current player and opponent score
$scope.$apply(function(){
  $scope.player_score = scores[me.piece];
  $scope.opponent_score = scores[$scope.opponent_piece];
});

Main Template

Create a home.html file under the www/templates directory and add the following:

<ion-view title="Home" ng-controller="HomeController as home_ctrl" ng-init="connect()">
  <header class="bar bar-header bar-stable">
    <h1 class="title">Ionic Horizon Tic Tac Toe</h1>
  </header>

  <ion-content class="has-header" ng-show="home_ctrl.ready">
    <div id="join" class="padding" ng-hide="home_ctrl.has_joined">
      <div class="list">
        <label class="item item-input">
          <input type="text" ng-model="home_ctrl.room" placeholder="Room Name">
        </label>
        <label class="item item-input">
          <input type="text" ng-model="home_ctrl.username" placeholder="User Name">
        </label>
      </div>

      <button class="button button-positive button-block" ng-click="join(home_ctrl.username, home_ctrl.room)">
        join
      </button>
    </div>

    <div id="game" ng-show="home_ctrl.has_joined">
      <div id="board">
        <div class="row">
          <div class="col" ng-click="placePiece(1)" id="1"></div>
          <div class="col" ng-click="placePiece(2)" id="2"></div>
          <div class="col" ng-click="placePiece(3)" id="3"></div>
        </div>
        <div class="row">
          <div class="col" ng-click="placePiece(4)" id="4"></div>
          <div class="col" ng-click="placePiece(5)" id="5"></div>
          <div class="col" ng-click="placePiece(6)" id="6"></div>
        </div>
        <div class="row">
          <div class="col" ng-click="placePiece(7)" id="7"></div>
          <div class="col" ng-click="placePiece(8)" id="8"></div>
          <div class="col" ng-click="placePiece(9)" id="9"></div>
        </div>
      </div>
      <div id="scores">
        <div class="row">
          <div class="col col-50 player">
            <div class="player-name" ng-bind="player"></div>
            <div class="player-score" ng-bind="player_score"></div>
          </div>
          <div class="col col-50 player">
            <div class="player-name" ng-bind="opponent"></div>
            <div class="player-score" ng-bind="opponent_score"></div>
          </div>
        </div>
      </div>
    </div>
  </ion-content>
</ion-view>

Breaking down the above code, you have the main wrapper which you don’t show until the user is connected to the Horizon server.

<ion-content class="has-header" ng-show="home_ctrl.ready">
    ...
</ion-content>

The form for joining a room:

<div id="join" class="padding" ng-hide="home_ctrl.has_joined">
  <div class="list">
    <label class="item item-input">
      <input type="text" ng-model="home_ctrl.room" placeholder="Room Name">
    </label>
    <label class="item item-input">
      <input type="text" ng-model="home_ctrl.username" placeholder="User Name">
    </label>
  </div>

  <button class="button button-positive button-block" ng-click="join(home_ctrl.username, home_ctrl.room)">
    join
  </button>
</div>

The tic-tac-toe board:

<div id="board">
  <div class="row">
    <div class="col" ng-click="placePiece(1)" id="1"></div>
    <div class="col" ng-click="placePiece(2)" id="2"></div>
    <div class="col" ng-click="placePiece(3)" id="3"></div>
  </div>
  <div class="row">
    <div class="col" ng-click="placePiece(4)" id="4"></div>
    <div class="col" ng-click="placePiece(5)" id="5"></div>
    <div class="col" ng-click="placePiece(6)" id="6"></div>
  </div>
  <div class="row">
    <div class="col" ng-click="placePiece(7)" id="7"></div>
    <div class="col" ng-click="placePiece(8)" id="8"></div>
    <div class="col" ng-click="placePiece(9)" id="9"></div>
  </div>
</div>

And the player scores:

<div id="scores">
  <div class="row">
    <div class="col col-50 player">
      <div class="player-name" ng-bind="player"></div>
      <div class="player-score" ng-bind="player_score"></div>
    </div>
    <div class="col col-50 player">
      <div class="player-name" ng-bind="opponent"></div>
      <div class="player-score" ng-bind="opponent_score"></div>
    </div>
  </div>
</div>

Styling

Here are the styles for the app:

#board .col {
  text-align: center;
  height: 100px;
  line-height: 100px;
  font-size: 30px;
  padding: 0;
}

#board .col:nth-child(2) {
  border-right: 1px solid;
  border-left: 1px solid;
}

#board .row:nth-child(2) .col {
  border-top: 1px solid;
  border-bottom: 1px solid;
}

.player {
  font-weight: bold;
  text-align: center;
}

.player-name {
  font-size: 18px;
}

.player-score {
  margin-top: 15px;
  font-size: 30px;
}

#scores {
  margin-top: 30px;
}

Running the App

You can test the app in your browser by executing the following in the root directory of the app:

ionic serve

This serves the project locally and opens up a new tab in your default browser.

If you want to test with a friend, you can use Ngrok to expose the Horizon server to the internet:

ngrok http 8181

This generates a URL which you can use as the value for the host when connecting to a Horizon server:

const horizon = Horizon({host: 'xxxx.ngrok.io'});

Also change the reference to the horizon.js file in your index.html file:

<script src="http://xxxx.ngrok.io/horizon/horizon.js"></script>

To create a mobile version add a platform to your project (for this example, Android). This assumes that you have already installed the Android SDK on your machine.

ionic platform add android

Then generate the .apk file:

ionic build android

You can then send the generated .apk file to your friend so you can both enjoy the game. Or you can also play solo if that’s your thing.

Where to Go from Here

In this tutorial you built a simple app and there are many things that could be improved. Below are a few areas you might want to try. Think of these as your homework for taking the skills that you’ve learned further.

  • Build a 4×4 or 5×5 version: The 3×3 version you’ve built almost always leads to a stalemate, especially if both players are experts at playing tic-tac-toe.
  • Scoring logic: You had to do a lot of looping just to get the scores for each player. Maybe you can think of a better way to implement it.
  • Improve styling: The current styling is plain and it emulates the tic-tac-toe kids used to play on paper.
  • Add animations: You might want to add a “slide down” animation for the board when the user joins a room or a “bounce in” animation when a player places their piece on the board. You can implement these kinds of animations using animate.css.
  • Add social login: It might be overkill for such a simple app but if you want to learn how authentication in Horizon works then it’s a good excercise. With Horizon authentication you can have users login with their Facebook, Twitter or Github accounts.
  • Add a play again feature: Show a “Play Again” button after the game has finished. When pressed, it clears the board and the scores so the players can play again.
  • Add a realtime leaderboard: Add a leaderboard showing who has won the most games, a leaderboard for rooms with the most restarts (if you’ve implemented the play again feature).

In you have any questions, comments or great app improvement ideas, please let me know in the comments below.

  • Momo

    Thank you Wern Ancheta, thanks to you guys, we are discovering these cool services and libraries. There is so much to learn right now in the web and mobile development that we can’t track everything and this kind of article are really helpful.

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in Mobile, once a week, for free.