Files
sanspie ab6ea7ef58
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m26s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
editor: improve scene tools and prepare native CI
2026-11-06 16:49:11 +03:00

18 KiB

id, icon, group, title, order
id icon group title order
platformer doc.tutorial start Platformer from scratch 2

Platformer from scratch

This guide builds a small platformer in an empty 2D project: a character that runs and jumps, a camera that follows it, a pit with a checkpoint, coins and an enemy. Every step ends with the scene file as it looks after the step, and every scene and script on this page is run by the documentation checks.

Start with Your first project if you have not opened the editor yet.

The finished example is a separate, more detailed project at examples/platformer. Open it with aego edit examples/platformer and load its main scene to inspect the game used by the website demo. Move with A/D or the arrow keys, jump with Space, attack with J and open settings with Esc. F6 toggles lighting and F7 cycles its debug views.

This guide starts from simpler shapes rather than reproducing every asset in the finished project. For its lighting, surface maps, particles and post-processing, see Platformer lighting and effects.

1. Floor and player

Create a project from the 2D template. In the Hierarchy, create an empty node and name it Floor. Give it these components in the Inspector:

  • Sprite Renderer, Size 30, 1, a grey Tint;
  • Rigid Body 2D, Type static;
  • Box Collider 2D, Size 30, 1.

Move it to 0, 3. Create a second node, Player, with:

  • Sprite Renderer, Size 0.6, 1, an orange Tint;
  • Rigid Body 2D, Type kinematic, Fixed Rotation on;
  • Box Collider 2D, Size 0.6, 1, Friction 0;
  • Motion 2D, Mode platformer.

Motion 2D moves a kinematic body: it slides along walls, stands on floors and climbs small steps. Press Play: the player falls and lands on the floor.

scene version=1 {
  node "f1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[30, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[30, 1]
    }
  }
  node "f1000000000000000000000000000002" name="Player" enabled=true layers=1 {
    component "engine.SpriteRenderer" version=1 {
      tint=#f59e0bff
      size=[0.6, 1]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.6, 1]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
  }
  node "f1000000000000000000000000000003" name="Camera" enabled=true layers=1 {
    component "engine.Camera2D" version=1 {
      zoom=36
    }
  }
}

2. Run and jump

Create a script Player in scripts/game and attach it to the player:

package game

import "aego/input"

script Player {
	func Update(dt float) {
		m := node.Get<Motion2D>()
		m.Move(input.Axis("Move"))
		if input.Pressed("Jump") {
			m.Jump()
		}
		if input.Released("Jump") {
			m.JumpReleased()
		}
	}
}

Move and Jump are input actions of the 2D template: the arrows or A and D, and Space, or a gamepad. Move takes a value from -1 to 1 and Motion 2D accelerates to that share of its Max Speed. JumpReleased cuts the jump short when the button is let go early, so a tap makes a small hop. Jump Height, Max Speed and Coyote Time are fields of Motion 2D.

scene version=1 {
  node "f1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[30, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[30, 1]
    }
  }
  node "f1000000000000000000000000000002" name="Player" enabled=true layers=1 {
    component "engine.SpriteRenderer" version=1 {
      tint=#f59e0bff
      size=[0.6, 1]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.6, 1]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
    component "engine.Script" version=1 {
      script "game.game.Player" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000003" name="Camera" enabled=true layers=1 {
    component "engine.Camera2D" version=1 {
      zoom=36
    }
  }
}

The scene names a script by module, package and name. Here the module is game; in your project it is the module in project.aego.

3. A camera that follows

Select the camera and add Virtual Camera 2D and Camera Follow 2D. Drag the player from the Hierarchy onto the Target field of Camera Follow 2D. The camera now keeps the player in view and catches up smoothly; Deadzone and Damping set how loosely. See Cameras for zones, shake and blends.

scene version=1 {
  node "f1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[30, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[30, 1]
    }
  }
  node "f1000000000000000000000000000002" name="Player" enabled=true layers=1 {
    component "engine.SpriteRenderer" version=1 {
      tint=#f59e0bff
      size=[0.6, 1]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.6, 1]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
    component "engine.Script" version=1 {
      script "game.game.Player" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000003" name="Camera" enabled=true layers=1 {
    component "engine.Camera2D" version=1 {
      zoom=36
    }
    component "camera.VirtualCamera2D" version=1 {
    }
    component "camera.CameraFollow2D" version=1 {
      target=node("f1000000000000000000000000000002")
    }
  }
}

