HTML5 prototype of a planet gravity platform using Phaser 4 and Arcade physics, written in TypeScript

Talking about Game development, HTML5, Javascript, Phaser and TypeScript.

This is the Phaser 4 + TypeScript update of the post HTML5 prototype of a planet gravity platform using Phaser 3 and Arcade physics, because I am about to expand this concept to build a platformer with multiple gravities.

What I wanted to build with this experiment was a very specific kind of movement: a character walking on a floating platform where gravity is not fixed, but instead rotates when the character reaches an edge. Instead of falling into the void, the character smoothly transitions to the next side of the terrain and keeps walking, as if gravity itself were glued to the surface.

This idea sits somewhere between classic platformers and games that play with orientation and perspective. The goal was not to fake the effect visually, but to actually reassign gravity and movement logic so that Arcade Physics remains fully in control.

Look at the working example:

Move with LEFT and RIGHT arrow, jump with UP arrow. Try to fall down the platform and see what happens.

The core concept is that movement is no longer defined in world space (left, right, up, down), but in surface space. At any given time, the character is walking on one of four sides of the terrain. That state is tracked using the walkingSide enum. Each value represents both where the surface is and where gravity should pull the character.

index.html

The web page which hosts the game, to be run inside thegame element.

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script type="module" src="/src/main.ts"></script>
</head>
<body>
    <div id="thegame"></div>    
</body>
</html>

style.css

The cascading style sheets of the main web page.

CSS
/* 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.

TypeScript
export const GameOptions: any = {
    gameGravity : 900,
    heroSpeed   : 200,
    jumpForce   : 250
}

main.ts

This is where the game is created, with all Phaser related options.

TypeScript
import 'phaser';                                            // Phaser    
import { PreloadAssets }    from './scenes/preloadAssets';  // PreloadAssets scene
import { PlayGame }         from './scenes/playGame';       // PlayGame scene
import './style.css';                                       // main page stylesheet

// game configuration object
let configObject : Phaser.Types.Core.GameConfig = {
    scale : {
        mode        : Phaser.Scale.FIT,                     // set game size to fit the entire screen
        autoCenter  : Phaser.Scale.CENTER_BOTH,             // center the canvas both horizontally and vertically in the parent div
        parent      : 'thegame',                            // parent div element
        width       : 800,                                  // game width, in pixels
        height      : 400,                                  // game height, in pixels
    },
    backgroundColor : 0x444444,                             // game background color
    physics         : {
        default     : 'arcade'                              // use arcade physics in game
    },
    scene           : [  
        PreloadAssets,                                      // scene to preload all game assets
        PlayGame                                            // the game itself
    ]
};
 
new Phaser.Game(configObject);

scenes > preloadAssets.ts

Here we preload all assets to be used in the game.

TypeScript
// CLASS TO PRELOAD ASSETS

// PreloadAssets class extends Phaser.Scene class
export class PreloadAssets extends Phaser.Scene {
  
    // constructor    
    constructor() {
        super({
            key : 'PreloadAssets'
        });
    }
  
    // method to be called during class preloading
    preload() : void {
        this.load.image('hero', 'assets/sprites/hero.png');
        this.load.image('tile', 'assets/sprites/tile.png');
    }
  
    // method to be executed when the scene is created
    create() : void {

        // start PlayGame scene
        this.scene.start('PlayGame');
    }
}

scenes > playGame.ts

Main game file, all game logic is stored here.

TypeScript
// THE GAME ITSELF

import { GameOptions } from '../gameOptions';

/*
    Represents the side of the terrain the character is currently walking on.
    The enum maps the four orthogonal orientations of gravity and movement in
    arcade physics: when the character reaches the end of a platform, gravity
    is rotated so the character can keep walking on the next side instead of
    falling off.
*/
enum walkingSide {
    UP,
    RIGHT,
    DOWN,
    LEFT
}

/*
    Indicates the rotational movement direction of the character around the terrain.
    This enum is only concerned with whether movement progresses clockwise or
    counterclockwise, independent of gravity strength or the specific side being walked on.
*/
enum walkingDirection {
    CLOCKWISE,
    COUNTERCLOCKWISE
}

// PlayGame class extends Phaser.Scene class
export class PlayGame extends Phaser.Scene {
   
    constructor() {
        super('PlayGame');
    }

