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, Friction0; - 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/platformerin the engine source is this game grown further: animation, sound, stomping, saves, settings and 2D lighting.