Files
sanspie 6e8ebcd90f
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 5m39s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m49s
ci / release (push) Successful in 7m23s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
docs: the scenes docs check plays after each step stay out of the pages
2026-10-25 18:13:09 +03:00

14 KiB

id, icon, group, title, order
id icon group title order
recipes doc.recipe start Recipes 3

Recipes

Short answers to common platformer questions. Each recipe is a script and the scene it runs in, built on the player from Platformer from scratch; every one is run by the documentation checks.

Double jump

Motion 2D jumps only from the ground or within Coyote Time. A second jump in the air sets the upward speed directly, and the count resets on landing:

package game

import "aego/input"

script Player {
	@export
	var AirJumps int = 1

	@export
	var AirJumpSpeed float = 8

	var left int

	func Ready() {
		node.Get<Motion2D>().Landed.Connect(OnLanded)
	}

	func OnLanded(speed float) {
		left = AirJumps
	}

	func Update(dt float) {
		m := node.Get<Motion2D>()
		m.Move(input.Axis("Move"))
		if input.Released("Jump") {
			m.JumpReleased()
		}
		if input.Pressed("Jump") {
			if m.Grounded {
				m.Jump()
			} else if left > 0 {
				left--
				m.AddVelocity(Vec2{0, -m.Velocity.Y - AirJumpSpeed})
			}
		}
	}
}

The velocity is in units per second and up is negative Y, so the added velocity cancels the current vertical speed and sets AirJumpSpeed upwards.

scene version=1 {
  node "e1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[40, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[40, 1]
    }
  }
  node "e1000000000000000000000000000002" 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 "e1000000000000000000000000000003" 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("e1000000000000000000000000000002")
    }
  }
}

One-way platforms

A platform the player jumps through from below and stands on from above needs no script. Give it an Edge Collider 2D with two points and One Sided on. The player script is the plain one from the guide:

package game

import "aego/input"

script Player {
	func Update(dt float) {
		m := node.Get<Motion2D>()
		m.Move(input.Axis("Move"))
		if input.Released("Jump") {
			m.JumpReleased()
		}
		if input.Pressed("Jump") {
			m.Jump()
		}
	}
}
scene version=1 {
  node "e1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[40, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[40, 1]
    }
  }
  node "e1000000000000000000000000000004" name="Ledge" enabled=true layers=1 {
    transform {
      position=[2, 0.8]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#94a3b8ff
      size=[3, 0.2]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.EdgeCollider2D" version=1 {
      points=[[-1.5, -0.1], [1.5, -0.1]]
      oneSided=true
    }
  }
  node "e1000000000000000000000000000002" 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 "e1000000000000000000000000000003" 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("e1000000000000000000000000000002")
    }
  }
}

Moving platforms

A kinematic body moved with MoveTo carries a Motion 2D standing on it while Moving Platforms is on, which is the default. This script moves its body back and forth between its start and Travel:

package game

import "aego/math"

script Lift {
	@export
	var Travel Vec2 = Vec2{4, 0}

	@export
	var Period float = 4

	var t float
	var start Vec2

	func Ready() {
		start = node.Position()
	}

	func FixedUpdate(dt float) {
		t += dt
		k := 0.5 - 0.5*math.Cos(t/Period*2*math.Pi)
		node.Get<Body2D>().MoveTo(Vec2{start.X + Travel.X*k, start.Y + Travel.Y*k}, 0)
	}
}
scene version=1 {
  node "e1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[40, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[40, 1]
    }
  }
  node "e1000000000000000000000000000005" name="Lift" enabled=true layers=1 {
    transform {
      position=[3, 1.4]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#0ea5e9ff
      size=[2, 0.3]
    }
    component "physics.Body2D" version=1 {
      type=kinematic
    }
    component "physics.BoxCollider2D" version=1 {
      size=[2, 0.3]
    }
    component "engine.Script" version=1 {
      script "game.game.Lift" enabled=true {
      }
    }
  }
  node "e1000000000000000000000000000002" 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 "e1000000000000000000000000000003" 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("e1000000000000000000000000000002")
    }
  }
}

Platform On Leave on the player's Motion 2D decides what happens to the platform's speed when the player jumps off.

Dash

Dash moves at a fixed velocity for a time and ignores input and gravity meanwhile, unless Dash Gravity is on. A cooldown keeps it from being spammed:

package game

import "aego/input"

