doc 3 of 11

Stage 01 — Ground zero: a player with weight

You will build: the project, a 640×360 pixel-perfect viewport, the input map, and a player that walks with acceleration and friction instead of teleporting at a fixed speed. You'll learn: Godot 4.7 project setup · the low-res viewport trick · CharacterBody2D · move_toward as the one function that gives movement weight.

Why this exists#

A survivors game is a movement game with combat wrapped around it. If walking feels like sliding a die, nothing you add later — 300 enemies, level-ups, juice — will save it. The whole stage is about one idea: velocity is a value you steer toward, not a value you set.

Fixed-speed movement (velocity = direction * speed) snaps. Steered movement (velocity = velocity.move_toward(target, accel * delta)) approaches. The difference is the feel, and it's four characters of code: .move_toward instead of =.

Build it#

A — The project#

New Godot 4.7 project, no template. Rename it Survivors. In Project Settings:

  1. Display → Window → Viewport → Size: 640 × 360.
  2. Display → Window → Stretch → Mode: canvas_items, Aspect: keep.
  3. Display → Window → Size → Window Width Override: 1280, Height Override: 720.

That triple is the pixel-art trick: the game runs at 640×360 and the OS window is a 2× scale of it. Your art occupies one logical pixel and never blurs. (The pixel art shelf's fundamentals is where to go when you're ready for the art itself.)

Input Map — add four actions with a single key each, W/A/S/D, physical keycodes (Project Settings → Input Map → move_up etc., left-click to add a key). Physical keycodes mean the keys work the same on AZERTY and QWERTY layouts — a two-minute future-you favour.

B — The player scene#

New scene, root CharacterBody2D named Player. Add it to the group player (the group name is in the inspector next to the node name, or via right-click → Groups). Children:

Player (CharacterBody2D)
├── Skin (Sprite2D)            ← a 16×16 placeholder square is fine
├── AnimationPlayer            ← two empty animations: "Idle" and "Run"
└── Collider (CollisionShape2D) ← a RectangleShape2D, 12×12

Attach a script to Player:

class_name Player
extends CharacterBody2D

const MOVE_SPEED := 80.0
const ACCELERATION := MOVE_SPEED * 10.0
const FRICTION := MOVE_SPEED * 10.0

@onready var skin: Sprite2D = $Skin
@onready var animation_player: AnimationPlayer = $AnimationPlayer

func _physics_process(delta: float) -> void:
	var direction := Input.get_axis("move_left", "move_right", "move_up", "move_down")

	if direction != Vector2.ZERO:
		velocity = velocity.move_toward(direction * MOVE_SPEED, ACCELERATION * delta)
		animation_player.play("Run")
		skin.flip_h = direction.x < 0.0
	else:
		velocity = velocity.move_toward(Vector2.ZERO, FRICTION * delta)
		animation_player.play("Idle")

	move_and_slide()

Four things to notice, because they are load-bearing:

  • Input.get_axis(neg, pos, neg, pos) reads the whole cross in one call and returns a normalized-ish Vector2. Two separate get_axis calls work too — the two-argument form — but one call is one fewer place for the axes to disagree.
  • move_toward(current, target, step) moves current toward target by at most step. It cannot overshoot, which is why acceleration and friction are the same function with a different target.
  • * 10.0 is the weight dial. At 10× top speed, full acceleration takes 0.1 s; at 2× it takes 0.5 s and the player feels like they're in water. Tune it in the editor and watch it, not in your head.
  • move_and_slide() runs after velocity is set, on the physics tick. Movement code lives in _physics_process, never _process — the physics world and the render world are different clocks, and moving bodies on the wrong one is a jitter you will spend a week chasing. (Why two callbacks)

C — The world and the camera#

New scene Game (Node2D). Add a TileMapLayer named Floor (any tileset you can paint with — a solid colour tile works), paint a 20×12 area around the origin, instance Player at the centre, and add a Camera2D as a child of Player.

Predict before you run: the camera is a child of the player, and the player will walk to the edge of the floor. What do you think the camera does at the edge — and how do you stop it showing the void?

Set the camera's Limit Left/Top/Right/Bottom to the floor's bounds. That's the whole follow system: parenting does the following, limits do the clamping, and no code was harmed. This is the same trick the farming game uses in its fences stage.

Set Game as the main scene, hit F6, and walk.

D — Commit#

git add . && git commit -m "stage 01: project, viewport, weighted player movement"

Checkpoint — definition of done#

  • Walking feels like stopping takes a moment, not like the sprite is on rails — walk to a wall of your own attention and release the key
  • The window is 1280×720 and the game is 640×360; a 16×16 sprite is crisp, not blurry
  • The camera follows and cannot show past the floor's limits
  • Player is in group player — verify in the debugger's Groups panel or with get_tree().get_nodes_in_group("player") printed once
  • Zero warnings; committed

Stretch (no instructions)#

A run action (Shift): holding it doubles MOVE_SPEED while held. Notice what happens to your acceleration — does it need to change too, or does the weight survive the speed change?

If you get stuck#

  • Player moves but the camera is a full screen behind → the camera isn't a child of the player, or the player is a child of the camera. Draw the parent chain; following is inheritance, not a chase.
  • Movement jitters or stutters → your code is in _process. Move it to _physics_process.
  • The window is 640×360 tiny instead of 1280×720 → the override sizes live in Window under Display, not under Viewport.
  • Input.get_axis returns 0 for everything → the action names in code and in the Input Map differ by one letter. GDScript will not tell you which one you misspelled; the Input Map will.