Building CanyonRunner was a tremendous amount of fun, thanks largely to Richard Davey's excellent Phaser framework. Along the way, I was assisted by many helpful Phaser community members and developers, so I wanted to give back by:
I've written before about how I was able to build the game from start to finish in 76 days. In the course of developing it, one of the running themes I noticed on the Phaser forums was that most developers were tackling their first game and were unsure about how to implement common game features like saved games, multiple levels, different experiences for mobile and desktop, etc.
Phaser is well organized and documented, so while its various API's and systems were easy to get started with, it was less clear to many developers how to fit everything together into a coherent gaming experience. I open sourced CanyonRunner and decided to do an in-depth post about its various features in order to create a resource for other developers that might be in the middle of developing their own HTML5 game.
Hopefully some of the features I built into CanyonRunner, such as player-specific saved games, multiple levels each with their own atmosphere and game mechanics, different experiences optimized for desktop / mobile, and alternate endings determined by player performance, will resonate with and assist other game developers.
To get a sense of CanyonRunner, or to play it through in its entirety (which will take you less than 10 minutes if you make zero mistakes), click the button below to play the full desktop version hosted on this site, or the Android App badge to get to the android version in the Google Play Store.
Here's a look at some screenshots from the actual game. I wanted the game to have a retro feel. At the same time, the story, presented via inter-level navigation sequences, builds up an eerie atmosphere.
Want to get a sense of the gameplay without dodging spires yourself? Watch this full playthrough of the game to quickly get up to speed on the feel and main game mechanics of CanyonRunner.
What you'll find in this post:
CanyonRunner is a 2D side-scrolling action & adventure game, complete with a story, two possible endings, automatically saved game progress, aerial dogfights and air to air missle combat, and atmospheric special effects.
You assume the role of the mysterious CanyonRunner, a lone pilot navigating their rocket through a perilous 3 stage journey as they struggle to return to their family with desperately needed supplies.
Depending upon their performance, players are shown one of two possible endings after completing the game.
The CanyonRunner project is structured such that:
You can view the full project on Github here if you want to explore the structure on your own.
Let's take a look at the project's file tree, then consider the general purpose of each directory in turn:
. ├── Gruntfile.js ├── assets │ ├── audio │ │ ├── audio.json │ │ ├── audio.m4a │ │ └── audio.ogg │ ├── backgrounds │ │ ├── desert-open.png │ │ ├── level1-background.png │ │ ├── level2-background.png │ │ ├── level3-background.png │ │ └── sad-desert.png │ ├── favicon.png │ └── sprites │ ├── advance-button.png │ ├── asteroid1.png │ ├── asteroid10.png │ ├── asteroid11.png │ ├── asteroid12.png │ ├── asteroid13.png │ ├── asteroid14.png │ ├── asteroid15.png │ ├── asteroid16.png │ ├── asteroid17.png │ ├── asteroid18.png │ ├── asteroid19.png │ ├── asteroid2.png │ ├── asteroid20.png │ ├── asteroid3.png │ ├── asteroid4.png │ ├── asteroid5.png │ ├── asteroid6.png │ ├── asteroid7.png │ ├── asteroid8.png │ ├── asteroid9.png │ ├── bandit-missile.png │ ├── bandit.png │ ├── canyon-runner-splash.png │ ├── cry-about-it-button.png │ ├── down-arrow.png │ ├── explosion1.png │ ├── explosion10.png │ ├── explosion11.png │ ├── explosion12.png │ ├── explosion13.png │ ├── explosion14.png │ ├── explosion15.png │ ├── explosion16.png │ ├── explosion2.png │ ├── explosion3.png │ ├── explosion4.png │ ├── explosion5.png │ ├── explosion6.png │ ├── explosion7.png │ ├── explosion8.png │ ├── explosion9.png │ ├── fire-missile-button-desktop.png │ ├── fire-missile-button-mobile.png │ ├── fire1.png │ ├── fire2.png │ ├── fire3.png │ ├── happy-splashscreen.png │ ├── healthkit.png │ ├── healthorb1.png │ ├── healthorb2.png │ ├── healthorb3.png │ ├── home-burning.png │ ├── how-to-play-desktop.png │ ├── how-to-play-mobile.png │ ├── inverted-rock.png │ ├── kaboom.png │ ├── left-arrow.png │ ├── missile.png │ ├── navigation-bandit.png │ ├── navigation-home.png │ ├── navigation-supply.png │ ├── pause-button.png │ ├── play-again-button.png │ ├── progress.png │ ├── right-arrow.png │ ├── rock.png │ ├── rocket-sprite.png │ ├── sad-splashscreen.png │ ├── scrap1.png │ ├── scrap2.png │ ├── scrap3.png │ ├── scrap4.png │ ├── share-the-love-button.png │ ├── smoke-puff.png │ ├── sound-icon.png │ ├── sprites.json │ ├── sprites.png │ ├── start-button.png │ ├── success.png │ ├── try-again-button.png │ └── up-arrow.png ├── build │ ├── CanyonRunner.js │ ├── CanyonRunner.min.js │ ├── config.php │ ├── custom │ │ ├── ninja.js │ │ ├── ninja.min.js │ │ ├── p2.js │ │ ├── p2.min.js │ │ ├── phaser-arcade-physics.js │ │ ├── phaser-arcade-physics.min.js │ │ ├── phaser-ninja-physics.js │ │ ├── phaser-ninja-physics.min.js │ │ ├── phaser-no-libs.js │ │ ├── phaser-no-libs.min.js │ │ ├── phaser-no-physics.js │ │ ├── phaser-no-physics.min.js │ │ ├── pixi.js │ │ └── pixi.min.js │ ├── phaser.d.ts │ ├── phaser.js │ ├── phaser.map │ └── phaser.min.js ├── compiler.jar ├── css │ └── stylesheet.css ├── icons │ ├── app_icon_1024x1024.png │ ├── app_icon_114x114.png │ ├── app_icon_120x120.png │ ├── app_icon_144x144.png │ ├── app_icon_152x152.png │ ├── app_icon_256x256.png │ ├── app_icon_512x512.png │ ├── app_icon_57x57.png │ ├── app_icon_60x60.png │ ├── app_icon_72x72.png │ └── app_icon_76x76.png ├── images │ └── orientation.jpg ├── index.html ├── package.json ├── server.js └── src ├── Boot.js ├── EmotionalFulcrum.js ├── EveryThingYouBelievedAboutYourFamilyWasHellishlyWrong.js ├── HomeSweetHome.js ├── HowToPlay.js ├── Level1.js ├── Level2.js ├── Level3.js ├── MainMenu.js ├── NavigationBandit.js ├── NavigationHome.js ├── NavigationSupply.js └── Preloader.js 10 directories, 143 files
.gitignore: This special file tells the version control system, git, which files it can "ignore" or not worry about placing under source control. If your game project is generating logs, debug output, or uses node_modules, you can save space in your repository by specifying these files and directories in your .gitignore file.
Gruntfile.js: I used the command line task-runner Grunt in order to automate some of tedious and repetitive development tasks. Grunt will be familiar to many web developers, but for those of you who have not encountered it before, Grunt allows you to define tasks, namely those that you find yourself repeatedly having to perform while developing, and bundle them together into a single or a few commands.
As an example, if you are working with scss, you may constantly find yourself performing the same mundane tasks as you build out your project, such as concatenting 4 different scss files together, then compiling them to raw css, then minifying that resulting css file and moving it into a specific folder where it can be served.
Note that the assets are also moved by Grunt into their correct destinations during the build. Once your Gruntfile is in place and configured correctly, it's much easier to just type "grunt" in your terminal and get a perfectly built game as an output than to build one manually. This is doubly true if you're testing something that requires you to make changes and then build, or if you're trying to remember how to build the project after 3 months of not touching it.
The reason we soldier through the initial tedium of configuring Grunt is that once we have everything set up, we can literally build a distribution-ready copy of our game in a single command:
This distribution directory can now be named whatever you want and handed off to a customer or game hosting site for distribution. Having this build infrastructure in place will save you dozens of hours over the course of a project.
Now, let's consider the purpose of each directory.
This directory holds the audio files, background images, and spritesheets used by our game.
This directory is home to the files that are required by our game to work, such as the actual Phaser framework files. Our build tool gathers up only what is needed from here when creating a distribution.
Holds the simple stylesheet required to make the orientation (rotate your phone!) image work properly.
Holds the various sized app icons that would be required by, say, an iOS app that was loading your Phaser game in a webview.
This directory holds a special image required by Phaser to render the screen telling the user they should rotate their phone to landscape mode in order to play the game. When phaser detects that the user's phone is being held upright, this image is used to render that hint on screen.
This is the directory where npm, node's package manager, installs dependencies. When you require a module in your node.js script, one of the places node looks for that module is here. In the case of this project, our server.js file (see next section) uses the express module, which would end up here after running the npm install command.
You could set up a traditional web stack with apache, or use something that handles this for you such as Mamp. I feel these options are too involved for what we want to do: simply serve up our index.html file locally so we can view it at localhost:8080. Our index.html file will in turn load the Phaser library, and then our game itself so we can test changes with low hassle and iterate quickly.
Follow these instructions to install Node.js on your machine. Once that's done, you can run the server.js file in the project root by typing:
$ server.jsNow you can play and test your Phaser game by typing localhost:8080 into your browser.
Let's take a look at what this simple utility script looks like:
Notice we're requiring the Express module to abstract away serving static assets. This means you'll need to install express library locally to your project in order for server.js to work. If you don't already have express installed globally on your system, type:
$ sudo npm iThis command will pull down all required dependencies from npm, node's package management system.
With our simple fileserver in place, all we have to do to view changes to our source code or playtest our game is run our server and visit localhost:8080.
Phaser games use preloaders as special game states to perform setup and configuration tasks that can or must be run before a more interactive game state is loaded.
Let's examine CanyonRunner's preloader state. It has a few important setup tasks to perform. First, it sets up the background for the splashscreen and loads the various levels' background images.
There's a very handy Phaser convenience feature known as a Preload Sprite that I'm taking advantage of here to render the loading bar that says "loading" and expands from 0 to 100% as the splashscreen itself is being prepared. First, you set up the sprite that will be used as the preloadBar. Then you can call the method setPreloadBar and pass in the sprite - Phaser handles all the internal timing and display logic for us.
Another important task that falls to the preloader is to determine which type of audiosprite should be loaded. An audiosprite is a single file that contains all the sound effects and songs required by the game mashed together, to save space and bandwidth during asset delivery. Tools that create audiosprites also export a map file, usually in json or xml, that explicitly states the time slices of the audiosprite wherein each individual sound and song starts and ends. Let's take a look:
This mapping file allows frameworks like Phaser to efficiently deliver a single audio file, while still allowing the game developer the convenience of referring to individual sounds and songs by their key names while programming.
After developing CanyonRunner by using separate audio files for each sound, I used a free tool to create one single audiosprite for each supported filetype (different browsers support different audiosprite filetypes). That's why the preloader uses Phaser's device helper methods to determine which browser the player is using. Firefox and Chrome support .ogg files, while Safari supports .m4a. You can convert your final audiosprite to both formats and include it in your assets directory. With your preloader determining the proper format based on your user's browser, each player will get a single optimized audiosprite that will run perfectly for them.
Successfully building a complete game requires attention to lots of small details which, taken together, build up a feeling of a polished and finished product.
One of the first contact points our players will have with our game is the splashscreen. A good splashscreen can set up the feel and mood of the game, begin introducing the themes that will run throughout, and get the player excited about playing. Let's take a look at how we can create a splashscreen for our Phaser HTML5 game.
Here is the full MainMenu.js file for CanyonRunner, which sets up the intro splashscreen and waits for the player to click the Start button:
From reading through the source code you can see that the MainMenu.js file does a few key things:
A good splashscreen could be as simple as a static background image with a start button. The main goal is to provide an introduction to the game. Notice I've created a mute button on the intro scene - but not a pause button. It's a good idea to give your player the option to mute the game early on in case they're playing in a situation where they don't want sound output. However, on this particular screen a pause button is irrelevant, since the intro scene will loop forever until the user taps or clicks Start.
HTML5 features a robust storage system known as Local Storage. Local storage offers an attractive means of persisting user data for HTML5 game developers. It is widely supported across many different browser and devices and offers a simple interface for storing and retrieving custom objects.
In the case of CanyonRunner, I store a few key things on the user's system so that I can persist their game progress in case they complete only one or two levels in one session and return later. I call this object playerStats - it's a json object with 3 attributes:
The create function of a given Phaser state is the perfect time to inspect localStorage to see if the player already has an object stored (and to create one if they don't).
Invoking the Local Storage API, I use the localStore.getItem method to check for the special object name I use to set save objects for CanyonRunner. The idea here is similar to namespacing your WordPress plugins - you don't have control over the storage keynames that other developers might write to the user's browser via other games, webapps or websites. To prevent collisions, you should namespace your storage object's name to your game - adding some random numbers decreases the chances of collision.
In the previous gist above, you can see the logic for updating the player's progress and scores in the handlUserDataLoss and handleUserDataLevelComplete functions.
This is probably my personal favorite feature of CanyonRunner. Let's say I have CanyonRunner set up and hosted at a particular URL. If you visit this URL with your desktop / laptop browser, you'll get the full desktop version - complete with the keyboard control scheme and the extra fancy (and resource intensive!) particle effects like rocket and missile afterburners and glowing healing mist on healthkits.
However, should you happen to hit the same URL with your smartphone, you'll be given the optimized mobile version, with touchpad controls rendered right over the game scene, and no particle effects (to drastically improve mobile performance).
I implemented this feature because I wanted one single instance of the CanyonRunner game to work for all players regardless of what device they were using to play. As the game developer, this also makes my life easier, because once I have the logic and assets in place to handle and serve the two different versions of the game, I don't have to worry about supporting and keeping on parity two actually separate codebases.
The two main pieces to this feature are the game logic that checks for whether the player is using a desktop or mobile device, and the assets and functions that work together to render the mobile touchpad on screen and bind its buttons to the correct player actions. Let's take a look:
You can see I'm leveraging Phaser's game.device.desktop method to determine which type of device the player is using, allowing me to implement the two control schemes within an if else statement. Notice that when rendering the mobile gamepad, I'm setting each button's fixedToCamera property to true. Given that CanyonRunner is a side-scroller, doing this prevents the buttons from sliding off the screen at the start of the level, which would make them considerably less useful to the player.
Phaser's helper device methods that determine which kind of device your players are using make it easy to optimize your game experience for desktop, mobile and tablet form-factors simultaneously.
Recent triple A titles as well as classic old school games have explored the concept of multiple endings. Multiple endings increase replay value by allowing players to do multiple playthroughs, following different paths or making different major plot decisions depending on the type of ending they are trying to get.
Multiple endings also allow you to make thematic statements about the kinds of choices, behaviors or chance occurrences that lead to your protagonist achieving either glory or infamy, salvation or condemnation.
I wanted to explore this concept with CanyonRunner, so I implemented a simple multiple ending system. You will get one of two possible endings when you play through CanyonRunner, depending upon how quickly you complete the game. This is one of the reasons that I keep track of the player's "Top Time" or total number of seconds since beginning to play through Level 1. This concept of time being precious and limited is thematically harmonious with CanyonRunner's story: you are racing desperately needed food and supplies home to your family in a barren post-apocalyptic wasteland. If you take too long doing so, you simply no longer have a family to return to.
If you want to implement multiple endings in your own Phaser game, the underlying logic of how you determine which ending a player unlocks is up to you, but here's a high level overview of how you would organize such a concept in your code:
As the player progresses through your game, you keep tabs on one or more performance metrics. This could be their total score, how many hostages they rescued, what physical percentage of the world they explored and walked over, how much gold they ended up with, how many innocents they waxed, etc. If you want this to persist between game sessions, you'll want to store this information either via Local Storage, a cookie, or your user database if you have one.
After the player has completed the final level, or slain the final boss, or found the final hidden object, at whichever point in your particular game the player is considered to have "won", you can have some logic that inspects this player performance information to make a determination about which game state they will proceed to. Maybe your player collected over 1500 gold throughout the course of playing, and rescued 25 innocents, so they'll receive the "You are rich and beneficent and live happily ever after" ending. Maybe they killed every NPC they came across to enrich themselves, so they'll get the "You're an infamous monster that nobody likes" ending.
At this point, actually showing the player the correct ending is simply a matter of calling game.state.start with the right state name for the ending they've earned.
Let's take a look at how I implemented this in CanyonRunner. Regardless of which ending the player will ultimately unlock, all players will see this interstitial scene after completing the 3rd level. It's the scene that shows the CanyonRunner obtaining a lock on their home beacon and descending to land at home. This makes it a great place to execute the logic that determines which ending to give the player, since this is something that can be done in the background while the player is watching the actual scene on screen. You can see where I'm determining and starting the correct ending within the rocketLanding function:
I hope this tutorial and examination of some of CanyonRunner's game mechanics and features was helpful to you. If it was, please say thanks by tweeting or sharing this post or starring the CanyonRunner repo on Github.Tweet
If something isn't clear or if you'd like to see some other feature or mechanic explained that isn't called out here, or if you just have general feedback, please drop me an e-mail.