The Project
Fatebook is a social platform for creating and sharing interactive, choose-your-own-adventure stories. As an Angular on Rails application, it is the culmination of my Rails and Angular studies through Bloc.
Tools
Rails, Javascript, Angular, UI-Router, ngCookies, ActiveModelSerializers, Underscore.js, ckEditor, Bootstrap, Atom, Ubuntu
The App
Upon entering the app, users are able to play through any existing published story. When playing through a story, they will see a paragraph or two of text, followed my a set of options. Each option will take the user down a new story thread. Users may select these chooses by clicking or pressing the corresponding number on their keyboard.
Should a user want to create their own story, they must log in and select “create story”. From here they can provide a title and description of their story. Upon creating the story, the first page is automatically generated. After populating the content of the page, they can click “create option” to define a new option players can choose. They can then click the “Edit Page” button for that new option to go directly to a page generated for that option.
At any time, users can use the buttons along the top to navigate to the parent page, parent story, or view sibling stories on the same level of the story tree. Additionally, users may view the story tree at the bottom of the page, allowing them to easily traverse their story and identify which story threads remain unfinished.
The Process
While planning the project, I defined two main states: Play mode, where a user is playing through someone’s story, and Edit mode, where a user is creating or editing one of their own stories.
I began with the Play state, starting initially as a pure Angular application. I created a system where a Story
object would contain an array of Pages
. Each Page
would contain an array of choices
, which would be an object hash with the attributes of text
for the text of the choice the users sees, and dest
for the ID of the destination page.
var Page = function(args){
this.parentPage = args.parentPage;
this.id = args.id;
this.content = args.content;
this.init = function(args){
this.title = args.title;
this.summary = args.summary;
this.content = args.content;
this.choices = args.choices; //Displayed to user
return this;
}
this.createChildPages = function(){
if(this.choices){
for(var i = 0; i < this.choices.length; i++){
var newPageID = getBranchID(this, i);
createPlaceholderPage(this, newPageID);
this.choices[i].dest = newPageID;
}
}
}
this.init(args);
};
To make nested seeding of page data easier, I used a page ID system I came up with for a short branching story I wrote years ago called Doors (which I ended up using as the example story for my app.) The naming convention consisted of alternating numbers and letters to indicate the number of levels down through the story a user had navigated. For example, the first page would have a id
of “1”, with child pages of “1A” and “1B”. This could be chained indefinitely, so a page like “1A2B2” would be the child of page “1A2B” and sibling to “1A2B1”.
This id
was auto-generated using the following code (annotations added as comments):
var getBranchID = function(parent, index){
if(parent){
var idString = parent.id.toString();
var lastChar = idString[idString.length -1];
//Uses a regular expression to test if last character
// of parent pageID is a number or letter
var reg = /^\d+$/;
if(reg.test(lastChar)){
return parent.id + indexToAlpha(index);
}else{
return parent.id + (index+1);
}
}
}
Using this id
system and the functions built into the Page
object, I set up a system for easily populating a test array of pages. To do this, I first created a dummy first page, then used an editPage
function I created to create an repeatable format I could use to populate all pages.
StoryNavSrv.editPage = function(id, args){
var page = getPageFromID(id);
if(page){
page
.init(args)
.createChildPages();
}else{
console.warn('Attempt to edit with invalid page id "' + id + '" SKIPPING');
}
}
...
createPlaceholderPage(null, 1);
StoryNavSrv.currentPage = StoryNavSrv.currentStory.pages[0];
StoryNavSrv
.editPage('1',
{
title: 'Initial page',
summary: 'First page',
content: 'You see two doors. Which do you choose?',
choices: [
{text: 'Left door'},
{text: 'Right door'}
],
});
StoryNavSrv
.editPage('1A',
{
title: 'Left door',
summary: 'First page',
content: 'You entered the LEFT door.',
});
StoryNavSrv
.editPage('1B',
{
title: 'Left door',
summary: 'First page',
content: 'You entered the RIGHT door.',
choices: [
{text: 'Or did I?'},
{text: 'No I didn\'t'},
],
});
...
With a working story navigation system working, I decided to add in keyboard controls as well.
angular.element(document).bind('keyup', function (e) {
if(StoryNavSrv.currentPage && StoryNavSrv.currentPage.choices){
var choiceIndex = e.keyCode - 49;
var page = StoryNavSrv.currentPage;
var choice = page.choices[choiceIndex];
if(choice){
$scope.$apply($scope.setPage(choice.dest));
}
}
});
With a working frontend implementation, I then moved on to add a Rails backend. Following a guide I found on youtube, I was able to get Angular to communicate with Rails, though it was a tricky process. I had to do some research to find the gems I required, namely angular-rails-templates
to enable my Angular templates to be used in the Rails asset pipeline.
My Rails backend became an API to store and pass data to Angular via the UI-Router service. It accomplished this by passing returning JSON to my Frontend, after being processed by the appropriate ActiveModelSerializer
to ensure the relevant attributes were properly passed.
It took me a little while to make sense of creating these accessor services, but eventually it clicked and made sense how the urls they used corresponded to the routes created by Rails.
(function(){
function PageSrv($resource) {
return $resource('/pages/:id.json', {id: '@id'},
{
query: {method: 'GET', isArray: true},
create: {method: 'POST'},
show: {method: 'GET'},
update: {method: 'PUT'},
delete: {method: 'DELETE'},
first: {method: 'GET', url: '/pages/get_first_page/:story_id'}
});
}
angular
.module('fatebook')
.factory('PageSrv',['$resource', PageSrv])
})()
I hit some friction coming up with the best approach for setting up the ActiveRecord
associations between Stories, Pages, and child Pages. My initial thought was to create a has_many :through
relationship, using a branch
model to link a parent page to a destination page. This created an odd situation where each page has_many
pages and belongs_to
a parent page, which just wasn’t working right.
class Page < ActiveRecord::Base
belongs_to :story
has_many :branches, :class_name => 'Page', foreign_key: :parent_id
belongs_to :parent_page, :class_name => 'Page', foreign_key: :parent_id
end
After some thought and consultation with my mentor, I realized there was a better approach. Rather than having pages be linked to each other directly, I could instead define a Branch
join table to link pages together. The result was a much cleaner association.
class Page < ActiveRecord::Base
belongs_to :story
has_many :branches, foreign_key: :parent_id
end
class Branch < ActiveRecord::Base
belongs_to :story
belongs_to :parent_page, :class_name => 'Page', foreign_key: :parent_id
belongs_to :destination_page, :class_name => 'Page', foreign_key: :destination_id, dependent: :destroy
end
With the associations created, I set to out to seed the database with a full, branching story I wrote back in 2007. Using the naming conventions I set up before, I was able to fairly easily import my story into the database page by page. While no longer functionally required, I used a similar naming convention for Pages
to keep track of which stories were connected to each other, using a create_branch
method to link them together in the database.
def create_branch(child, parent_id)
parent = Page.find(parent_id)
branch = parent.branches.create!({
destination_id: child[:id],
choice_text: child[:text]
})
story = @user.stories.first
story.branches << branch
end
---
@A1 = {
text: 'Super Mega Awesome Soda', id: story.pages.create!(
{
title: 'The Taste of Super Mega Awesome Soda',
content: "The moment the delectable fluid touches your tongue you are overwhelmed
with a magical flavor sensation that makes every nerve in your body radiate with blissful
satisfaction. After savoring the flavor you open you eyes and you are staring down a hideous
man-beast twice your size and thrice your width. He doesn't look too happy. What do you say?"
}
).id
}
create_branch(@A1, @A[:id])
For each Page
, I created a hash containing the choice text and reference to the page ID (combining the page initialization and id getter into a single statement for brevity). create_branch
would then take this new object and use it to define the child page for the parent page via a branch.
On the parent page (from the second parameter), a new branch would be created, passing in the choice text (choice_text
) of the child object as well as the id of the associated child page (destination_id
). To ensure all branches are associated with the correct Story
, the new branch is associated with the story’s branch collection via the shovel operator.
Returning to the view, I wanted to ensure the author was able to easily navigate through the pages of their story. I decided to add buttons to allow a user to quickly navigate to the parent page, parent story, and sibling stories. To do this, I had to create a system to ensure any given page rendered routed to the appropriate page.
To do this, I created a few additional routes for my BranchSrv
service, integrated with the Rails BranchesController
:
PageEditCtrl.js
BranchSrv.findPageByDestination({id: $scope.page.id}).$promise.then(function(data){
if(data){
$scope.fromChoiceText = data.choice_text;
initParentPage(data.parent_id);
}
});
BranchSrv.js
function BranchSrv($resource) {
return $resource('/branches/find_by_destination/:id',{},
{
findPageByDestination: {method: 'GET'},
query: {method: 'GET', isArray: true, url:'/branches'},
delete: {method: 'DELETE', url:'/branches/:id'}
});
}
branches_controller.rb
def find_by_destination
@branch = Branch.find_by(destination_id: params[:id])
render json: @branch
end
Getting sibling pages was comparably simpler, as that only involved looping through the branches of the parent page.
PageEditCtrl.js
var initSiblingPages = function(){
$scope.siblingPages = [];
_.each($scope.parentPage.branches, function(branch){
PageSrv.show({id: branch.destination_id}).$promise.then(function(data){
$scope.siblingPages.push(data);
});
});
};
While navigation between pages was functional, I still felt the linear display of pages within a story wasn’t visually appealing or useful to users. Thus I set out to create a tree view that would render out all the pages, visually displaying the various story paths a player could go down.
To do this, I used a recursive function to populate a multidimensional tree
array, then a recursive Angular template to render it out.
StoryEditCtrl.js
var getTree = function(page){
var tree = [];
var branches = _.where($scope.branches, {parent_id: page.id});
var pages = _.map(branches, function(branch){ return branch.destination_page });
_.each(pages, function(page){
tree.push({page: page, children: getTree(page)});
});
return tree;
};
branch_tree.html.erb
<ul>
<li ng-repeat='data in tree' ng-include="'tree_element_renderer.html'"> </li>
</ul>
tree_element_renderer.html.erb
<span>
<span class='btn btn-warning' ui-sref='edit_page({story_id: currentStory.id, page_id: data.page.id } )'>Edit</span>
<span class='btn btn-danger' ng-hide='data.page.complete'>Incomplete</span>
</span>
<ul>
<li ng-repeat='data in data.children' ng-include="'tree_element_renderer.html'"> </li>
</ul>
With these navigation features implemented, I moved on to adding a User system. For the sake of the project, I kept the user system fairly simple, allowing easy signup and login without special authorization or authentication. This was done to constrain the focus of my project to its core features, though it is an area I intend to revist in the future.
The User system primarily served as a means to associate stories with their authors. Whenever a User creates a Story, by default the story is not published. When a User clicks the button to save their story as published (via their published
boolean attribute), then it will be visible to all Users. By the same token, Pages
can be saved as finished or unfinished, and a Story cannot be published until all its child pages are marked as finished (via their finished
boolean attribute).
With these features implemented, all that remained was styling, cookies, and integration of ckEditor for text editing, which were comparably straightforward.
The Outcome
As intended, this proved to be my most challenging and sophisticated project to date. While I felt I had a solid handle on Angular going into the project, integrating it with a Rails backend took some effort and research to tailor my code to work with it. Once I understood how ngResource
queried the routes generated by Rails, the connection clicked, and it became much easier to work with.
The best way to set up my Page
and Branch
associations was something I agonized over for a while, but ultimately what cemented my decision was the question of flexibility. If a page directly knew its parents and children, then that would mean the path to any given page would be fixed. Hypothetically, a user may want to have certain choices lead back to an existing page, and I wanted to be able to support that. In its present form, my app has no easy way for players to do this, but I made the decision with an eye for where I want to take the app in its future incarnations.
The final challenge was deployment to Heroku. When my page failed to load, my first thought was that it wasn’t properly loading the dependencies needed to initialize Angular properly. I tried switching from npm to bower, and using RequireJS for loading dependencies instead, but ultimately it came down to an error with minifying the code. Once that was disabled, the page worked perfectly.
The Future
As with DSet, I created this app as a proof of concept of a larger app I intend to build. My ultimate goal is to create a platform for users to create and share not only choose-your-own-adventure stories, but text-based games complete with dice rolling mechanics, inventory, and even multiplayer. This app marks the first step down that road, laying out the core functionality for future development.