4. A pit and a checkpoint

Split the floor in two with a gap between them. Under the gap, add a node Pit with a Kill Zone 2D whose Area covers the bottom of the level. Give the player Health and Respawn. Add a node Checkpoint past the pit with a Checkpoint 2D.

Falling into the pit kills the player. After Respawn's Delay it comes back at the last checkpoint it touched, or where it started, with full health.

scene version=1 {
  node "f1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[-6, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[16, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[16, 1]
    }
  }
  node "f1000000000000000000000000000004" name="Floor 2" enabled=true layers=1 {
    transform {
      position=[13, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[16, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[16, 1]
    }
  }
  node "f1000000000000000000000000000005" name="Pit" enabled=true layers=1 {
    transform {
      position=[3.5, 8]
    }
    component "gameplay.KillZone2D" version=1 {
      area=[-30, -1, 60, 2]
    }
  }
  node "f1000000000000000000000000000006" name="Checkpoint" enabled=true layers=1 {
    transform {
      position=[8, 1.5]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#22c55eff
      size=[0.15, 2]
    }
    component "gameplay.Checkpoint2D" version=1 {
    }
  }
  node "f1000000000000000000000000000002" name="Player" enabled=true layers=1 {
    component "engine.SpriteRenderer" version=1 {
      tint=#f59e0bff
      size=[0.6, 1]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.6, 1]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
    component "gameplay.Health" version=1 {
      max=3
    }
    component "gameplay.Respawn" version=1 {
    }
    component "engine.Script" version=1 {
      script "game.game.Player" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000003" name="Camera" enabled=true layers=1 {
    component "engine.Camera2D" version=1 {
      zoom=36
    }
    component "camera.VirtualCamera2D" version=1 {
    }
    component "camera.CameraFollow2D" version=1 {
      target=node("f1000000000000000000000000000002")
    }
  }
}

5. Coins

A coin is a node with a Pickup 2D. It is collected when a node with Motion 2D enters its Area, and then removed. To count coins, give the player a counter:

package game

import (
	"aego/input"
	"aego/log"
)

script Player {
	var coins int

	func Update(dt float) {
		m := node.Get<Motion2D>()
		m.Move(input.Axis("Move"))
		if input.Pressed("Jump") {
			m.Jump()
		}
		if input.Released("Jump") {
			m.JumpReleased()
		}
	}

	func AddCoin() {
		coins++
		log.Info("coins: " + string(coins))
	}
}

and give each coin a script that tells the player:

package game

script Coin {
	func Ready() {
		node.Get<Pickup2D>().Collected.Connect(OnCollected)
	}

	func OnCollected(by NodeRef) {
		p := by.GetScript<Player>()
		if p != nil {
			p.AddCoin()
		}
	}
}

Make one coin, then turn it into a prefab (Create Prefab in the Hierarchy menu) and place copies of it; a change to the prefab reaches every copy. See Prefabs.

scene version=1 {
  node "f1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[-6, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[16, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[16, 1]
    }
  }
  node "f1000000000000000000000000000004" name="Floor 2" enabled=true layers=1 {
    transform {
      position=[13, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[16, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[16, 1]
    }
  }
  node "f1000000000000000000000000000005" name="Pit" enabled=true layers=1 {
    transform {
      position=[3.5, 8]
    }
    component "gameplay.KillZone2D" version=1 {
      area=[-30, -1, 60, 2]
    }
  }
  node "f1000000000000000000000000000006" name="Checkpoint" enabled=true layers=1 {
    transform {
      position=[8, 1.5]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#22c55eff
      size=[0.15, 2]
    }
    component "gameplay.Checkpoint2D" version=1 {
    }
  }
  node "f1000000000000000000000000000007" name="Coin" enabled=true layers=1 {
    transform {
      position=[2, 2]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#facc15ff
      size=[0.4, 0.4]
    }
    component "gameplay.Pickup2D" version=1 {
    }
    component "engine.Script" version=1 {
      script "game.game.Coin" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000002" name="Player" enabled=true layers=1 {
    component "engine.SpriteRenderer" version=1 {
      tint=#f59e0bff
      size=[0.6, 1]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.6, 1]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
    component "gameplay.Health" version=1 {
      max=3
    }
    component "gameplay.Respawn" version=1 {
    }
    component "engine.Script" version=1 {
      script "game.game.Player" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000003" name="Camera" enabled=true layers=1 {
    component "engine.Camera2D" version=1 {
      zoom=36
    }
    component "camera.VirtualCamera2D" version=1 {
    }
    component "camera.CameraFollow2D" version=1 {
      target=node("f1000000000000000000000000000002")
    }
  }
}

6. An enemy

An enemy walks back and forth and hurts the player on contact. Give it the same body as the player, a Motion 2D and a Hitbox 2D with Team 1, so it never hurts other enemies on team 1. Give the player a Hurtbox 2D: the hitbox damages the Health of the node whose hurtbox it touches. The player then blinks for Invuln Time and cannot be hit again until it ends.

The enemy turns around at walls:

package game

script Patrol {
	@export
	var Speed float = 0.5

	var dir float = 1

	func Ready() {
		node.Get<Motion2D>().HitWall.Connect(OnWall)
	}

	func OnWall(normal Vec2) {
		if normal.X != 0 {
			dir = normal.X
		}
	}

	func FixedUpdate(dt float) {
		node.Get<Motion2D>().Move(dir * Speed)
	}
}

HitWall gives the wall's normal, which points away from the wall, so its X is the new direction.

scene version=1 {
  node "f1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[-6, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[16, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[16, 1]
    }
  }
  node "f1000000000000000000000000000004" name="Floor 2" enabled=true layers=1 {
    transform {
      position=[13, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[16, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[16, 1]
    }
  }
  node "f1000000000000000000000000000009" name="Wall" enabled=true layers=1 {
    transform {
      position=[20.5, 1.5]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[1, 2]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[1, 2]
    }
  }
  node "f1000000000000000000000000000005" name="Pit" enabled=true layers=1 {
    transform {
      position=[3.5, 8]
    }
    component "gameplay.KillZone2D" version=1 {
      area=[-30, -1, 60, 2]
    }
  }
  node "f1000000000000000000000000000006" name="Checkpoint" enabled=true layers=1 {
    transform {
      position=[8, 1.5]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#22c55eff
      size=[0.15, 2]
    }
    component "gameplay.Checkpoint2D" version=1 {
    }
  }
  node "f1000000000000000000000000000007" name="Coin" enabled=true layers=1 {
    transform {
      position=[2, 2]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#facc15ff
      size=[0.4, 0.4]
    }
    component "gameplay.Pickup2D" version=1 {
    }
    component "engine.Script" version=1 {
      script "game.game.Coin" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000008" name="Enemy" enabled=true layers=1 {
    transform {
      position=[14, 2]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#ef4444ff
      size=[0.8, 0.8]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.8, 0.8]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
    component "gameplay.Hitbox2D" version=1 {
      area=[-0.4, -0.4, 0.8, 0.8]
      team=1
      active=true
    }
    component "engine.Script" version=1 {
      script "game.game.Patrol" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000002" name="Player" enabled=true layers=1 {
    component "engine.SpriteRenderer" version=1 {
      tint=#f59e0bff
      size=[0.6, 1]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
      fixedRotation=true
    }
    component "physics.BoxCollider2D" version=1 {
      friction=0
      size=[0.6, 1]
    }
    component "gameplay.Motion2D" version=1 {
      mode=platformer
    }
    component "gameplay.Health" version=1 {
      max=3
    }
    component "gameplay.Hurtbox2D" version=1 {
      area=[-0.3, -0.5, 0.6, 1]
    }
    component "gameplay.Respawn" version=1 {
    }
    component "engine.Script" version=1 {
      script "game.game.Player" enabled=true {
      }
    }
  }
  node "f1000000000000000000000000000003" name="Camera" enabled=true layers=1 {
    component "engine.Camera2D" version=1 {
      zoom=36
    }
    component "camera.VirtualCamera2D" version=1 {
    }
    component "camera.CameraFollow2D" version=1 {
      target=node("f1000000000000000000000000000002")
    }
  }
}

7. Play it anywhere

File → Export… builds the game for Linux, Windows, macOS or the web, as in Your first project. On a phone, the web build shows a touch gamepad: the stick moves and A jumps, because the actions are also bound to a gamepad.

Where to go next

  • Recipes: double jump, one-way platforms, moving platforms, dash and more.
  • Gameplay kit: every gameplay component in one place.
  • Animation: sprite animation and state machines for the player.
  • Platformer lighting and effects: surface maps, lights, particles and camera post-processing in the finished example.
  • examples/platformer in the engine source is this game grown further: animation, sound, stomping, saves, settings and 2D lighting.