<main ng-app="FalloutConsole" ng-controller="MainController">
    <div id="terminal">
      <div class="landing-view" ng-show="gamestate === 'landing'" ng-cloak>
        <div id="terminal-head">
          <pre>ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL</pre>
          <br>
          <pre>Welcome to my Fallout Terminal</pre>
          <pre>Find the password to win.</pre>
          <br>
          <pre>Press Enter to start</pre>
        </div>
      </div>
      <div class="playing-view" ng-show="gamestate === 'playing'" ng-cloak>
        <div id="terminal-head">
          <pre>ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL</pre>
          <pre>!!! WARNING: LOCKOUT IMMINENT !!! 
          </pre>
          <pre>ATTEMPTS REMAINING: <i ng-repeat="num in boxes"></i></pre>
        </div>
        <div id="terminal-screen">
          <div id="terminal-left">
            <span ng-repeat="line in left" class="code-line">
              <pre class="byte">0xF000 </pre><pre class="data">{{line[0]}}</pre><pre class="data">{{line[1]}}</pre><pre class="data">{{line[2]}}</pre><pre class="data">{{line[3]}}</pre><pre class="data">{{line[4]}}</pre><pre class="data">{{line[5]}}</pre><pre class="data">{{line[6]}}</pre><pre class="data">{{line[7]}}</pre><pre class="data">{{line[8]}}</pre><pre class="data">{{line[9]}}</pre><pre class="data">{{line[10]}}</pre><pre class="data">{{line[11]}}</pre>
              <br>
            </span>
          </div>
          <div id="terminal-right">
            <span ng-repeat="line in right" class="code-line">
              <pre class="byte">0xF000 </pre><pre class="data">{{line[0]}}</pre><pre class="data">{{line[1]}}</pre><pre class="data">{{line[2]}}</pre><pre class="data">{{line[3]}}</pre><pre class="data">{{line[4]}}</pre><pre class="data">{{line[5]}}</pre><pre class="data">{{line[6]}}</pre><pre class="data">{{line[7]}}</pre><pre class="data">{{line[8]}}</pre><pre class="data">{{line[9]}}</pre><pre class="data">{{line[10]}}</pre><pre class="data">{{line[11]}}</pre>
              <br>
            </span>
          </div>
        </div>
        <div id="terminal-sidebar">
          <div id="terminal-history">
            <pre ng-repeat="line in sidebar track by $index"><span class="cursor" ng-show="line !== ''">></span>&nbsp;{{line}}</pre>
          </div>
          <pre id="selection">></pre>
        </div>
      </div>
      <div class="game-over-view" ng-show="gamestate === 'game-over'" ng-cloak>
        <div id="terminal-head">
          <pre>ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL</pre>
          <br>
          <pre>Lockout.</pre>
          <pre>Press Enter to play again</pre>
        </div>
      </div>
      <div class="game-win-view" ng-show="gamestate === 'game-win'" ng-cloak>
        <div id="terminal-head">
          <pre>ROBCO INDUSTRIES (TM) TERMLINK PROTOCOL</pre>
          <br>
          <pre>Password Accepted.</pre>
          <pre>Press Enter to play again</pre>
        </div>
      </div>
    </div>
  </main>
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
  display: none !important;
}
body {
  background-color: #222;
}
#terminal {
  background-color: #121;
  width:890px;
  height: 616px;
  margin:40px auto;
  border-radius: 5px;
  border: 5px solid #111;
  padding: 20px;
  font-size:22px;
  font-family: monospace;
  color:#393;
  overflow: auto;
}
#terminal-screen {
  width:600px;
  float:left;
}
#terminal-sidebar {
  width:250px;
  float:right;
}
#terminal-history {
  height:416px;
}
#terminal-head {
  margin-bottom: 20px;
}
#terminal pre, #terminal span {
  margin:0;
}
#terminal span {
  display: inline;
}

#terminal-left {
  width: 50%;
  float:left;
}
#terminal-right {
  width: 50%;
  float: right;
}
.data, .byte {
  display:inline;
}

.data.selected {
  color:#111;
  background-color: #393;
}
angular.module('FalloutConsole', [])

