Tuesday, January 7, 2014

Zoggle - Rewritten Using AngularJS


Zoggle, the ultimate word game!
Play Zoggle (source) FREE on:





A Long Time Ago...

Zoggle was my very first app on Google Play, and also happened to be the topic of my first blog post over a year ago: http://www.zolmeister.com/2012/04/zoggle.html. I finally got around to giving it a proper upgrade by rebuilding from the ground up. Zoggle is now better than ever, with a new theme, less bugs, and an illustriously impeccable code-base.

Round 2

Originally Zoggle was built using many javascript and css hacks (not maintainable) and depended zero external libraries (not even jQuery). As a result, it quickly became messy spaghetti code, making even simple changes take hours to implement and test.

Now, after 6 days and 74 commits (and many hours of lost sleep), I finished re-writing Zoggle using AngularJS, jQuery, Lo-Dash, and Grunt to create a highly maintainable and scalable application. So, without further ado, let's go over some of the key aspects of the re-write.



Boggle Library

While searching NPM for a boggle library (before writing my own), I came across Boggle which leverages tries (mentioned in my original blog post) for optimal performance. However the library had some issues (no 'Qu' single tile support), and a poor dictionary list. I contacted the developer bahmutov, got the code moved to GitHub, and added the features. (He was also awesome enough to let me push to npm and the core repo, so you may want to check out his blog).

AngularJS