    // static physics body representing the current walking surface the hero adheres to
    wall: Phaser.Types.Physics.Arcade.SpriteWithStaticBody;

    // dynamic arcade sprite controlled by gravity rotation and player input
    hero: Phaser.Physics.Arcade.Sprite;

    // indicates which side of the terrain the hero is currently walking on
    direction: walkingSide;

    // true while a rotation tween is in progress to prevent input and physics conflicts
    rotating: boolean;

    // cached cursor key inputs used to drive movement and jumping
    controls: Phaser.Types.Input.Keyboard.CursorKeys;

    /*
        Initializes the scene by creating the static walking surface, spawning the hero
        with Arcade physics, and setting up input and initial gravity orientation.
        The wall is resized and its collision body refreshed, while the hero starts
        attached to the upper side of the terrain, ready to walk and rotate around it.
    */
    create(): void {

        const halfWidth: number = this.game.config.width as number / 2; 
        const halfHeight: number =  this.game.config.height as number / 2; 
        
        this.wall = this.physics.add.staticSprite(halfWidth, halfHeight, 'tile');
        this.wall.displayWidth = halfWidth;
        this.wall.displayHeight = halfHeight;
        this.wall.refreshBody();

        this.hero = this.physics.add.sprite(halfWidth, this.wall.getBounds().top - 100, 'hero');
        this.hero.setGravityY(GameOptions.gameGravity);
        
        this.controls = this.input.keyboard!.createCursorKeys();

        this.rotating = false;
        this.direction = walkingSide.UP;
    }

    /*
        Main per-frame update loop handling player physics, input, and terrain interaction.
        Collision resolution is processed first to keep the hero correctly attached to the
        current surface.
        While not rotating, horizontal input is interpreted as clockwise or
        counterclockwise movement along the active walkingSide, with mutually exclusive input
        to avoid conflicting directions.
        Jumping is evaluated independently, and finally the
        rotation state is checked to determine whether gravity and orientation must change
        when the hero reaches the end of the current terrain side.
    */
    update(): void {
        this.physics.world.collide(this.hero, this.wall);
        if (!this.rotating) {
            if (this.controls.left.isDown && !this.controls.right.isDown) {
                this.move(walkingDirection.COUNTERCLOCKWISE);
            }
            else {
                if (this.controls.right.isDown && !this.controls.left.isDown) {
                    this.move(walkingDirection.CLOCKWISE);
                }
                else {
                    this.stopMoving();
                }
            }
            if (this.controls.up.isDown) {
                this.jump();
            }
            this.checkRotation();
        }
    }

    /*
        Moves the character along the current walking side by applying velocity
        tangential to the active gravity direction.
        The input direction only specifies clockwise or counterclockwise movement;
        the actual velocity axis and sign are derived from the current walkingSide,
        allowing the character to seamlessly walk around floating terrain while
        gravity keeps pulling it toward the surface.
    */
    move(direction: walkingDirection): void {
        this.hero.setFlipX(direction == walkingDirection.COUNTERCLOCKWISE);
        const speed: number = GameOptions.heroSpeed * (direction == walkingDirection.CLOCKWISE ? 1 : -1);
        switch (this.direction) {
            case walkingSide.UP:
                this.hero.setVelocity(speed, this.hero.body!.velocity.y);
                break;
            case walkingSide.RIGHT:
                this.hero.setVelocity(this.hero.body!.velocity.x, speed);
                break;
            case walkingSide.DOWN:
                this.hero.setVelocity(-speed, this.hero.body!.velocity.y);
                break;
            case walkingSide.LEFT:
                this.hero.setVelocity(this.hero.body!.velocity.x, -speed);
                break;
        }
    }

    /*
        Atops the character’s movement along the current walking side without
        affecting the velocity component aligned with gravity.
        Only the tangential velocity is reset to zero, ensuring the hero remains
        properly attached to the surface while standing still on floating terrain.
    */
    stopMoving(): void {
        switch(this.direction){
            case walkingSide.UP:
                this.hero.setVelocity(0, this.hero.body!.velocity.y);
                break;
            case walkingSide.RIGHT:
                this.hero.setVelocity(this.hero.body!.velocity.x, 0);
                break;
            case walkingSide.DOWN:
                this.hero.setVelocity(0, this.hero.body!.velocity.y);
                break;
            case walkingSide.LEFT:
                this.hero.setVelocity(this.hero.body!.velocity.x, 0);
                break;
        }
    }