script Player {
	@export
	var DashSpeed float = 16

	@export
	var DashTime float = 0.15

	@export
	var Cooldown float = 0.6

	var facing float = 1
	var wait float

	func Update(dt float) {
		m := node.Get<Motion2D>()
		m.Move(input.Axis("Move"))
		if input.Released("Jump") {
			m.JumpReleased()
		}
		if input.Pressed("Jump") {
			m.Jump()
		}
		x := input.Axis("Move")
		if x != 0 {
			facing = x
		}
		wait -= dt
		if input.KeyPressed(input.KeyLShift) && wait <= 0 {
			m.Dash(Vec2{facing * DashSpeed, 0}, DashTime)
			wait = Cooldown
		}
	}
}

The key is read directly here. In a game, add a Dash action in Project Settings → Input and read it with input.Pressed("Dash"), so it can be rebound and works on a gamepad.

scene version=1 {
  node "e1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[40, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[40, 1]
    }
  }
  node "e1000000000000000000000000000002" 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 "e1000000000000000000000000000003" 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("e1000000000000000000000000000002")
    }
  }
}

Shake the camera on a hard landing

Landed passes the speed of the fall. Shake the camera only when it is high, so ordinary hops stay calm. The camera needs a Camera Shake 2D:

package game

import (
	"aego/camera"
	"aego/input"
)

script Player {
	@export
	var HardLanding float = 12

	func Ready() {
		node.Get<Motion2D>().Landed.Connect(OnLanded)
	}

	func OnLanded(speed float) {
		if speed > HardLanding {
			camera.Shake(0.4)
		}
	}

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

Shake adds trauma from 0 to 1; Camera Shake 2D turns it into movement and lets it decay. Players can scale or turn off shake in the accessibility settings.

scene version=1 {
  node "e1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[40, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[40, 1]
    }
  }
  node "e1000000000000000000000000000002" 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 "e1000000000000000000000000000003" 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("e1000000000000000000000000000002")
    }
    component "camera.CameraShake2D" version=1 {
    }
  }
}

Stomp an enemy

Jumping on an enemy's head is a hitbox on the player's feet that is active only while falling, and a hurtbox on the enemy. The bounce comes from the hitbox's Hit signal:

package game

import "aego/input"

script Player {
	@export
	var Bounce float = 9

	func Ready() {
		node.Get<Hitbox2D>().Hit.Connect(OnHit)
	}

	func OnHit(target NodeRef, damage int) {
		m := node.Get<Motion2D>()
		m.AddVelocity(Vec2{0, -m.Velocity.Y - Bounce})
	}

	func FixedUpdate(dt float) {
		m := node.Get<Motion2D>()
		node.Get<Hitbox2D>().Active = !m.Grounded && m.Velocity.Y > 0
	}

	func Update(dt float) {
		m := node.Get<Motion2D>()
		m.Move(input.Axis("Move"))
		if input.Released("Jump") {
			m.JumpReleased()
		}
		if input.Pressed("Jump") {
			m.Jump()
		}
	}
}
scene version=1 {
  node "e1000000000000000000000000000001" name="Floor" enabled=true layers=1 {
    transform {
      position=[0, 3]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#4b5563ff
      size=[40, 1]
    }
    component "physics.Body2D" version=1 {
      type=static
    }
    component "physics.BoxCollider2D" version=1 {
      size=[40, 1]
    }
  }
  node "e1000000000000000000000000000006" name="Enemy" enabled=true layers=1 {
    transform {
      position=[4, 2.1]
    }
    component "engine.SpriteRenderer" version=1 {
      tint=#ef4444ff
      size=[0.8, 0.8]
    }
    component "gameplay.Health" version=1 {
      max=1
      team=1
      destroyOnDeath=true
    }
    component "gameplay.Hurtbox2D" version=1 {
      area=[-0.4, -0.4, 0.8, 0.8]
    }
  }
  node "e1000000000000000000000000000002" 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.Hitbox2D" version=1 {
      area=[-0.25, 0.35, 0.5, 0.25]
      damage=1
      active=false
    }
    component "engine.Script" version=1 {
      script "game.game.Player" enabled=true {
      }
    }
  }
  node "e1000000000000000000000000000003" 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("e1000000000000000000000000000002")
    }
  }
}

The player's Health puts it on team 0 and the enemy's on team 1, so the feet hitbox, which takes the team of the nearest Health above it, hurts the enemy and never the player. examples/platformer uses the same setup.

See also