Character Platformer

CharacterBody2D with gravity, jump, and platforms.

▶ Run in browser

Tags: physics 2d character-body gravity input-actions

A blue block runs and jumps across floating ledges above a full-width ground, and respawns at the top if it falls off the bottom of the level.

CharacterBody2D is a kinematic body: the simulation never pushes it, so the game owns its velocity. Each physics tick, on_fixed_update sets the velocity and calls move_and_slide(dt), which sweeps the collider along it, stops at what it hits and slides the remainder along the surface. Afterwards is_on_floor() says whether it ended standing on something, which is what gates the jump. World gravity only accelerates dynamic bodies, so the character adds self.world.gravity to its own velocity, keeping one source of truth.

The ledges are PhysicsBody2D in BodyMode.STATIC with a RectangleShape2D collider. Their w and h are Properties whose change hook rebuilds the collider, so a ledge always collides at the size it draws.

The world is in screen space, Y pointing down: the PhysicsRoot2D gets a +Y gravity and the character’s up_direction is flipped to -Y, since the floor test behind is_on_floor() needs to know which way is up. The level is authored in an 800 x 600 space, and ContentScaleMode.FIT on the tree scales it to fit the window.

Input is two typed actions on the root’s input_context: move is one signed Axis1D driven by A/D, the arrow keys or a stick, read with Input.value("move"), and jump is a button read with Input.just_pressed("jump"). Space, W, Up, pad A, or a click or tap (Pointer.PRIMARY) jumps, and a TouchController gives a phone a stick and a jump button.

vy = self.velocity.y + self.world.gravity.y * dt
if Input.just_pressed("jump") and self.is_on_floor():
    vy = -self.jump_speed
self.velocity = Vec2(Input.value("move") * self.speed, vy)
self.move_and_slide(dt)

Source

  1#!/usr/bin/env python3
  2# /// simvx
  3# tags = ["physics", "2d", "character-body", "gravity", "input-actions"]
  4# ///
  5"""Character Platformer: CharacterBody2D with gravity, jump, and platforms.
  6
  7A blue block runs and jumps across floating ledges above a full-width ground,
  8and respawns at the top if it falls off the bottom of the level.
  9
 10``CharacterBody2D`` is a kinematic body: the simulation never pushes it, so the
 11game owns its ``velocity``. Each physics tick, ``on_fixed_update`` sets the
 12velocity and calls ``move_and_slide(dt)``, which sweeps the collider along it,
 13stops at what it hits and slides the remainder along the surface. Afterwards
 14``is_on_floor()`` says whether it ended standing on something, which is what
 15gates the jump. World gravity only accelerates dynamic bodies, so the character
 16adds ``self.world.gravity`` to its own velocity, keeping one source of truth.
 17
 18The ledges are ``PhysicsBody2D`` in ``BodyMode.STATIC`` with a
 19``RectangleShape2D`` collider. Their ``w`` and ``h`` are Properties whose change
 20hook rebuilds the collider, so a ledge always collides at the size it draws.
 21
 22The world is in screen space, Y pointing down: the ``PhysicsRoot2D`` gets a
 23+Y ``gravity`` and the character's ``up_direction`` is flipped to -Y, since the
 24floor test behind ``is_on_floor()`` needs to know which way is up. The level is
 25authored in an 800 x 600 space, and ``ContentScaleMode.FIT`` on the tree scales
 26it to fit the window.
 27
 28Input is two typed actions on the root's ``input_context``: ``move`` is one
 29signed ``Axis1D`` driven by A/D, the arrow keys or a stick, read with
 30``Input.value("move")``, and ``jump`` is a button read with
 31``Input.just_pressed("jump")``. Space, W, Up, pad A, or a click or tap
 32(``Pointer.PRIMARY``) jumps, and a ``TouchController`` gives a phone a stick
 33and a jump button.
 34
 35```python
 36vy = self.velocity.y + self.world.gravity.y * dt
 37if Input.just_pressed("jump") and self.is_on_floor():
 38    vy = -self.jump_speed
 39self.velocity = Vec2(Input.value("move") * self.speed, vy)
 40self.move_and_slide(dt)
 41```
 42"""
 43
 44from simvx.core import (
 45    Axis1D,
 46    BodyMode,
 47    CharacterBody2D,
 48    ContentScaleMode,
 49    Controller,
 50    Input,
 51    Key,
 52    Node2D,
 53    PhysicsBody2D,
 54    PhysicsRoot2D,
 55    Pointer,
 56    Property,
 57    RectangleShape2D,
 58    Vec2,
 59)
 60from simvx.core.ui import TouchController
 61from simvx.graphics import App
 62
 63LEVEL_SIZE = Vec2(800, 600)
 64SPAWN = Vec2(400, 100)
 65# Each ledge is (centre x, centre y, width), in level units.
 66LEDGES = [
 67    (200, 450, 150),
 68    (400, 350, 120),
 69    (600, 450, 150),
 70    (300, 250, 100),
 71    (500, 250, 100),
 72    (150, 150, 130),
 73    (650, 150, 130),
 74]
 75
 76
 77class Platform(PhysicsBody2D):
 78    """A static ledge; changing ``w`` or ``h`` rebuilds its collider to match."""
 79
 80    w = Property(120.0, range=(20.0, 2000.0), on_change="_fit_collider")
 81    h = Property(16.0, range=(8.0, 60.0), on_change="_fit_collider")
 82
 83    def __init__(self, **kwargs):
 84        super().__init__(mode=BodyMode.STATIC, **kwargs)
 85        self._fit_collider()
 86
 87    def _fit_collider(self):
 88        self.shape = RectangleShape2D(half_extents=Vec2(self.w / 2, self.h / 2))
 89
 90    def on_draw(self, renderer):
 91        renderer.rect((-self.w / 2, -self.h / 2), (self.w, self.h), fill=(0.39, 0.71, 0.31, 1.0))
 92
 93
 94class Player(CharacterBody2D):
 95    speed = Property(250.0, range=(100, 500))
 96    jump_speed = Property(450.0, range=(200, 800))
 97    half_size = Vec2(10, 14)
 98
 99    def __init__(self, **kwargs):