    /*
        Checks whether the hero has reached or crossed the end of the current walking side
        and triggers a rotation when necessary.
        For each walkingSide, the hero bounds are compared against the corresponding wall
        bounds to detect exiting the surface. when this happens, handleRotation is called
        with a rotation direction and a repositioning target that realigns the hero onto
        the next side of the floating terrain, allowing gravity and movement orientation
        to rotate seamlessly instead of letting the character fall.
    */
    checkRotation(): void {
        switch(this.direction){
            case walkingSide.UP:
                if(this.hero.getBounds().left > this.wall.getBounds().right && !this.rotating){
                    this.handleRotation(1, this.wall.getBounds().right + this.hero.displayWidth / 2 + this.getHeight(), this.wall.getBounds().top + this.hero.displayHeight / 2);
                }
                if(this.hero.getBounds().right < this.wall.getBounds().left && !this.rotating){
                    this.handleRotation(-1, this.wall.getBounds().left - this.hero.displayWidth / 2 - this.getHeight(), this.wall.getBounds().top + this.hero.displayHeight / 2);
                }
                break;
            case walkingSide.RIGHT:
                if(this.hero.getBounds().top > this.wall.getBounds().bottom && !this.rotating){
                    this.handleRotation(1, this.wall.getBounds().right - this.hero.displayWidth / 2, this.wall.getBounds().bottom + this.hero.displayHeight / 2 + this.getHeight());
                }
                if(this.hero.getBounds().bottom < this.wall.getBounds().top && !this.rotating){
                    this.handleRotation(-1, this.wall.getBounds().right - this.hero.displayWidth / 2, this.wall.getBounds().top - this.hero.displayHeight / 2 - this.getHeight());
                }
                break;
            case walkingSide.DOWN:
                if(this.hero.getBounds().right < this.wall.getBounds().left && !this.rotating){
                    this.handleRotation(1, this.wall.getBounds().left - this.hero.displayWidth / 2 - this.getHeight(), this.wall.getBounds().bottom - this.hero.displayHeight / 2);
                }
                if(this.hero.getBounds().left > this.wall.getBounds().right && !this.rotating){
                    this.handleRotation(-1, this.wall.getBounds().right + this.hero.displayWidth / 2 + this.getHeight(), this.wall.getBounds().bottom - this.hero.displayHeight / 2);
                }
                break;
            case walkingSide.LEFT:
                if(this.hero.getBounds().bottom < this.wall.getBounds().top && !this.rotating){
                    this.handleRotation(1, this.wall.getBounds().left + this.hero.displayWidth / 2, this.wall.getBounds().top - this.hero.displayHeight / 2 - this.getHeight());
                }
                if(this.hero.getBounds().top > this.wall.getBounds().bottom && !this.rotating){
                    this.handleRotation(-1, this.wall.getBounds().left + this.hero.displayWidth / 2, this.wall.getBounds().bottom + this.hero.displayHeight / 2 + this.getHeight());
                }
                break;
        }
    }

    /*
        Handles the transition between walking sides by temporarily disabling physics
        and animating the hero into the new orientation.
        Movement and gravity are reset to avoid interference during the rotation tween;
        the hero is then rotated and repositioned around the terrain corner based on
        the delta direction.
        Once the tween completes, the current walkingSide is
        updated and gravity is reassigned so the character can continue walking on
        the next surface.
    */
    handleRotation(delta: number, targetX: number, targetY: number): void {
        this.hero.setGravity(0, 0);
        this.hero.setVelocity(0, 0)
        this.rotating = true;
        this.tweens.add({
            targets: [this.hero],
            angle: this.hero.angle + 90 * delta,
            x: targetX,
            y: targetY,
            duration: 200,
            callbackScope: this,
            onComplete: () => {
                this.rotating = false;
                this.direction = Phaser.Math.Wrap(this.direction + delta, 0, 4);
                this.setGravity();
            }
        });
    }

