Build Neon Leap with Phaser
Build a TypeScript endless runner with Phaser physics, scoring, and restart.
Introduction
30 Second Summary
A one-button game feels simple because every press produces an immediate result. Keeping that rhythm fair becomes harder once obstacles and scores enter the picture.
In this project, you will build Neon Leap using TypeScript and Phaser. Using only code-drawn shapes, you will create a complete game loop for jumping, scoring, collisions, and restarts.
What You'll Build
You'll press space or click to launch a cyan runner over pink obstacles while the score tracks your longest run.
By the end of this project, you'll have:
- A playable one-button runner where space-bar presses and pointer presses trigger the same jump.
- An endless obstacle loop that recycles pink barriers without reloading the page.
- A complete score chase that rewards each cleared obstacle before restarting from zero after a collision.
- Secret Mission: Increase the obstacle speed after every point while keeping the pace capped and playable.
Are there any prerequisites?
You should be comfortable building TypeScript applications. Node.js and VS Code should already be installed on your Mac.
Before We Start
Before the hands-on work begins, commit to building Neon Leap as a one-button TypeScript endless runner. Its focused jump-and-avoid loop gives you a clear way to learn Phaser scenes, input, physics, collisions, and game state.
Set Up the Phaser Project
Neon Leap needs a fast browser feedback loop so each later mechanic is easy to test.
A minimal local Vite project keeps assets and extra frameworks out of the way. You will pin Phaser 4.2.1 alongside Vite 8.3.5 before connecting a starter TypeScript scene to the browser.
In this step, get ready to:
- Create the Neon Leap project files on your Desktop.
- Install the pinned Phaser and Vite dependencies.
- Run a styled Phaser canvas in your browser.
Create the project workspace
The neon-leap folder keeps the source files and local dependencies for your game together. You will create its complete file structure before opening it in VS Code.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Terminal and press Enter to open it.
- Create the neon-leap folder with its four empty project files by running these commands:
cd ~/Desktop
mkdir -p neon-leap/src
touch neon-leap/package.json neon-leap/index.html neon-leap/src/main.ts neon-leap/src/style.css
cd neon-leap
ls -R
What do these commands do?
- The cd command moves the terminal to your Desktop.
- The mkdir command creates the project folder and its src subfolder.
- The touch command creates the four empty files in their required locations.
- The ls command lists the result so you can confirm the structure immediately.
You should see index.html and package.json in the main folder. The src listing should contain main.ts and style.css.
Files missing from the listing?
Check that the terminal moved to your Desktop before it created the folder. A spelling difference in neon-leap can place you inside a different directory.
If the src folder is empty, rerun the file-creation command from the block above. Help me check why my Neon Leap files are missing.
- Press Cmd+Space to return to Spotlight.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the neon-leap folder on your Desktop.
- Click Open.
VS Code should now show package.json and index.html beside an expandable src folder in the Explorer sidebar.
The package manifest records the exact dependencies and scripts that power the local feedback loop. Pinning the versions gives your project a repeatable starting point.
- Select package.json in the Explorer sidebar.
- Replace its empty contents with this package manifest:
{
"name": "neon-leap",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"phaser": "4.2.1"
},
"devDependencies": {
"vite": "8.3.5"
}
}
What does this manifest control?
- The scripts object defines the commands for running and building the project.
- The dependencies object installs Phaser for the game code.
- The devDependencies object installs Vite for the local development server.
- Save package.json.
- Click Terminal in the VS Code menu bar.
- Click New Terminal.
- Install the dependencies from package.json by running:
npm install
What does this command do?
The npm installer reads package.json and downloads the pinned dependencies into the project. The local scripts can now use Vite without a global installation.
The installation should finish without a dependency error. That is the foundation in place: your project now has the exact Phaser and Vite versions used throughout the build.
Seeing a Node.js engine warning?
Vite 8.3.5 requires Node.js 20.19+ or 22.12+. An older Node.js release can prevent the development server from starting.
- Visit the official Node.js download page.
- Install the current LTS release v24.22.1 LTS.
- Return to the VS Code terminal from earlier.
- Retry the dependency installation by running:
npm install
This repeats the installation with the supported Node.js runtime. Help me resolve my npm installation problem.
Connect the page to a starter scene
The browser needs an HTML mount point before Phaser can create its canvas. The page also loads src/main.ts as the project entry point.
- Select index.html in the Explorer sidebar.
- Replace its empty contents with this page shell:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Neon Leap</title>
</head>
<body>
<main class="page-shell">
<div id="game-container" aria-label="Neon Leap game"></div>
<p class="instructions">Press space or click the game to jump.</p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
How does the page shell work?
- The game-container element gives Phaser a specific place to insert its canvas.
- The instructions remain outside the canvas so the control hint stays part of the page.
- The module script loads src/main.ts as the application entry point.
- Save index.html.
- Return to the integrated terminal from earlier.
- Start the Vite development server by running:
npm run dev
What does this command do?
The command runs the dev script from package.json. Vite prints a Local URL that points to the project running on your Mac.
The development server keeps this terminal busy while the project is running. Leave it active while you edit the source files.
- Record the Local URL here: the Local URL from Vite.
- Press Cmd+Space to open Spotlight.
- Type the name of your browser and press Enter to open it.
- Paste the Local URL from Vite into the address bar.
- Press Enter to load the project.
You should see the instruction text on a plain page. Your local feedback loop is working, so the next saved file can turn that page into a game canvas.
Local page not loading?
Keep the VS Code terminal running while the browser uses its Local URL. If the terminal stopped after an error, check that the dependency installation completed before starting the server again.
Confirm that you copied the complete Local URL printed by Vite. Help me diagnose why my Vite Local URL is not loading.
A Phaser scene groups the objects and behavior that belong to one part of a game. This starter GameScene only draws a setup message, which gives you a concrete signal that the scene lifecycle is running.
- Return to VS Code.
- Expand the src folder in the Explorer sidebar.
- Select main.ts.
- Replace its empty contents with this starter scene:
import Phaser from 'phaser';
import './style.css';
class GameScene extends Phaser.Scene {
constructor() {
super('game');
}
create() {
this.add.text(40, 40, 'Setup complete', {
fontFamily: 'system-ui',
fontSize: '30px',
color: '#e2e8f0',
});
}
}
new Phaser.Game({
width: 800,
height: 450,
parent: 'game-container',
backgroundColor: '#0b1020',
scene: GameScene,
});
What does this scene do?
- The imports load Phaser and the page stylesheet.
- The GameScene class extends Phaser's base scene so the framework can call its lifecycle methods.
- The create() method adds the visible Setup complete message.
- The game configuration creates an 800 by 450 canvas inside game-container.
- Save src/main.ts.
- Return to the browser tab from earlier.
- Refresh the page.
You should see a dark Phaser canvas with Setup complete near its upper-left corner. The scene is now running inside the page.
Canvas missing from the page?
Check that main.ts sits inside the src folder. Confirm that game-container uses the same spelling in index.html and the Phaser configuration.
Look at the running terminal for a file path or syntax problem. Help me find why my Phaser canvas is missing.
Style the responsive game shell
The canvas already proves Phaser is running. The stylesheet now centers it on a dark page while preserving its shape when the browser becomes narrower.
- Return to VS Code.
- Select style.css inside the src folder.
- Add the page foundation with this first styling block:
:root {
font-family: Inter, system-ui, sans-serif;
color: #e5e7eb;
background: #020617;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
.page-shell {
min-height: 100vh;
display: grid;
place-items: center;
align-content: center;
gap: 16px;
padding: 24px;
}
What does this styling control?
- The root styles create the dark page background and light default text.
- The universal selector keeps element dimensions predictable when padding or borders are added.
- The body fills the browser height without its default margin.
- The page-shell grid centers the game area with consistent spacing.
- Save src/style.css.
- Return to the browser.
- Refresh the page.
You should see the canvas move to the center of a dark full-height page. The instruction text should sit directly below it.
Page still using the default layout?
Confirm that main.ts imports ./style.css. Check that the stylesheet is saved beside main.ts inside src.
If the canvas remains at the top-left corner, compare the page-shell spelling with the class in index.html. Help me debug why the page styles are not loading.
The game container needs its own boundary and responsive canvas rules. These styles keep the canvas within the page while adding a visible frame around the game.
- Place this block below the existing page-shell styles:
#game-container {
width: min(800px, 100%);
overflow: hidden;
border: 1px solid #334155;
border-radius: 12px;
box-shadow: 0 24px 70px rgb(0 0 0 / 45%);
}
#game-container canvas {
display: block;
width: 100%;
height: auto;
}
.instructions {
margin: 0;
color: #94a3b8;
}
How does the canvas stay responsive?
- The container uses the canvas width as its maximum while allowing narrower browser windows.
- The canvas fills the available container width while its automatic height preserves the game proportions.
- The border and shadow separate the game from the surrounding page.
- The instructions use a muted color so the canvas remains the focus.
- Save src/style.css.
- Return to the browser.
- Refresh the page.
You should see a rounded border and shadow around the canvas. The instruction text should appear in a muted grey below it.
Canvas wider than the browser?
Check that the container width uses min(800px, 100%). Confirm that the canvas width is 100% with its height set to auto.
A missing closing brace can prevent the remaining rules from loading. Help me repair the responsive canvas styles.
✔️ Awesome, I've got everything!
Great. Double-check that all four files are saved before the final browser test.
ⓧ I'd like to double check the full code
Check package.json
{
"name": "neon-leap",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"phaser": "4.2.1"
},
"devDependencies": {
"vite": "8.3.5"
}
}
What should match?
Your manifest should pin the same dependency versions and contain the three Vite scripts shown above.
Check index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Neon Leap</title>
</head>
<body>
<main class="page-shell">
<div id="game-container" aria-label="Neon Leap game"></div>
<p class="instructions">Press space or click the game to jump.</p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
What should match?
Your page should contain the game container and load src/main.ts as a module.
Check src/main.ts
import Phaser from 'phaser';
import './style.css';
class GameScene extends Phaser.Scene {
constructor() {
super('game');
}
create() {
this.add.text(40, 40, 'Setup complete', {
fontFamily: 'system-ui',
fontSize: '30px',
color: '#e2e8f0',
});
}
}
new Phaser.Game({
width: 800,
height: 450,
parent: 'game-container',
backgroundColor: '#0b1020',
scene: GameScene,
});
What should match?
Your starter scene should render Setup complete inside an 800 by 450 Phaser canvas.
Check src/style.css
:root {
font-family: Inter, system-ui, sans-serif;
color: #e5e7eb;
background: #020617;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
.page-shell {
min-height: 100vh;
display: grid;
place-items: center;
align-content: center;
gap: 16px;
padding: 24px;
}
#game-container {
width: min(800px, 100%);
overflow: hidden;
border: 1px solid #334155;
border-radius: 12px;
box-shadow: 0 24px 70px rgb(0 0 0 / 45%);
}
#game-container canvas {
display: block;
width: 100%;
height: auto;
}
.instructions {
margin: 0;
color: #94a3b8;
}
What should match?
Your stylesheet should contain the complete page shell and responsive canvas rules shown above.
Before the final refresh, what do you expect to see if the page shell and starter scene are connected correctly?
- Return to the browser tab using the Local URL from Vite.
- Refresh the page.
You should see a centered dark 800 by 450 Phaser canvas displaying Setup complete. The control instructions should remain visible below the canvas.
Your local game loop is ready for mechanics. Next, you will replace the setup message with a floor and a runner that responds to space or pointer input.
Make the Runner Jump
Your starter Phaser canvas proves that the local feedback loop works. The development server already refreshes the browser when you save your code.
Now you will turn that canvas into something you can control. The first jump rule deliberately accepts input in mid-air so you can feel the problem before fixing it.
In this step, get ready to:
- Create a cyan runner above a full-width floor.
- Connect space-bar input and pointer input to one jump method.
- Expose the unrealistic result of repeated mid-air input.
Enable Arcade Physics
The runner needs an Arcade Physics body before gravity or velocity can move it. The game configuration activates that physics system for the scene.
- Return to src/main.ts in the editor.
- Find the configuration lines from backgroundColor through scene.
- Replace those lines with the configuration below:
backgroundColor: '#0b1020',
physics: {
default: 'arcade',
},
scene: GameScene,
What does this configuration do?
- The physics block activates the game's physics system.
- The default value selects Arcade Physics for the scene.
- The existing background colour and scene remain unchanged.
- Find the class GameScene extends Phaser.Scene { line.
- Replace that line with the declarations below:
const WIDTH = 800;
const HEIGHT = 450;
const GROUND_Y = 390;
const JUMP_SPEED = -520;
class GameScene extends Phaser.Scene {
private player!: Phaser.GameObjects.Rectangle;
private spaceKey!: Phaser.Input.Keyboard.Key;
What do these declarations store?
- The size constants keep the canvas dimensions available throughout the scene.
- The GROUND_Y constant gives the floor and runner one vertical reference point.
- The negative JUMP_SPEED value moves the runner upward.
- The class fields keep the runner and space key available to other methods.
- Save src/main.ts.
- Return to the browser preview.
You should still see Setup complete on the dark canvas. This confirms that the physics configuration and scene declarations compile successfully.
Did the canvas disappear?
- Check that the physics block sits inside the object passed to new Phaser.Game().
- Check that the constants sit below both import statements.
- Check that the class fields sit inside GameScene.
Ask for help with the physics setup.
Create the runner and controls
Keyboard presses and pointer presses need one shared movement rule. The handleAction() method gives both controls the same jump behaviour.
- Find the closing brace of the current create() method.
- Add the jump method directly below it by pasting this code:
private handleAction() {
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setVelocityY(JUMP_SPEED);
}
How does the jump work?
- The body reference gives the method access to the runner's movement.
- The setVelocityY() call replaces the runner's vertical velocity with JUMP_SPEED.
- Every call launches the runner upward without checking its position.
- Find the current create() method that displays Setup complete.
- Replace that method with the scene code below:
create() {
const ground = this.add.rectangle(
WIDTH / 2,
GROUND_Y + 30,
WIDTH,
60,
0x1f2937,
);
this.physics.add.existing(ground, true);
this.player = this.add.rectangle(
120,
GROUND_Y - 25,
50,
50,
0x22d3ee,
);
this.physics.add.existing(this.player);
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setGravityY(1200);
this.physics.add.collider(this.player, ground);
this.spaceKey = this.input.keyboard!.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE,
);
this.input.on('pointerdown', this.handleAction, this);
}
What does this scene create?
- The dark rectangle spans the full canvas width and receives a static physics body.
- The cyan rectangle receives a dynamic physics body.
- Vertical gravity pulls the runner toward the floor.
- The collider prevents the runner from falling through the floor.
- The pointer listener sends every canvas press to handleAction().
- Save src/main.ts.
- Return to the browser preview.
- Click once inside the game canvas.
You should see a cyan square rise from the dark floor before gravity pulls it back down. Great work. Your pointer control now produces a visible jump.
Click not making the runner jump?
- Click inside the canvas instead of the instructions below it.
- Check that the listener uses the exact event name pointerdown.
- Check that handleAction() sits inside the GameScene class.
Ask for help with pointer input.
Pointer input calls the action method as soon as the canvas receives a press. Keyboard input needs the scene's game loop to detect each new space-bar press.
- Find the closing brace of create().
- Add the game-loop method directly above handleAction() by pasting this code:
update() {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.handleAction();
}
}
Why check JustDown?
- Phaser calls update() during every game step.
- The JustDown() check becomes true once for each new press.
- A fresh space-bar press calls the same action method as a pointer press.
- Save src/main.ts.
- Return to the browser preview.
- Press the space bar once.
You should see the cyan runner jump before landing on the floor. Both controls now share one movement rule.
Space bar not making the runner jump?
- Click inside the canvas to give the game keyboard focus.
- Check that this.spaceKey is assigned inside create().
- Check that update() sits above handleAction() inside the class.
Ask for help with keyboard input.
✔️ Awesome, I've got everything!
Your runner responds to both controls. Double-check that src/main.ts is saved before testing rapid input.
ⓧ I'd like to double check the full code
import Phaser from 'phaser';
import './style.css';
const WIDTH = 800;
const HEIGHT = 450;
const GROUND_Y = 390;
const JUMP_SPEED = -520;
class GameScene extends Phaser.Scene {
private player!: Phaser.GameObjects.Rectangle;
private spaceKey!: Phaser.Input.Keyboard.Key;
constructor() {
super('game');
}
create() {
const ground = this.add.rectangle(
WIDTH / 2,
GROUND_Y + 30,
WIDTH,
60,
0x1f2937,
);
this.physics.add.existing(ground, true);
this.player = this.add.rectangle(
120,
GROUND_Y - 25,
50,
50,
0x22d3ee,
);
this.physics.add.existing(this.player);
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setGravityY(1200);
this.physics.add.collider(this.player, ground);
this.spaceKey = this.input.keyboard!.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE,
);
this.input.on('pointerdown', this.handleAction, this);
}
update() {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.handleAction();
}
}
private handleAction() {
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setVelocityY(JUMP_SPEED);
}
}
new Phaser.Game({
width: WIDTH,
height: HEIGHT,
parent: 'game-container',
backgroundColor: '#0b1020',
physics: {
default: 'arcade',
},
scene: GameScene,
});
Test the mid-air problem
The current action method applies JUMP_SPEED every time it receives input. It never checks whether the runner has returned to the floor.
Before you test it, what do you think several rapid presses will do while the runner is airborne?
- Press the space bar several times rapidly during one jump.
- Repeat the rapid-input test with pointer presses inside the canvas.
You should see the runner repeatedly change its upward velocity while airborne. Each press launches it upward again before it reaches the floor.
Why does the runner keep jumping?
Every input calls handleAction(). That method immediately replaces the runner's vertical velocity with JUMP_SPEED.
This unrealistic movement is the intended result of the first jump rule. You have exposed the exact gap that a grounded movement check must close.
Your runner now responds to space-bar and pointer input through one action method. Next, you will use its physics body to allow another jump only after it returns to the floor.
Stop Mid-Air Jump Spamming
Your shared input method responds to both space bar presses and pointer presses. However, every press currently resets the runner's upward velocity, even while the runner is airborne.
Now you'll read the grounded state from the runner's Phaser Arcade Physics body. The blocked.down flag lets the jump fire only after the runner touches the floor.
In this step, get ready to:
- Read the grounded state from the runner's physics body.
- Allow jumps only while the runner is standing on the floor.
- Compare rapid inputs on the floor with rapid inputs in the air.
Read the player's grounded state
A dynamic physics body records which sides are blocked by collisions. A downward block means the runner is resting on the static floor.
- Return to src/main.ts in the VS Code editor.
- Scroll to handleAction() near the bottom of the GameScene class.
- Locate the existing const playerBody = this.player.body as Phaser.Physics.Arcade.Body; line.
What does blocked.down tell us?
The playerBody object holds the runner's current collision state. Its blocked.down value becomes true while a static body blocks downward movement.
The floor is a static body with an active collider. That makes blocked.down a direct grounded check for this runner.
Guard the jump with the floor check
Both input routes already call handleAction(). Placing one condition inside this method applies the same grounded rule to the keyboard and pointer controls.
- Replace the existing handleAction() method in src/main.ts with this version:
private handleAction() {
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
if (playerBody.blocked.down) {
playerBody.setVelocityY(JUMP_SPEED);
}
}
What does this code do?
- The playerBody cast gives the method access to the runner's physics state.
- The if condition checks whether the floor currently blocks the runner from below.
- The setVelocityY() call runs only while that grounded condition is true.
- Save src/main.ts.
Before you test it, do you think rapid presses can still change the runner's velocity after it leaves the floor?
- Switch back to the browser preview from the previous step.
- Wait for the cyan runner to settle on the floor.
- Press the space bar repeatedly during one jump.
- Repeat the test with rapid pointer presses during the next jump.
You'll see the first press launch the runner. Every airborne press is ignored until the runner returns to the floor.
That's the control rule working. Both input methods now produce one grounded jump at a time.
Still jumping in mid-air?
- Check that playerBody.setVelocityY(JUMP_SPEED); sits inside the if (playerBody.blocked.down) braces.
- Check that the condition uses blocked.down with the same capitalization.
- Confirm that you saved src/main.ts before testing the browser preview.
Help me check why my grounded jump still allows mid-air input.
✔️ Awesome, I've got everything!
Your grounded jump is in place. The runner now ignores extra input until it lands.
ⓧ I'd like to double check the full code
- Compare your src/main.ts file with this complete version.
import Phaser from 'phaser';
import './style.css';
const WIDTH = 800;
const HEIGHT = 450;
const GROUND_Y = 390;
const JUMP_SPEED = -520;
class GameScene extends Phaser.Scene {
private player!: Phaser.GameObjects.Rectangle;
private spaceKey!: Phaser.Input.Keyboard.Key;
constructor() {
super('game');
}
create() {
const ground = this.add.rectangle(
WIDTH / 2,
GROUND_Y + 30,
WIDTH,
60,
0x1f2937,
);
this.physics.add.existing(ground, true);
this.player = this.add.rectangle(
120,
GROUND_Y - 25,
50,
50,
0x22d3ee,
);
this.physics.add.existing(this.player);
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setGravityY(1200);
this.physics.add.collider(this.player, ground);
this.spaceKey = this.input.keyboard!.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE,
);
this.input.on('pointerdown', this.handleAction, this);
}
update() {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.handleAction();
}
}
private handleAction() {
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
if (playerBody.blocked.down) {
playerBody.setVelocityY(JUMP_SPEED);
}
}
}
new Phaser.Game({
width: WIDTH,
height: HEIGHT,
parent: 'game-container',
backgroundColor: '#0b1020',
physics: {
default: 'arcade',
},
scene: GameScene,
});
Your runner now obeys a believable grounded jump rule. Next, you'll give it a moving obstacle that turns each jump into a real challenge.
Turn the Prototype into a Runner
Your grounded jump now responds to the floor correctly. However, the runner has nothing to react to.
A moving obstacle turns your Phaser prototype into a game with a clear loss condition. Arcade Physics detects the collision while scene state controls what happens next.
In this step, get ready to:
- Create a pink obstacle that moves toward the runner.
- Detect a collision between the runner and obstacle.
- Stop the obstacle when the game ends.
Add the game state and status text
The scene needs to remember whether a run has ended. It also needs a visible message that can change after a collision.
- In src/main.ts, select everything from const WIDTH = 800; through the opening line of create().
- Replace the selected code by pasting this updated scene setup:
const WIDTH = 800;
const HEIGHT = 450;
const GROUND_Y = 390;
const RUN_SPEED = 300;
const JUMP_SPEED = -520;
class GameScene extends Phaser.Scene {
private player!: Phaser.GameObjects.Rectangle;
private obstacle!: Phaser.GameObjects.Rectangle;
private spaceKey!: Phaser.Input.Keyboard.Key;
private statusText!: Phaser.GameObjects.Text;
private gameOver = false;
constructor() {
super('game');
}
create() {
this.gameOver = false;
this.statusText = this.add
.text(WIDTH / 2, 105, 'Space or click to jump', {
fontFamily: 'system-ui',
fontSize: '20px',
color: '#cbd5e1',
align: 'center',
})
.setOrigin(0.5);
What does this code do?
- The RUN_SPEED constant keeps the obstacle speed in one reusable value.
- The obstacle field stores the pink rectangle that the runner must avoid.
- The gameOver field records whether the current run has ended.
- The statusText field gives the scene a message it can update after a collision.
- Save src/main.ts.
- Return to the browser preview from earlier.
You should see Space or click to jump above the runner. Your scene can now communicate the current state of the run.
Status text missing?
- Check that the new status code sits at the start of create() before the ground is created.
- Check that .setOrigin(0.5) ends with a full stop character in the code.
Help me find why the status text is missing from my Phaser scene.
Create the moving obstacle
The obstacle needs a dynamic physics body so velocity can move it. Gravity stays disabled so the rectangle holds its position above the floor.
- In src/main.ts, find playerBody.setGravityY(1200);.
- Add the obstacle setup directly below that line by pasting this code:
this.obstacle = this.add.rectangle(
WIDTH + 100,
GROUND_Y - 35,
40,
70,
0xfb7185,
);
this.physics.add.existing(this.obstacle);
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody
.setAllowGravity(false)
.setImmovable(true)
.setVelocityX(-RUN_SPEED);
How does the obstacle move?
- The starting horizontal position uses WIDTH + 100 to place the obstacle beyond the right edge.
- The pink fill value makes the obstacle easy to distinguish from the cyan runner.
- The setAllowGravity(false) call keeps the obstacle at a fixed height.
- The negative velocity from setVelocityX(-RUN_SPEED) moves the obstacle toward the left.
- Save src/main.ts.
- Return to the active browser preview.
You should see a tall pink rectangle enter from the right. It travels past the runner because no collision rule connects them yet.
Obstacle not moving?
- Check that this.physics.add.existing(this.obstacle) appears before you read this.obstacle.body.
- Check that the velocity contains a minus sign before RUN_SPEED.
Help me troubleshoot a Phaser obstacle that does not move left.
End the run on collision
A collider connects the moving obstacle to an outcome. Its callback records the loss before stopping the obstacle and changing the message.
- In src/main.ts, find the closing brace of handleAction().
- Add the collision callback below handleAction() by pasting this method:
private endGame() {
if (this.gameOver) {
return;
}
this.gameOver = true;
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody.setVelocityX(0);
this.statusText
.setText('Game over\nSpace or click to restart')
.setColor('#fda4af');
}
What happens when the run ends?
- The first condition prevents repeated collision processing after gameOver becomes true.
- The obstacle body receives a horizontal velocity of 0 so it stops at the collision point.
- The status message changes to two lines of game-over text.
- The new pink text color reinforces the loss state visually.
- Find this.physics.add.collider(this.player, ground); inside create().
- Replace that line by pasting both collider registrations:
this.physics.add.collider(this.player, ground);
this.physics.add.collider(
this.player,
this.obstacle,
this.endGame,
undefined,
this,
);
How does the collider use the callback?
- The first collider keeps the runner standing on the floor.
- The second collider watches the runner and obstacle.
- Phaser calls endGame when those two physics bodies collide.
- The final this keeps the callback connected to the current scene instance.
- Save src/main.ts.
- Return to the browser preview.
- Let the pink obstacle reach the runner without jumping.
The obstacle should stop when it touches the runner. You should also see Game over with the restart instructions underneath.
Collision not ending the game?
- Check that the obstacle collider passes this.endGame as its callback.
- Check that the final collider argument is this.
- Check that endGame() remains inside the GameScene class.
Help me debug why my Phaser collider does not trigger the game-over state.
The scene also needs a guard for any update logic that belongs only to an active run. This keeps later game-loop behavior behind the gameOver state.
- In src/main.ts, find the existing update() method.
- Replace the complete method by pasting this guarded version:
update() {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.handleAction();
}
if (this.gameOver) {
return;
}
}
Why guard the update loop?
The update() method runs once per game step. Returning when gameOver is true prevents active-run logic added later from continuing after a collision.
Before you save and test, which run do you expect to reach the game-over state: the successful jump or the direct collision?
- Save src/main.ts.
- Return to the browser preview.
- Press the space bar at the right moment to clear the obstacle.
You should see the cyan runner pass above the pink obstacle. The obstacle should continue moving beyond the left edge.
- Refresh the browser preview.
- Let the pink obstacle reach the runner without pressing space or clicking.
You should see the obstacle stop at the runner. The canvas should display Game over with Space or click to restart below it.
Game-over state looks wrong?
- Check that this.gameOver = true runs before the obstacle velocity changes.
- Check that the status string contains \n between its two lines.
- Check that the gameOver guard sits after the keyboard input check in update().
Help me compare my game-over behavior with the expected Phaser scene state.
✔️ Awesome, I've got everything!
Great work. Save src/main.ts so your moving obstacle and game-over state are ready for the endless loop.
ⓧ I'd like to double check the full code
import Phaser from 'phaser';
import './style.css';
const WIDTH = 800;
const HEIGHT = 450;
const GROUND_Y = 390;
const RUN_SPEED = 300;
const JUMP_SPEED = -520;
class GameScene extends Phaser.Scene {
private player!: Phaser.GameObjects.Rectangle;
private obstacle!: Phaser.GameObjects.Rectangle;
private spaceKey!: Phaser.Input.Keyboard.Key;
private statusText!: Phaser.GameObjects.Text;
private gameOver = false;
constructor() {
super('game');
}
create() {
this.gameOver = false;
this.statusText = this.add
.text(WIDTH / 2, 105, 'Space or click to jump', {
fontFamily: 'system-ui',
fontSize: '20px',
color: '#cbd5e1',
align: 'center',
})
.setOrigin(0.5);
const ground = this.add.rectangle(
WIDTH / 2,
GROUND_Y + 30,
WIDTH,
60,
0x1f2937,
);
this.physics.add.existing(ground, true);
this.player = this.add.rectangle(
120,
GROUND_Y - 25,
50,
50,
0x22d3ee,
);
this.physics.add.existing(this.player);
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setGravityY(1200);
this.obstacle = this.add.rectangle(
WIDTH + 100,
GROUND_Y - 35,
40,
70,
0xfb7185,
);
this.physics.add.existing(this.obstacle);
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody
.setAllowGravity(false)
.setImmovable(true)
.setVelocityX(-RUN_SPEED);
this.physics.add.collider(this.player, ground);
this.physics.add.collider(
this.player,
this.obstacle,
this.endGame,
undefined,
this,
);
this.spaceKey = this.input.keyboard!.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE,
);
this.input.on('pointerdown', this.handleAction, this);
}
update() {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.handleAction();
}
if (this.gameOver) {
return;
}
}
private handleAction() {
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
if (playerBody.blocked.down) {
playerBody.setVelocityY(JUMP_SPEED);
}
}
private endGame() {
if (this.gameOver) {
return;
}
this.gameOver = true;
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody.setVelocityX(0);
this.statusText
.setText('Game over\nSpace or click to restart')
.setColor('#fda4af');
}
}
new Phaser.Game({
width: WIDTH,
height: HEIGHT,
parent: 'game-container',
backgroundColor: '#0b1020',
physics: {
default: 'arcade',
},
scene: GameScene,
});
Your prototype now has a moving threat and a visible loss condition. Next, you'll recycle that obstacle into an endless scoring loop and make the restart instructions functional.
Add the Endless Game Loop
Your Phaser prototype now has a grounded jump plus a pink obstacle that can end the run.
A single obstacle still makes the game a collision demo. This step turns it into an endless game loop.
In this step, get ready to:
- Recycle the obstacle after it leaves the screen.
- Award exactly one point after each successful pass.
- Restart the scene with the same jump action.
Prepare the score state
The scene needs to remember the current score plus whether the active obstacle has already awarded its point. Resetting those values inside create() gives every new run a clean starting state.
- Switch back to src/main.ts in VS Code.
- Find the statusText property declaration below spaceKey.
- Select from that declaration through the initial gameOver assignment inside create().
- Replace the selected code with this block:
private scoreText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private score = 0;
private obstacleScored = false;
private gameOver = false;
constructor() {
super('game');
}
create() {
this.score = 0;
this.obstacleScored = false;
this.gameOver = false;
this.add
.text(WIDTH / 2, 36, 'NEON LEAP', {
fontFamily: 'system-ui',
fontSize: '30px',
color: '#e2e8f0',
})
.setOrigin(0.5);
this.scoreText = this.add.text(24, 24, 'Score: 0', {
fontFamily: 'system-ui',
fontSize: '22px',
color: '#f8fafc',
});
What does this code do?
- The score field stores the points earned during the current run.
- The obstacleScored field records whether the active obstacle has awarded its point.
- The assignments inside create() restore the starting state whenever the scene begins.
- The title names the game above the new score display.
- Save src/main.ts.
- Return to the browser preview from earlier.
You'll see NEON LEAP above the game plus Score: 0 in the top-left corner. Your collision demo now has a visible target to chase.
Score display missing?
- Check that scoreText is declared directly below spaceKey.
- Confirm the new block ends before the existing statusText assignment.
- Make sure you saved src/main.ts.
Help me fix the missing Neon Leap score display.
Recycle and score the obstacle
Arcade Physics can reset a dynamic body to new coordinates. This lets one pink rectangle return repeatedly without creating more objects.
- Find the endGame() method near the bottom of src/main.ts.
- Add this new method directly above endGame():
private resetObstacle() {
this.obstacleScored = false;
const nextX = WIDTH + 240 + Math.random() * 260;
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody
.reset(nextX, GROUND_Y - 35)
.setAllowGravity(false)
.setImmovable(true)
.setVelocityX(-RUN_SPEED);
}
How does obstacle recycling work?
- The nextX calculation chooses a randomized position beyond the right edge.
- The reset() call moves the rectangle plus its physics body to that position.
- The method disables gravity again after the reset.
- The final velocity sends the recycled obstacle left at RUN_SPEED.
- Find the gameOver check inside update().
- Add this condition directly below that check:
if (this.obstacle.x < -this.obstacle.width) {
this.resetObstacle();
}
What does this check do?
- The condition waits until the obstacle has moved beyond the left edge.
- The call to resetObstacle() begins another pass from the right.
- Save src/main.ts.
- Return to the browser preview from earlier.
- Clear the pink obstacle with one jump.
After the obstacle exits on the left, you'll see it return from a new position beyond the right edge. One physics object now creates a continuous challenge.
Obstacle not returning?
- Confirm resetObstacle() sits inside the GameScene class.
- Check that update() calls resetObstacle() after the obstacle crosses the left edge.
- Verify that the reset chain finishes with setVelocityX(-RUN_SPEED).
Help me debug an obstacle that does not recycle.
The recycling check runs every frame. The obstacleScored guard makes sure one pass can award only one point.
- Find the obstacle reset condition inside update().
- Add this scoring block directly above that condition:
const obstacleRight = this.obstacle.x + this.obstacle.width / 2;
if (obstacleRight < this.player.x && !this.obstacleScored) {
this.obstacleScored = true;
this.score += 1;
this.scoreText.setText(`Score: ${this.score}`);
}
How is each point protected?
- The obstacleRight value tracks the trailing edge of the obstacle.
- The position check waits until that edge passes the runner.
- The obstacleScored guard blocks repeated points from later frames.
- The score display updates immediately after a successful pass.
- Save src/main.ts.
- Return to the browser preview from earlier.
- Clear one pink obstacle with a jump.
You'll see the display change from Score: 0 to Score: 1 after the obstacle passes the runner. That point confirms the pass was counted exactly once.
Score not increasing correctly?
- Check that the scoring block appears before the obstacle reset condition.
- Confirm obstacleScored becomes true when the score increases.
- Verify that resetObstacle() restores obstacleScored to false.
Help me debug the Neon Leap scoring rule.
Restart with the same action
Game over already stops the obstacle. The action handler can now use that state to restart the current scene before it considers another jump.
- Find the handleAction() method in src/main.ts.
- Add this branch at the beginning of the method:
if (this.gameOver) {
this.scene.restart();
return;
}
How does one action restart the run?
- The gameOver branch restarts the current scene.
- The early return prevents the same action from reaching the jump logic.
- The restarted scene runs create() with fresh score state.
Before you test, consider what the score should show after the scene restarts.
- Save src/main.ts.
- Return to the browser preview from earlier.
- Clear one obstacle with a jump.
- Let a later obstacle hit the runner.
- Press the space bar after Game over appears.
You'll see Score: 1 after the first successful pass. The collision then displays Game over with restart instructions.
Your next action starts a fresh run with Score: 0 plus a moving obstacle. You now have the complete play, score, lose, restart loop.
Game not restarting?
- Confirm the gameOver branch appears before the playerBody declaration.
- Check that the branch calls this.scene.restart().
- Make sure the branch includes return after restarting.
Help me fix the one-action scene restart.
The finished src/main.ts file should match the complete game loop below.
✔️ Awesome, I've got everything!
Your saved file now supports obstacle recycling, protected scoring, collisions, plus one-action restart.
ⓧ I'd like to double check the full code
import Phaser from 'phaser';
import './style.css';
const WIDTH = 800;
const HEIGHT = 450;
const GROUND_Y = 390;
const RUN_SPEED = 300;
const JUMP_SPEED = -520;
class GameScene extends Phaser.Scene {
private player!: Phaser.GameObjects.Rectangle;
private obstacle!: Phaser.GameObjects.Rectangle;
private spaceKey!: Phaser.Input.Keyboard.Key;
private scoreText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private score = 0;
private obstacleScored = false;
private gameOver = false;
constructor() {
super('game');
}
create() {
this.score = 0;
this.obstacleScored = false;
this.gameOver = false;
this.add
.text(WIDTH / 2, 36, 'NEON LEAP', {
fontFamily: 'system-ui',
fontSize: '30px',
color: '#e2e8f0',
})
.setOrigin(0.5);
this.scoreText = this.add.text(24, 24, 'Score: 0', {
fontFamily: 'system-ui',
fontSize: '22px',
color: '#f8fafc',
});
this.statusText = this.add
.text(WIDTH / 2, 105, 'Space or click to jump', {
fontFamily: 'system-ui',
fontSize: '20px',
color: '#cbd5e1',
align: 'center',
})
.setOrigin(0.5);
const ground = this.add.rectangle(
WIDTH / 2,
GROUND_Y + 30,
WIDTH,
60,
0x1f2937,
);
this.physics.add.existing(ground, true);
this.player = this.add.rectangle(
120,
GROUND_Y - 25,
50,
50,
0x22d3ee,
);
this.physics.add.existing(this.player);
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
playerBody.setGravityY(1200);
this.obstacle = this.add.rectangle(
WIDTH + 100,
GROUND_Y - 35,
40,
70,
0xfb7185,
);
this.physics.add.existing(this.obstacle);
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody
.setAllowGravity(false)
.setImmovable(true)
.setVelocityX(-RUN_SPEED);
this.physics.add.collider(this.player, ground);
this.physics.add.collider(
this.player,
this.obstacle,
this.endGame,
undefined,
this,
);
this.spaceKey = this.input.keyboard!.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE,
);
this.input.on('pointerdown', this.handleAction, this);
}
update() {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.handleAction();
}
if (this.gameOver) {
return;
}
const obstacleRight = this.obstacle.x + this.obstacle.width / 2;
if (obstacleRight < this.player.x && !this.obstacleScored) {
this.obstacleScored = true;
this.score += 1;
this.scoreText.setText(`Score: ${this.score}`);
}
if (this.obstacle.x < -this.obstacle.width) {
this.resetObstacle();
}
}
private handleAction() {
if (this.gameOver) {
this.scene.restart();
return;
}
const playerBody = this.player.body as Phaser.Physics.Arcade.Body;
if (playerBody.blocked.down) {
playerBody.setVelocityY(JUMP_SPEED);
}
}
private resetObstacle() {
this.obstacleScored = false;
const nextX = WIDTH + 240 + Math.random() * 260;
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody
.reset(nextX, GROUND_Y - 35)
.setAllowGravity(false)
.setImmovable(true)
.setVelocityX(-RUN_SPEED);
}
private endGame() {
if (this.gameOver) {
return;
}
this.gameOver = true;
const obstacleBody = this.obstacle.body as Phaser.Physics.Arcade.Body;
obstacleBody.setVelocityX(0);
this.statusText
.setText('Game over\nSpace or click to restart')
.setColor('#fda4af');
}
}
new Phaser.Game({
width: WIDTH,
height: HEIGHT,
parent: 'game-container',
backgroundColor: '#0b1020',
physics: {
default: 'arcade',
},
scene: GameScene,
});
What should the complete file contain?
- The update() method awards one point before recycling the obstacle.
- The resetObstacle() method returns the obstacle at RUN_SPEED.
- The handleAction() method restarts a finished run before checking for a grounded jump.
Secret mission
Make Every Run Faster
Turn each successful jump into a difficulty increase. The obstacle accelerates with every point until it reaches a fair speed cap.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to leave Neon Leap available, stop its local server for later, or remove the project completely. The local Vite development server uses only your Mac, so there are no ongoing service charges.
Resources you used:
- The active Vite development server that powers the browser preview.
- The local neon-leap folder containing package.json, index.html, src/main.ts, src/style.css, plus the installed npm dependencies.
Keep everything running
No action needed. Choose this if you are still refining Neon Leap or replaying it in the browser.
- Leave the Vite development server running in the terminal panel.
- Keep the neon-leap folder on your Mac.
- Use the active browser preview whenever you want to play the game.
Pause - I'll come back to this later
Shut down the development server to free its local resources while keeping every project file ready for later.
- Switch back to the terminal panel in VS Code from earlier.
- Press Control+C to stop the active Vite server.
- Confirm the terminal prompt returns.
- Refresh the browser preview.
The browser can no longer reach the local game. Your project files remain inside neon-leap for your next session.
Delete - I don't want to use this again
This option removes the development server process plus the entire local project. Deleting the folder is permanent, so your game files remain safe only until you run the removal command.
- Switch back to the terminal panel in VS Code from earlier.
- Press Control+C if the Vite server is still active.
- Confirm the terminal prompt returns.
- Move out of neon-leap and delete the project folder by running these commands:
cd ..
rm -rf neon-leap
What does this cleanup do?
- The first command moves the terminal into the folder that contains neon-leap.
- The second command permanently removes the project files plus the installed dependencies.
- Confirm the project folder is gone by running this command:
ls
What should I see?
This command lists the contents of the parent folder. You should no longer see neon-leap in that list.
Still see the project folder?
- Check the folder location shown in VS Code before repeating the cleanup.
- Help me safely remove the local Neon Leap folder.
Nice Work!
Nice Work!
That's a wrap! Neon Leap is now a complete TypeScript endless runner built with Phaser 4.2.1. Its difficulty rises with every successful jump before resetting for a fresh run.
You've learned how to:
- Build a playable TypeScript endless runner with Phaser 4.2.1. Draw every game object from code-based rectangles.
- Unify keyboard plus pointer input behind one action method. Gate jumps with a grounded physics-body check.
- Use Arcade Physics to power gravity. Trigger a game-over state through collision callbacks. Recycle one obstacle into a scoring loop with one-action restarts.
- Complete the optional Secret Mission by adding a capped speed ramp. Reset its difficulty state whenever a new run begins.
Ready to quiz yourself?