Build a highly customizable mobile friendly HTML5 level selection screen controllable by tap, swipe and mouse wheel, written in TypeScript and powered by Phaser

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

If you are building a game with levels, then you need a level selection screen, and I am showing you how to do this since 2014 with the post HTML5 Phaser Tutorial: how to create a level selection screen with locked levels and stars.

Then I built another example in 2018 with the post HTML5 level select screen featuring swipe control, stars, progress saved on local storage, smooth scrolling, pagination and more. Powered by Phaser 3.

In 2021 I moved to TypeScript with Build a highly customizable mobile friendly HTML5 level selection screen controllable by tap and swipe written in TypeScript and powered by Phaser.

Now it’s time to publish a Phaser 4 version, fixing some bugs and adding mouse wheel control.

A level selection screen looks simple, until you try to make it feel right.

In this article we’ll build a horizontal paginated level selector in Phaser, with proper input handling and event-driven UI components, featuring horizontal swipe navigation, snap-to-page behavior, mouse wheel support, pagination and clickable level thumbnails.

This is the result:

Select pages by swiping, dragging, using mouse wheel or by clicking page thumbnails below.

Pagination is handled by placing pages side by side inside a single container. The container moves horizontally, not the individual elements. Drag updates the container position, but it is always clamped within valid bounds so the system never leaves the defined page range.

When the drag ends, the snap logic decides where to go. A simple round-to-nearest-page works mathematically, but it ignores intention.

A better approach considers the direction and magnitude of the swipe. If the movement exceeds a small threshold, the snap favors the swipe direction. This makes navigation feel deliberate rather than mechanical.

Mouse wheel input is discrete, so it directly increments or decrements the current page and animates to that position. If wheel input happens during a drag, the drag is cancelled.

Only one input mode controls the container at a time.

Let’s see the source code:

index.html

The web page which hosts the game, which will 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
// CONFIGURABLE GAME OPTIONS
 
export const GameOptions = {
    pages : 6,
    tintColors: [0xff0000, 0x00ff00, 0x0000ff],
    columns: 3,
    rows: 4,
    unlocked: 7,
    thumbWidth: 60,
    thumbHeight: 60,
    spacing: 20,
    threshold: 0.2
}

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       : 640,                                  // game width, in pixels
        height      : 480,                                  // game height, in pixels
    },
    backgroundColor : 0x444444,                             // game background color
    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.spritesheet('levelthumb', 'assets/sprites/levelthumb.png', {
            frameWidth : 60,
            frameHeight : 60
        });

        this.load.spritesheet('levelpages', 'assets/sprites/levelpages.png', {
            frameWidth: 30,
            frameHeight: 30
        });

        this.load.image('transp', 'assets/sprites/transp.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
 
// modules to import
import { GameOptions } from '../gameOptions';
import { LevelThumbnail } from '../levelThumbnail';
import { PageSelector } from '../pageSelector';
 
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
 
    // constructor
    constructor() {
        super({
            key: 'PlayGame'
        });
    }
 
    // method to be called once the class has been created
    create() : void {
 
        let isDragging: boolean = false;
        const pageSelectors: PageSelector[] = [];     
        let startX: number = 0;
        let startContainerX: number = 0;
        let currentPage: number = 0;

        const contentWidth: number = GameOptions.pages * this.scale.width;

        const container: Phaser.GameObjects.Container = this.add.container(0, 0);
        
        const scrollingMap: Phaser.GameObjects.TileSprite = this.add.tileSprite(0, 0, contentWidth, this.scale.height, 'transp');
        scrollingMap.setOrigin(0, 0);
        container.add(scrollingMap);

        const pageText: Phaser.GameObjects.Text = this.add.text(this.scale.width / 2, 16, 'Page 1 / ' + GameOptions.pages, {
            font : '18px Arial',
            color : '#ffffff',
            align : 'center'
        });
        pageText.setOrigin(0.5);

        const rowLength : number = GameOptions.thumbWidth * GameOptions.columns + GameOptions.spacing * (GameOptions.columns - 1);
        const columnHeight : number = GameOptions.thumbHeight * GameOptions.rows + GameOptions.spacing * (GameOptions.rows - 1);
        const leftMargin : number = (this.scale.width - rowLength) / 2 + GameOptions.thumbWidth / 2;  
        const topMargin : number = (this.scale.height - columnHeight) / 2 + GameOptions.thumbHeight / 2;
        let levelNumber: number = 0;
        for (let k : number = 0; k < GameOptions.pages; k ++) {
            for (let i : number = 0; i < GameOptions.rows; i ++) {
                for(let j : number = 0; j < GameOptions.columns; j ++) {
                    const posX : number = k * this.scale.width + leftMargin + j * (GameOptions.thumbWidth + GameOptions.spacing);
                    const posY : number = topMargin + i * (GameOptions.thumbHeight + GameOptions.spacing);
                    const thumb : LevelThumbnail = new LevelThumbnail(this, posX, posY, 'levelthumb', levelNumber + 1, k, levelNumber >= GameOptions.unlocked);
                    container.add(thumb)
                    thumb.on('levelSelected', (locked: boolean, level: number) => {
                        if (locked) {
                            alert('Level ' + level + ' is locked');
                        }
                        else {
                            alert('Play level ' + level);
                        }
                    });
                    levelNumber ++;
                }
            }
            pageSelectors[k] = new PageSelector(this, this.scale.width / 2 + (k - Math.floor(GameOptions.pages / 2) + 0.5 * (1 - GameOptions.pages % 2)) * 40, this.scale.height - 40, 'levelpages', k, 0);
            pageSelectors[k].on('pageSelected', (page: number) => {
                currentPage = page;
                const targetX: number = -currentPage * this.scale.width;
                this.tweens.add({
                    targets: container,
                    x: targetX,
                    duration: 250,
                    ease: 'Cubic.easeOut',
                    onComplete: () => {
                        snapToPage();    
                    }
                });
            });    
        }  

        this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
            isDragging = true;
            startX = pointer.x;
            startContainerX = container.x;
        });

        this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
            if (isDragging) {
                const delta: number = pointer.x - startX;
                container.x = Phaser.Math.Clamp(startContainerX + delta, this.scale.width - contentWidth, 0);
            }
        });

        this.input.on('pointerup', () => {
            if (isDragging) {;
                isDragging = false;
                snapToPage();
            }
        });

        this.input.on('pointerupoutside', () => {
            if (isDragging) {
                isDragging = false;
                snapToPage();
            }
        });

        this.input.on('wheel', (_pointer: Phaser.Input.Pointer, _gameObjects: Phaser.GameObjects.GameObject[], deltaX: number, deltaY: number) => {
            const delta: number = (deltaX !== 0) ? deltaX : deltaY;
            if (delta != 0) {
                if (isDragging) {
                    isDragging = false;
                    startContainerX = container.x;
                }
                const direction: number = delta > 0 ? 1 : -1;
                const newPage: number = Phaser.Math.Clamp(currentPage + direction, 0, GameOptions.pages - 1);
                if (newPage != currentPage) {
                    currentPage = newPage;
                    moveToPage(currentPage);
                }
            }
        });

        const snapToPage = (): void => {
            const delta: number = startContainerX - container.x;
            currentPage = Math.round(-container.x / this.scale.width);
            if (Math.abs(delta) > this.scale.width * GameOptions.threshold) {
                if (delta > 0) {
                    currentPage = Math.ceil(-container.x / this.scale.width);
                }
                else {
                    currentPage = Math.floor(-container.x / this.scale.width);    
                }
            }
            currentPage = Phaser.Math.Clamp(currentPage, 0, GameOptions.pages - 1);
            moveToPage(currentPage);
        };

        const moveToPage = (page: number): void => {
            const targetX: number = -page * this.scale.width;
            this.tweens.add({
                targets: container,
                x: targetX,
                duration: 250,
                ease: 'Cubic.easeOut'
            });
            pageText.setText('Page ' + (currentPage + 1).toString() + ' / ' + GameOptions.pages);
            pageSelectors.forEach((selector) => {
                selector.updateThumb(currentPage)
            })
        }
    }  
}

