Table of Contents
Introduction
Have you ever played Air Hockey? Air Hockey is a fairly popular game in game centers and other places where people hang out. Not that many people had a chance to try it as a pre-installed game on operating systems. The purpose of the game is to hit the puck to the opponent’s goal with a paddle. The puck is set on a special table, which produces a cushion of air on the play surface through tiny holes with the purpose of reducing friction and increasing play speed. The winner is the player who scores 7 points first.
In this article, we will create an air hockey game for three mobile platforms: iOS, Android and Bada using Moscrif SDK.
About the Game
Our goal is to create a single player game playing against artificial intelligence. When the game starts, the playground shows up right away. Menu will be created as a dialog window with only three buttons: New game, Continue and Quit (Quit button will not be available on iOS).
Graphic Design
Let’s start our development process by preparing the graphics. Our game is really simple so the graphics do not need to be too complicated either. They consist of the menu, playground backgrounds, buttons, paddles and the puck.
Image: graphic design
The current mobile market offers devices with many various resolutions. To ensure the best appearance on every device, the graphics are created separately for all commonly used resolutions.
Physics Engine
The puck in the game behaves according to physical laws. It bounces from the paddles and barriers, and it moves with a small damping. To simulate this physical behavior, we used box2d physical engine which is supported by Moscrif SDK. This engine can be seen also in other platforms like Nintendo DS or Wii. The chapters about box2d world and bodies are similar as in my last article. If you read it in the last article, you can skip to Let’s start – start up file & resources chapter.
World
The world creates a background for all bodies, joints or contacts. The world has always width which is equivalent of 10 metres in real world. All objects and things in the world are scaled to ensure this width. The scale property says how many pixels are in one meter. The box2d also uses its own coordinates which start in the left bottom corner and are counted in meters.
Image: box2d coordinates
Fortunately, Moscrif’s framework usually uses normal pixel coordinates and conversions are made automatically.
Bodies
Bodies are another important part of box2d physical engine. Using bodies, we create all objects which interact in the world. In our game puck, paddles, barriers and goals are created as bodies. Box2d supports three types of bodies which have different behaviour. First difference is that not all types of bodies collide together. The next table shows which bodies collide together.
Different types of bodies also behave differently under simulation. Static bodies do not move under simulation and have infinite mass. They do not move according to forces or velocity. In our game, static bodies are barriers and goals. Kinematic bodies move only according to its velocity and it also behaves as infinite mass. Dynamic bodies are fully simulated. In our game, the dynamic bodies are paddles and goals.
Other important body properties are density, friction and bounce. The bounce property affects the speed of the body after bounce. V 1.0 means that the body bounces with the same speed as it fell; however, there is a possibility to increase the bounce rate and the ball will bounce off with more velocity.
Let’s Start – Start Up File & Resources
Start Up File
We are going to create our game in Moscrif IDE where we need to start a new project based on the game’s framework. By default, the start up file is main.ms. This file contains an instance of Game
framework class, which is a base class for all game projects in Moscrif. The Moscrif‘s game
framework also offers PhysicsScene
class which creates physics world and combines all other physical and non-physical objects. PhysicsScene
class is also used for creating a GameScene
class, which puts playground into existence and its instance is created in onStart
event of Game
class which is called when the game starts.
Example: Create a new instance of GameScene
game.onStart = function(sender)
{
if (res.supportedResolution) {
this.gameScene = new GameScene();
this.push(this.gameScene);
this.gameScene.reset();
}
this._paint = new Paint();
this._paint.textSize = System.height / 30;
var (w, h) = this._paint.measureText("Unsupported resolution");
this._textWidth = w;
}
In main.ms are also managed users‘ events like pointer or key pressed.
Example: Manage user events
game.onKeyPressed = function(sender, keyCode)
{
if (keyCode == #back || keyCode == #home)
game.quit();
}
game.onPointerPressed = function()
{
if (!res.supportedResolution)
game.quit();
} game.onKeyPressed = function(sender, keyCode)
{
if (keyCode == #back || keyCode == #home)
game.quit();
}
game.onPointerPressed = function()
{
if (!res.supportedResolution)
game.quit();
}
Resources
As I wrote earlier, the graphics are made separately for more resolutions. However, all images are managed by Resource
class which means that we do not need to worry about device resolutions in the next development process. An instance of the resource
class is created as a second global variable in main.ms which also ensures that all images will be loaded only once and to be saved into device’s memory and performance.
Example: load resources in class constructor
function this()
{
this._supportedResolution = true;
this._images = {
background : this._loadImage("backGame", "jpg");
playerHuman : this._loadImage("player2");
playerAI : this._loadImage("player1");
puck : this._loadImage("puck");
menuBg : this._loadImage("menuBack");
menuButton : this._loadImage("menuBtn");
menuButtonPressed : this._loadImage("menuBtnPress");
menuPart : this._loadImage("menuPart");
};
...
}
The _loadImage
function loads images according to device resolution.
Example: load images according to device resolution
function _loadImage(filename, format = "png")
{
var file = "app://" + System.width + "_" + System.height + "/" + filename + "." + format;
var bitmap;
if (System.isFile(file)) {
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
if (System.width == 600) {
file = "app://" + System.width + "_1024" + "/" + filename + "." + format;
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
if (System.width == 800) {
file = "app://" + System.width + "_1280" + "/" + filename + "." + format;
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
if (System.width == 480) {
file = "app://" + System.width + "_800" + "/" + filename + "." + format;
bitmap = Bitmap.fromFile(file);
if (bitmap != null)
return bitmap;
}
this._supportedResolution = false;
return null;
}
Game Scene
Now, let’s create the most important part of our game -> game scene. The game scene is the playground: table, barriers, goals, puck and paddles. The game scene is created by GameScene
class extended from PhysicsScene
, which creates the physics world. When framework classes are constructed, they call init
(also beforeInit
and afterInit
) method. In our game, we are going to create the physics world for the scene, set events onBeginContact
and onEndContact
which are called when two bodies collide in the scene, and also create all other objects in init
function. Other game elements like the puck, barriers, goals or paddles are created by separate functions like PhysicsSprite
objects.
The game scene also draws table image and score. The image is resized to the full screen for cases when the Resource
class cannot find images for current device resolution.
Example: Draw background image and score
function draw(canvas)
{
canvas.drawBitmapRect(res.images.background, 0, 0, res.images.background.width,
res.images.background.height, 0, 0, System.width, System.height);
canvas.save();
canvas.rotate(270);
canvas.drawText(this.playerAI.score.toString(),
System.height / - 2 - 2 * this._scoreW,
System.width / 16 + this._scoreH, res.paints.scoreBlue);
canvas.drawText(this.playerHuman.score.toString(),
System.height / - 2 + this._scoreW,
System.width / 16 + this._scoreH, res.paints.scoreGreen);
canvas.restore()
super.draw(canvas);
}
Puck
Puck
is created as an instance of PhysicsSprite
class. Its type is set to dynamic which means that it collides with other static or dynamic bodies. The puck‘s bounce property is one, which means that it bounces from other bodies with some force as it falls. To achieve the realistic physical behavior, we need to apply linear damping to the body. In real air hockey, the puck moves on the air cushion so the damping between the puck and table is really small and the puck moves fast. It’s image is as well as all other images in the game loaded from the resources.
Example: create puck
function _createPuck()
{
const density = 1.0, friction = 0.2, bounce = 1.0;
var puck = this.addCircleBody(res.images.puck, #dynamic,
density, friction, bounce, res.images.puck.width / 2);
puck.setPosition(System.width / 2, System.height / 2);
puck.fixedRotation = true;
puck.bullet = true;
puck.setLinearDamping(0.3);
return puck;
}
Barriers
Barriers prevent the puck and mallets from leaving the table. They are around the whole table except the goals. Their type is set to static
, which means that they do not move under the simulation but collide with other dynamic bodies (puck and paddles). Together with barriers around the playground, we are going to create also four invisible squares in the table corners. Without these squares, the puck gets stuck sometimes near the left or right barrier and moves along up and down. However, when the puck hits one of these squares in the corners, it bounces away from the barrier.
Image: barriers and „inivisible“ squares around the playground
Example: create barriers
function _createBarriers()
{
const density = 0.0, friction = 0.0, bounce = 0.0;
const width = System.width / 4, height = System.width / 32;
var topWallA = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
topWallA.setPosition(System.width / 8, 1);
var topWallB = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
topWallB.setPosition(System.width - System.width / 8, 1);
var bottomWallA = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
bottomWallA.setPosition(System.width/8, System.height);
var bottomWallB = this.addPolygonBody
(null, #static, density, friction, bounce, width, height);
bottomWallB.setPosition(System.width - System.width / 8, System.height);
var leftWall = this.addPolygonBody(null, #static, density, friction,
bounce, System.width / 32, System.height);
leftWall.setPosition(1, System.height / 2);
var rightWall = this.addPolygonBody(null, #static, density, friction,
bounce, System.width / 32, System.height);
rightWall.setPosition(System.width, System.height / 2);
var leftTop = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
leftTop.setPosition(this.puckRadius / 2, System.width / 60);
var rightTop = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
rightTop.setPosition(System.width - this.puckRadius / 2, System.width / 60);
var leftBottom = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
leftBottom.setPosition(this.puckRadius / 2, System.height - System.width / 60);
var rightBottom = this.addPolygonBody(null, #static, density, friction,
bounce, this.puckRadius, this.puckRadius);
rightBottom.setPosition(System.width - this.puckRadius / 2,
System.height - System.width / 60);
}
Goals
The goals are situated in the center of the top and bottom barrier. The goals are also bordered by three barriers on their left, right and top (or bottom) side, but these barriers are out of the screen to allow the puck to leave the playground. When the puck collides with some barriers inside the goals, the puck is removed and the score updated.
Image: goals
Example: create goals
function _createGoals()
{
const density = 0.0, friction = 0.0, bounce = 0.0;
var goalA = this.addPolygonBody(null, #static, density, friction, bounce,
this.goalsWidth, System.width / 32);
goalA.beginContact = function(contact) { this super._checkGoal(contact, #playerAI); }
goalA.setPosition(System.width / 2, -2 * this.puckRadius + System.width / 32);
var goalALeft = this.addPolygonBody(null, #static, density, friction, bounce,
System.width / 32, 2 * this.puckRadius);
goalALeft.beginContact = function(contact) { this super._checkGoal(contact, #playerAI); }
goalALeft.setPosition(System.width / 2 - this.goalsWidth / 2 - this.puckRadius,
-1 * this.puckRadius);
var goalARight = this.addPolygonBody(null, #static, density, friction, bounce,
System.width / 32, 2 * this.puckRadius);
goalARight.beginContact = function(contact) { this super._checkGoal(contact, #playerAI); }
goalARight.setPosition(System.width / 2 + this.goalsWidth / 2 + this.puckRadius,
-1 * this.puckRadius);
...}
Paddles
Both paddles are created by the same function _createPaddle
function, which creates circular body with different image for human and for AI paddle. The density of the paddle is bigger than the puck’s density, which causes that with bigger size comes bigger mass comparing to the puck. When colliding two bodies with different mass, the movement of the body with larger mass is affected less.
Example: create paddles
function _createPaddle(paddleType)
{
assert paddleType == #playerAI || paddleType == #playerHuman;
const density = 1.1, friction = 0.3, bounce = 0.0;
var paddle = (paddleType == #playerAI)
? this.addCircleBody(res.images.playerAI, #dynamic, density,
friction, bounce, res.images.playerAI.width / 2)
: this.addCircleBody(res.images.playerHuman, #dynamic, density,
friction, bounce, res.images.playerHuman.width / 2);
paddle.fixedRotation = true;
paddle.setLinearDamping(5.0);
return paddle;
}
Contacts
When two bodies collide in the scene, two events are called: onBeginContact
, when collision starts and onEndContact
when collision ends. We map both events onto class member functions: _beginConcat
and _endContact
in game scene’s init
function.
Example: map both event functions
function init()
{
this._world = new b2World(0.0, 0.0, true, true);
this.onBeginContact = function(sender, contact) { this super._beginContact(contact); }
this.onEndContact = function(sender, contact) { this super._endContact(contact); }
...
}
There are many bodies that can come into contact with one another (human paddle & puck, AI paddle & puck, human paddle & AI paddle, puck & barriers, etc.). Checking particular bodies which collide throws any if
or switch
condition which may be too complicated. To simplify the contact management, we add to all bodies, which should raise an event when they collide, call back function to beginContact
or endContact
variable. Moscrif’s JavaScript engine allows to create both local and member function anywhere in the code. It means that the variables do not have to be defined in the class, but it can be simply added to any separate objects similar to that in the next example:
Example
this.paddleAI = this._createPaddle(#playerAI);
this.paddleAI.endContact = function(body)
{
this super.playerAI.hit();
}
Then when some contact appears, we only call object’s beginContact
or endContact
method for both bodies, which manages all other needed operations.
Example: begin contact
function _beginContact(contact)
{
var bodyA = contact.getBodyA();
var bodyB = contact.getBodyB();
if(bodyA.beginContact)
bodyA.beginContact(bodyB, contact);
if(bodyB.beginContact)
bodyB.beginContact(bodyA, contact);
}
Human Player
At this point, everything is prepared, but paddles do not move. In order to do that, two classes are created: playerHuman
and playerAI
. PlayerHuman
class moves paddle controlled by the player. To move the paddle, we use mouse joint. Mouse joint allows manipulating with the physical bodies to wanted position. They can be also moved by setPosition
method, but when body is moved by this method, it does not interact with other physical objects.
When user taps on the screen, the scene calls human player’s handlePressed
method. In handlePressed
method, mouse joint is created to manipulate the paddle controlled by the player. If player taps on the opponent’s half of playground, the paddle moves along the central line.
Example: create mouse joint
function handlePressed(x, y)
{
if (y < System.height / 2)
y = System.height / 2;
const table = this.table;
var mouseJointDef = {
maxForce : 10000,
frequencyHz : 1000,
dampingRatio : 0.0,
targetX : table.x2box2d(x), targetY : table.y2box2d(y) };
this.paddle.setTransform(x, y);
if (this.joint)
this.table.destroyJoint(this.joint);
this.joint = table.createMouseJoint(table.ground, this.paddle, mouseJointDef, true);
}
When user moves his finger on the screen, the scene calls handleDragged
method from human player class. In this method, setTarget
method of mouseJoint
is called. This method moves the paddle to the current finger position.
Example: move the paddle to the current finger position
function handleDragged(x, y)
{
if (y < System.height / 2 + this.puckRadius)
y = System.height / 2 + this.puckRadius;
if (this.joint != null)
this.joint.setTarget(this.table.x2box2d(x), this.table.y2box2d(y));
}
AI Player
Opponent’s game is controlled by playerAI
class. AI player also moves by mouse joint similar to a human player but coordinates to setTarget
method is calculated by our algorithms. The AI player does only two actions -> defence or attack. To make player more realistic, the class implements the following features:
- The gap between two attacks is at least 700 milliseconds
- The AI player does not respond directly after the line, but only after a small gap after line
- The AI player does not hit the puck if it is in the table corner.
- The AI player moves to defend position also if puck is on opponents half of the playground
- The AI player’s paddle moves with realistic speed
The gap between two opponents attacks is minimally 700 milliseconds. Every 25 milliseconds, method handleProcess
is called which checks if it takes at least 700 milliseconds after the last attack. If yes, the opponent attacks again, otherwise, it goes back to defence.
Example: check if last attack was before at least 700 milliseconds
function handleProcess()
{
var (x, y) = this.paddle.getPosition();
var (px, py) = this.table.puck.getPosition();
if (System.tick - this.hitTime < 700) {
this._defense(x, y, px, py);
return;
}
this._makeDecision(x, y, px, py);
}
When the time from the last attack is at least 700 milliseconds, the player may attack, but does not have to. To decide if attack or not function _makeDecision
. If the puck is not too close to table’s corner and it is on the player’s half, the player attacks onto the puck.
Example: decide if the player should attack
function _makeDecision(x, y, px, py)
{
var puckInCorner = px < System.width / 5 || px > 4*System.width / 5;
if (puckInCorner && py < 2 * this.puckRadius ) {
return ;
}
if (py < ( 9 * System.height / 20))
return this._moveTo(x, y, px, py - this.puckRadius / 4);
return this._defense(x, y, px, py);
}
The defense
function moves the paddle horizontally in the front of the goal. However, the paddle does not move from the left barrier to the right. It moves only to the middle of the playground according to the current puck position.
Example: defense position
function _defense(x, y, px, py)
{
if (py < y && Math.abs(System.width / 2 - px) > System.width / 5)
return this._moveTo(x, y, px, py - this.puckRadius);
this._moveTo(x, y, System.width / 4 + System.width / 2 * (px / (1.0 * System.width)),
System.height / 6);
return true;
}
As you can see in all previous methods, function _moveTo
is used to move the paddle. This function moves the paddle gradually according to required speed. This function ensures that AI player’s speed is similar to human speed, because directly using setTarget
method may cause the AI player to be too fast.
Example: move AI player's paddle
function _moveTo(ox, oy, px, py)
{
var speed = Integer.min(640, System.width) / (40.0 + rand(20));
const dx = px - ox;
const dy = py - oy;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > speed) {
px = ox + speed / distance * dx;
py = oy + speed / distance * dy;
}
this.joint.setTarget(this.table.x2box2d(px), this.table.y2box2d(py));
return true;
}
The whole logic of AI player game is shown in the next diagram:
Image: AI player logic
Summary
This article showed you how to create an air hockey game. Some parts of this example can be easily rewritten to other programming languages and used in your projects. But for the best way to create this game for the largest number of devices with the minimum amount of work, I highly recommend using Moscrif SDK.
History
- 1st August, 2012: Initial version