Build a HTML5 game like Geometry Dash using Phaser 4 and TypeScript, with a simple custom physics engine
Talking about Geometry Dash game, Game development, HTML5, Javascript, Phaser and TypeScript.
In Geometry Dash, the cube spins through the air and always lands flush on a flat side. It looks like careful rigid-body physics. It isn’t, and the real trick is much simpler.

This post explains it and builds a working prototype in Phaser 4 and TypeScript, starting from the template built by the official Create Game App.
First, let’s have a look at the prototype I built:
You know how to play: click, tap or press SPACEBAR to jump, avoid spikes, and that’s it.
The trick: rotation is a lie
The spin is purely cosmetic. The physics treats the cube as an upright square that never rotates: it moves right at constant speed and follows a simple jump-and-gravity parabola, and its collision box stays axis-aligned the whole time. The spin is painted on top, and the instant the cube lands the game snaps the angle to the nearest 90 degrees. Because the spin rate is tuned to arrive almost square already, the snap is invisible.
It’s a game-feel choice, not a shortcut: an upright hitbox keeps the game fair and readable at speed, where a real rotating box would kill you on corner grazes.
Why not Arcade Physics?
I wrote my own tiny physics instead of using Phaser’s Arcade. It’s a bit more work, but it keeps the simulation separate from the engine, so it’s deterministic, testable, and portable anywhere. Phaser is just the renderer.
The physics
Each step: move horizontally, jump if held and grounded, apply gravity, spin if airborne, resolve collisions, and snap the angle on landing. Since nothing rotates, collision is a plain box overlap which requires four comparisons and no trigonometry. Spikes kill on contact; blocks are landable from the top, lethal from the side. The snap is one line: Math.round(angle / 90) * 90.
Fixed timestep
To stay identical across framerates, the simulation advances in constant steps (240 Hz), decoupled from rendering. Each frame accumulates elapsed time and runs as many fixed steps as fit, so the same inputs always give the same result, which is what makes replays possible.
Levels as grids
Levels are 2D grids (0 empty, 1 block, 2 spike), laid out like a Tiled CSV export so you can design them visually. A loader expands each grid into obstacles bucketed by column for cheap lookups. Spikes get a deliberately smaller hitbox than their sprite, so near-misses feel fair.
The result
Three files with a one-way dependency flow: physics.ts (pure engine + level loader, no Phaser), levels.ts (just the grids), and Game.ts (the Phaser scene: input, rendering, camera, level flow). The lesson is the rotation trick applied to architecture: decide what’s real and what’s for show, and keep them apart.
Here is the full source code, with the physics library commented in JSDoc format, for you to use it anywhere.
index.html
The web page which hosts the game, which will run inside game-container element.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="game-container"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>public\style.css
The cascading style sheets of the main web page.
body {
margin: 0;
padding: 0;
color: rgba(255, 255, 255, 0.87);
background-color: #0f0f0f;
}
#app {
width: 100%;
height: 100vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
src\main.ts
Game launcher created by the official Create Game App.
import StartGame from './game/main';
document.addEventListener('DOMContentLoaded', () => {
StartGame('game-container');
});src\game\main.ts
This is where the game is created, with all Phaser related options.
import { Game as MainGame } from './scenes/Game';
import { AUTO, Game, Scale, Types } from 'phaser';
const config: Types.Core.GameConfig = {
type: AUTO,
width: 900,
height: 420,
parent: 'game-container',
backgroundColor: '0f0f0f',
scale: {
mode: Scale.FIT,
autoCenter: Scale.CENTER_BOTH,
},
scene: [
MainGame
]
};
const StartGame = (parent: string) => {
return new Game({ ...config, parent });
}
export default StartGame;src\game\levels.ts
Array with all game levels.
/**
* @file levels.ts: level data.
*
* 0: empty
* 1: block
* 2: spike
*/
export const levels: readonly number[][][] = [
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0],
],
];src\game\physics.ts
The core of the project, the physics engine.
/**
* @file physics.ts: pure game logic
* Simulation, level loading and fixed-timestep advancement.
*/
/**
* Tuning constants.
*
* @property tileSize Cell size and player side.
* @property playerX Fixed on-screen X of the cube.
* @property groundY World Y of the ground surface.
* @property playerSpeed Constant horizontal speed.
* @property gravity Downward acceleration.
* @property jumpForce Upward velocity on jump.
* @property rotationSpeed Cosmetic spin, tuned so a jump lands near 180 degrees.
* @property timeStep Fixed physics step (1/240 = 240 Hz).
*/
export const GAME_CONFIG = {
tileSize: 40,
playerX: 220,
groundY: 340,
playerSpeed: 320,
gravity: 3200,
jumpForce: -930,
rotationSpeed: 310,
timeStep: 1 / 240
} as const;
/** `'block'`: solid, landable from above, lethal from the side. `'spike'`: lethal on contact. */
export type ObstacleType = 'block' | 'spike';
/** Axis-aligned box (top-left in world space): the only collision shape. */
export interface Box {
readonly posX: number;
readonly posY: number;
readonly width: number;
readonly height: number;
}
/**
* A {@link Box} plus obstacle data. `drawX`/`drawY` (sprite top-left) may
* differ from the hitbox: a spike's sprite fills the cell, its box is inset
* to make the game easier.
*/
export interface Obstacle extends Box {
readonly type: ObstacleType;
readonly drawX: number;
readonly drawY: number;
}
/** AABB overlap test.
* Shared edges don't overlap).
*/
export function boxesOverlap(a: Box, b: Box): boolean {
return (
a.posX < b.posX + b.width &&
a.posX + a.width > b.posX &&
a.posY < b.posY + b.height &&
a.posY + a.height > b.posY
);
}
/** The player's hitbox: a tileSize square at its top-left, never rotated. */
function playerBounds(state: playerState): Box {
return {
posX: state.posX,
posY: state.posY,
width: GAME_CONFIG.tileSize,
height: GAME_CONFIG.tileSize,
};
}
/** Supplies obstacles near a world X. `() => []` = flat ground. */
export type ObstacleQuery = (x: number) => readonly Obstacle[];
/**
* Full mutable simulation state.
*
* @property posX World X of the hitbox top-left.
* @property posY World Y of the hitbox top-left.
* @property velocityY Vertical velocity.
* @property onGround Resting on a surface this instant.
* @property angle Cosmetic rotation, deg; never affects collisions.
* @property dead Dead state, inert until reset.
* @property justSnapped Landed-and-snapped this step.
* @property justDied Died this step.
*/
export interface playerState {
posX: number;
posY: number;
velocityY: number;
onGround: boolean;
angle: number;
dead: boolean;
justSnapped: boolean;
justDied: boolean;
}
/** Fresh state at the beginning of the game */
export function createState(): playerState {
return {
posX: 0,
posY: GAME_CONFIG.groundY - GAME_CONFIG.tileSize,
velocityY: 0,
onGround: true,
angle: 0,
dead: false,
justSnapped: false,
justDied: false,
};
}
/**
* Advances the simulation by one fixed step, mutating `state`.
*
* @param input `true` while jump is held.
* @param query Obstacles near the player this step.
*/
export function physicsStep(state: playerState, input: boolean, query: ObstacleQuery): void {
// Clear before the dead early-out, else a dead state keeps justDied set.
state.justSnapped = false;
state.justDied = false;
if (state.dead) {
return;
}
// Horizontal motion.
state.posX += GAME_CONFIG.playerSpeed * GAME_CONFIG.timeStep;
// Jump.
if (input && state.onGround) {
state.velocityY = GAME_CONFIG.jumpForce;
state.onGround = false;
}
// Gravity.
state.velocityY += GAME_CONFIG.gravity * GAME_CONFIG.timeStep;
state.posY += state.velocityY * GAME_CONFIG.timeStep;
// Rotation.
if (!state.onGround) {
state.angle += GAME_CONFIG.rotationSpeed * GAME_CONFIG.timeStep;
}
// Ground collision.
let landed: boolean = false;
if (state.posY + GAME_CONFIG.tileSize >= GAME_CONFIG.groundY) {
state.posY = GAME_CONFIG.groundY - GAME_CONFIG.tileSize;
state.velocityY = 0;
landed = true;
}
// Obstacle collisions: AABB vs AABB.
for (const object of query(state.posX)) {
if (!boxesOverlap(playerBounds(state), object)) {
continue;
}
if (object.type === 'spike') {
state.dead = true;
state.justDied = true;
return;
}
// Descending onto the top (within 6 px) = landing; else side hit = death.
const prevFoot: number = state.posY + GAME_CONFIG.tileSize - state.velocityY * GAME_CONFIG.timeStep;
if (state.velocityY >= 0 && prevFoot <= object.posY + 6) {
state.posY = object.posY - GAME_CONFIG.tileSize;
state.velocityY = 0;
landed = true;
}
else {
state.dead = true;
state.justDied = true;
return;
}
}
// Walked off a block edge: fall again.
if (!landed && state.onGround && !isSupported(state, query)) {
state.onGround = false;
}
// On landing, snap the angle to the nearest 90 degrees.
if (landed && !state.onGround) {
state.angle = Math.round(state.angle / 90) * 90;
state.justSnapped = true;
}
if (landed) {
state.onGround = true;
}
}
/** Whether a surface is directly under the player's feet. */
function isSupported(state: playerState, query: ObstacleQuery): boolean {
if (state.posY + GAME_CONFIG.tileSize >= GAME_CONFIG.groundY - 0.5) {
return true;
}
const footY: number = state.posY + GAME_CONFIG.tileSize;
for (const object of query(state.posX)) {
if (object.type !== 'block') {
continue;
}
if (Math.abs(footY - object.posY) < 1 && state.posX < object.posX + object.width && state.posX + GAME_CONFIG.tileSize > object.posX) {
return true;
}
}
return false;
}
/** Level array values. */
const blockTile: number = 1;
const spikeTile: number = 2;
/**
* A parsed, ready-to-play level.
*
* @property columns Obstacles bucketed by tile column.
* @property length Level length in px; run complete past this X.
* @property query {@link ObstacleQuery} over this level.
*/
export interface LevelIndex {
readonly columns: readonly (readonly Obstacle[])[];
readonly length: number;
readonly query: ObstacleQuery;
}
/**
* World Y of a grid row's top edge.
*
* @param rows Total row count of the grid.
*/
function rowToWorldY(row: number, rows: number): number {
return GAME_CONFIG.groundY - (rows - row) * GAME_CONFIG.tileSize;
}
/**
* Expands one grid cell to its {@link Obstacle} (or `null` if empty).
*
* @param rows Total row count of the grid.
*/
function cellToObstacle(cell: number, row: number, column: number, rows: number): Obstacle | null {
const x: number = column * GAME_CONFIG.tileSize;
const y: number = rowToWorldY(row, rows);
switch (cell) {
case blockTile:
return {
type: 'block',
posX: x,
posY: y,
width: GAME_CONFIG.tileSize,
height: GAME_CONFIG.tileSize,
drawX: x,
drawY: y
};
case spikeTile:
return {
type: 'spike',
posX: x + GAME_CONFIG.tileSize * 0.3,
posY: y + GAME_CONFIG.tileSize * 0.45,
width: GAME_CONFIG.tileSize * 0.4,
height: GAME_CONFIG.tileSize * 0.55,
drawX: x,
drawY: y,
};
default:
return null;
}
}
/** Parses a raw grid into a playable {@link LevelIndex}. */
export function loadLevel(grid: number[][]): LevelIndex {
const rows: number = grid.length;
const cols: number = grid[0].length;
const columns: Obstacle[][] = Array.from({ length: cols }, () => []);
for (let row = 0; row < rows; row++) {
for (let column = 0; column < cols; column++) {
const obstacle: Obstacle | null = cellToObstacle(grid[row][column], row, column, rows);
if (obstacle) {
columns[column].push(obstacle);
}
}
}
const length: number = cols * GAME_CONFIG.tileSize;
// Broad phase: obstacles in the player's column, +- 1.
const query: ObstacleQuery = (x: number): readonly Obstacle[] => {
const near: Obstacle[] = [];
const first: number = Math.max(0, Math.floor(x / GAME_CONFIG.tileSize) - 1);
const last: number = Math.min(cols - 1, Math.floor((x + GAME_CONFIG.tileSize) / GAME_CONFIG.tileSize) + 1);
for (let column: number = first; column <= last; column++) {
for (const obstacle of columns[column]) {
near.push(obstacle);
}
}
return near;
};
return { columns, length, query };
}
/**
* One frame's worth of advancement.
*
* @property accumulator Leftover time to carry into the next frame.
* @property died A death occurred on any step this frame.
* @property snapped A landing snap occurred on any step this frame.
*/
export interface StepResult {
readonly accumulator: number;
readonly died: boolean;
readonly snapped: boolean;
}
/**
* Advances the simulation by `deltaSeconds` at fixed steps.
* The caller keeps the accumulator between frames and passes the delta.
*
* @param accumulator Leftover time from the previous frame (0 on the first).
* @param deltaSeconds Real elapsed time this frame.
* @param input Held for every step this frame.
*/
export function advance(
state: playerState,
accumulator: number,
deltaSeconds: number,
input: boolean,
query: ObstacleQuery,
): StepResult {
accumulator += deltaSeconds;
let died: boolean = false;
let snapped: boolean = false;
while (accumulator >= GAME_CONFIG.timeStep) {
physicsStep(state, input, query);
if (state.justDied) {
died = true;
}
if (state.justSnapped) {
snapped = true;
}
accumulator -= GAME_CONFIG.timeStep;
}
return { accumulator, died, snapped };
}src/game/scene/Game.ts
Main game file, all game logic is stored here.
import * as Phaser from 'phaser';
import { GAME_CONFIG, playerState, LevelIndex, createState, advance, loadLevel } from '../physics';
import { levels } from '../levels';
export class Game extends Phaser.Scene {
// Simulation state, owned here, advanced by physics.
private state: playerState;
private accumulator: number = 0;
// Current level and its index into levels.
private levelNumber: number = 0;
private level: LevelIndex;
// Input.
private holding = false;
private spaceKey: Phaser.Input.Keyboard.Key;
// Sprites.
private player: Phaser.GameObjects.Sprite;
private background: Phaser.GameObjects.TileSprite;
private ground: Phaser.GameObjects.TileSprite;
// True while a death/complete transition is pending.
private resetting: boolean = false;
constructor() {
super('Game');
}
preload(): void {
this.load.setPath('assets');
this.load.image('hero', 'sprites/hero.png');
this.load.image('block', 'sprites/block.png');
this.load.image('spike', 'sprites/spike.png');
this.load.image('ground', 'sprites/ground.png');
this.load.image('background', 'sprites/background.png');
}
create(): void {
this.state = createState();
this.accumulator = 0;
this.resetting = false;
this.level = loadLevel(levels[this.levelNumber]);
// Background and ground: fixed to the screen, scrolled via tilePositionX.
this.background = this.add.tileSprite(0, 0, this.scale.width, this.scale.height, 'background')
this.background.setOrigin(0)
this.background.setScrollFactor(0);
this.ground = this.add.tileSprite(0, GAME_CONFIG.groundY, this.scale.width, this.scale.height - GAME_CONFIG.groundY, 'ground')
this.ground.setOrigin(0)
this.ground.setScrollFactor(0);
// One sprite per obstacle, placed once in world coordinates.
for (const column of this.level.columns) {
for (const obstacle of column) {
this.add.sprite(obstacle.drawX, obstacle.drawY, obstacle.type).setOrigin(0);
}
}
this.player = this.add.sprite(0, 0, 'hero');
// Hold-to-jump: pointer or spacebar.
this.holding = false;
this.input.on('pointerdown', () => (this.holding = true));
this.input.on('pointerup', () => (this.holding = false));
this.spaceKey = this.input.keyboard!.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE
);
}
// Death: shake, then restart the same level.
private onDeath(): void {
if (this.resetting) {
return;
}
this.resetting = true;
this.cameras.main.shake(150, 0.01);
this.time.delayedCall(500, () => this.restart());
}
// Level cleared: advance to the next level then restart.
private onLevelComplete(): void {
if (this.resetting) {
return;
}
this.resetting = true;
this.state.dead = true;
this.levelNumber = (this.levelNumber + 1) % levels.length;
this.time.delayedCall(1500, () => this.restart());
}
// Restart a level.
private restart(): void {
this.scene.restart();
}
update(_time: number, delta: number): void {
// Advance physics: pass the delta, keep the accumulator between frames.
const pressing: boolean = this.holding || this.spaceKey.isDown;
const result = advance(this.state, this.accumulator, delta / 1000, pressing, this.level.query);
this.accumulator = result.accumulator;
// Sync sprite with state.
this.player.setPosition(this.state.posX + GAME_CONFIG.tileSize / 2, this.state.posY + GAME_CONFIG.tileSize / 2);
this.player.setAngle(this.state.angle);
// Camera follows the player.
// Background parallax at half speed.
const scrollX: number = this.state.posX - GAME_CONFIG.playerX;
this.cameras.main.setScroll(scrollX, 0);
this.background.tilePositionX = scrollX * 0.5;
this.ground.tilePositionX = scrollX;
if (result.died) {
this.onDeath();
}
// Reached the end of the level.
if (!this.state.dead && this.state.posX >= this.level.length) {
this.onLevelComplete();
}
}
}Now you can try to build your Geometry Dash-like game. Here you can download the full Phaser project, powered by Vite. Don’t know what I am talking about? There’s a free minibook to get you started, and a guide to Create Phaser Game app.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.