100        super().__init__(shape=RectangleShape2D(half_extents=self.half_size), **kwargs)
101        self.up_direction = Vec2(0, -1)
102
103    def on_fixed_update(self, dt):
104        vy = self.velocity.y + self.world.gravity.y * dt
105        if Input.just_pressed("jump") and self.is_on_floor():
106            vy = -self.jump_speed
107        self.velocity = Vec2(Input.value("move") * self.speed, vy)
108        self.move_and_slide(dt)
109
110        if self.position.y > LEVEL_SIZE.y + 50:
111            self.position = SPAWN
112            self.velocity = Vec2()
113
114    def on_draw(self, renderer):
115        hw, hh = self.half_size
116        renderer.rect((-hw, -hh), (hw * 2, hh * 2), fill=(0.24, 0.55, 1.0, 1.0))
117        renderer.rect((-5, 4 - hh), (4, 4), fill=(1.0, 1.0, 1.0, 1.0))
118        renderer.rect((1, 4 - hh), (4, 4), fill=(1.0, 1.0, 1.0, 1.0))
119
120
121class PlatformerDemo(Node2D):
122    input_context = {
123        "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
124        "jump": [Key.SPACE, Key.W, Key.UP, Pointer.PRIMARY, Controller.A],
125    }
126
127    def on_ready(self):
128        self.tree.content_scale_mode = ContentScaleMode.FIT
129        self.tree.content_scale_size = tuple(LEVEL_SIZE)
130        world = self.add_child(PhysicsRoot2D(name="World", gravity=Vec2(0, 800)))
131        world.add_child(Player(name="Player", position=SPAWN))
132        world.add_child(Platform(name="Ground", w=LEVEL_SIZE.x, h=20, position=(LEVEL_SIZE.x / 2, LEVEL_SIZE.y - 10)))
133        for i, (x, y, w) in enumerate(LEDGES):
134            world.add_child(Platform(name=f"Ledge{i}", w=w, position=(x, y)))
135        self.add_child(TouchController(buttons=(Controller.A,)))
136
137    # Example help overlay: not part of the technique shown.
138    def on_draw(self, renderer):
139        renderer.text("Character Platformer", (10, 10), fill=(1.0, 1.0, 1.0), scale=2)
140        renderer.text("A/D or arrows: move   Space, W, Up, click or tap: jump", (10, 45), fill=(0.71, 0.71, 0.71))
141
142
143if __name__ == "__main__":
144    App(title="Character Platformer", width=800, height=600).run(PlatformerDemo())