.controller('MainController', ['$scope', '$http', function($scope, $http) {
  $http.get('https://raw.githubusercontent.com/LawlietBlack/fallout-terminal/master/js/words.json').success(function(data) {
    $scope.words = data;

    $scope.gamestate = 'landing';
    //configuration variables
    var difficulty = 5;
    var lineLength = 12;
    var lines = 34;
    var characters = lineLength * lines;
    var margin = 2;
    var words, secretWord, places, display;

    function random(min, max) {
      return Math.floor(Math.random() * (max - min + 1) + min);
    }
    //randomizes word locations, returns 2d array with paired index and word
    function findPlaces(words, difficulty, characters, margin) {
      var places = [];
      var placed = false;
      var blacklist = [];
      for(var i = 0; i < words.length; i++) {
        placed = false;
        while(!placed && blacklist.length < characters * 0.8) {
          var place = Math.floor(Math.random()*(characters - difficulty));
          //check blacklist array to make sure there isn't a word conflict
          if(blacklist.indexOf(place) < 0) {
            places.push([place, words[i]]);
            placed = true;
            //add used spaces with margin to blacklist array
            for(var j = place - difficulty - margin; j <= place + margin + difficulty; j++) {
              blacklist.push(j);
            }
          }
        }
      }
      return places.sort(function(a, b) { return a[0] - b[0];});
    }
    //randomizes junk characters, creating the full grid
    function generateDisplay(places, difficulty, characters) {
      places = places.slice(0);
      var display = '';
      for(var i = 0; i < characters; i++) {
        if(places.length > 0 && i === places[0][0]) {
          display += places[0][1];
          i += difficulty - 1;
          places.shift();
        } else {
          display += randomChar();
        }
      }
      return display;
    }
    //gives a random filler character
    function randomChar() {
      var fillers = {
        '1':'(',
        '2':')',
        '3':'<',
        '4':'>',
        '5':'{',
        '6':'}',
        '7':'[',
        '8':']',
        '9':'=',
        '10':'_',
        '11':'-',
        '12':'.',
        '13':'/',
        '14':'$',
        '15':'@',
        '16':';',
        '17':':',
        '18':'"',
        '19':'%',
        '20':'^',
        '21':'&',
        '22':'|',
        '23':',',
        '24':'*'
      };
      var random = Math.floor(Math.random()*24 + 1);
      return fillers[random.toString()];
    }
    //takes the whole code string and breaks it into lines
    function renderScreen(display, lines, lineLength) {
      var screen = [];
      for(var i = 0; i < lines; i++) {
        screen.push(display.slice(0, lineLength));
        display = display.slice(lineLength);
      }
      return screen;
    }
    function renderWords(places, difficulty) {
      for(var i=0;i<places.length;i++) {
        var position = parseInt(places[i][0])
        for(var j=position;j<position + difficulty;j++) {
          var index = $('.code-line pre.data').eq(j);
          index.addClass('word');
          index.addClass(places[i][1]);
        }
      } 
    }
    function renderHacks(lines) {
      var openers = ['(', '[', '{', '<'];
      var closers = [')', ']', '}', '>'];
      var hacks = []
      var index = 0
      //iterates over each line to find matching <>
      for(var i=0;i<lines.length;i++) {
        for(var j=0;j<lines[i].length;j++) {
          var letter = lines[i][j]
          if(openers.indexOf(letter) >= 0) {
            var brIndex = openers.indexOf(letter);
            var str = lines[i].slice(j);
            if(str.indexOf(closers[brIndex]) > 0) {
              var endIndex = str.indexOf(closers[brIndex]);
              snippet = str.slice(0, endIndex+1);
              var re = /[A-Z]/g
              if(re.test(snippet)) {
              } else {
                hacks.push([index, index + endIndex, snippet])
                $('.code-line pre.data').eq(index).addClass('hack')
              }
            }
          }
          index += 1
        }
      }
      return hacks
    }
    //function to handle selection highlighting
    function changeSelection(num) {
      $('.selected').removeClass('selected');
      var selected = $('.code-line pre.data').eq(num);
      selected.addClass('selected');
      $('#selection').html("> " + selected.html())

      //if a letter in a word is selected, highlights and selects the whole word, as expected
      if(selected.hasClass('word')) {
        // class is 'data ng-binding word TIRES selected', 21 gets to start of word in this order
        var currentWord = selected[0].className.slice(21, 21 + difficulty);
        $('.' + currentWord).addClass('selected')
        $('#selection').html("> " + currentWord)
      }
      if(selected.hasClass('hack')) {
        var hack = $scope.hacks.filter(function(h, i) {
          return h[0] == $scope.number;
        })
        for(var i = $scope.number; i <= hack[0][1]; i++) {
          $('.code-line pre.data').eq(i).addClass('selected')
          $('#selection').html("> " + hack[0][2])
        }
      }
    }
    // prints a output to the terminal sidebar
    function printLine(line) {
      $scope.sidebar.shift();
      $scope.sidebar.push(line);
    }
    // runs hack function, removing duds or resetting tries
    function hack() {
      var hackIndex;
      var hack = $scope.hacks.filter(function(h, i) {
        if(h[0] == $scope.number) {
          hackIndex = i;
          return true;
        }
        return false;
      });
      printLine(hack[0][2]);
      if($scope.triesReset[0] === $scope.number) {
        $scope.boxes = [1, 2, 3, 4];
        printLine('Tries Reset.');
      } else {
        removeBadWord();
        printLine('Dud Removed.');
      }
      $scope.hacks.splice(hackIndex, 1);
      $('.code-line pre.data').eq($scope.number).removeClass('hack');
      $scope.$apply();
    }
    // removes dud
    function removeBadWord() {
      var badWords = words.slice(0, words.indexOf(secretWord)).concat(words.slice(words.indexOf(secretWord) + 1));
      var removedWord = badWords[random(0, badWords.length - 1)];
      words.splice(words.indexOf(removedWord), 1);
      $("." + removedWord).addClass('dud').removeClass('word').removeClass(removedWord).html(".");
    }
    // gets character likeness
    function getLikeness(word, secretWord) {
      var likeness = 0;
      for(var i=0;i<word.length;i++) {
        if(word[i] === secretWord[i]) {
          likeness ++;
        }
      }
      return likeness;
    }
    // character guess
    function guess() {
      var selected = $('.code-line pre.data').eq($scope.number);
      var currentWord = selected[0].className.slice(21, 21 + difficulty);
      if(currentWord === secretWord) {
        printLine("Password Accepted.");
        $scope.gamestate = 'game-win';
      } else {
        $scope.boxes.pop();
        if($scope.boxes.length === 0) {
          printLine('Lockout');
          $scope.gamestate = 'game-over';
        } else {
          printLine(currentWord);
          printLine("Entry denied.");
          printLine("Likeness=" + getLikeness(currentWord, secretWord));
        }
      } 
      $scope.$apply();
    }
    function getWords(secretWord) {
      var word, likeness;
      var wordset = $scope.words[difficulty];
        var words = [secretWord];
        for(var i=0;i<15;i++) {
          var count = 0;
          do {
            word = wordset[random(0, wordset.length - 1)];
            likeness = getLikeness(word, secretWord);
            count ++;
            if(words.indexOf(word) < 0 && count > 100) {
              break;
            } else if(words.indexOf(word) < 0 && count > 50 && likeness > 1) {
              break;
            }
          } while(words.indexOf(word) >=0 || likeness < 3);
          words.push(word);
        }
      return words;
    }
    $scope.initialize = function() {
      var wordset = $scope.words[difficulty];
      secretWord = wordset[random(0, wordset.length - 1)];
      words = getWords(secretWord);
      
      // console.log(secretWord)
      places = findPlaces(words, difficulty, characters, 2);
      display = generateDisplay(places, difficulty, characters);
      $scope.screen = renderScreen(display, lines, lineLength);

      //view variables
      $scope.left = $scope.screen.slice(0,17);
      $scope.right = $scope.screen.slice(17,34);
      $scope.boxes = [1,2,3,4];
      $scope.sidebar = ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''];
      $scope.number = 0;

      setTimeout(function() {
        changeSelection($scope.number)
        renderWords(places, difficulty)
        $scope.hacks = renderHacks($scope.screen);
        $scope.triesReset = $scope.hacks[random(0, $scope.hacks.length - 1)];
      }, 200);
        $scope.gamestate = 'playing';
    }

    //keydown event handler to manage cursor
    $('body').keydown(function(e) {
      if([13, 37, 38, 39, 40].indexOf(e.keyCode) >= 0) {
        e.preventDefault();
        if($scope.gamestate !== 'playing') {
          $scope.initialize();
          $scope.$apply();
          return
        }
      }
      

      var num = $scope.number
      var previous = $('.code-line pre.data').eq(num);
      //keypress right, if not max number
      if(e.keyCode === 39 && num < 407) {

        //checks if inside a word, if so, moves to the end of the word
        if(previous.hasClass('word')) {
          var inWord = true;
          while(inWord && $scope.number < 407) {
            $scope.number += 1;
            var current = $('.code-line pre.data').eq($scope.number)
            if(!current.hasClass('word')) {
              inWord = false;
            }
          } 

        //checks if end of row to move to other collumn
        } else if(num < 204 && (num + 1) % 12 === 0 ) {
          $scope.number += 193;
        //otherwise, moves left
        } else {
          $scope.number += 1;
        }

      //keypress left, if not first number
      } else if(e.keyCode === 37 && num > 0) {

        //checks if inside a word is selected, if so moves to the end of the word
        if(previous.hasClass('word')) {
          var inWord = true;
          while(inWord && $scope.number > 0) {
            $scope.number -= 1;
            var current = $('.code-line pre.data').eq($scope.number)
            if(!current.hasClass('word')) {
              inWord = false;
            }
          } 

        //checks if end of row to move to other collumn
        } else if(num > 203 && (num + 1) % 12 === 1 ) {
          $scope.number -= 193;
        //otherwise, moves left
        } else {
          $scope.number -= 1;
        }

      //keypress up, if not first row
      } else if(e.keyCode === 38 && num > 11) {
        $scope.number -= 12;

      //keypress down, if not last row
      } else if(e.keyCode === 40  && num < 396) {
        $scope.number += 12;


      //keypress enter
      } else if(e.keyCode === 13) {
        var selected = $('.code-line pre.data').eq(num);
        if(selected.hasClass('hack')) {
          hack();
        } else if(selected.hasClass('word')) {
          guess();
        } else {
          $scope.boxes.pop();
          printLine(selected.html());
          printLine('Error');
          if($scope.boxes.length === 0) {
            printLine('Lockout');
            $scope.gamestate = 'game-over';
          }
          $scope.$apply();
        }
      }

      changeSelection($scope.number);
    });
  });
}]);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular.min.js
  2. //cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js