    /*
        Applies a jump impulse relative to the current walking side.
        The jump is only triggered if the hero is touching the surface opposing
        the active gravity direction, ensuring jumps occur only when the character
        is properly grounded.
        The impulse is applied along the axis opposite to
        gravity so the hero always jumps away from the surface, regardless of
        orientation on the floating terrain.
    */
    jump(): void {   
        switch (this.direction) {
            case walkingSide.UP:
                if (this.hero.body!.touching.down) {
                    this.hero.setVelocityY(-GameOptions.jumpForce);
                }
                break;
            case walkingSide.DOWN:
                if (this.hero.body!.touching.up) {
                    this.hero.setVelocityY(GameOptions.jumpForce);
                }
                break;
            case walkingSide.LEFT:
                if (this.hero.body!.touching.right) {
                    this.hero.setVelocityX(-GameOptions.jumpForce);
                }
                break;
            case walkingSide.RIGHT:
                if (this.hero.body!.touching.left) {
                    this.hero.setVelocityX(GameOptions.jumpForce);
                }
                break;
        }
    }

    /*
        Assigns gravity based on the current walking side so it always pulls the hero
        toward the active surface.
        Gravity is rotated in 90-degree steps to match the terrain orientation,
        allowing the character to remain grounded on floating platforms while
        moving around corners instead of falling off.
    */
    setGravity(): void {
        switch (this.direction) {
            case walkingSide.UP:
                this.hero.setGravity(0, GameOptions.gameGravity);
                break;
            case walkingSide.RIGHT:
                this.hero.setGravity(-GameOptions.gameGravity, 0);
                break;
            case walkingSide.DOWN:
                this.hero.setGravity(0, -GameOptions.gameGravity);
                break;
            case walkingSide.LEFT:
                this.hero.setGravity(GameOptions.gameGravity, 0);
                break;
        }
    }

    /*
        Calculates the distance between the hero and the current walking surface
        along the gravity axis.
        the returned value represents how far the hero is offset from the wall
        in the direction opposite to gravity, and is used during rotations to
        reposition the character so it stays correctly aligned with the new
        surface when gravity changes.
    */
    getHeight(): number {
        switch (this.direction) {
            case walkingSide.UP:
                return this.wall.getBounds().top - this.hero.getBounds().bottom;
            case walkingSide.RIGHT:
                return this.hero.getBounds().left - this.wall.getBounds().right;
            case walkingSide.DOWN:
                return this.hero.getBounds().top - this.wall.getBounds().bottom;
            case walkingSide.LEFT:
                return this.wall.getBounds().left - this.hero.getBounds().right;
        }
    }
}

Movement input is deliberately simplified. Instead of mapping keys directly to axes, I reduce player intent to just two possibilities: clockwise or counterclockwise. The walkingDirection enum does exactly that. This abstraction allows the same input logic to work no matter which side the character is currently walking on. The actual axis and sign of the velocity are derived from the current walking side.

In the update loop, physics collisions are resolved first to ensure the character stays attached to the surface. Input is then processed only if no rotation is currently happening. This is important because rotation is handled via a tween, and mixing player input with a rotating gravity state would quickly lead to inconsistent results. While grounded, horizontal input is translated into clockwise or counterclockwise motion along the active surface, jumping is evaluated relative to gravity, and finally the system checks whether the character has reached the edge of the terrain.

Edge detection is done entirely through bounds comparison. For each walking side, I compare the hero’s bounds against the corresponding wall bounds. When the hero crosses an edge, a rotation is triggered. At this point, gravity and velocity are temporarily reset to avoid interference, and a short tween rotates and repositions the character around the corner. This is where the illusion becomes physical reality: once the tween completes, the walking side is updated and gravity is reassigned so it pulls the character toward the new surface.

Jumping follows the same philosophy. A jump is not “up” in world space, but away from the surface. Depending on the current walking side, the jump impulse is applied on the correct axis and only if the character is touching the surface opposing gravity. This ensures consistent behavior regardless of orientation.

One subtle but important detail is the getHeight method. During rotation, the character must be repositioned so it remains aligned with the new surface, preserving its distance from the terrain. This method calculates that offset along the gravity axis and is used to compute precise target positions during rotation. Without it, small errors would accumulate and the character would slowly drift away from the surface.

What I like about this approach is that it stays true to Arcade Physics. Gravity is never turned off conceptually; it is just reassigned. Movement is always velocity-based. Collisions still matter. The result is a system that feels solid, predictable, and extensible. You could add multiple platforms, different gravity strengths, or even curved transitions, all building on the same core idea.

Here you can find the complete Vite project to start playing with it.

Don’t know where to start developing with Phaser and TypeScript? I’ll explain it to you step by step in this free minibook.