3D Colony Survival Sim Foundation

Build a Banished-style 3D colony sim with terrain, seasons, and resources.

Introduction

30 Second Summary

Survival colony games like Banished drop you into an unforgiving world where every season matters. One bad winter without firewood and your colony is gone.

In this project, you will build the foundation of a 3D colony survival sim in Godot. You will create a grid world with terrain data, implement seasonal cycles that change the landscape, gather resources, and place buildings to keep your colony alive through winter.

What You'll Build

A 3D colony survival prototype with a configurable tile grid, per-tile simulation data, seasonal weather cycles, and a resource-driven building system, all built in Godot 4.7 using GDScript.

By the end of this project, you'll have:

  • A 64x64 grid world where every tile stores moisture and fertility data, ready for future water simulation systems.
  • A seasonal cycle (Spring, Summer, Autumn, Winter) that visually transforms terrain colors and drives crop growth or death.
  • A resource gathering and building placement system where you chop trees for wood, harvest farms for food, and construct houses, farms, and stockpiles.
  • Secret Mission: Add a living colonist with hunger and warmth needs who must survive winter or die.

Are there any prerequisites?

You need basic programming familiarity (variables, loops, functions). No prior Godot or GDScript experience is required.

Set Up the 3D Scene

Every colony simulation needs a world to exist in. Before you write any game logic, you need a 3D scene with a camera looking down at the terrain and a ground plane that detects mouse clicks.

In this step, you will create the Godot project, build the scene tree with the nodes your simulation needs, and configure the camera for a top-down colony view.

In this step, get ready to:
  • Create a new Godot 4.7 project named NewWorldColony.
  • Build the scene tree with a ground plane and camera rig.
  • Configure lighting and camera for a top-down orthogonal view.
Create the NewWorldColony project

The Godot Project Manager is the first window you see when Godot launches. You will create a fresh project here to hold all your colony sim files.

  • In the Project Manager, click the Create button in the top-left of the window.
  • Type NewWorldColony in the project name field.
  • Click Browse and choose an empty folder on your computer to save the project. You can also enable Create Folder to automatically create a subfolder named after the project.
  • Leave the renderer on its default setting (you can change this later if needed).
  • Click Create to create the project folder and open it in the editor.

The Godot editor opens with an empty scene. You should see the Scene dock on the left and the Inspector dock on the right.

Don't see the editor?

Make sure the folder you selected was empty. A green tick next to the path confirms Godot detected a valid empty folder. If the folder already contained files, create a new empty folder and try again.

help me troubleshoot why my Godot project won't create

Build the scene tree with ground plane and camera

Your scene needs a root node to hold everything, a physics body for the ground (so raycasts can detect where the player clicks), and a camera that looks down at the world from above.

  • In the Scene dock on the left, click 3D Scene to add a Node3D root node.
  • Double-click the node name in the Scene dock and rename it to Main.
  • With Main selected, click the + button at the top of the Scene dock (or press Cmd+A on macOS / Ctrl+A on Windows) to add a child node.
  • In the Create New Node dialog, type StaticBody3D in the search bar, select it, and click Create.
  • Rename this node to GroundPlane.
  • With GroundPlane selected, click + again to add a child node. Search for CollisionShape3D, select it, and click Create.
  • Select the new CollisionShape3D node. In the Inspector on the right, find the Shape property. Click the dropdown (it shows <empty>) and select New WorldBoundaryShape3D.

Why WorldBoundaryShape3D?

A WorldBoundaryShape3D acts like an infinite flat plane for physics. When you click the screen later, a ray will shoot from the camera and hit this invisible boundary. That hit point tells your script which grid tile the player clicked.

Unlike a visible mesh, this shape has no rendering cost. It exists purely for physics calculations.

Now add the camera rig. The camera sits inside a pivot node so you can move the whole view by repositioning the pivot.

  • Select the Main node. Click + and add a Node3D. Rename it to CameraPivot.
  • With CameraPivot selected, look at the Inspector on the right. Under Transform, set Position to x: 32, y: 0, z: 32.
  • With CameraPivot selected, click + and add a Camera3D.

Why position the pivot at (32, 0, 32)?

Your grid world will be 64x64 tiles starting at coordinate (0, 0). Placing the camera pivot at (32, 0, 32) centers the view on the middle of the grid so the player sees the world spread evenly in all directions.

Configure the camera and add lighting

An orthogonal camera shows all tiles at the same size regardless of distance. This is ideal for a colony sim where you need to read the grid clearly.

  • Select the Camera3D node under CameraPivot.
  • In the Inspector, find the Projection property and change it from Perspective to Orthogonal.
  • Set Size to 40. This controls how many world units fit on screen. A larger value lets you see more of the 64x64 world at once.
  • Under Transform, set Position to x: 0, y: 20, z: 15.
  • Under Transform, set Rotation (in degrees) to x: -55, y: 0, z: 0.

What does the -55 degree rotation do?

Rotating the camera -55 degrees on the X axis tilts it downward, giving you an angled top-down perspective. This lets the player see the tops of buildings while still reading the grid clearly. A fully top-down (90 degree) camera would make tall objects invisible.

Now add a light so the scene is not pitch black.

  • Select the Main node. Click + and add a DirectionalLight3D.
  • With the DirectionalLight3D selected, set its Rotation in the Inspector to x: -45, y: 30, z: 0.
  • Save the scene by pressing Cmd+S (macOS) or Ctrl+S (Windows). Name the file main.tscn.

Run the scene by pressing F5. Godot will ask you to select a main scene. Click Select Current to use main.tscn.

You should see a flat grey ground from an angled top-down perspective with consistent lighting across the surface.

Seeing a black screen?

Check that your DirectionalLight3D exists under the Main node. Without a light, the scene renders dark.

Also confirm the Camera3D has Current set to On in the Inspector. If there is only one camera in the scene, Godot uses it by default, but it is worth checking.

help me figure out why my Godot 3D scene is rendering black

✔️ Awesome, I've got everything!

Great. Double check you have saved your scene as main.tscn.

ⓧ I'd like to double check the full scene tree

Your scene tree in the Scene dock should look exactly like this:

Main (Node3D)
  GroundPlane (StaticBody3D)
    CollisionShape3D (WorldBoundaryShape3D)
  CameraPivot (Node3D) [position: (32, 0, 32)]
    Camera3D [projection: Orthogonal, size: 40, position: (0, 20, 15), rotation: (-55, 0, 0)]
  DirectionalLight3D [rotation: (-45, 30, 0)]

Your 3D scene is ready. Next up, you will write the script that generates a 64x64 grid of colored terrain tiles across this ground plane.

Build the Grid World with Terrain Data

Your 3D scene is set up with a camera, lighting, and a ground plane. Now it's time to fill that empty space with a living world.

Every colony sim needs terrain that carries data. In this step, you'll write a script that generates a 64x64 grid of colored tiles, where each tile stores moisture and fertility values that will drive crop growth and resource distribution in later steps.

In this step, get ready to:
  • Create a world_grid.gd script with configurable world constants.
  • Generate a 64x64 grid of tiles with per-tile simulation data.
  • Color tiles based on terrain quality and spawn trees across the world.
Create the world grid script

The entire world simulation lives in a single script attached to the root node. This script will define the grid size, store per-tile data in a Dictionary, and generate all the visual meshes.

  • In the Scene dock, right-click the Main node and select Attach Script.
  • In the Attach Node Script window, change the Template field from Node: Default to Object: Empty.
  • Change the Path field to res://world_grid.gd.
  • Click Create.

The script editor opens with a blank file. Clear any template content and paste the following code that defines the script's data foundation:

extends Node3D

# World size configuration - change this single constant to resize the world.
const GRID_SIZE := 64
const TILE_SIZE := 1.0

# Per-tile data storage
var grid_data: Dictionary = {}
var tile_meshes: Dictionary = {}
var tree_nodes: Dictionary = {}
var building_nodes: Dictionary = {}

# Global game state
var resources := {"wood": 20, "food": 10, "stone": 0}
var current_season := "Spring"
var current_day := 1
var build_mode := ""

# Season tracking
var season_order := ["Spring", "Summer", "Autumn", "Winter"]
var season_index := 0