pageSelector.ts

Class to define the page selector at the bottom of the canvas.

TypeScript
import { GameOptions } from './gameOptions';
 
export class PageSelector extends Phaser.GameObjects.Sprite {
 
    private pageIndex: number;
    private isPressed: boolean = false;
 
    constructor(scene: Phaser.Scene, x: number, y: number, key: string, pageIndex: number, current: number) {
 
        super(scene, x, y, key);
       
        this.pageIndex = pageIndex;
        scene.add.existing(this);
        
        this.setTint(GameOptions.tintColors[pageIndex % GameOptions.tintColors.length]);
        this.setFrame(pageIndex === current ? 1 : 0);

        this.updateThumb(current);

        this.setInteractive();
            
        this.on('pointerdown', () => {
            this.isPressed = true;
        });

        this.on('pointerup', () => {
            if (this.isPressed) {
                this.isPressed = false;
                this.emit('pageSelected', pageIndex);
            }
        });

        this.on('pointerout', () => {
            this.isPressed = false;
        });
    }

    updateThumb(n: number): void {
        this.setFrame(this.pageIndex === n ? 1 : 0);
    }
}

levelThumbnail.ts

Class to define level thumbnail.

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

export class LevelThumbnail extends Phaser.GameObjects.Container {
 
    private levelText: Phaser.GameObjects.Text;
    private levelSprite: Phaser.GameObjects.Sprite;
    private isPressed: boolean = false;
 
    constructor(scene: Phaser.Scene, x: number, y: number, key: string, level: number, page: number, locked: boolean) {
        
        super(scene, x, y);
        scene.add.existing(this);
      
        this.levelSprite = scene.add.sprite(0, 0, key);
        this.levelSprite.setFrame(locked ? 0 : 1);
        this.levelSprite.setTint(GameOptions.tintColors[page % GameOptions.tintColors.length]);
        this.add(this.levelSprite);
        
        this.levelText = scene.add.text(0, - 14, level.toString(), {
            font: '24px Arial',
            color: '#000000'
        });
        this.levelText.setOrigin(0.5);
        this.add(this.levelText);

        this.levelSprite.setInteractive();

        this.levelSprite.on('pointerdown', () => {
            this.isPressed = true;
        });

        this.levelSprite.on('pointerup', () => {
            if (this.isPressed) {
                this.isPressed = false;
                this.emit('levelSelected', locked, level);
            }
        });

        this.levelSprite.on('pointerout', () => {
            this.isPressed = false;
        });  
    }
}

Now you can create your own multi page level selector.

 Download the full source code along with the Vite project. Don’t know what I am talking about? There’s a free minibook to get you started.