If you frequent developer related forums, this question probably caused your eyes to roll and the bile to rise in your throat. People with absolutely no development experience get the game creation bug and of all things want to start with MMOs. Amazingly enough, there is now a very simple answer… start here.
See, the folks over at Mozilla decided to create an HTML5 MMO to showcase the use of Web Sockets a technology for easy two way communication between a webpage and server ( that isn’t without it’s controversy ). Additionally it makes use of HTML features including Canvas, local storage, web workers and audio. Node is used on the server side. Here is the game in action on my desktop:
It is graphically simple, but when I played there were 73 other players online and it played fairly flawlessly. I also fired it up on my Android Phone ( Samsung Galaxy Note ) and here are the results:
Encouraging….
And… failure. It never leaves the connecting to server screen. Perhaps it’s just me, but for now it simply doesn’t work on my phone. Will try later on my ICS tablet and iPhone, it may just be launch day jitters so I will try again later.
Failures aside, why am I talking about a primitive browser based MMO on this site? Well, that’s because Mozilla made the complete source code available on GitHub!
Here for example is the code from character.js for defining the players character. As you can see, once you get over the Javascriptisms, it’s quite clean and easy to follow:
define(['entity', 'transition', 'timer'], function(Entity, Transition, Timer) { var Character = Entity.extend({ init: function(id, kind) { var self = this; this._super(id, kind); // Position and orientation this.nextGridX = -1; this.nextGridY = -1; this.orientation = Types.Orientations.DOWN; // Speeds this.atkSpeed = 50; this.moveSpeed = 120; this.walkSpeed = 100; this.idleSpeed = 450; this.setAttackRate(800); // Pathing this.movement = new Transition(); this.path = null; this.newDestination = null; this.adjacentTiles = {}; // Combat this.target = null; this.unconfirmedTarget = null; this.attackers = {}; // Health this.hitPoints = 0; this.maxHitPoints = 0; // Modes this.isDead = false; this.attackingMode = false; this.followingMode = false; }, clean: function() { this.forEachAttacker(function(attacker) { attacker.disengage(); attacker.idle(); }); }, setMaxHitPoints: function(hp) { this.maxHitPoints = hp; this.hitPoints = hp; }, setDefaultAnimation: function() { this.idle(); }, hasWeapon: function() { return false; }, hasShadow: function() { return true; }, animate: function(animation, speed, count, onEndCount) { var oriented = ['atk', 'walk', 'idle']; o = this.orientation; if(!(this.currentAnimation && this.currentAnimation.name === "death")) { // don't change animation if the character is dying this.flipSpriteX = false; this.flipSpriteY = false; if(_.indexOf(oriented, animation) >= 0) { animation += "_" + (o === Types.Orientations.LEFT ? "right" : Types.getOrientationAsString(o)); this.flipSpriteX = (this.orientation === Types.Orientations.LEFT) ? true : false; } this.setAnimation(animation, speed, count, onEndCount); } }, turnTo: function(orientation) { this.orientation = orientation; this.idle(); }, setOrientation: function(orientation) { if(orientation) { this.orientation = orientation; } }, idle: function(orientation) { this.setOrientation(orientation); this.animate("idle", this.idleSpeed); }, hit: function(orientation) { this.setOrientation(orientation); this.animate("atk", this.atkSpeed, 1); }, walk: function(orientation) { this.setOrientation(orientation); this.animate("walk", this.walkSpeed); }, moveTo_: function(x, y, callback) { this.destination = { gridX: x, gridY: y }; this.adjacentTiles = {}; if(this.isMoving()) { this.continueTo(x, y); } else { var path = this.requestPathfindingTo(x, y); this.followPath(path); } }, requestPathfindingTo: function(x, y) { if(this.request_path_callback) { return this.request_path_callback(x, y); } else { log.error(this.id + " couldn't request pathfinding to "+x+", "+y); return []; } }, onRequestPath: function(callback) { this.request_path_callback = callback; }, onStartPathing: function(callback) { this.start_pathing_callback = callback; }, onStopPathing: function(callback) { this.stop_pathing_callback = callback; }, followPath: function(path) { if(path.length > 1) { // Length of 1 means the player has clicked on himself this.path = path; this.step = 0; if(this.followingMode) { // following a character path.pop(); } if(this.start_pathing_callback) { this.start_pathing_callback(path); } this.nextStep(); } }, continueTo: function(x, y) { this.newDestination = { x: x, y: y }; }, updateMovement: function() { var p = this.path, i = this.step; if(p[i][0] < p[i-1][0]) { this.walk(Types.Orientations.LEFT); } if(p[i][0] > p[i-1][0]) { this.walk(Types.Orientations.RIGHT); } if(p[i][1] < p[i-1][1]) { this.walk(Types.Orientations.UP); } if(p[i][1] > p[i-1][1]) { this.walk(Types.Orientations.DOWN); } }, updatePositionOnGrid: function() { this.setGridPosition(this.path[this.step][0], this.path[this.step][1]); }, nextStep: function() { var stop = false, x, y, path; if(this.isMoving()) { if(this.before_step_callback) { this.before_step_callback(); } this.updatePositionOnGrid(); this.checkAggro(); if(this.interrupted) { // if Character.stop() has been called stop = true; this.interrupted = false; } else { if(this.hasNextStep()) { this.nextGridX = this.path[this.step+1][0]; this.nextGridY = this.path[this.step+1][1]; } if(this.step_callback) { this.step_callback(); } if(this.hasChangedItsPath()) { x = this.newDestination.x; y = this.newDestination.y; path = this.requestPathfindingTo(x, y); this.newDestination = null; if(path.length < 2) { stop = true; } else { this.followPath(path); } } else if(this.hasNextStep()) { this.step += 1; this.updateMovement(); } else { stop = true; } } if(stop) { // Path is complete or has been interrupted this.path = null; this.idle(); if(this.stop_pathing_callback) { this.stop_pathing_callback(this.gridX, this.gridY); } } } }, onBeforeStep: function(callback) { this.before_step_callback = callback; }, onStep: function(callback) { this.step_callback = callback; }, isMoving: function() { return !(this.path === null); }, hasNextStep: function() { return (this.path.length - 1 > this.step); }, hasChangedItsPath: function() { return !(this.newDestination === null); }, isNear: function(character, distance) { var dx, dy, near = false; dx = Math.abs(this.gridX - character.gridX); dy = Math.abs(this.gridY - character.gridY); if(dx <= distance && dy <= distance) { near = true; } return near; }, onAggro: function(callback) { this.aggro_callback = callback; }, onCheckAggro: function(callback) { this.checkaggro_callback = callback; }, checkAggro: function() { if(this.checkaggro_callback) { this.checkaggro_callback(); } }, aggro: function(character) { if(this.aggro_callback) { this.aggro_callback(character); } }, onDeath: function(callback) { this.death_callback = callback; }, /** * Changes the character's orientation so that it is facing its target. */ lookAtTarget: function() { if(this.target) { this.turnTo(this.getOrientationTo(this.target)); } }, /** * */ go: function(x, y) { if(this.isAttacking()) { this.disengage(); } else if(this.followingMode) { this.followingMode = false; this.target = null; } this.moveTo_(x, y); }, /** * Makes the character follow another one. */ follow: function(entity) { if(entity) { this.followingMode = true; this.moveTo_(entity.gridX, entity.gridY); } }, /** * Stops a moving character. */ stop: function() { if(this.isMoving()) { this.interrupted = true; } }, /** * Makes the character attack another character. Same as Character.follow but with an auto-attacking behavior. * @see Character.follow */ engage: function(character) { this.attackingMode = true; this.setTarget(character); this.follow(character); }, disengage: function() { this.attackingMode = false; this.followingMode = false; this.removeTarget(); }, /** * Returns true if the character is currently attacking. */ isAttacking: function() { return this.attackingMode; }, /** * Gets the right orientation to face a target character from the current position. * Note: * In order to work properly, this method should be used in the following * situation : * S * S T S * S * (where S is self, T is target character) * * @param {Character} character The character to face. * @returns {String} The orientation. */ getOrientationTo: function(character) { if(this.gridX < character.gridX) { return Types.Orientations.RIGHT; } else if(this.gridX > character.gridX) { return Types.Orientations.LEFT; } else if(this.gridY > character.gridY) { return Types.Orientations.UP; } else { return Types.Orientations.DOWN; } }, /** * Returns true if this character is currently attacked by a given character. * @param {Character} character The attacking character. * @returns {Boolean} Whether this is an attacker of this character. */ isAttackedBy: function(character) { return (character.id in this.attackers); }, /** * Registers a character as a current attacker of this one. * @param {Character} character The attacking character. */ addAttacker: function(character) { if(!this.isAttackedBy(character)) { this.attackers[character.id] = character; } else { log.error(this.id + " is already attacked by " + character.id); } }, /** * Unregisters a character as a current attacker of this one. * @param {Character} character The attacking character. */ removeAttacker: function(character) { if(this.isAttackedBy(character)) { delete this.attackers[character.id]; } else { log.error(this.id + " is not attacked by " + character.id); } }, /** * Loops through all the characters currently attacking this one. * @param {Function} callback Function which must accept one character argument. */ forEachAttacker: function(callback) { _.each(this.attackers, function(attacker) { callback(attacker); }); }, /** * Sets this character's attack target. It can only have one target at any time. * @param {Character} character The target character. */ setTarget: function(character) { if(this.target !== character) { // If it's not already set as the target if(this.hasTarget()) { this.removeTarget(); // Cleanly remove the previous one } this.unconfirmedTarget = null; this.target = character; } else { log.debug(character.id + " is already the target of " + this.id); } }, /** * Removes the current attack target. */ removeTarget: function() { var self = this; if(this.target) { if(this.target instanceof Character) { this.target.removeAttacker(this); } this.target = null; } }, /** * Returns true if this character has a current attack target. * @returns {Boolean} Whether this character has a target. */ hasTarget: function() { return !(this.target === null); }, /** * Marks this character as waiting to attack a target. * By sending an "attack" message, the server will later confirm (or not) * that this character is allowed to acquire this target. * * @param {Character} character The target character */ waitToAttack: function(character) { this.unconfirmedTarget = character; }, /** * Returns true if this character is currently waiting to attack the target character. * @param {Character} character The target character. * @returns {Boolean} Whether this character is waiting to attack. */ isWaitingToAttack: function(character) { return (this.unconfirmedTarget === character); }, /** * */ canAttack: function(time) { if(this.canReachTarget() && this.attackCooldown.isOver(time)) { return true; } return false; }, canReachTarget: function() { if(this.hasTarget() && this.isAdjacentNonDiagonal(this.target)) { return true; } return false; }, /** * */ die: function() { this.removeTarget(); this.isDead = true; if(this.death_callback) { this.death_callback(); } }, onHasMoved: function(callback) { this.hasmoved_callback = callback; }, hasMoved: function() { this.setDirty(); if(this.hasmoved_callback) { this.hasmoved_callback(this); } }, hurt: function() { var self = this; this.stopHurting(); this.sprite = this.hurtSprite; this.hurting = setTimeout(this.stopHurting.bind(this), 75); }, stopHurting: function() { this.sprite = this.normalSprite; clearTimeout(this.hurting); }, setAttackRate: function(rate) { this.attackCooldown = new Timer(rate); } }); return Character; });
So now, the next time somebody asks you “I want to create an MMO, now what?”, you have an easy answer. Send them over to Browser Quest.
Oh, and for the record… starting your game development career off with an MMO is still a right stupid idea!
EDIT: As per a comment on reddit, this code doesn’t appear to be released under a specific license yet. Keep that in mind before using any of this code in your own project. Of course, this should be rectified shortly.
News