func _ready() -> void:
	generate_grid()

What does this code do?

This sets up the entire data layer for the colony sim.

  • GRID_SIZE controls how many tiles the world has (64x64 = 4,096 tiles). Change this one constant later to scale the world up or down.
  • grid_data is a Dictionary keyed by Vector2i grid coordinates. Each entry stores moisture, fertility, building state, and tree presence for one tile.
  • resources tracks global resources (wood, food, stone) that the player spends on buildings.
  • _ready() calls generate_grid() when the scene loads, which you'll write next.

Why a Dictionary instead of GridMap?

Godot's GridMap is optimized for placing static meshes with collisions, but you cannot attach scripts or dynamic data to individual cells. A Dictionary keyed by Vector2i lets you store arbitrary simulation data per tile (moisture, fertility, crop growth) and update it every game tick.

  • You won't see a change yet. The grid will appear once you add the generation functions below.
Write the grid generation loop

The generation function loops through every coordinate in the grid, creates per-tile data with randomized moisture and fertility, and spawns a visible mesh for each tile.

  • Add the generate_grid() function below _ready() by copying this code:
func generate_grid() -> void:
	for x in range(GRID_SIZE):
		for z in range(GRID_SIZE):
			var coord := Vector2i(x, z)
			var moisture := randf_range(0.2, 0.9)
			var fertility := randf_range(0.1, 1.0)
			var has_tree := randf() < 0.2
			grid_data[coord] = {
				"moisture": moisture,
				"fertility": fertility,
				"building": "",
				"has_tree": has_tree,
				"crop_growth": 0.0
			}
			var tile := create_tile_mesh(coord, fertility, moisture)
			tile_meshes[coord] = tile
			add_child(tile)
			if has_tree:
				var tree := create_tree(coord)
				tree_nodes[coord] = tree
				add_child(tree)

What does this code do?

This nested loop visits every (x, z) coordinate in the grid and builds the world tile by tile.

  • randf_range(0.2, 0.9) gives each tile a random moisture value between 0.2 and 0.9. Similarly, fertility ranges from 0.1 to 1.0. This creates natural variation across the terrain.
  • randf() < 0.2 means roughly 20% of tiles will spawn a tree.
  • Each tile's data is stored in grid_data so the seasonal system and building placement can read and modify it later.
  • The function calls create_tile_mesh() and create_tree() which you'll add next.
  • Next, add the create_tile_mesh() function below generate_grid():
