Talking about Teeter Up game, Box2D, Game development, HTML5, Javascript, Phaser and TypeScript.
During the past few days I’ve been watching a few Netflix video games on my iPhone, and I particularly liked Teeter (Up).
It’s a simple physics game with a ball, a platform, and a hole. What could possibly go wrong?
In this first part of the series, I am focusing on platform movement. There’s a ball, a dynamic body, on a platform, which is a static or a kinematic body.
When the player taps on the left side of the screen, the left side of the bar raises, and the same concept applies to the right side of the screen.
So I am rotating the platform accordingly, around its center, and moving it up using trigonometry.
This is the result:
Tap left and right to raise the bar. Don’t make the ball fall.
To use Box2D powered by Planck.js you should install this package with:
npm install –save planck
If you don’t know how to install a npm package or set up a project this way, I wrote a free minibook explaining everything you need to know to get started.
This first step is very simple, but since I am a nice person, I commented each and every line of code.
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="style.css">
<script src="main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
style.css
The cascading style sheets of the main web page.
/* remove margin and padding from all elements */
* {
padding : 0;
margin : 0;
}
/* set body background color */
body {
background-color : #000000;
}
/* Disable browser handling of all panning and zooming gestures. */
canvas {
touch-action : none;
}
gameOptions.ts
Configurable game options.
It’s a good practice to place all configurable game options, if possible, in a single and separate file, for a quick tuning of the game. I also grouped the variables to keep them more organized.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions : any = {
// game canvas size and color
game : {
// width of the game, in pixels
width : 1080,
// height of the game, in pixels
height : 1920,
// game background color
bgColor : 0x444444
},
// Box2D related options
Box2D : {
// gravity, in meters per second squared
gravity : 10,
// pixels per meters
worldScale : 120
},
// bar properties
bar : {
// bar width, in pixels
width : 1000,
// bar height, in pixels
height : 40,
// bar Y start coordinate, in pixels
startY : 1800,
// rotation speed, in degrees per second
speed : 25
},
// ball radius, in pixels
ballRadius : 25
}
main.ts
This is where the game is created, with all Phaser related options.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './scenes/preloadAssets';
import { PlayGame } from './scenes/playGame';
import { GameOptions } from './gameOptions';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : GameOptions.game.width,
height : GameOptions.game.height
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : GameOptions.game.bgColor,
scale : scaleObject,
scene : [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
scenes > preloadAssets.ts
Here we preload all assets to be used in the game.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be called during class preloading
preload() : void {
// this is how to load images
this.load.image('ball', 'assets/sprites/ball.png');
this.load.image('bar', 'assets/sprites/bar.png');
}
// method to be called once the instance has been created
create() : void {
// call PlayGame scene
this.scene.start('PlayGame');
}
}
scenes >playGame.ts
Main game file, all game logic is stored here.
// THE GAME ITSELF
// modules to import
import Planck, { Box, Circle } from 'planck';
import { toMeters, toPixels } from '../planckUtils';
import { GameOptions } from '../gameOptions';
// enum to represent bar side
enum barSide {
LEFT,
RIGHT,
NONE
}
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
constructor() {
super({
key : 'PlayGame'
});
}
world : Planck.World; // the Box2D world
liftingSide : barSide; // the lifting side of the bar
bar : Planck.Body; // the bar itself
// method to be called once the instance has been created
create() : void {
// when we start playing, no bar side is lifting
this.liftingSide = barSide.NONE;
// create a Box2D world with gravity
this.world = new Planck.World(new Planck.Vec2(0, GameOptions.Box2D.gravity));
// set bar and ball sprites
const barSprite : Phaser.GameObjects.TileSprite = this.add.tileSprite(this.game.config.width as number / 2, GameOptions.bar.startY, GameOptions.bar.width, GameOptions.bar.height, 'bar');
const ballSprite : Phaser.GameObjects.Sprite = this.add.sprite(this.game.config.width as number / 2, GameOptions.bar.startY - GameOptions.bar.height / 2 - GameOptions.ballRadius, 'ball');
ballSprite.setDisplaySize(GameOptions.ballRadius * 2, GameOptions.ballRadius * 2);
// create the bar kinematic body
this.bar = this.world.createKinematicBody({
position : new Planck.Vec2(toMeters(barSprite.x), toMeters(barSprite.y))
})
// attach a fixture to bar body
this.bar.createFixture({
shape : new Box(toMeters(GameOptions.bar.width / 2), toMeters(GameOptions.bar.height / 2)),
density : 1,
friction : 0,
restitution : 0
})
// set custom bar body user data
this.bar.setUserData({
sprite : barSprite
})
// create ball dynamic body
const ball : Planck.Body = this.world.createDynamicBody({
position : new Planck.Vec2(toMeters(ballSprite.x), toMeters(ballSprite.y))
})
// attach a fixture to ball body
ball.createFixture({
shape : new Circle(toMeters(GameOptions.ballRadius)),
density : 1,
friction : 0,
restitution : 0
})
// set custom ball body user data
ball.setUserData({
sprite : ballSprite
})
// listener waiting for pointer to be down (pressed)
this.input.on('pointerdown', (pointer : Phaser.Input.Pointer) => {
// check lifting side according to pointer x position
this.liftingSide = Math.floor(pointer.x / (this.game.config.width as number / 2));
});
// listener waiting for pointer to be up (released)
this.input.on('pointerup', (pointer : Phaser.Input.Pointer) => {
// bar is not lifting
this.liftingSide = barSide.NONE;
})
}
// method to be executed at each frame
// totalTime : time, in milliseconds, since the game started
// deltaTime : time, in milliseconds, passed since previous "update" call
update(totalTime : number, deltaTime : number) : void {
// advance world simulation
this.world.step(deltaTime / 1000, 10, 8);
this.world.clearForces();
// do we have to lift the bar?
if (this.liftingSide != barSide.NONE) {
// determine delta angle
const deltaAngle : number = Phaser.Math.DegToRad(GameOptions.bar.speed) * deltaTime / 1000;
// given the angle, determine bar movement
const barMovement : number = toMeters((GameOptions.bar.width / 2) * Math.sin(deltaAngle));
// get bar position
const barPosition : Planck.Vec2 = this.bar.getPosition();
// set new bar angle according to lifting side
this.bar.setAngle(this.bar.getAngle() + deltaAngle * (this.liftingSide == barSide.LEFT ? 1 : -1));
// set new bar position
this.bar.setPosition(new Planck.Vec2(barPosition.x, barPosition.y - barMovement));
}
// loop through all bodies
for (let body : Planck.Body = this.world.getBodyList() as Planck.Body; body; body = body.getNext() as Planck.Body) {
// get body user data
const userData : any = body.getUserData();
// get body position
const bodyPosition : Planck.Vec2 = body.getPosition();
// get body angle
const bodyAngle : number = body.getAngle();
// update sprite position and rotation accordingly
userData.sprite.setPosition(toPixels(bodyPosition.x), toPixels(bodyPosition.y));
userData.sprite.setRotation(bodyAngle);
}
}
}
planckUtils.ts
Useful functions to be used in Planck, just to convert pixels to meters and meters to pixels.
// PLANCK UTILITIES
import { GameOptions } from './gameOptions';
// simple function to convert pixels to meters
// pixels : amount of pixels to convert
export function toMeters(pixels : number) : number {
return pixels / GameOptions.Box2D.worldScale;
}
// simple function to convert meters to pixels
// meters : amount of meters to convert
export function toPixels(meters : number) : number {
return meters * GameOptions.Box2D.worldScale;
}
This is going to be a great tutorial series, because the game is simple but open to various challenges. Download the source code.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.