Build a 2D Colony Survival Game
Build a browser-based dynasty survival game with Phaser 4 and JavaScript.
Introduction
30 Second Summary
Every great civilization starts with one person trying to survive. Manage your food, gather resources, and hope the next generation inherits enough to carry on.
In this project, you will build a browser-based 2D colony survival game called Dynasty Settler. You'll control a settler on a tile map, gather food and wood to stay alive, and pass resources to an heir when death inevitably comes.
What You'll Build
Dynasty Settler is a 2D survival game built with Phaser 4 and Vite where you manage a settler across generations on a procedurally generated tile map.
By the end of this project, you'll have:
- A Vite + Phaser 4 project with a playable game running in the browser.
- A procedurally generated tile map with grass, forest, and water terrain types that your settler navigates.
- A season and dynasty system where your settler ages, consumes food each season, and passes resources to an heir on death.
- Secret Mission: Add a building system where shelters boost food gathered from adjacent tiles.
Are there any prerequisites?
This project assumes you have VS Code and Node.js installed. If you don't have them yet, Step 1 will walk you through everything you need.
Set Up Your Phaser 4 Project
Every game needs a foundation before you can start building mechanics. For Dynasty Settler, that foundation is a development environment that lets you write JavaScript, bundle it, and see changes live in your browser.
In this step, you'll scaffold a new project using Vite and install Phaser 4, the game framework that handles rendering, input, and timing for you.
In this step, get ready to:
- Scaffold a Vite project and install Phaser 4 as a dependency.
- Clean the default files and create the game bootstrap.
- Verify the game loop works by drawing a test shape.
Create a new Vite project and install Phaser
Vite is a build tool that bundles your JavaScript and serves it with hot reload. It gives you instant feedback as you code your game.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new Vite project by running this command:
npm create vite@latest dynasty-settler -- --template vanilla
What might npm ask you?
npm may ask you to confirm installing the create-vite package. Type y and press Enter to proceed.
Vite may also ask if you want to install dependencies and start now. Select No for that prompt. You'll install dependencies manually in the next command.
Using PowerShell on Windows?
PowerShell treats -- as a special character. If the command triggers an interactive framework selection instead of using the vanilla template, run it with three dashes instead:
npm create vite@latest dynasty-settler --- --template vanilla
Alternatively, switch to the standard Command Prompt or Git Bash to avoid this issue.
help me fix my Vite scaffold command on Windows
- Move into your new project folder and install Phaser by running these commands:
cd dynasty-settler
npm install phaser@4.2.0
- Confirm Phaser installed correctly by running:
ls node_modules/phaser
- You should see a list of files inside the phaser package folder. You can also open package.json and confirm phaser appears under dependencies.
Getting an error during install?
Make sure you ran cd dynasty-settler before running npm install. The install command must run inside the project folder where package.json lives.
help me debug my npm install error
- Open the dynasty-settler folder in VS Code so you can edit files in the next substep.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the dynasty-settler folder, then click Open.
Clean the scaffold and create the game entry point
Vite's vanilla template comes with demo files you don't need. You'll replace them with your own game bootstrap.
- Open the integrated terminal again with Ctrl+` if it closed when you opened the folder.
- Delete the default demo files and create your src folder by running these commands:
rm counter.js javascript.svg style.css main.js
mkdir src
What about Windows?
If you're on Windows Command Prompt, use del instead of rm and mkdir works the same. If you're using VS Code's integrated terminal with Git Bash (the default on many setups), the commands above work as-is.
- Confirm the cleanup worked by running:
ls
- You should see index.html, node_modules, package.json, package-lock.json, public, and your new src folder. The demo files are gone.
Now you'll create the two files your game needs. First up is index.html, the page that hosts your game canvas.
- In VS Code's Explorer sidebar, click on index.html to open it.
- Select all the existing content and replace it with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynasty Settler</title>
<style>
/* Center the game canvas and set a dark background */
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #1a1a1a;
}
</style>
</head>
<body>
<!-- Load the game as an ES module -->
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this code do?
The inline <style> block removes the default page margin and uses flexbox to center the game canvas both horizontally and vertically. The dark background matches the game canvas color.
The <script type="module"> tag loads your game code as an ES module, which lets you use import statements in your JavaScript.
✔️ Awesome, I've got everything!
Great. Make sure you've saved index.html before moving on.
ⓧ I'd like to double check the full code
Your index.html should look exactly like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynasty Settler</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #1a1a1a;
}
</style>
</head>
<body>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Next, you'll create the game's main JavaScript file where Phaser boots up.
- In the Explorer sidebar, right-click the src folder and select New File.
- Name the file main.js and press Enter.
- Paste the following code into src/main.js:
import * as Phaser from 'phaser';
// Tile and map dimensions
const TILE_SIZE = 64;
const MAP_WIDTH = 10;
const MAP_HEIGHT = 8;
// The main game scene where all logic lives
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
}
}
// Phaser game configuration
const config = {
type: Phaser.AUTO,
width: MAP_WIDTH * TILE_SIZE,
height: MAP_HEIGHT * TILE_SIZE + 40,
backgroundColor: '#1a1a1a',
scene: GameScene,
};
// Boot the game
new Phaser.Game(config);
What does this code do?
The import * as Phaser from 'phaser' line loads the Phaser 4 library using ES module syntax. This is the required import format for Phaser 4.
The constants define your map size: 10 tiles wide and 8 tiles tall at 64 pixels each, plus 40 extra pixels at the bottom reserved for the HUD. GameScene is where all your game logic will live. Right now create() is empty. You'll add content in the next substep.
The config object tells Phaser how to set up the canvas. Phaser.AUTO lets Phaser choose the best renderer (WebGL or Canvas) for the browser.
- Save src/main.js.
- Start the dev server by running this command in your terminal:
npm run dev
- Vite will print a local URL (usually http://localhost:5173). Hold Cmd (macOS) or Ctrl (Windows) and click the link to open it in your browser.
- You should see a blank dark page with no errors in the browser console. The game canvas is there, but nothing is drawn yet.
Seeing a blank white page or errors?
Check that index.html references /src/main.js (not /main.js). The src/ prefix must be there.
If you see a console error about import, confirm the script tag has type="module" on it.
help me fix my Phaser game not loading
Verify the game loop works
A dark canvas confirms Phaser booted, but you need visual proof that the game loop is running. Drawing a shape inside create() proves the scene lifecycle is working.
- In src/main.js, find the empty create() method:
create() {
}
- Add the following line inside the curly braces so it looks like this:
create() {
this.add.rectangle(100, 100, 80, 80, 0xff0000);
}
What does this line do?
The this.add.rectangle() call draws a rectangle game object at position (100, 100) with a width and height of 80 pixels and a red fill color (0xff0000). If you can see this red square, Phaser's scene is running.
- Save src/main.js.
- Switch back to your browser. Vite auto-refreshes on save. You should see a red square in the upper-left area of the dark canvas.
Don't see the red square?
Make sure you added the rectangle line inside the create() method's curly braces, not outside the class. The line must be indented inside the function body.
Also check your terminal for errors. If Vite shows a syntax error, look for missing semicolons or mismatched brackets.
help me fix my Phaser rectangle not showing
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.js with the rectangle line in create().
ⓧ I'd like to double check the full code
Your src/main.js should look exactly like this:
import * as Phaser from 'phaser';
const TILE_SIZE = 64;
const MAP_WIDTH = 10;
const MAP_HEIGHT = 8;
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
this.add.rectangle(100, 100, 80, 80, 0xff0000);
}
}
const config = {
type: Phaser.AUTO,
width: MAP_WIDTH * TILE_SIZE,
height: MAP_HEIGHT * TILE_SIZE + 40,
backgroundColor: '#1a1a1a',
scene: GameScene,
};
new Phaser.Game(config);
Your game engine is alive. That red square confirms Phaser's scene lifecycle is running. Next up, you'll replace it with a procedurally generated tile map full of grass, forest, and water.
Generate the Tile Map
Your Phaser canvas is alive and rendering shapes. Now it is time to build the world your settler will explore.
Every colony survival game needs terrain. In this step, you will build a procedural map generator that creates a randomized grid of grass, forest, and water tiles each time the game starts. Then you will render that grid as colored rectangles so the player can see the world.
In this step, get ready to:
- Build a procedural map generator that creates random terrain.
- Render the map as a colored tile grid in the browser.
Create a procedural map generator
A procedurally generated map means the world is created by code using randomness, not hand-designed. Each time the game starts, the player gets a fresh map to explore.
Before writing the generator function, you need to define what types of terrain exist and what colors represent them.
- In src/main.js, find the existing constants at the top of the file (TILE_SIZE, MAP_WIDTH, MAP_HEIGHT).
- Add the following constants directly below them:
const SEASON_DURATION = 5000;
const MAX_AGE = 20;
const HEIR_FOOD = 3;
const HEIR_WOOD_RATIO = 0.5;
// Tile type identifiers
const TILE_GRASS = 0;
const TILE_FOREST = 1;
const TILE_WATER = 2;
// Colors for each tile type (hex values)
const TILE_COLORS = {
[TILE_GRASS]: 0x4caf50,
[TILE_FOREST]: 0x2e7d32,
[TILE_WATER]: 0x1976d2,
};
// Darker shades for depleted (already-gathered) tiles
const TILE_DEPLETED_COLORS = {
[TILE_GRASS]: 0x388e3c,
[TILE_FOREST]: 0x1b5e20,
};
What does this code do?
The first four constants (SEASON_DURATION, MAX_AGE, HEIR_FOOD, HEIR_WOOD_RATIO) configure the game's survival mechanics. You will use them in later steps when you build the season loop and dynasty system.
TILE_GRASS, TILE_FOREST, and TILE_WATER are numeric IDs that represent each terrain type. Using named constants makes your code readable instead of checking for magic numbers like 0 or 2 everywhere.
TILE_COLORS maps each tile type to its display color. TILE_DEPLETED_COLORS provides darker shades for tiles that have already been gathered from.
- Save src/main.js.
Now you will write the function that generates the map. It creates a 2D array where each cell holds a tile type chosen by weighted randomness.
- Below the TILE_DEPLETED_COLORS object and above the class GameScene line, add the generateMap function:
function generateMap(width, height) {
const map = [];
// Build the grid row by row
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
// Weighted random: 60% grass, 25% forest, 15% water
const rand = Math.random();
if (rand < 0.6) {
row.push(TILE_GRASS);
} else if (rand < 0.85) {
row.push(TILE_FOREST);
} else {
row.push(TILE_WATER);
}
}
map.push(row);
}
// Force the center tile to grass so the settler always has a safe spawn
const centerX = Math.floor(width / 2);
const centerY = Math.floor(height / 2);
map[centerY][centerX] = TILE_GRASS;
return map;
}
What does this code do?
- The nested for loops create a 2D array (rows and columns) matching the map dimensions.
- Math.random() returns a number between 0 and 1. By checking thresholds (0.6, 0.85), you assign weighted probabilities: 60% grass, 25% forest, 15% water.
- The last three lines force the center tile to be grass. This guarantees the settler always spawns on walkable ground.
- Save src/main.js.
You will not see a change yet because the function is defined but not called. The next substep connects it to the scene.
Seeing a syntax error in the terminal?
Make sure the generateMap function is placed between the TILE_DEPLETED_COLORS object and the class GameScene line. If it is inside the class, JavaScript will throw an error because classes use method syntax, not standalone functions.
Help me fix a syntax error in my map generator
Render the map visually
The map generator returns data, but data is invisible. You need a rendering function that reads the 2D array and draws a colored rectangle for each tile on the Phaser canvas.
- In src/main.js, find the create() method inside the GameScene class. It currently looks like this:
create() {
this.add.rectangle(100, 100, 80, 80, 0xff0000);
}
- Replace the entire create() method with the following updated version, and add the new drawMap() method directly below it:
create() {
// Generate the 2D tile array and store it on the scene
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
this.drawMap();
}
drawMap() {
this.tileGraphics = [];
for (let y = 0; y < MAP_HEIGHT; y++) {
this.tileGraphics[y] = [];
for (let x = 0; x < MAP_WIDTH; x++) {
// Look up the tile type and its color
const tileType = this.map[y][x];
const color = TILE_COLORS[tileType];
// Draw a colored rectangle centered on this grid cell
const rect = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 2,
TILE_SIZE - 2,
color
);
// Store the reference so we can update the tile later
this.tileGraphics[y][x] = rect;
}
}
}
What does this code do?
- create() now calls generateMap() and stores the returned 2D array in this.map. This makes the map data accessible to other methods in the scene.
- drawMap() loops through every cell. For each tile, it calculates the pixel position using x * TILE_SIZE + TILE_SIZE / 2 (centering the rectangle in its grid cell).
- TILE_SIZE - 2 makes each tile 2 pixels smaller than the grid cell, creating a subtle gap between tiles so you can see the grid structure.
- this.tileGraphics stores a reference to every rectangle. You will use these references later to change tile colors when resources are gathered.
- Save src/main.js.
- Switch to your browser. You should see a colorful 10x8 grid of tiles filling the canvas. Green tiles are grass, dark green tiles are forest, and blue tiles are water.
Why is every map different?
Each time the page loads, Math.random() produces new values, so the map layout is different every time. Refresh the page a few times to see different arrangements. The center tile is always green because your code forces it to grass for the settler spawn.
Don't see the tile grid?
Make sure you deleted the old this.add.rectangle(100, 100, 80, 80, 0xff0000) line from create(). If the red square still shows but no grid appears, the old code is still there.
Check that drawMap() is inside the GameScene class (between the class braces), not outside it.
Help me debug my tile map rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.js and your browser shows the tile grid.
ⓧ I'd like to double check the full code
Here is the complete src/main.js at this point. Compare yours against it:
import * as Phaser from 'phaser';
const TILE_SIZE = 64;
const MAP_WIDTH = 10;
const MAP_HEIGHT = 8;
const SEASON_DURATION = 5000;
const MAX_AGE = 20;
const HEIR_FOOD = 3;
const HEIR_WOOD_RATIO = 0.5;
const TILE_GRASS = 0;
const TILE_FOREST = 1;
const TILE_WATER = 2;
const TILE_COLORS = {
[TILE_GRASS]: 0x4caf50,
[TILE_FOREST]: 0x2e7d32,
[TILE_WATER]: 0x1976d2,
};
const TILE_DEPLETED_COLORS = {
[TILE_GRASS]: 0x388e3c,
[TILE_FOREST]: 0x1b5e20,
};
function generateMap(width, height) {
const map = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const rand = Math.random();
if (rand < 0.6) {
row.push(TILE_GRASS);
} else if (rand < 0.85) {
row.push(TILE_FOREST);
} else {
row.push(TILE_WATER);
}
}
map.push(row);
}
const centerX = Math.floor(width / 2);
const centerY = Math.floor(height / 2);
map[centerY][centerX] = TILE_GRASS;
return map;
}
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
this.drawMap();
}
drawMap() {
this.tileGraphics = [];
for (let y = 0; y < MAP_HEIGHT; y++) {
this.tileGraphics[y] = [];
for (let x = 0; x < MAP_WIDTH; x++) {
const tileType = this.map[y][x];
const color = TILE_COLORS[tileType];
const rect = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 2,
TILE_SIZE - 2,
color
);
this.tileGraphics[y][x] = rect;
}
}
}
}
const config = {
type: Phaser.AUTO,
width: MAP_WIDTH * TILE_SIZE,
height: MAP_HEIGHT * TILE_SIZE + 40,
backgroundColor: '#1a1a1a',
scene: GameScene,
};
new Phaser.Game(config);
Your world exists. Next up, you will drop a settler onto this map and give them the ability to move around with arrow keys.
Add the Settler and Movement
Your map looks great, but right now it is just a static picture. A game needs a player character that can move around and interact with the world.
In this step, you will create a settler that appears on the map, responds to arrow key input, and gathers resources from the terrain. This is where your project transforms from a rendered image into an interactive game.
In this step, get ready to:
- Spawn a settler on the center tile of your map.
- Move the settler with arrow keys, blocking water and edges.
- Gather food and wood from tiles as you walk over them.
Create the settler as a game object
Every game needs a character the player controls. Your settler will be a yellow rectangle that lives on the tile grid and carries state like position, age, food, and wood.
- In src/main.js, find your existing create() method inside the GameScene class and replace it with this updated version:
create() {
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
// Track which tiles have been gathered
this.depletedTiles = [];
// Running total of all resources gathered across the game
this.totalResourcesGathered = 0;
this.drawMap();
// Spawn the settler at the center of the map
this.spawnSettler(Math.floor(MAP_WIDTH / 2), Math.floor(MAP_HEIGHT / 2), HEIR_FOOD, 0);
// Set up arrow key input
this.cursors = this.input.keyboard.createCursorKeys();
this.lastMoveTime = 0;
}
What does this code do?
This updated create() method now initializes everything the settler needs to function.
- this.depletedTiles tracks tiles that have already been gathered so they cannot be harvested again immediately.
- this.spawnSettler() places the settler in the middle of the map with starting food from the HEIR_FOOD constant (3) and 0 wood.
- this.input.keyboard.createCursorKeys() gives you references to the arrow keys so you can check for presses each frame.
- Now add the spawnSettler method. Place it directly below the drawMap() method's closing brace, still inside the GameScene class:
spawnSettler(x, y, food, wood) {
// Store settler state as a plain object
this.settler = { x, y, age: 0, food, wood };
// Remove previous graphic if it exists (for respawning later)
if (this.settlerGraphic) this.settlerGraphic.destroy();
// Draw the settler as a yellow square, slightly smaller than a tile
this.settlerGraphic = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 16,
TILE_SIZE - 16,
0xffeb3b
);
}
What does this code do?
The spawnSettler method does two things. It creates a this.settler object that stores all the settler's game state (position, age, food, wood). It also draws a yellow rectangle (0xffeb3b) that is 16 pixels smaller than a tile on each side, making it clearly visible as a character sitting on a tile.
The if (this.settlerGraphic) this.settlerGraphic.destroy() line is forward-thinking. When the dynasty system spawns a new heir later, this prevents duplicate settlers from stacking on the canvas.
- Save src/main.js.
- Switch to your browser. You should see a small yellow square sitting at the center of your tile grid.
Don't see the yellow settler?
- Make sure spawnSettler is defined inside the GameScene class (between the class braces), not outside it.
- Check that the this.spawnSettler(...) call in create() comes AFTER this.drawMap(). If it comes before, the map tiles will draw on top of the settler.
- Open your browser's DevTools console and check for errors. A typo in the method name will show as TypeError.
Help me debug my settler not appearing on the tile map.
Implement grid-based movement with arrow keys
In Phaser, the update() method runs every single frame (60 times per second). This is the game loop. You will check for key presses here and move the settler one tile at a time.
- Add the update method at the bottom of your GameScene class, below the spawnSettler method:
update(time) {
// Prevent movement if keys are held (150ms cooldown)
const moveDelay = 150;
if (time - this.lastMoveTime < moveDelay) return;
let newX = this.settler.x;
let newY = this.settler.y;
// Check which arrow key was just pressed
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) newX--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) newX++;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) newY--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) newY++;
// Only move if position actually changed
if (newX !== this.settler.x || newY !== this.settler.y) {
// Block movement onto water or outside map bounds
if (newX >= 0 && newX < MAP_WIDTH && newY >= 0 && newY < MAP_HEIGHT && this.map[newY][newX] !== TILE_WATER) {
this.settler.x = newX;
this.settler.y = newY;
this.settlerGraphic.setPosition(newX * TILE_SIZE + TILE_SIZE / 2, newY * TILE_SIZE + TILE_SIZE / 2);
this.lastMoveTime = time;
}
}
}
What does this code do?
The update() method is called every frame by Phaser. The moveDelay of 150 milliseconds prevents the settler from flying across the map when you hold an arrow key.
Phaser.Input.Keyboard.JustDown() only returns true on the first frame a key is pressed, giving you one-tile-per-press movement. The boundary check ensures newX and newY stay within the grid and never land on a TILE_WATER cell.
- Save src/main.js.
- Switch to your browser and press the arrow keys. The yellow settler should move one tile per press, stopping at blue (water) tiles and at the edges of the map.
Settler not moving?
- Click on the game canvas first. The browser needs focus on the canvas to register key presses.
- Confirm you named the method exactly update (not Update or onUpdate). Phaser calls this method by its exact name.
- Check that this.cursors is set in create() before update() tries to read it.
Help me debug why my settler won't move with arrow keys.
Implement resource gathering
Movement alone is not enough for survival. Your settler needs to pick up food and wood as it walks over tiles. Grass provides food. Forest provides wood.
- Add the gatherResource method below spawnSettler (and above update), inside the GameScene class:
gatherResource(x, y) {
const tileType = this.map[y][x];
// Check if this tile was already harvested recently
const isDepleted = this.depletedTiles.some(dt => dt.x === x && dt.y === y);
if (isDepleted) return;
if (tileType === TILE_GRASS) {
this.settler.food++;
this.totalResourcesGathered++;
this.depleteTile(x, y);
} else if (tileType === TILE_FOREST) {
this.settler.wood++;
this.totalResourcesGathered++;
this.depleteTile(x, y);
}
}
What does this code do?
When the settler steps on a grass tile, it gains 1 food. When it steps on a forest tile, it gains 1 wood. The isDepleted check prevents a tile from being harvested twice in a row. Water tiles give nothing because they fail both if conditions.
- Add the depleteTile method directly below gatherResource:
depleteTile(x, y) {
const tileType = this.map[y][x];
// Darken the tile to show it has been harvested
const depletedColor = TILE_DEPLETED_COLORS[tileType];
if (depletedColor) this.tileGraphics[y][x].setFillStyle(depletedColor);
// Track this tile for regeneration (5 seasons until it recovers)
this.depletedTiles.push({ x, y, turnsLeft: 5 });
}
What does this code do?
The depleteTile method visually darkens a harvested tile using the colors from TILE_DEPLETED_COLORS (the darker greens you defined earlier). It also pushes the tile's coordinates into this.depletedTiles with a turnsLeft: 5 counter. In the next step, the season timer will count these down and restore tiles when they hit zero.
- Now connect gathering to movement. In your update() method, find this line:
this.lastMoveTime = time;
- Add this.gatherResource(newX, newY); on the next line, so the block looks like this:
this.lastMoveTime = time;
this.gatherResource(newX, newY);
- Save src/main.js.
- Switch to your browser and move your settler onto green and dark-green tiles. You should see the tiles darken slightly as you walk over them.
- To confirm resources are increasing, open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and type document.querySelector('canvas').__scene to inspect the scene. You will add a proper HUD in the next step.
Tiles not darkening when you walk over them?
- Make sure you added the this.gatherResource(newX, newY) call inside the if block in update(), right after this.lastMoveTime = time.
- Confirm depleteTile references TILE_DEPLETED_COLORS (with an S at the end), not TILE_COLORS.
- Check that your gatherResource method is inside the class braces, not outside them.
Help me figure out why tiles aren't changing color when my settler walks on them.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.js and that your browser shows no console errors.
ⓧ I'd like to double check the full code
Here is the complete src/main.js as it should look at the end of this step:
import * as Phaser from 'phaser';
const TILE_SIZE = 64;
const MAP_WIDTH = 10;
const MAP_HEIGHT = 8;
const SEASON_DURATION = 5000;
const MAX_AGE = 20;
const HEIR_FOOD = 3;
const HEIR_WOOD_RATIO = 0.5;
const TILE_GRASS = 0;
const TILE_FOREST = 1;
const TILE_WATER = 2;
const TILE_COLORS = {
[TILE_GRASS]: 0x4caf50,
[TILE_FOREST]: 0x2e7d32,
[TILE_WATER]: 0x1976d2,
};
const TILE_DEPLETED_COLORS = {
[TILE_GRASS]: 0x388e3c,
[TILE_FOREST]: 0x1b5e20,
};
function generateMap(width, height) {
const map = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const rand = Math.random();
if (rand < 0.6) {
row.push(TILE_GRASS);
} else if (rand < 0.85) {
row.push(TILE_FOREST);
} else {
row.push(TILE_WATER);
}
}
map.push(row);
}
const centerX = Math.floor(width / 2);
const centerY = Math.floor(height / 2);
map[centerY][centerX] = TILE_GRASS;
return map;
}
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
this.depletedTiles = [];
this.totalResourcesGathered = 0;
this.drawMap();
this.spawnSettler(Math.floor(MAP_WIDTH / 2), Math.floor(MAP_HEIGHT / 2), HEIR_FOOD, 0);
this.cursors = this.input.keyboard.createCursorKeys();
this.lastMoveTime = 0;
}
drawMap() {
this.tileGraphics = [];
for (let y = 0; y < MAP_HEIGHT; y++) {
this.tileGraphics[y] = [];
for (let x = 0; x < MAP_WIDTH; x++) {
const tileType = this.map[y][x];
const color = TILE_COLORS[tileType];
const rect = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 2,
TILE_SIZE - 2,
color
);
this.tileGraphics[y][x] = rect;
}
}
}
spawnSettler(x, y, food, wood) {
this.settler = { x, y, age: 0, food, wood };
if (this.settlerGraphic) this.settlerGraphic.destroy();
this.settlerGraphic = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 16,
TILE_SIZE - 16,
0xffeb3b
);
}
gatherResource(x, y) {
const tileType = this.map[y][x];
const isDepleted = this.depletedTiles.some(dt => dt.x === x && dt.y === y);
if (isDepleted) return;
if (tileType === TILE_GRASS) {
this.settler.food++;
this.totalResourcesGathered++;
this.depleteTile(x, y);
} else if (tileType === TILE_FOREST) {
this.settler.wood++;
this.totalResourcesGathered++;
this.depleteTile(x, y);
}
}
depleteTile(x, y) {
const tileType = this.map[y][x];
const depletedColor = TILE_DEPLETED_COLORS[tileType];
if (depletedColor) this.tileGraphics[y][x].setFillStyle(depletedColor);
this.depletedTiles.push({ x, y, turnsLeft: 5 });
}
update(time) {
const moveDelay = 150;
if (time - this.lastMoveTime < moveDelay) return;
let newX = this.settler.x;
let newY = this.settler.y;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) newX--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) newX++;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) newY--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) newY++;
if (newX !== this.settler.x || newY !== this.settler.y) {
if (newX >= 0 && newX < MAP_WIDTH && newY >= 0 && newY < MAP_HEIGHT && this.map[newY][newX] !== TILE_WATER) {
this.settler.x = newX;
this.settler.y = newY;
this.settlerGraphic.setPosition(newX * TILE_SIZE + TILE_SIZE / 2, newY * TILE_SIZE + TILE_SIZE / 2);
this.lastMoveTime = time;
this.gatherResource(newX, newY);
}
}
}
}
const config = {
type: Phaser.AUTO,
width: MAP_WIDTH * TILE_SIZE,
height: MAP_HEIGHT * TILE_SIZE + 40,
backgroundColor: '#1a1a1a',
scene: GameScene,
};
new Phaser.Game(config);
Your settler is alive and moving. It can gather food and wood, and the map responds visually to your actions. Next up, you will add a season timer that consumes food automatically and ages your settler toward death.
Build the Season and Resource Loop
Your settler can move and gather resources, but nothing pressures them to act. Without urgency, the game has no stakes.
In this step, you will add a season system that ticks every 5 seconds. Each season consumes food, ages the settler, and regenerates depleted tiles. If food runs out or old age strikes, the settler dies.
In this step, get ready to:
- Create a repeating season timer that ages the settler and consumes food.
- Implement starvation and old age death conditions.
- Build a HUD overlay showing food, wood, age, season, and generation.
Implement the season timer
Every 5 seconds of real time equals one in-game season. Each season the settler ages and burns one food to survive. This timer is the heartbeat of the entire game loop.
- In src/main.js, find the existing create() method inside GameScene and replace it with this updated version:
create() {
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
this.depletedTiles = [];
// Season and dynasty state
this.season = 1;
this.totalSeasons = 0;
this.totalResourcesGathered = 0;
this.generation = 1;
this.bestDynasty = parseInt(localStorage.getItem('bestDynasty') || '0');
this.gameOver = false;
this.inTransition = false;
this.drawMap();
this.spawnSettler(Math.floor(MAP_WIDTH / 2), Math.floor(MAP_HEIGHT / 2), HEIR_FOOD, 0);
this.createHUD();
this.cursors = this.input.keyboard.createCursorKeys();
this.lastMoveTime = 0;
// Season timer: fires advanceSeason every 5 seconds
this.seasonTimer = this.time.addEvent({
delay: SEASON_DURATION,
callback: this.advanceSeason,
callbackScope: this,
loop: true,
});
// Register SPACE key for restart later
this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
}
What does this code do?
The new state variables track the game's progression. this.season counts the current season number. this.gameOver and this.inTransition act as flags to pause the game during death sequences.
The this.time.addEvent() call creates a repeating timer. Every 5000 milliseconds, Phaser automatically calls advanceSeason() for us. The loop: true property keeps it firing indefinitely.
You won't see a change yet. The browser will show errors because createHUD() and advanceSeason() don't exist yet. The next two substeps add them.
Implement death conditions
Two things kill your settler. Running out of food (starvation) or reaching age 20 (old age). Both are checked at the end of each season tick.
- Add the advanceSeason() method inside GameScene, after the spawnSettler() method:
advanceSeason() {
// Skip if game is over or between generations
if (this.gameOver || this.inTransition) return;
this.season++;
this.totalSeasons++;
this.settler.age++;
this.settler.food--;
// Regenerate depleted tiles after 5 seasons
for (let i = this.depletedTiles.length - 1; i >= 0; i--) {
this.depletedTiles[i].turnsLeft--;
if (this.depletedTiles[i].turnsLeft <= 0) {
const dt = this.depletedTiles[i];
this.tileGraphics[dt.y][dt.x].setFillStyle(TILE_COLORS[this.map[dt.y][dt.x]]);
this.depletedTiles.splice(i, 1);
}
}
// Check death conditions
if (this.settler.food < 0) {
this.settler.food = 0;
this.settlerDied('starvation');
} else if (this.settler.age >= MAX_AGE) {
this.settlerDied('old age');
}
this.updateHUD();
}
What does this code do?
Each season tick does four things. It increments the season and age counters. It decrements food by 1. It counts down depleted tiles and restores their color when the timer hits zero.
The loop iterates backwards (length - 1 down to 0) because splice() removes items from the array. Iterating forwards would skip entries after each removal.
Finally, the death checks fire. If food dips below zero, the settler starves. If age reaches MAX_AGE (20 seasons), old age claims them.
- Add the settlerDied() method directly below advanceSeason():
settlerDied(cause) {
// Pause the game during death message
this.inTransition = true;
this.messageText.setText(`Settler died of ${cause}.`);
this.messageText.setVisible(true);
// Hide message after 2 seconds
this.time.delayedCall(2000, () => { this.messageText.setVisible(false); this.inTransition = false; });
}
What does this code do?
When the settler dies, inTransition flips to true. This freezes movement and season ticks while the death message is on screen.
After 2 seconds, this.time.delayedCall() hides the message and resets the flag. This is a temporary stub. In the next step, you will replace it with the full dynasty inheritance mechanic.
You won't see a change yet. The createHUD() method is still missing, so the browser will still show an error. The next substep adds it.
Create the HUD overlay
The player needs to see what is happening. A HUD (heads-up display) at the bottom of the canvas shows all the critical stats at a glance.
- Add the createHUD() method inside GameScene, after the spawnSettler() method:
createHUD() {
// Position HUD text below the tile grid
const hudY = MAP_HEIGHT * TILE_SIZE + 10;
this.hudText = this.add.text(10, hudY, '', { fontSize: '16px', color: '#ffffff', fontFamily: 'monospace' });
// Centered message text for death notifications
this.messageText = this.add.text(
(MAP_WIDTH * TILE_SIZE) / 2, (MAP_HEIGHT * TILE_SIZE) / 2, '',
{ fontSize: '24px', color: '#ffffff', fontFamily: 'monospace', align: 'center', backgroundColor: '#000000aa', padding: { x: 20, y: 10 } }
);
this.messageText.setOrigin(0.5);
this.messageText.setVisible(false);
}
What does this code do?
Two text objects serve different purposes. this.hudText sits in the 40px strip below the tile grid (that's the extra height in your game config). It shows persistent stats.
The this.messageText is centered on the canvas with a semi-transparent black background. It starts hidden and only appears when the settler dies.
- Add the updateHUD() method directly below createHUD():
updateHUD() {
const s = this.settler;
this.hudText.setText(`Food: ${s.food} Wood: ${s.wood} Age: ${s.age}/${MAX_AGE} Season: ${this.season} Gen: ${this.generation} Best: ${this.bestDynasty}`);
}
What does this code do?
This builds a single string showing every stat the player needs. It reads from this.settler for personal stats (food, wood, age) and from the scene for global stats (season, generation, best dynasty).
One last change. The update() method needs to stop processing input during death transitions and refresh the HUD after each move.
- Replace your existing update() method with this updated version:
update(time) {
// Freeze input during game over or transition
if (this.gameOver || this.inTransition) return;
const moveDelay = 150;
if (time - this.lastMoveTime < moveDelay) return;
let newX = this.settler.x, newY = this.settler.y;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) newX--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) newX++;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) newY--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) newY++;
if (newX !== this.settler.x || newY !== this.settler.y) {
if (newX >= 0 && newX < MAP_WIDTH && newY >= 0 && newY < MAP_HEIGHT && this.map[newY][newX] !== TILE_WATER) {
this.settler.x = newX; this.settler.y = newY;
this.settlerGraphic.setPosition(newX * TILE_SIZE + TILE_SIZE / 2, newY * TILE_SIZE + TILE_SIZE / 2);
this.lastMoveTime = time;
this.gatherResource(newX, newY);
this.updateHUD();
}
}
}
What changed here?
Two additions. The guard at the top (if (this.gameOver || this.inTransition) return) freezes the settler during death messages so the player can't move while text is on screen.
The this.updateHUD() call after gatherResource() refreshes the display every time the settler moves. This way gathering food or wood shows up immediately.
- Save src/main.js.
- Switch to your browser. You should see a white text strip at the bottom of the canvas showing Food: 3 Wood: 0 Age: 0/20 Season: 1 Gen: 1 Best: 0.
- Wait 5 seconds. The Season counter ticks up, Age increases, and Food decreases by 1.
- Move onto a green tile. Food should increase immediately in the HUD.
HUD not appearing?
Make sure createHUD() is placed inside the GameScene class (after spawnSettler()) and that this.createHUD() is called in create() after this.spawnSettler().
If the canvas cuts off the HUD, confirm your game config height is MAP_HEIGHT * TILE_SIZE + 40 (552 pixels). The extra 40 pixels provide room for the text.
help me debug my HUD not showing up in Phaser
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.js and can see the season ticking in your browser.
ⓧ I'd like to double check the full code
import * as Phaser from 'phaser';
const TILE_SIZE = 64;
const MAP_WIDTH = 10;
const MAP_HEIGHT = 8;
const SEASON_DURATION = 5000;
const MAX_AGE = 20;
const HEIR_FOOD = 3;
const HEIR_WOOD_RATIO = 0.5;
const TILE_GRASS = 0;
const TILE_FOREST = 1;
const TILE_WATER = 2;
const TILE_COLORS = {
[TILE_GRASS]: 0x4caf50,
[TILE_FOREST]: 0x2e7d32,
[TILE_WATER]: 0x1976d2,
};
const TILE_DEPLETED_COLORS = {
[TILE_GRASS]: 0x388e3c,
[TILE_FOREST]: 0x1b5e20,
};
function generateMap(width, height) {
const map = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const rand = Math.random();
if (rand < 0.6) row.push(TILE_GRASS);
else if (rand < 0.85) row.push(TILE_FOREST);
else row.push(TILE_WATER);
}
map.push(row);
}
map[Math.floor(height / 2)][Math.floor(width / 2)] = TILE_GRASS;
return map;
}
class GameScene extends Phaser.Scene {
constructor() { super('GameScene'); }
create() {
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
this.depletedTiles = [];
this.season = 1;
this.totalSeasons = 0;
this.totalResourcesGathered = 0;
this.generation = 1;
this.bestDynasty = parseInt(localStorage.getItem('bestDynasty') || '0');
this.gameOver = false;
this.inTransition = false;
this.drawMap();
this.spawnSettler(Math.floor(MAP_WIDTH / 2), Math.floor(MAP_HEIGHT / 2), HEIR_FOOD, 0);
this.createHUD();
this.cursors = this.input.keyboard.createCursorKeys();
this.lastMoveTime = 0;
this.seasonTimer = this.time.addEvent({
delay: SEASON_DURATION,
callback: this.advanceSeason,
callbackScope: this,
loop: true,
});
this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
}
drawMap() {
this.tileGraphics = [];
for (let y = 0; y < MAP_HEIGHT; y++) {
this.tileGraphics[y] = [];
for (let x = 0; x < MAP_WIDTH; x++) {
const color = TILE_COLORS[this.map[y][x]];
this.tileGraphics[y][x] = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 2, TILE_SIZE - 2, color
);
}
}
}
spawnSettler(x, y, food, wood) {
this.settler = { x, y, age: 0, food, wood };
if (this.settlerGraphic) this.settlerGraphic.destroy();
this.settlerGraphic = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2, y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 16, TILE_SIZE - 16, 0xffeb3b
);
}
createHUD() {
const hudY = MAP_HEIGHT * TILE_SIZE + 10;
this.hudText = this.add.text(10, hudY, '', { fontSize: '16px', color: '#ffffff', fontFamily: 'monospace' });
this.messageText = this.add.text(
(MAP_WIDTH * TILE_SIZE) / 2, (MAP_HEIGHT * TILE_SIZE) / 2, '',
{ fontSize: '24px', color: '#ffffff', fontFamily: 'monospace', align: 'center', backgroundColor: '#000000aa', padding: { x: 20, y: 10 } }
);
this.messageText.setOrigin(0.5);
this.messageText.setVisible(false);
}
updateHUD() {
const s = this.settler;
this.hudText.setText(`Food: ${s.food} Wood: ${s.wood} Age: ${s.age}/${MAX_AGE} Season: ${this.season} Gen: ${this.generation} Best: ${this.bestDynasty}`);
}
advanceSeason() {
if (this.gameOver || this.inTransition) return;
this.season++;
this.totalSeasons++;
this.settler.age++;
this.settler.food--;
for (let i = this.depletedTiles.length - 1; i >= 0; i--) {
this.depletedTiles[i].turnsLeft--;
if (this.depletedTiles[i].turnsLeft <= 0) {
const dt = this.depletedTiles[i];
this.tileGraphics[dt.y][dt.x].setFillStyle(TILE_COLORS[this.map[dt.y][dt.x]]);
this.depletedTiles.splice(i, 1);
}
}
if (this.settler.food < 0) {
this.settler.food = 0;
this.settlerDied('starvation');
} else if (this.settler.age >= MAX_AGE) {
this.settlerDied('old age');
}
this.updateHUD();
}
settlerDied(cause) {
this.inTransition = true;
this.messageText.setText(`Settler died of ${cause}.`);
this.messageText.setVisible(true);
this.time.delayedCall(2000, () => { this.messageText.setVisible(false); this.inTransition = false; });
}
gatherResource(x, y) {
const tileType = this.map[y][x];
const isDepleted = this.depletedTiles.some(dt => dt.x === x && dt.y === y);
if (isDepleted) return;
if (tileType === TILE_GRASS) {
this.settler.food++; this.totalResourcesGathered++; this.depleteTile(x, y);
} else if (tileType === TILE_FOREST) {
this.settler.wood++; this.totalResourcesGathered++; this.depleteTile(x, y);
}
}
depleteTile(x, y) {
const depletedColor = TILE_DEPLETED_COLORS[this.map[y][x]];
if (depletedColor) this.tileGraphics[y][x].setFillStyle(depletedColor);
this.depletedTiles.push({ x, y, turnsLeft: 5 });
}
update(time) {
if (this.gameOver || this.inTransition) return;
const moveDelay = 150;
if (time - this.lastMoveTime < moveDelay) return;
let newX = this.settler.x, newY = this.settler.y;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) newX--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) newX++;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) newY--;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) newY++;
if (newX !== this.settler.x || newY !== this.settler.y) {
if (newX >= 0 && newX < MAP_WIDTH && newY >= 0 && newY < MAP_HEIGHT && this.map[newY][newX] !== TILE_WATER) {
this.settler.x = newX; this.settler.y = newY;
this.settlerGraphic.setPosition(newX * TILE_SIZE + TILE_SIZE / 2, newY * TILE_SIZE + TILE_SIZE / 2);
this.lastMoveTime = time;
this.gatherResource(newX, newY);
this.updateHUD();
}
}
}
}
const config = {
type: Phaser.AUTO,
width: MAP_WIDTH * TILE_SIZE,
height: MAP_HEIGHT * TILE_SIZE + 40,
backgroundColor: '#1a1a1a',
scene: GameScene,
};
new Phaser.Game(config);
Your game now has real stakes. The clock is ticking, food is draining, and death is inevitable. Next up, you will add the dynasty mechanic so that death is not the end.
Implement the Dynasty System
Your settler can move, gather, and age. But right now, death is a dead end. The game freezes when your settler dies.
The dynasty system transforms death from an ending into a transition. When your settler dies, an heir spawns with a portion of inherited resources. The game only truly ends when the dynasty can no longer sustain itself.
In this step, get ready to:
- Create an inheritance mechanic that passes resources to an heir.
- Implement a game over screen when the dynasty can no longer survive.
- Polish the generational transition with a high score tracker.
Create the inheritance mechanic
When a settler dies, the dynasty continues through an heir. The heir inherits half of the previous settler's wood (rounded down) and starts with a small amount of food. A fresh map regenerates to give the new generation a fighting chance.
- In src/main.js, find the existing settlerDied(cause) method:
settlerDied(cause) {
this.inTransition = true;
this.messageText.setText(`Settler died of ${cause}.`);
this.messageText.setVisible(true);
this.time.delayedCall(2000, () => { this.messageText.setVisible(false); this.inTransition = false; });
}
- Replace the entire settlerDied(cause) method with this version:
settlerDied(cause) {
// Calculate how much wood the heir inherits (50% rounded down)
const inheritedWood = Math.floor(this.settler.wood * HEIR_WOOD_RATIO);
// If starvation AND no wood to pass on, dynasty ends
if (cause === 'starvation' && inheritedWood === 0) {
this.endGame();
return;
}
this.inTransition = true;
this.generation++;
// Track best dynasty in localStorage
if (this.generation - 1 > this.bestDynasty) {
this.bestDynasty = this.generation - 1;
localStorage.setItem('bestDynasty', String(this.bestDynasty));
}
// Show transition message for 2 seconds
this.messageText.setText(`Settler died of ${cause}.\nGeneration ${this.generation} begins...`);
this.messageText.setVisible(true);
this.time.delayedCall(2000, () => {
this.messageText.setVisible(false);
this.inTransition = false;
// Destroy old map tiles
for (let y = 0; y < MAP_HEIGHT; y++) {
for (let x = 0; x < MAP_WIDTH; x++) {
this.tileGraphics[y][x].destroy();
}
}
// Generate fresh map and spawn heir
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.depletedTiles = [];
this.drawMap();
this.spawnSettler(
Math.floor(MAP_WIDTH / 2),
Math.floor(MAP_HEIGHT / 2),
HEIR_FOOD,
inheritedWood
);
this.updateHUD();
});
}
What does this code do?
This method now handles the full dynasty transition when a settler dies.
- inheritedWood calculates 50% of the dead settler's wood, rounded down with Math.floor(). This creates a meaningful trade-off between hoarding wood (investment for your heir) and gathering food (immediate survival).
- The game over check at the top calls endGame() if the settler starved AND has no wood to pass on. You will write endGame() in the next substep.
- The generation counter increments and the best dynasty score saves to localStorage so it persists across browser refreshes.
- After a 2-second transition screen, the old map is destroyed, a fresh map generates, and the heir spawns at the center with HEIR_FOOD (3) food plus the inherited wood.
Seeing an error about endGame?
Your game will throw an error if you let the settler starve right now because endGame() does not exist yet. That is expected. You will add it in the next substep.
Help me debug my settlerDied method
Implement game over
The dynasty ends when conditions are truly hopeless. If the settler starved AND has no wood to pass to an heir, there is nothing left to inherit. The game displays final stats and lets the player restart.
- In src/main.js, add the endGame() method directly after the settlerDied() method you just replaced:
endGame() {
this.gameOver = true;
this.seasonTimer.remove(false);
// Update best dynasty one final time
if (this.generation - 1 > this.bestDynasty) {
this.bestDynasty = this.generation - 1;
localStorage.setItem('bestDynasty', String(this.bestDynasty));
}
// Display final stats
this.messageText.setText(
`Dynasty Ended!\n\nGenerations: ${this.generation - 1}\nSeasons survived: ${this.totalSeasons}\nResources gathered: ${this.totalResourcesGathered}\n\nPress SPACE to restart`
);
this.messageText.setVisible(true);
}
What does this code do?
- this.gameOver = true stops the update() loop from processing movement.
- this.seasonTimer.remove(false) stops seasons from ticking after the game ends.
- The message displays a summary of the player's run: how many generations survived, total seasons, and resources gathered.
Now you need to let the player restart by pressing SPACE. The update() method currently returns early when gameOver is true, but it does not listen for a restart key.
- In src/main.js, find the beginning of the update(time) method. It currently starts with:
update(time) {
if (this.gameOver || this.inTransition) return;
- Replace that opening section with this code, which checks for SPACE when the game is over:
update(time) {
if (this.gameOver) {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.scene.restart();
}
return;
}
if (this.inTransition) return;
What changed here?
Instead of lumping gameOver and inTransition into one check, the game over state now has its own block. Inside it, Phaser.Input.Keyboard.JustDown(this.spaceKey) detects a single SPACE press.
When detected, this.scene.restart() resets the entire scene back to its initial state.
- Save src/main.js.
- Switch to your browser. Wait for the season timer to consume all your food (or just do not gather any). When your settler starves with no wood, you should see the "Dynasty Ended!" screen with your stats and a "Press SPACE to restart" prompt.
- Press SPACE. The game should reset completely with a fresh map and a new settler.
Game over screen not appearing?
- Make sure endGame() is placed after settlerDied() and before update(). If the method is not inside the GameScene class, it will not be found.
- Check that the game over condition in settlerDied() uses === (strict equality) for both checks: cause === 'starvation' && inheritedWood === 0.
- If SPACE does not restart, confirm you replaced the single-line if (this.gameOver || this.inTransition) return; with the new split check at the top of update().
Help me debug my game over screen
Polish the generational transition
The inheritance mechanic and game over are both in place. The transition screen and best dynasty high score are already wired into the settlerDied() code you wrote earlier. Now it is time to play through a full dynasty lifecycle and confirm everything connects.
- Switch to your browser and refresh the page.
- Gather some wood by moving onto dark green forest tiles.
- Wait for your settler to die of old age (at season 20) or starvation.
- Watch the transition screen appear. It should read something like "Settler died of old age." followed by "Generation 2 begins..." for 2 seconds.
- After the transition, confirm that a fresh map generates and a new settler spawns at the center with 3 food and half the previous settler's wood.
- Check the HUD. The Gen counter should read 2 and the Best counter should update.
- Let the heir starve without gathering any wood. The "Dynasty Ended!" screen should appear with your stats.
- Press SPACE to restart. The entire game resets to Generation 1 with a new map.
How does the high score work?
Every time a settler dies and the dynasty continues, the code checks whether this.generation - 1 exceeds the saved bestDynasty value. If it does, the new record saves to localStorage.
Because localStorage persists between browser sessions, the "Best" counter remembers your longest dynasty even after you close the tab.
Heir not spawning after death?
- If the game jumps straight to "Dynasty Ended!" every time, your settler likely has 0 wood when dying of starvation. Gather at least 1 wood before dying to test the inheritance path.
- If the transition message shows but the new map never appears, check that your this.time.delayedCall(2000, () => { ... }) callback properly destroys old tiles and calls this.drawMap().
- If the "Best" counter always shows 0, check that localStorage.setItem('bestDynasty', String(this.bestDynasty)) is inside the if (this.generation - 1 > this.bestDynasty) block.
Help me debug my dynasty transition
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.js and your dynasty system is fully functional.
ⓧ I'd like to double check the full code
Here is the complete src/main.js at this point in the project.
import * as Phaser from 'phaser';
const TILE_SIZE = 64;
const MAP_WIDTH = 10;
const MAP_HEIGHT = 8;
const SEASON_DURATION = 5000;
const MAX_AGE = 20;
const HEIR_FOOD = 3;
const HEIR_WOOD_RATIO = 0.5;
const TILE_GRASS = 0;
const TILE_FOREST = 1;
const TILE_WATER = 2;
const TILE_COLORS = {
[TILE_GRASS]: 0x4caf50,
[TILE_FOREST]: 0x2e7d32,
[TILE_WATER]: 0x1976d2,
};
const TILE_DEPLETED_COLORS = {
[TILE_GRASS]: 0x388e3c,
[TILE_FOREST]: 0x1b5e20,
};
function generateMap(width, height) {
const map = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const rand = Math.random();
if (rand < 0.6) {
row.push(TILE_GRASS);
} else if (rand < 0.85) {
row.push(TILE_FOREST);
} else {
row.push(TILE_WATER);
}
}
map.push(row);
}
const centerX = Math.floor(width / 2);
const centerY = Math.floor(height / 2);
map[centerY][centerX] = TILE_GRASS;
return map;
}
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.tileGraphics = [];
this.depletedTiles = [];
this.season = 1;
this.totalSeasons = 0;
this.totalResourcesGathered = 0;
this.generation = 1;
this.bestDynasty = parseInt(localStorage.getItem('bestDynasty') || '0');
this.gameOver = false;
this.inTransition = false;
this.drawMap();
this.spawnSettler(Math.floor(MAP_WIDTH / 2), Math.floor(MAP_HEIGHT / 2), HEIR_FOOD, 0);
this.createHUD();
this.cursors = this.input.keyboard.createCursorKeys();
this.lastMoveTime = 0;
this.seasonTimer = this.time.addEvent({
delay: SEASON_DURATION,
callback: this.advanceSeason,
callbackScope: this,
loop: true,
});
this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
}
drawMap() {
this.tileGraphics = [];
for (let y = 0; y < MAP_HEIGHT; y++) {
this.tileGraphics[y] = [];
for (let x = 0; x < MAP_WIDTH; x++) {
const tileType = this.map[y][x];
const color = TILE_COLORS[tileType];
const rect = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 2,
TILE_SIZE - 2,
color
);
this.tileGraphics[y][x] = rect;
}
}
}
spawnSettler(x, y, food, wood) {
this.settler = {
x: x,
y: y,
age: 0,
food: food,
wood: wood,
};
if (this.settlerGraphic) {
this.settlerGraphic.destroy();
}
this.settlerGraphic = this.add.rectangle(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
TILE_SIZE - 16,
TILE_SIZE - 16,
0xffeb3b
);
}
createHUD() {
const hudY = MAP_HEIGHT * TILE_SIZE + 10;
this.hudText = this.add.text(10, hudY, '', {
fontSize: '16px',
color: '#ffffff',
fontFamily: 'monospace',
});
this.messageText = this.add.text(
(MAP_WIDTH * TILE_SIZE) / 2,
(MAP_HEIGHT * TILE_SIZE) / 2,
'',
{
fontSize: '24px',
color: '#ffffff',
fontFamily: 'monospace',
align: 'center',
backgroundColor: '#000000aa',
padding: { x: 20, y: 10 },
}
);
this.messageText.setOrigin(0.5);
this.messageText.setVisible(false);
}
updateHUD() {
const s = this.settler;
this.hudText.setText(
`Food: ${s.food} Wood: ${s.wood} Age: ${s.age}/${MAX_AGE} Season: ${this.season} Gen: ${this.generation} Best: ${this.bestDynasty}`
);
}
advanceSeason() {
if (this.gameOver || this.inTransition) return;
this.season++;
this.totalSeasons++;
this.settler.age++;
this.settler.food--;
for (let i = this.depletedTiles.length - 1; i >= 0; i--) {
this.depletedTiles[i].turnsLeft--;
if (this.depletedTiles[i].turnsLeft <= 0) {
const dt = this.depletedTiles[i];
const color = TILE_COLORS[this.map[dt.y][dt.x]];
this.tileGraphics[dt.y][dt.x].setFillStyle(color);
this.depletedTiles.splice(i, 1);
}
}
if (this.settler.food < 0) {
this.settler.food = 0;
this.settlerDied('starvation');
} else if (this.settler.age >= MAX_AGE) {
this.settlerDied('old age');
}
this.updateHUD();
}
settlerDied(cause) {
const inheritedWood = Math.floor(this.settler.wood * HEIR_WOOD_RATIO);
if (cause === 'starvation' && inheritedWood === 0) {
this.endGame();
return;
}
this.inTransition = true;
this.generation++;
if (this.generation - 1 > this.bestDynasty) {
this.bestDynasty = this.generation - 1;
localStorage.setItem('bestDynasty', String(this.bestDynasty));
}
this.messageText.setText(`Settler died of ${cause}.\nGeneration ${this.generation} begins...`);
this.messageText.setVisible(true);
this.time.delayedCall(2000, () => {
this.messageText.setVisible(false);
this.inTransition = false;
for (let y = 0; y < MAP_HEIGHT; y++) {
for (let x = 0; x < MAP_WIDTH; x++) {
this.tileGraphics[y][x].destroy();
}
}
this.map = generateMap(MAP_WIDTH, MAP_HEIGHT);
this.depletedTiles = [];
this.drawMap();
this.spawnSettler(
Math.floor(MAP_WIDTH / 2),
Math.floor(MAP_HEIGHT / 2),
HEIR_FOOD,
inheritedWood
);
this.updateHUD();
});
}
endGame() {
this.gameOver = true;
this.seasonTimer.remove(false);
if (this.generation - 1 > this.bestDynasty) {
this.bestDynasty = this.generation - 1;
localStorage.setItem('bestDynasty', String(this.bestDynasty));
}
this.messageText.setText(
`Dynasty Ended!\n\nGenerations: ${this.generation - 1}\nSeasons survived: ${this.totalSeasons}\nResources gathered: ${this.totalResourcesGathered}\n\nPress SPACE to restart`
);
this.messageText.setVisible(true);
}
update(time) {
if (this.gameOver) {
if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
this.scene.restart();
}
return;
}
if (this.inTransition) return;
const moveDelay = 150;
if (time - this.lastMoveTime < moveDelay) return;
let newX = this.settler.x;
let newY = this.settler.y;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left)) {
newX--;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.right)) {
newX++;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) {
newY--;
} else if (Phaser.Input.Keyboard.JustDown(this.cursors.down)) {
newY++;
}
if (newX !== this.settler.x || newY !== this.settler.y) {
if (
newX >= 0 &&
newX < MAP_WIDTH &&
newY >= 0 &&
newY < MAP_HEIGHT &&
this.map[newY][newX] !== TILE_WATER
) {
this.settler.x = newX;
this.settler.y = newY;
this.settlerGraphic.setPosition(
newX * TILE_SIZE + TILE_SIZE / 2,
newY * TILE_SIZE + TILE_SIZE / 2
);
this.lastMoveTime = time;
this.gatherResource(newX, newY);
this.updateHUD();
}
}
}
gatherResource(x, y) {
const tileType = this.map[y][x];
const isDepleted = this.depletedTiles.some(
(dt) => dt.x === x && dt.y === y
);
if (isDepleted) return;
if (tileType === TILE_GRASS) {
this.settler.food++;
this.totalResourcesGathered++;
this.depleteTile(x, y);
} else if (tileType === TILE_FOREST) {
this.settler.wood++;
this.totalResourcesGathered++;
this.depleteTile(x, y);
}
}
depleteTile(x, y) {
const tileType = this.map[y][x];
const depletedColor = TILE_DEPLETED_COLORS[tileType];
if (depletedColor) {
this.tileGraphics[y][x].setFillStyle(depletedColor);
}
this.depletedTiles.push({ x, y, turnsLeft: 5 });
}
}
const config = {
type: Phaser.AUTO,
width: MAP_WIDTH * TILE_SIZE,
height: MAP_HEIGHT * TILE_SIZE + 40,
backgroundColor: '#1a1a1a',
scene: GameScene,
};
new Phaser.Game(config);
Your dynasty system is complete. Every death is now a meaningful event that either continues the legacy or ends it. Next up, you will take on the Secret Mission and add a building system that lets you invest wood into permanent shelters.
Secret mission
Add a Building System
Your settlers can gather resources, but they can't shape the land. What if you could spend wood to build shelters that boost food production for every generation that follows? Add a building system that creates permanent structures on the map with adjacency bonuses.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The dynasty-settler project folder on your Desktop (contains all source code and node_modules).
- The Vite dev server (running process in your terminal).
- A bestDynasty entry in your browser's localStorage.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep playing your game.
- Your Vite dev server stays running in the terminal.
- Reopen your game anytime by visiting the local URL shown in the terminal.
- Your high score persists in localStorage between sessions.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your project files stay intact on disk.
- Switch to the terminal where the Vite dev server is running.
- Press Ctrl+C to stop the server.
- When you want to resume, open the dynasty-settler folder in VS Code and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- Switch to the terminal where the Vite dev server is running and press Ctrl+C to stop it.
- Delete the project folder by running the command for your operating system:
macOS
cd ~/Desktop
rm -rf dynasty-settler
Windows
cd ~\Desktop
rmdir /s /q dynasty-settler
- To clear your high score from localStorage, open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and run:
localStorage.removeItem('bestDynasty')
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully playable 2D colony survival game from scratch using Phaser 4 and Vite.
Your dynasty settlers can now explore a procedurally generated world, gather resources, survive seasons, pass on their legacy to heirs, and even build shelters for a strategic edge.
You've learned how to:
- Build a complete 2D browser game with a Phaser 4 scene lifecycle, tile-based rendering, and keyboard input.
- Implement a game loop with seasonal timers, resource consumption, aging, and death conditions that drive real gameplay tension.
- Create a dynasty inheritance system where heirs spawn with inherited resources, the map regenerates, and a high score persists across sessions using localStorage.
- Secret Mission: Added a building system with adjacency bonuses that persist across generations.
Ready to quiz yourself?