func create_tile_mesh(coord: Vector2i, fertility: float, moisture: float) -> MeshInstance3D:
	var mesh_instance := MeshInstance3D.new()
	var box := BoxMesh.new()
	# Flat box representing one tile on the ground
	box.size = Vector3(0.95, 0.1, 0.95)
	mesh_instance.mesh = box
	mesh_instance.position = Vector3(coord.x * TILE_SIZE, 0.0, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = get_tile_color(fertility, moisture, current_season)
	mesh_instance.material_override = mat
	return mesh_instance

What does this code do?

Each tile is a flat box (0.95 x 0.1 x 0.95 units) positioned on the XZ plane at integer coordinates. The 0.95 width leaves a tiny gap between tiles so you can see the grid structure. The tile's color is determined by get_tile_color() which maps fertility and moisture values to a visible color.

  • You won't see a change yet. The next two functions complete the visual system.
Add terrain coloring and tree spawning

The coloring function translates terrain data into visible colors. High fertility means greener tiles. High moisture adds a blue tint. The tree function spawns tall green boxes to represent forests.

  • Add the get_tile_color() function below create_tile_mesh():
func get_tile_color(fertility: float, moisture: float, season: String) -> Color:
	var base_green := lerp(0.3, 0.8, fertility)
	var blue_tint := moisture * 0.2
	match season:
		"Spring":
			return Color(0.2, base_green, 0.2 + blue_tint)
		"Summer":
			return Color(0.1, base_green * 1.1, 0.1 + blue_tint)
		"Autumn":
			return Color(base_green * 0.8, base_green * 0.6, 0.1)
		"Winter":
			return Color(0.85, 0.85, 0.9)
		_:
			return Color(0.2, base_green, 0.2)

What does this code do?

This function converts raw terrain numbers into colors the player can read at a glance.

  • lerp(0.3, 0.8, fertility) blends between dark green (low fertility) and bright green (high fertility).
  • moisture * 0.2 adds a subtle blue tint to wet tiles.
  • The match block prepares the function for seasonal color shifts. Right now, every tile starts in Spring.
  • Now add the create_tree() function below get_tile_color():
func create_tree(coord: Vector2i) -> MeshInstance3D:
	var tree := MeshInstance3D.new()
	var box := BoxMesh.new()
	# Tall narrow box representing a tree
	box.size = Vector3(0.3, 1.0, 0.3)
	tree.mesh = box
	tree.position = Vector3(coord.x * TILE_SIZE, 0.55, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = Color(0.1, 0.5, 0.1)
	tree.material_override = mat
	return tree

What does this code do?

Trees are tall, narrow boxes (0.3 x 1.0 x 0.3) positioned at Y=0.55 so they sit on top of the tile. The dark green color (0.1, 0.5, 0.1) distinguishes them from the lighter terrain tiles. The player will click these to gather wood in a later step.

  • Save world_grid.gd with Ctrl+S (Windows) or Cmd+S (macOS).
  • Run the scene by pressing F5. You should see a large 64x64 grid of colored tiles (varying greens and browns) with scattered tall green boxes representing trees.
  • Pan around with the arrow keys to explore the full world.

Don't see the colored grid?

  • Make sure world_grid.gd is attached to the Main node. You can verify by selecting Main in the Scene dock and checking the Script property in the Inspector.
  • Check for red error text in the Output panel at the bottom. A typo in a function name will prevent the script from running.
  • If you see only a grey plane with no tiles, make sure generate_grid() is called inside _ready().

Help me debug why my grid isn't showing up in Godot.

Extra for Experts: Why not use MultiMeshInstance3D?

At 64x64, creating 4,096 individual MeshInstance3D nodes is manageable. For larger worlds (128x128 or bigger), you would switch to MultiMeshInstance3D which batches thousands of meshes into a single draw call. That optimization is covered in a follow-on project when chunk-based rendering is added.

✔️ Awesome, I've got everything!

Great. Double check you've saved world_grid.gd and your scene runs with the colored grid visible.

ⓧ I'd like to double check the full code

Here is the complete world_grid.gd at this point:

extends Node3D

const GRID_SIZE := 64
const TILE_SIZE := 1.0

var grid_data: Dictionary = {}
var tile_meshes: Dictionary = {}
var tree_nodes: Dictionary = {}
var building_nodes: Dictionary = {}

var resources := {"wood": 20, "food": 10, "stone": 0}
var current_season := "Spring"
var current_day := 1
var build_mode := ""

var season_order := ["Spring", "Summer", "Autumn", "Winter"]
var season_index := 0

func _ready() -> void:
	generate_grid()

func generate_grid() -> void:
	for x in range(GRID_SIZE):
		for z in range(GRID_SIZE):
			var coord := Vector2i(x, z)
			var moisture := randf_range(0.2, 0.9)
			var fertility := randf_range(0.1, 1.0)
			var has_tree := randf() < 0.2
			grid_data[coord] = {
				"moisture": moisture,
				"fertility": fertility,
				"building": "",
				"has_tree": has_tree,
				"crop_growth": 0.0
			}
			var tile := create_tile_mesh(coord, fertility, moisture)
			tile_meshes[coord] = tile
			add_child(tile)
			if has_tree:
				var tree := create_tree(coord)
				tree_nodes[coord] = tree
				add_child(tree)

func create_tile_mesh(coord: Vector2i, fertility: float, moisture: float) -> MeshInstance3D:
	var mesh_instance := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.95, 0.1, 0.95)
	mesh_instance.mesh = box
	mesh_instance.position = Vector3(coord.x * TILE_SIZE, 0.0, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = get_tile_color(fertility, moisture, current_season)
	mesh_instance.material_override = mat
	return mesh_instance

func get_tile_color(fertility: float, moisture: float, season: String) -> Color:
	var base_green := lerp(0.3, 0.8, fertility)
	var blue_tint := moisture * 0.2
	match season:
		"Spring":
			return Color(0.2, base_green, 0.2 + blue_tint)
		"Summer":
			return Color(0.1, base_green * 1.1, 0.1 + blue_tint)
		"Autumn":
			return Color(base_green * 0.8, base_green * 0.6, 0.1)
		"Winter":
			return Color(0.85, 0.85, 0.9)
		_:
			return Color(0.2, base_green, 0.2)

func create_tree(coord: Vector2i) -> MeshInstance3D:
	var tree := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.3, 1.0, 0.3)
	tree.mesh = box
	tree.position = Vector3(coord.x * TILE_SIZE, 0.55, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = Color(0.1, 0.5, 0.1)
	tree.material_override = mat
	return tree

Your world is alive with terrain data. Next up, you'll add a seasonal cycle that transforms these tile colors and drives crop growth throughout the year.

Add the Seasonal Cycle and HUD

A static world with colored tiles is a good start, but a colony sim needs time to pass. Seasons create survival pressure by determining when crops grow and when colonists freeze.

In this step, you will add a day/season clock driven by a Timer node, write the logic that transforms the terrain each season, and display the current state in a heads-up display. You will also add arrow-key camera panning so you can explore the full 64x64 world.

In this step, get ready to:
  • Add a Timer node and HUD Label to the scene tree.
  • Write day-tick logic that advances seasons every 8 days and updates tile colors.
  • Display the season, day count, and resources in a HUD and add camera panning.
Add the Timer and HUD nodes to the scene tree

Before writing any code, you need two new pieces in the scene: a Timer that fires every 2 seconds to represent a "day", and a Label to display game info on screen.

  • In the Scene dock on the left, click the Main node to select it.
  • Click the + icon at the top of the Scene dock to add a child node.
  • In the search bar, type Timer and click Create.
  • Rename the new node to DayTimer by double-clicking its name in the Scene dock.
  • With DayTimer selected, look at the Inspector panel on the right.
  • Set Wait Time to 2.
  • Check the Autostart checkbox so the timer starts when the scene runs.

Now add the HUD layer. The HUD needs a CanvasLayer so it draws on top of the 3D world.

  • Click Main again in the Scene dock, then click the + icon.
  • Search for CanvasLayer and click Create.
  • With CanvasLayer selected, click the + icon again.
  • Search for Control and click Create.
  • Rename it to HUD.
  • With HUD selected, in the Inspector under Layout, set Anchors Preset to Full Rect so it covers the whole screen.
  • With HUD selected, click the + icon.
  • Search for Label and click Create.
  • Rename it to InfoLabel.
  • With InfoLabel selected, in the Inspector set Position x to 10 and y to 10 so it sits in the top-left corner.

What is a CanvasLayer?

In Godot, 3D objects and 2D UI elements live on different rendering layers. A CanvasLayer draws its children on top of the 3D viewport, so your HUD text is always visible regardless of where the camera points.

  • Save the scene with Ctrl+S (Windows) or Cmd+S (macOS).

Your Scene dock should now show DayTimer as a child of Main, and a CanvasLayer > HUD > InfoLabel branch.

Don't see the nodes in the right hierarchy?

You can drag and drop nodes in the Scene dock to reparent them. Make sure DayTimer is a direct child of Main, CanvasLayer is also a direct child of Main, HUD is inside CanvasLayer, and InfoLabel is inside HUD.

Help me fix my scene tree hierarchy for the Timer and HUD.

Connect the timer and write season logic

With the scene nodes in place, you can now wire up the script. The timer connection, day-tick function, and season processing all work together to drive the passage of time.

  • Open world_grid.gd in the script editor.
  • Find the line var season_index := 0 near the top. Add the following @onready variables directly below it, and update the _ready() function to match:
@onready var hud_label: Label = $CanvasLayer/HUD/InfoLabel
@onready var camera: Camera3D = $CameraPivot/Camera3D
@onready var day_timer: Timer = $DayTimer

func _ready() -> void:
	generate_grid()
	update_hud()
	day_timer.timeout.connect(_on_day_tick)
	day_timer.start()

What does this code do?

The @onready keyword grabs a reference to a scene node as soon as the node enters the tree. This avoids looking up nodes repeatedly at runtime.

In _ready(), day_timer.timeout.connect(_on_day_tick) tells Godot to call _on_day_tick every time the timer fires. This is GDScript's signal system, similar to event listeners in other languages.

You won't see a change yet. The next chunk adds the timer logic that this connection triggers.

  • Below the _ready() function (after the create_tree() function at the bottom of your existing code), add the day-tick and season-change functions:
func _on_day_tick() -> void:
	current_day += 1
	if current_day > 8:
		current_day = 1
		season_index = (season_index + 1) % 4
		current_season = season_order[season_index]
		on_season_change()
	process_day()
	update_tile_visuals()
	update_hud()

func on_season_change() -> void:
	if current_season == "Winter":
		winter_survival_check()
		kill_unharvested_crops()
	if current_season == "Spring":
		reset_for_spring()

func process_day() -> void:
	for coord in grid_data:
		var data: Dictionary = grid_data[coord]
		if current_season == "Spring":
			if data["moisture"] > 0.5:
				data["fertility"] = min(data["fertility"] + 0.02, 1.0)
		if current_season in ["Summer", "Autumn"]:
			if data["building"] == "Farm" and data["crop_growth"] < 1.0:
				data["crop_growth"] += 0.15 * data["fertility"]

func winter_survival_check() -> void:
	pass

func kill_unharvested_crops() -> void:
	for coord in grid_data:
		if grid_data[coord]["building"] == "Farm":
			grid_data[coord]["crop_growth"] = 0.0

func reset_for_spring() -> void:
	pass

What does this code do?

  • _on_day_tick() increments the day counter each time the timer fires (every 2 seconds). After 8 days, it advances to the next season.
  • on_season_change() triggers season-specific events. Winter kills unharvested crops. Spring is a placeholder for future reset logic.
  • process_day() runs per-tile simulation each day. In Spring, moist tiles gain fertility. In Summer/Autumn, farms accumulate crop growth based on their tile's fertility value.
  • winter_survival_check() and reset_for_spring() are placeholder functions. You will fill these in during a later step.

You still won't see a visual change yet. The display functions in the next chunk make this logic visible on screen.

  • Add the tile visual update and HUD display functions below the code you just wrote:
func update_tile_visuals() -> void:
	for coord in tile_meshes:
		var data: Dictionary = grid_data[coord]
		var mesh: MeshInstance3D = tile_meshes[coord]
		var mat: StandardMaterial3D = mesh.material_override
		mat.albedo_color = get_tile_color(data["fertility"], data["moisture"], current_season)

func update_hud() -> void:
	var mode_text := ""
	if build_mode != "":
		mode_text = " | Build: " + build_mode
	hud_label.text = "%s - Day %d | Wood: %d | Food: %d%s" % [
		current_season, current_day,
		resources["wood"], resources["food"], mode_text
	]

What does this code do?

  • update_tile_visuals() loops through every tile mesh and recolors it based on the current season. This is what makes the world turn orange in Autumn and white in Winter.
  • update_hud() formats a status string showing the season name, day number, and resource counts. The %s and %d placeholders work like Python's string formatting.
  • Save world_grid.gd.
  • Run the scene with F5. You should see Spring - Day 1 | Wood: 20 | Food: 10 in the top-left corner, counting up every 2 seconds.
  • Wait until Day 8 passes. The label should switch to Summer - Day 1.
  • Keep watching through Autumn (tiles turn orange) and Winter (tiles turn white/grey).

HUD not showing or stuck on Day 1?

  • Make sure your DayTimer node has Wait Time set to 2 and Autostart checked in the Inspector.
  • Check that the node paths in @onready match your scene hierarchy exactly: $CanvasLayer/HUD/InfoLabel requires that CanvasLayer is a direct child of Main.
  • If you see an error about a null instance, the node names are likely misspelled. Double-check capitalization in both the Scene dock and the script.

Help me debug why my HUD or timer isn't working in Godot.

Add camera panning

The 64x64 world is much larger than what the camera shows at once. Arrow-key panning lets you explore the full grid.

  • At the bottom of world_grid.gd, add the camera input functions:
func _process(_delta: float) -> void:
	handle_camera_input(_delta)

func handle_camera_input(delta: float) -> void:
	var move_speed := 15.0 * delta
	var pivot: Node3D = $CameraPivot
	if Input.is_action_pressed("ui_left"):
		pivot.position.x -= move_speed
	if Input.is_action_pressed("ui_right"):
		pivot.position.x += move_speed
	if Input.is_action_pressed("ui_up"):
		pivot.position.z -= move_speed
	if Input.is_action_pressed("ui_down"):
		pivot.position.z += move_speed

What does this code do?

_process() runs every frame. It delegates to handle_camera_input() which moves the CameraPivot node based on arrow key input.

Multiplying by delta ensures the camera moves at a consistent speed regardless of frame rate. The ui_left, ui_right, ui_up, and ui_down actions are built-in Godot input actions mapped to the arrow keys by default.

  • Save world_grid.gd and run the scene with F5.
  • Press the arrow keys. The camera should pan smoothly across the world, letting you see tiles far from the starting position.

Camera not moving?

  • Make sure the CameraPivot node in your scene is named exactly CameraPivot (capital C, capital P, no spaces).
  • Verify that the _process function is not indented inside another function. It must be at the top level of the script.

Help me fix my camera panning code in Godot.

✔️ Awesome, I've got everything!

Great. Double check you have saved world_grid.gd and saved the scene.

ⓧ I'd like to double check the full code

Here is the complete world_grid.gd at the end of this step:

extends Node3D

const GRID_SIZE := 64
const TILE_SIZE := 1.0

var grid_data: Dictionary = {}
var tile_meshes: Dictionary = {}
var tree_nodes: Dictionary = {}
var building_nodes: Dictionary = {}

var resources := {"wood": 20, "food": 10, "stone": 0}
var current_season := "Spring"
var current_day := 1
var build_mode := ""

var season_order := ["Spring", "Summer", "Autumn", "Winter"]
var season_index := 0

@onready var hud_label: Label = $CanvasLayer/HUD/InfoLabel
@onready var camera: Camera3D = $CameraPivot/Camera3D
@onready var day_timer: Timer = $DayTimer

func _ready() -> void:
	generate_grid()
	update_hud()
	day_timer.timeout.connect(_on_day_tick)
	day_timer.start()

func generate_grid() -> void:
	for x in range(GRID_SIZE):
		for z in range(GRID_SIZE):
			var coord := Vector2i(x, z)
			var moisture := randf_range(0.2, 0.9)
			var fertility := randf_range(0.1, 1.0)
			var has_tree := randf() < 0.2
			grid_data[coord] = {
				"moisture": moisture,
				"fertility": fertility,
				"building": "",
				"has_tree": has_tree,
				"crop_growth": 0.0
			}
			var tile := create_tile_mesh(coord, fertility, moisture)
			tile_meshes[coord] = tile
			add_child(tile)
			if has_tree:
				var tree := create_tree(coord)
				tree_nodes[coord] = tree
				add_child(tree)

func create_tile_mesh(coord: Vector2i, fertility: float, moisture: float) -> MeshInstance3D:
	var mesh_instance := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.95, 0.1, 0.95)
	mesh_instance.mesh = box
	mesh_instance.position = Vector3(coord.x * TILE_SIZE, 0.0, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = get_tile_color(fertility, moisture, current_season)
	mesh_instance.material_override = mat
	return mesh_instance

func get_tile_color(fertility: float, moisture: float, season: String) -> Color:
	var base_green := lerp(0.3, 0.8, fertility)
	var blue_tint := moisture * 0.2
	match season:
		"Spring":
			return Color(0.2, base_green, 0.2 + blue_tint)
		"Summer":
			return Color(0.1, base_green * 1.1, 0.1 + blue_tint)
		"Autumn":
			return Color(base_green * 0.8, base_green * 0.6, 0.1)
		"Winter":
			return Color(0.85, 0.85, 0.9)
		_:
			return Color(0.2, base_green, 0.2)

func create_tree(coord: Vector2i) -> MeshInstance3D:
	var tree := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.3, 1.0, 0.3)
	tree.mesh = box
	tree.position = Vector3(coord.x * TILE_SIZE, 0.55, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = Color(0.1, 0.5, 0.1)
	tree.material_override = mat
	return tree

func _on_day_tick() -> void:
	current_day += 1
	if current_day > 8:
		current_day = 1
		season_index = (season_index + 1) % 4
		current_season = season_order[season_index]
		on_season_change()
	process_day()
	update_tile_visuals()
	update_hud()

func on_season_change() -> void:
	if current_season == "Winter":
		winter_survival_check()
		kill_unharvested_crops()
	if current_season == "Spring":
		reset_for_spring()

func process_day() -> void:
	for coord in grid_data:
		var data: Dictionary = grid_data[coord]
		if current_season == "Spring":
			if data["moisture"] > 0.5:
				data["fertility"] = min(data["fertility"] + 0.02, 1.0)
		if current_season in ["Summer", "Autumn"]:
			if data["building"] == "Farm" and data["crop_growth"] < 1.0:
				data["crop_growth"] += 0.15 * data["fertility"]

func winter_survival_check() -> void:
	pass

func kill_unharvested_crops() -> void:
	for coord in grid_data:
		if grid_data[coord]["building"] == "Farm":
			grid_data[coord]["crop_growth"] = 0.0

func reset_for_spring() -> void:
	pass

func update_tile_visuals() -> void:
	for coord in tile_meshes:
		var data: Dictionary = grid_data[coord]
		var mesh: MeshInstance3D = tile_meshes[coord]
		var mat: StandardMaterial3D = mesh.material_override
		mat.albedo_color = get_tile_color(data["fertility"], data["moisture"], current_season)

func update_hud() -> void:
	var mode_text := ""
	if build_mode != "":
		mode_text = " | Build: " + build_mode
	hud_label.text = "%s - Day %d | Wood: %d | Food: %d%s" % [
		current_season, current_day,
		resources["wood"], resources["food"], mode_text
	]

func _process(_delta: float) -> void:
	handle_camera_input(_delta)

func handle_camera_input(delta: float) -> void:
	var move_speed := 15.0 * delta
	var pivot: Node3D = $CameraPivot
	if Input.is_action_pressed("ui_left"):
		pivot.position.x -= move_speed
	if Input.is_action_pressed("ui_right"):
		pivot.position.x += move_speed
	if Input.is_action_pressed("ui_up"):
		pivot.position.z -= move_speed
	if Input.is_action_pressed("ui_down"):
		pivot.position.z += move_speed

And here is the scene tree structure:

Main (Node3D) [script: world_grid.gd]
  GroundPlane (StaticBody3D)
    CollisionShape3D (WorldBoundaryShape3D)
  CameraPivot (Node3D) [position: (32, 0, 32)]
    Camera3D [projection: Orthogonal, size: 40, position: (0, 20, 15), rotation: (-55, 0, 0)]
  DirectionalLight3D [rotation: (-45, 30, 0)]
  DayTimer (Timer) [wait_time: 2.0, autostart: true]
  CanvasLayer
    HUD (Control) [full_rect]
      InfoLabel (Label) [anchors: top-left, position: (10, 10)]

Your world now lives and breathes. Next up, you will add mouse interaction so you can gather resources from trees and place buildings on tiles.

Implement Resource Gathering and Building Placement

Your world has terrain data and seasonal cycles, but there is no way for the player to interact with it yet. In a colony sim like Banished, the core loop is: gather resources from the land, then spend those resources to place buildings.

In this step, you will wire up mouse clicks to the grid using raycasting (shooting an invisible ray from the camera through your click point to detect what tile you hit). Then you will add resource gathering and a building placement system that turns clicks into gameplay.

In this step, get ready to:
  • Implement mouse raycasting to detect which grid tile the player clicks.
  • Add resource gathering so clicking trees yields wood and clicking farms yields food.
  • Build a keyboard-driven building placement system with resource costs.
Implement mouse raycasting

To turn a mouse click into a grid action, you need to figure out which tile the player clicked on. The technique is called raycasting: the camera shoots an invisible ray through the screen position of the click, and Godot's physics engine reports where that ray hits a collision surface.

  • In world_grid.gd, find the update_tile_visuals() function.
  • Add the following _input() function directly below it (before update_hud()):
func _input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed:
		if event.button_index == MOUSE_BUTTON_LEFT:
			handle_click(event.position)
	if event is InputEventKey and event.pressed:
		match event.keycode:
			KEY_1:
				build_mode = "House"
			KEY_2:
				build_mode = "Farm"
			KEY_3:
				build_mode = "Stockpile"
			KEY_ESCAPE:
				build_mode = ""
		update_hud()

What does this code do?

This function intercepts two types of input events:

  • Left mouse clicks call handle_click() with the screen position of the click. You will write that function next.
  • Keyboard keys 1, 2, 3 set build_mode to the corresponding building type. Pressing Escape clears the mode so clicks go back to gathering.
  • Directly below _input(), add the handle_click() function that performs the raycast:
func handle_click(screen_pos: Vector2) -> void:
	# Cast a ray from the camera through the click position
	var from := camera.project_ray_origin(screen_pos)
	var to := from + camera.project_ray_normal(screen_pos) * 100.0
	var space_state := get_world_3d().direct_space_state
	var query := PhysicsRayQueryParameters3D.create(from, to)
	var result := space_state.intersect_ray(query)
	if result.is_empty():
		return
	# Convert the 3D hit position to grid coordinates
	var hit_pos: Vector3 = result["position"]
	var coord := Vector2i(int(round(hit_pos.x)), int(round(hit_pos.z)))
	if not grid_data.has(coord):
		return
	# Decide what to do based on current mode and tile state
	var data: Dictionary = grid_data[coord]
	if build_mode == "":
		if data["has_tree"]:
			gather_tree(coord)
		elif data["building"] == "Farm" and data["crop_growth"] >= 1.0:
			harvest_farm(coord)
	else:
		try_place_building(coord)

How does raycasting work here?

The camera uses project_ray_origin() and project_ray_normal() to create a ray from the screen point into 3D space. That ray travels 100 units and hits the StaticBody3D ground plane you set up in Step 1.

The hit position is a 3D world coordinate. Since your tiles sit at integer positions on the XZ plane, rounding the hit's X and Z values with int(round(hit_pos.x)) maps it back to the exact grid coordinate.

The routing logic at the end checks: if no build mode is active, try gathering. If a build mode is active, try placing a building.

  • Save world_grid.gd.
  • Run the scene with F5. Click anywhere on the grid. Nothing visible happens yet (the gathering and building functions do not exist), but the game should not crash.

Seeing an error about missing functions?

If Godot reports that gather_tree, harvest_farm, or try_place_building does not exist, that is expected. You will add those functions in the next substep. If you want to test the raycast alone first, temporarily comment out the routing section at the bottom of handle_click().

Help me debug my raycasting code

Add resource gathering functions

With the raycast wired up, you now need the functions it calls. Gathering is the simplest interaction: click a tree to chop it for wood, or click a mature farm to harvest food.

  • Directly below handle_click(), add the gather_tree() function:
func gather_tree(coord: Vector2i) -> void:
	# Remove tree data and add wood to resources
	grid_data[coord]["has_tree"] = false
	resources["wood"] += 5
	if tree_nodes.has(coord):
		tree_nodes[coord].queue_free()
		tree_nodes.erase(coord)
	update_hud()

What does this code do?

When the player clicks a tree tile, this function sets has_tree to false in the grid data, adds 5 wood to the player's resources, then removes the green box from the scene with queue_free(). The tree disappears instantly, giving clear feedback.

  • Directly below gather_tree(), add the harvest_farm() function:
func harvest_farm(coord: Vector2i) -> void:
	# Reset crop growth and add food to resources
	grid_data[coord]["crop_growth"] = 0.0
	resources["food"] += 3
	update_hud()

What does this code do?

When a farm tile's crop_growth reaches 1.0 (fully grown during Summer or Autumn), clicking it resets growth to zero and adds 3 food. The farm stays on the tile and begins growing again next season.

  • Save world_grid.gd and run the scene with F5.
  • Click on a tall green box (a tree). It disappears and the HUD shows your wood count increase by 5.

Tree not disappearing when you click?

Make sure the GroundPlane node is still a StaticBody3D with a WorldBoundaryShape3D collision shape. Without a collision surface, the raycast has nothing to hit.

Also verify you are clicking on the tree itself (the tall green box), not on an empty tile nearby.

Help me debug my tree gathering

Add building placement with resource costs

Gathering gives you resources. Now you need a way to spend them. The player selects a building type with a keyboard shortcut, then clicks a tile to place it (if they can afford it).

  • Directly below harvest_farm(), add the try_place_building() function:
func try_place_building(coord: Vector2i) -> void:
	var data: Dictionary = grid_data[coord]
	# Only place on empty tiles with no trees
	if data["building"] != "" or data["has_tree"]:
		return
	var cost := get_building_cost(build_mode)
	if resources["wood"] < cost:
		return
	# Deduct cost and place the building
	resources["wood"] -= cost
	data["building"] = build_mode
	var building := create_building_mesh(coord, build_mode)
	building_nodes[coord] = building
	add_child(building)
	update_hud()

What does this code do?

This function runs three checks before placing: is the tile empty (no building, no tree), and can the player afford it? If all checks pass, it deducts the wood cost, records the building in grid_data, creates a 3D mesh, and adds it to the scene.

  • Directly below try_place_building(), add the get_building_cost() helper:
func get_building_cost(building_type: String) -> int:
	match building_type:
		"House":
			return 10
		"Farm":
			return 5
		"Stockpile":
			return 8
		_:
			return 99

What does this code do?

A simple lookup: Houses cost 10 wood, Farms cost 5 wood, and Stockpiles cost 8 wood. The wildcard _ returns 99 as a safety catch for any undefined building type.

  • Finally, add create_building_mesh() directly below get_building_cost():
func create_building_mesh(coord: Vector2i, building_type: String) -> MeshInstance3D:
	var building := MeshInstance3D.new()
	var box := BoxMesh.new()
	var mat := StandardMaterial3D.new()
	# Size and color depend on building type
	match building_type:
		"House":
			box.size = Vector3(0.6, 0.5, 0.6)
			mat.albedo_color = Color(0.6, 0.3, 0.1)
		"Farm":
			box.size = Vector3(0.8, 0.15, 0.8)
			mat.albedo_color = Color(0.8, 0.7, 0.2)
		"Stockpile":
			box.size = Vector3(0.7, 0.3, 0.7)
			mat.albedo_color = Color(0.5, 0.5, 0.5)
	building.mesh = box
	# Position the building so it sits on top of the tile
	building.position = Vector3(coord.x * TILE_SIZE, box.size.y / 2.0 + 0.05, coord.y * TILE_SIZE)
	building.material_override = mat
	return building

What does this code do?

Each building type gets a distinct shape and color so the player can identify them at a glance:

  • House: a tall brown box (0.6 x 0.5 x 0.6).
  • Farm: a flat yellow slab (0.8 x 0.15 x 0.8).
  • Stockpile: a medium grey box (0.7 x 0.3 x 0.7).

The Y position is calculated as half the box height plus a small offset, so the building sits neatly on top of the tile surface.

  • Save world_grid.gd and run the scene with F5.
  • Click several trees to gather wood. Watch the Wood count in the HUD increase by 5 each time.
  • Press 2 on your keyboard. The HUD shows "Build: Farm".
  • Click an empty tile. A flat yellow box appears (the farm) and the Wood count decreases by 5.
  • Wait for the season to reach Summer (watch the HUD). After a few Summer days, click the farm. Your Food count increases by 3.
  • Press 1 and click another empty tile. A brown box appears if you have 10 wood available.
  • Press Escape to exit build mode. The "Build:" text disappears from the HUD.

Buildings not appearing when you click?

Check that you pressed a number key (1, 2, or 3) first. The HUD must show "Build: House", "Build: Farm", or "Build: Stockpile" before clicking will place anything.

If you see the build mode text but clicking does nothing, confirm you have enough wood. A Farm costs 5 wood, so you need to chop at least one tree first.

Also make sure you are clicking an empty tile (no tree and no existing building on it).

Help me debug my building placement

✔️ Awesome, I've got everything!

Great. Double check you have saved world_grid.gd and that clicking trees, placing buildings, and harvesting farms all work.

ⓧ I'd like to double check the full code

Here is the complete world_grid.gd file as it should look at the end of this step:

extends Node3D

const GRID_SIZE := 64
const TILE_SIZE := 1.0

var grid_data: Dictionary = {}
var tile_meshes: Dictionary = {}
var tree_nodes: Dictionary = {}
var building_nodes: Dictionary = {}

var resources := {"wood": 20, "food": 10, "stone": 0}
var current_season := "Spring"
var current_day := 1
var build_mode := ""

var season_order := ["Spring", "Summer", "Autumn", "Winter"]
var season_index := 0

@onready var hud_label: Label = $CanvasLayer/HUD/InfoLabel
@onready var camera: Camera3D = $CameraPivot/Camera3D
@onready var day_timer: Timer = $DayTimer

func _ready() -> void:
	generate_grid()
	update_hud()
	day_timer.timeout.connect(_on_day_tick)
	day_timer.start()

func generate_grid() -> void:
	for x in range(GRID_SIZE):
		for z in range(GRID_SIZE):
			var coord := Vector2i(x, z)
			var moisture := randf_range(0.2, 0.9)
			var fertility := randf_range(0.1, 1.0)
			var has_tree := randf() < 0.2
			grid_data[coord] = {
				"moisture": moisture,
				"fertility": fertility,
				"building": "",
				"has_tree": has_tree,
				"crop_growth": 0.0
			}
			var tile := create_tile_mesh(coord, fertility, moisture)
			tile_meshes[coord] = tile
			add_child(tile)
			if has_tree:
				var tree := create_tree(coord)
				tree_nodes[coord] = tree
				add_child(tree)

func create_tile_mesh(coord: Vector2i, fertility: float, moisture: float) -> MeshInstance3D:
	var mesh_instance := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.95, 0.1, 0.95)
	mesh_instance.mesh = box
	mesh_instance.position = Vector3(coord.x * TILE_SIZE, 0.0, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = get_tile_color(fertility, moisture, current_season)
	mesh_instance.material_override = mat
	return mesh_instance

func get_tile_color(fertility: float, moisture: float, season: String) -> Color:
	var base_green := lerp(0.3, 0.8, fertility)
	var blue_tint := moisture * 0.2
	match season:
		"Spring":
			return Color(0.2, base_green, 0.2 + blue_tint)
		"Summer":
			return Color(0.1, base_green * 1.1, 0.1 + blue_tint)
		"Autumn":
			return Color(base_green * 0.8, base_green * 0.6, 0.1)
		"Winter":
			return Color(0.85, 0.85, 0.9)
		_:
			return Color(0.2, base_green, 0.2)

func create_tree(coord: Vector2i) -> MeshInstance3D:
	var tree := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.3, 1.0, 0.3)
	tree.mesh = box
	tree.position = Vector3(coord.x * TILE_SIZE, 0.55, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = Color(0.1, 0.5, 0.1)
	tree.material_override = mat
	return tree

func _on_day_tick() -> void:
	current_day += 1
	if current_day > 8:
		current_day = 1
		season_index = (season_index + 1) % 4
		current_season = season_order[season_index]
		on_season_change()
	process_day()
	update_tile_visuals()
	update_hud()

func on_season_change() -> void:
	if current_season == "Winter":
		winter_survival_check()
		kill_unharvested_crops()
	if current_season == "Spring":
		reset_for_spring()

func process_day() -> void:
	for coord in grid_data:
		var data: Dictionary = grid_data[coord]
		if current_season == "Spring":
			if data["moisture"] > 0.5:
				data["fertility"] = min(data["fertility"] + 0.02, 1.0)
		if current_season in ["Summer", "Autumn"]:
			if data["building"] == "Farm" and data["crop_growth"] < 1.0:
				data["crop_growth"] += 0.15 * data["fertility"]

func winter_survival_check() -> void:
	pass

func kill_unharvested_crops() -> void:
	for coord in grid_data:
		if grid_data[coord]["building"] == "Farm":
			grid_data[coord]["crop_growth"] = 0.0

func reset_for_spring() -> void:
	pass

func update_tile_visuals() -> void:
	for coord in tile_meshes:
		var data: Dictionary = grid_data[coord]
		var mesh: MeshInstance3D = tile_meshes[coord]
		var mat: StandardMaterial3D = mesh.material_override
		mat.albedo_color = get_tile_color(data["fertility"], data["moisture"], current_season)

func _input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed:
		if event.button_index == MOUSE_BUTTON_LEFT:
			handle_click(event.position)
	if event is InputEventKey and event.pressed:
		match event.keycode:
			KEY_1:
				build_mode = "House"
			KEY_2:
				build_mode = "Farm"
			KEY_3:
				build_mode = "Stockpile"
			KEY_ESCAPE:
				build_mode = ""
		update_hud()

func handle_click(screen_pos: Vector2) -> void:
	var from := camera.project_ray_origin(screen_pos)
	var to := from + camera.project_ray_normal(screen_pos) * 100.0
	var space_state := get_world_3d().direct_space_state
	var query := PhysicsRayQueryParameters3D.create(from, to)
	var result := space_state.intersect_ray(query)
	if result.is_empty():
		return
	var hit_pos: Vector3 = result["position"]
	var coord := Vector2i(int(round(hit_pos.x)), int(round(hit_pos.z)))
	if not grid_data.has(coord):
		return
	var data: Dictionary = grid_data[coord]
	if build_mode == "":
		if data["has_tree"]:
			gather_tree(coord)
		elif data["building"] == "Farm" and data["crop_growth"] >= 1.0:
			harvest_farm(coord)
	else:
		try_place_building(coord)

func gather_tree(coord: Vector2i) -> void:
	grid_data[coord]["has_tree"] = false
	resources["wood"] += 5
	if tree_nodes.has(coord):
		tree_nodes[coord].queue_free()
		tree_nodes.erase(coord)
	update_hud()

func harvest_farm(coord: Vector2i) -> void:
	grid_data[coord]["crop_growth"] = 0.0
	resources["food"] += 3
	update_hud()

func try_place_building(coord: Vector2i) -> void:
	var data: Dictionary = grid_data[coord]
	if data["building"] != "" or data["has_tree"]:
		return
	var cost := get_building_cost(build_mode)
	if resources["wood"] < cost:
		return
	resources["wood"] -= cost
	data["building"] = build_mode
	var building := create_building_mesh(coord, build_mode)
	building_nodes[coord] = building
	add_child(building)
	update_hud()

func get_building_cost(building_type: String) -> int:
	match building_type:
		"House":
			return 10
		"Farm":
			return 5
		"Stockpile":
			return 8
		_:
			return 99

func create_building_mesh(coord: Vector2i, building_type: String) -> MeshInstance3D:
	var building := MeshInstance3D.new()
	var box := BoxMesh.new()
	var mat := StandardMaterial3D.new()
	match building_type:
		"House":
			box.size = Vector3(0.6, 0.5, 0.6)
			mat.albedo_color = Color(0.6, 0.3, 0.1)
		"Farm":
			box.size = Vector3(0.8, 0.15, 0.8)
			mat.albedo_color = Color(0.8, 0.7, 0.2)
		"Stockpile":
			box.size = Vector3(0.7, 0.3, 0.7)
			mat.albedo_color = Color(0.5, 0.5, 0.5)
	building.mesh = box
	building.position = Vector3(coord.x * TILE_SIZE, box.size.y / 2.0 + 0.05, coord.y * TILE_SIZE)
	building.material_override = mat
	return building

func update_hud() -> void:
	var mode_text := ""
	if build_mode != "":
		mode_text = " | Build: " + build_mode
	hud_label.text = "%s - Day %d | Wood: %d | Food: %d%s" % [
		current_season, current_day,
		resources["wood"], resources["food"], mode_text
	]

func _process(_delta: float) -> void:
	handle_camera_input(_delta)

func handle_camera_input(delta: float) -> void:
	var move_speed := 15.0 * delta
	var pivot: Node3D = $CameraPivot
	if Input.is_action_pressed("ui_left"):
		pivot.position.x -= move_speed
	if Input.is_action_pressed("ui_right"):
		pivot.position.x += move_speed
	if Input.is_action_pressed("ui_up"):
		pivot.position.z -= move_speed
	if Input.is_action_pressed("ui_down"):
		pivot.position.z += move_speed

Your colony now has a complete resource loop. Next up, you will add a winter survival check that punishes the player for not stockpiling enough wood before the cold hits.

Add Winter Survival and Game Over

Your colony can build houses, plant farms, and stockpile resources. But right now there's no consequence for poor planning. A real survival game needs stakes.

In this step, you'll add the survival pressure that makes the game meaningful. When Winter arrives, each house consumes firewood to keep colonists warm. If you run out of wood entirely, the colony is lost and the game ends.

In this step, get ready to:
  • Implement a winter survival check that consumes wood based on house count.
  • Add a game over screen with a restart button when the colony fails.
Implement the winter survival check

Every colony needs a reason to gather wood before Winter hits. The survival check counts how many houses you've built and demands 2 wood per house to heat them. Not enough wood means your colonists freeze.

  • In world_grid.gd, find the winter_survival_check() function. It currently looks like this:
func winter_survival_check() -> void:
	pass
  • Replace the entire winter_survival_check() function with this code:
func winter_survival_check() -> void:
	# Count all houses on the grid
	var house_count := 0
	for coord in grid_data:
		if grid_data[coord]["building"] == "House":
			house_count += 1
	# Each house needs 2 wood to survive Winter
	var wood_needed := house_count * 2
	if resources["wood"] < wood_needed and house_count > 0:
		hud_label.text += "\nColony is freezing!"
		if resources["wood"] <= 0:
			show_game_over()
	else:
		resources["wood"] -= wood_needed

What does this code do?

  • The loop counts every tile that has a "House" building. Each house costs 2 wood to heat through Winter.
  • If you have enough wood, it deducts the cost silently. If you don't have enough, the HUD displays a freezing warning.
  • If wood hits exactly 0 with houses still standing, it triggers show_game_over() which you'll write next.

Don't save yet. The function calls show_game_over() which doesn't exist. You'll add it in the next substep.

Add the game over screen

When the colony runs out of wood entirely, the game needs to end with a clear message and a way to try again. The game over function creates UI elements programmatically and stops the game loop.

  • In world_grid.gd, add the show_game_over() function. Place it after the reset_for_spring() function and before update_tile_visuals():
func show_game_over() -> void:
	# Create a large centered "COLONY LOST" label
	var game_over_label := Label.new()
	game_over_label.text = "COLONY LOST"
	game_over_label.add_theme_font_size_override("font_size", 48)
	game_over_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	game_over_label.anchors_preset = Control.PRESET_CENTER
	$CanvasLayer/HUD.add_child(game_over_label)
	# Add a restart button below the label
	var restart_btn := Button.new()
	restart_btn.text = "Restart"
	restart_btn.position = Vector2(0, 60)
	restart_btn.anchors_preset = Control.PRESET_CENTER
	restart_btn.pressed.connect(func(): get_tree().reload_current_scene())
	$CanvasLayer/HUD.add_child(restart_btn)
	# Stop the game loop
	set_process(false)

What does this code do?

  • It creates a new Label node at runtime with large centered text reading "COLONY LOST".
  • A Button node is added below it. When clicked, it calls get_tree().reload_current_scene() to restart the entire game from scratch.
  • set_process(false) stops the _process() loop so the camera stops responding to input. The game is frozen until the player clicks Restart.
  • Save world_grid.gd.
  • Run the scene with F5.
  • Build 2-3 houses by pressing 1 and clicking empty tiles. Spend most of your wood.
  • Wait for Winter to arrive. You should see "Colony is freezing!" appear below the HUD info if you don't have enough wood.
  • If your wood hits 0 with houses built, "COLONY LOST" appears in large text with a Restart button. Click it to reload the scene.

Not seeing the freezing warning or game over?

  • Make sure you built at least one House (press 1 then click an empty tile). The check only triggers when house_count > 0.
  • Check that show_game_over() is placed between reset_for_spring() and update_tile_visuals(). If it's nested inside another function by mistake, Godot won't find it.
  • Verify your wood is actually at 0 by watching the HUD. The game over only fires when resources["wood"] <= 0.

Help me debug my winter survival check in Godot

What about unharvested crops?

You may have noticed kill_unharvested_crops() already exists from the previous step. It resets crop_growth to 0 on all Farm tiles when Winter begins. This means any crops you forgot to harvest are lost.

The reset_for_spring() function stays as a pass because update_tile_visuals() already recolors tiles every day tick. When Spring arrives, the colors reset naturally.

✔️ Awesome, I've got everything!

Great. Double check you've saved world_grid.gd and your winter survival loop is working.

ⓧ I'd like to double check the full code

Here is the complete world_grid.gd file at this point:

extends Node3D

const GRID_SIZE := 64
const TILE_SIZE := 1.0

var grid_data: Dictionary = {}
var tile_meshes: Dictionary = {}
var tree_nodes: Dictionary = {}
var building_nodes: Dictionary = {}

var resources := {"wood": 20, "food": 10, "stone": 0}
var current_season := "Spring"
var current_day := 1
var build_mode := ""

var season_order := ["Spring", "Summer", "Autumn", "Winter"]
var season_index := 0

@onready var hud_label: Label = $CanvasLayer/HUD/InfoLabel
@onready var camera: Camera3D = $CameraPivot/Camera3D
@onready var day_timer: Timer = $DayTimer

func _ready() -> void:
	generate_grid()
	update_hud()
	day_timer.timeout.connect(_on_day_tick)
	day_timer.start()

func generate_grid() -> void:
	for x in range(GRID_SIZE):
		for z in range(GRID_SIZE):
			var coord := Vector2i(x, z)
			var moisture := randf_range(0.2, 0.9)
			var fertility := randf_range(0.1, 1.0)
			var has_tree := randf() < 0.2
			grid_data[coord] = {
				"moisture": moisture,
				"fertility": fertility,
				"building": "",
				"has_tree": has_tree,
				"crop_growth": 0.0
			}
			var tile := create_tile_mesh(coord, fertility, moisture)
			tile_meshes[coord] = tile
			add_child(tile)
			if has_tree:
				var tree := create_tree(coord)
				tree_nodes[coord] = tree
				add_child(tree)

func create_tile_mesh(coord: Vector2i, fertility: float, moisture: float) -> MeshInstance3D:
	var mesh_instance := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.95, 0.1, 0.95)
	mesh_instance.mesh = box
	mesh_instance.position = Vector3(coord.x * TILE_SIZE, 0.0, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = get_tile_color(fertility, moisture, current_season)
	mesh_instance.material_override = mat
	return mesh_instance

func get_tile_color(fertility: float, moisture: float, season: String) -> Color:
	var base_green := lerp(0.3, 0.8, fertility)
	var blue_tint := moisture * 0.2
	match season:
		"Spring":
			return Color(0.2, base_green, 0.2 + blue_tint)
		"Summer":
			return Color(0.1, base_green * 1.1, 0.1 + blue_tint)
		"Autumn":
			return Color(base_green * 0.8, base_green * 0.6, 0.1)
		"Winter":
			return Color(0.85, 0.85, 0.9)
		_:
			return Color(0.2, base_green, 0.2)

func create_tree(coord: Vector2i) -> MeshInstance3D:
	var tree := MeshInstance3D.new()
	var box := BoxMesh.new()
	box.size = Vector3(0.3, 1.0, 0.3)
	tree.mesh = box
	tree.position = Vector3(coord.x * TILE_SIZE, 0.55, coord.y * TILE_SIZE)
	var mat := StandardMaterial3D.new()
	mat.albedo_color = Color(0.1, 0.5, 0.1)
	tree.material_override = mat
	return tree

func _on_day_tick() -> void:
	current_day += 1
	if current_day > 8:
		current_day = 1
		season_index = (season_index + 1) % 4
		current_season = season_order[season_index]
		on_season_change()
	process_day()
	update_tile_visuals()
	update_hud()

func on_season_change() -> void:
	if current_season == "Winter":
		winter_survival_check()
		kill_unharvested_crops()
	if current_season == "Spring":
		reset_for_spring()

func process_day() -> void:
	for coord in grid_data:
		var data: Dictionary = grid_data[coord]
		if current_season == "Spring":
			if data["moisture"] > 0.5:
				data["fertility"] = min(data["fertility"] + 0.02, 1.0)
		if current_season in ["Summer", "Autumn"]:
			if data["building"] == "Farm" and data["crop_growth"] < 1.0:
				data["crop_growth"] += 0.15 * data["fertility"]

func winter_survival_check() -> void:
	var house_count := 0
	for coord in grid_data:
		if grid_data[coord]["building"] == "House":
			house_count += 1
	var wood_needed := house_count * 2
	if resources["wood"] < wood_needed and house_count > 0:
		hud_label.text += "\nColony is freezing!"
		if resources["wood"] <= 0:
			show_game_over()
	else:
		resources["wood"] -= wood_needed

func kill_unharvested_crops() -> void:
	for coord in grid_data:
		if grid_data[coord]["building"] == "Farm":
			grid_data[coord]["crop_growth"] = 0.0

func reset_for_spring() -> void:
	pass

func show_game_over() -> void:
	var game_over_label := Label.new()
	game_over_label.text = "COLONY LOST"
	game_over_label.add_theme_font_size_override("font_size", 48)
	game_over_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	game_over_label.anchors_preset = Control.PRESET_CENTER
	$CanvasLayer/HUD.add_child(game_over_label)
	var restart_btn := Button.new()
	restart_btn.text = "Restart"
	restart_btn.position = Vector2(0, 60)
	restart_btn.anchors_preset = Control.PRESET_CENTER
	restart_btn.pressed.connect(func(): get_tree().reload_current_scene())
	$CanvasLayer/HUD.add_child(restart_btn)
	set_process(false)

func update_tile_visuals() -> void:
	for coord in tile_meshes:
		var data: Dictionary = grid_data[coord]
		var mesh: MeshInstance3D = tile_meshes[coord]
		var mat: StandardMaterial3D = mesh.material_override
		mat.albedo_color = get_tile_color(data["fertility"], data["moisture"], current_season)

func _input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed:
		if event.button_index == MOUSE_BUTTON_LEFT:
			handle_click(event.position)
	if event is InputEventKey and event.pressed:
		match event.keycode:
			KEY_1:
				build_mode = "House"
			KEY_2:
				build_mode = "Farm"
			KEY_3:
				build_mode = "Stockpile"
			KEY_ESCAPE:
				build_mode = ""
		update_hud()

func handle_click(screen_pos: Vector2) -> void:
	var from := camera.project_ray_origin(screen_pos)
	var to := from + camera.project_ray_normal(screen_pos) * 100.0
	var space_state := get_world_3d().direct_space_state
	var query := PhysicsRayQueryParameters3D.create(from, to)
	var result := space_state.intersect_ray(query)
	if result.is_empty():
		return
	var hit_pos: Vector3 = result["position"]
	var coord := Vector2i(int(round(hit_pos.x)), int(round(hit_pos.z)))
	if not grid_data.has(coord):
		return
	var data: Dictionary = grid_data[coord]
	if build_mode == "":
		if data["has_tree"]:
			gather_tree(coord)
		elif data["building"] == "Farm" and data["crop_growth"] >= 1.0:
			harvest_farm(coord)
	else:
		try_place_building(coord)

func gather_tree(coord: Vector2i) -> void:
	grid_data[coord]["has_tree"] = false
	resources["wood"] += 5
	if tree_nodes.has(coord):
		tree_nodes[coord].queue_free()
		tree_nodes.erase(coord)
	update_hud()

func harvest_farm(coord: Vector2i) -> void:
	grid_data[coord]["crop_growth"] = 0.0
	resources["food"] += 3
	update_hud()

func try_place_building(coord: Vector2i) -> void:
	var data: Dictionary = grid_data[coord]
	if data["building"] != "" or data["has_tree"]:
		return
	var cost := get_building_cost(build_mode)
	if resources["wood"] < cost:
		return
	resources["wood"] -= cost
	data["building"] = build_mode
	var building := create_building_mesh(coord, build_mode)
	building_nodes[coord] = building
	add_child(building)
	update_hud()

func get_building_cost(building_type: String) -> int:
	match building_type:
		"House":
			return 10
		"Farm":
			return 5
		"Stockpile":
			return 8
		_:
			return 99

func create_building_mesh(coord: Vector2i, building_type: String) -> MeshInstance3D:
	var building := MeshInstance3D.new()
	var box := BoxMesh.new()
	var mat := StandardMaterial3D.new()
	match building_type:
		"House":
			box.size = Vector3(0.6, 0.5, 0.6)
			mat.albedo_color = Color(0.6, 0.3, 0.1)
		"Farm":
			box.size = Vector3(0.8, 0.15, 0.8)
			mat.albedo_color = Color(0.8, 0.7, 0.2)
		"Stockpile":
			box.size = Vector3(0.7, 0.3, 0.7)
			mat.albedo_color = Color(0.5, 0.5, 0.5)
	building.mesh = box
	building.position = Vector3(coord.x * TILE_SIZE, box.size.y / 2.0 + 0.05, coord.y * TILE_SIZE)
	building.material_override = mat
	return building

func update_hud() -> void:
	var mode_text := ""
	if build_mode != "":
		mode_text = " | Build: " + build_mode
	hud_label.text = "%s - Day %d | Wood: %d | Food: %d%s" % [
		current_season, current_day,
		resources["wood"], resources["food"], mode_text
	]

func _process(_delta: float) -> void:
	handle_camera_input(_delta)

func handle_camera_input(delta: float) -> void:
	var move_speed := 15.0 * delta
	var pivot: Node3D = $CameraPivot
	if Input.is_action_pressed("ui_left"):
		pivot.position.x -= move_speed
	if Input.is_action_pressed("ui_right"):
		pivot.position.x += move_speed
	if Input.is_action_pressed("ui_up"):
		pivot.position.z -= move_speed
	if Input.is_action_pressed("ui_down"):
		pivot.position.z += move_speed

Your colony now has real survival stakes. Winter demands preparation, and failure means starting over. This core loop of gather, build, survive is the foundation that every future system will build pressure on top of.

Secret mission

Add a Living Colonist

Your colony has buildings and resources, but no people. Add a colonist entity with hunger and warmth needs that deplete over time. Without food, they starve. Without shelter in winter, they freeze. Can you keep them alive?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for the next project in the series or delete them entirely.

Resources you used:

  • Local Godot project folder NewWorldColony on your machine.

Keep everything running

No action needed. Choose this if you plan to continue with the next project in the series (water digging and hydrology). Your project folder stays exactly as it is.

  • Your NewWorldColony folder remains on your machine, ready for the next project.
  • Godot 4.7 stays installed for future use.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk until you return.

  • Close Godot if it is still open. No background processes continue after you quit.
  • Your project folder and all scene files remain exactly where you left them.

Delete - I don't want to use this again

Remove the project folder entirely. This cannot be undone.

  • Close Godot if it is still open.
  • On macOS or Linux, open Terminal and run the command below to delete the project folder.
rm -rf ~/Desktop/NewWorldColony
  • On Windows, open PowerShell and run the command below instead.
Remove-Item -Recurse -Force ~\Desktop\NewWorldColony
  • Confirm the folder is gone by checking your Desktop. The NewWorldColony folder should no longer appear.

Nice Work!

Nice Work!

You just built a 3D colony survival prototype from scratch in Godot. Your world has terrain data, seasonal pressure, resource management, and a lose condition that makes every decision matter.

Here's what you learned:

  • Built a 3D grid world (64x64, configurable) with per-tile moisture and fertility data, ready for water simulation in the next project.
  • Implemented a seasonal cycle that visually transforms terrain and drives gameplay pressure through Spring, Summer, Autumn, and Winter.
  • Created a resource gathering and building placement system with wood costs for Houses, Farms, and Stockpiles.
  • Added winter survival mechanics with a freezing warning and a Colony Lost game over condition.
  • Secret Mission: Brought a colonist to life with hunger and warmth needs that interact with buildings and resources.

Ready to quiz yourself?