What can I say, except that AngularJS is absolutely amazing! It has a few bugs (you've been warned), but it is incredible to work with and makes webdev even more fun and exciting than before. If you're new to Angular, I recommend 25 days of AngularJS, and checking out the Angular-Seed project. I won't be writing a tutorial, but some things to watch out for.

AngularJS Gotchas

  • Data-binding on primitives (string, num, bool) doesn't always work properly. Update: updating primitives from views doesn't work as expected (more info).
    • Either nest data inside an object {} or call $scope.$apply()
  • Directives can be called before the DOM is ready
    • If you have a directive which depends on an ng-repeat to be completed, you need to add custom events. I used $rootScope.$emit and $rootScope.$on
    • Sometimes if you need the DOM to load you can run:
      • $timeout(function() { $timeout(theDomisReadyFn, 0) }, 0) 
      • or alternatively use $scope.$watch(function() {  })

Boggle Board

I could have rendered the board using either the DOM or Canvas (or WebGL) but I opted for using the DOM. One issue I ran across when writing the original Zoggle was that touch-move events do not cross over elements. This meat that I had to use the document.elementFromPoint() function in the original Zoggle. Unfortunately, it does not perform well on mobile, so this time around I decided to overlay a div over the whole board and calculate the position of the mouse over the elements manually. Unlike the non-performant code in the stackoverflow answer, I cached the position of the elements so that detection would be much faster.
var positions = [];

function cachePos() {
  var targets = $el.parent().find('.' + attrs.targets)
  positions = []
  targets.each(function (i, $el) {
    $el = $($el)
    positions.push({
      x: $el.offset().left,
      y: $el.offset().top,
      w: $el.outerWidth(),
      h: $el.outerHeight()
    })
  })
}

function collide(x1, y1, w1, h1, x2, y2, w2, h2) {
  if (y1 + h1 < y2 || y1 > y2 + h2 || x1 + w1 < x2 || x1 > x2 + w2) return false;
  return true
}

Letter Interpolation

The boggle board is represented internally as a single list (of 16 characters) because it made rendering easy. The logical thing (which I didn't do) would be to clone the data (as it's static per-game) and represent it as a 2D array. Anyways, one great feature I added to Zoggle was the ability to interpolate between two tiles. This means if you touch one tile (mobile device) and move too quickly and end up 2 tiles over, the algorithm will add the missing tile(s). The algorithm is pretty simple (see bonus for explanation of touching function):

function touching(x1, y1, x2, y2) {
  return _.some(_.filter(_.flatten(_.map(_.range(-1,2),_.compose(_.partial(_.zip,_.range(-1,2)),_.partial(_.compose(_.partial(_.map,_.range(3)),_.partial),_.identity))),!0),_.compose(_.first,_.compact)), function(dir) {
    return x1+dir[0] === x2 && y1+dir[0] === y2
  })
}

while (!touching(x1, y1, x2, y2)) {
  // interpolate position
  if(x1 < x2) x1++
  if(x1 > x2) x1--
  if(y1 < y2) y1++
  if(y1 > y2) y1--
  // add new y1 x1 to selected
}

Real-Time Word Highlighting

Another amazing new feature I added was highlighting as you type. The code for this is a simple depth-first search, except that instead of searching it adds every valid visited node as selected.

function depthFirstSearch(grid, word, pos, index, past) {
  index = index || 0
  past = past || []
  if (!word[index]) return past
  var currentLetter = grid[pos]
  // visiting a previous node or out of bounds
  if (pos < 0 || pos > grid.length ||
    _.contains(past, pos) ||
    word[index] !== currentLetter)
    return []

  // cardinal directions (N,S,E,W) + diagonals (NW, NE, SW, SE) in 1D
  var dirs = _.filter([-5, -4, -3, -1, 1, 3, 4, 5], function (dir) {
    var col = pos % 4
    if (col === 0 && _.contains([-1, 3, -5], dir)
      return false
    if (col === 3 && _.contains([-3, 1, 5], dir)
      return false
    return true
  })

  // recurse
  return _.map(dirs, function (dir) {
    return depthFirstSearch(grid, word, pos + dir, index + 1, past.concat(pos))
  })
}

Minification and Concatenation

For any production application, minifying and concatenating JS source is essential for an optimal user experience. To automate the process, I used Grunt which is an amazingly powerful tool for running a multitude of tasks.
module.exports = function(grunt) {

  grunt.initConfig({
    concat: {
      lib: {
        // order matters because jQuery needs to come before angular
        src: ['public/lib/*.js', 'public/lib/angular/**/*.js'],
        dest: 'public/dist/lib.js'
      },
      zoggle: {
        src: ['public/js/**/*.js'],
        dest: 'public/dist/zoggle.js'
      }
    },
    ngmin: {
      zoggle: {
        src: ['<%= concat.zoggle.dest %>'],
        dest: 'public/dist/zoggle.ngmin.js'
      }
    },
    uglify: {
      lib: {
        options: {
          sourceMap: 'public/dist/lib.js.map',
          sourceMappingURL: 'dist/lib.js.map',
          sourceMapPrefix: 1
        },
        files: {
          'public/dist/lib.min.js': ['<%= concat.lib.dest %>']
        }
      },
      zoggle: {
        options: {
          sourceMap: 'public/dist/zoggle.js.map',
          sourceMappingURL: 'dist/zoggle.js.map',
          sourceMapPrefix: 1
        },
        files: {
          'public/dist/zoggle.min.js': ['<%= ngmin.zoggle.dest %>']
        }
      }
    }
  });

  
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-ngmin');

  grunt.registerTask('default', ['concat', 'ngmin', 'uglify']);

};

And set this in package.json to use grunt on Heroku:
"scripts": {
  "postinstall": "echo postinstall time; ./node_modules/grunt-cli/bin/grunt"
}

A few things to note though. AngularJS injection relies on the variable names in functions. These change during compression, which is why it is necessary to use ngmin to prepare AngularJS code for compression. Additionally, the Angular-Seed project adds unnecessary files under /lib which must be deleted. Minified code (.min.js) (and source maps) should never be committed to source (add them to .gitignore), and should always be generated at compile time (by Grunt). Oh, and AngularJS is picky about what order you need to include jQuery (before Angular), or you may get unexpected errors.

Android application

Originally the mobile application was simply a web-view which pointed to the website, but I wanted to add a message in case there was no internet access. I figured that using Cordova (PhoneGap) would make it simple. I was wrong, but in the future I will be able to do it in mere minutes. Read my previous post on how to take a webapp and turn it into a native android mobile application in 5 minutes. http://www.zolmeister.com/2014/01/how-to-turn-webapp-into-native-android.html

Bonus

Recall the touching function from above:
function touching(x1, y1, x2, y2) {
  return _.some(_.filter(_.flatten(_.map(_.range(-1,2),_.compose(_.partial(_.zip,_.range(-1,2)),_.partial(_.compose(_.partial(_.map,_.range(3)),_.partial),_.identity))),!0),_.compose(_.first,_.compact)), function(dir) {
    return x1+dir[0] === x2 && y1+dir[0] === y2
  })
}

For fun, I decided to write a functional-only (using only LoDash functions) generator for the 8 cardinal directions (2D) - N, S, E, W, NW, NE, SW, SE. That list ([[0,1],[0,-1],[1,0],[-1,0],[-1,1]...]) is what populates 'dir' in the function call. Let's see how I composed it:
// Here is the original
var dirs = _.filter(_.flatten(_.map(_.range(-1,2),_.compose(_.partial(_.zip,_.range(-1,2)),_.partial(_.compose(_.partial(_.map,_.range(3)),_.partial),_.identity))),!0),_.compose(_.first,_.compact))

// Now let's decompose it

// [-1, 0, 1]
var oneDimention = _.range(-1,2)

// this first one is probably the most difficult to understand
// n -> [n, n, n]
var repeat3 = _.partial(_.compose(_.partial(_.map, oneDimention),_.partial),_.identity)

// n -> [[n, -1], [n, 0], [n, 1]]
var addToEachDimention = _.compose(_.partial(_.zip, oneDimention), repeat3)

// create a 3d matrix by joining 3x1 * 1x3 matrix
// [ [[-1, -1], [-1, 0], [-1, 1]], [[0, -1], [0, 0], [0, 1]], [[1, -1], [1, 0], [1, 1]] ]
var created3D = _.map(oneDimention, addToEachDimention)

// flatten
// [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1] ]
var created2D = _.flatten(created3D, !0)

// removes [0, 0] from list
//[ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]
var removedCenter = _.filter(created2D, _.compose(_.first,_.compact))

4 comments:

  1. Data binding on primitives works fine. It's javascript's own inheritance that you're having trouble with: https://github.com/angular/angular.js/wiki/Understanding-Scopes

    ReplyDelete
    Replies
    1. Yes, thank you. I updated the post to clarify that bit.

      Delete
  2. Good stuff :) I imagine this isn't too big of a worry for you, but you can make the game a bit more difficult to cheat by not passing the list of valid words to the client side. What we do for Word Wars (http://wordwars.clay.io) is send md5-hashed words to the client, then md5 any word the client tries to make to see if it's a valid word. A bit more on this here: http://css.dzone.com/articles/developing-cross-platform-2

    ReplyDelete
    Replies
    1. Thanks! Originally I only validated words server-side, but it was too slow. Hashing the words seems like a great solution, and your're right that it definitely beats sending the words in plain-text (though without compression does add some weight, maybe using a bloom filter or a trie instead could help that). If cheating becomes a problem I will definitely implement that feature.

      Thanks,
      - Zoli

      Delete