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 seven floating ledges above a full-width ground; fall off the bottom of the screen and it respawns near the top. Every piece of it is engine API, with no game framework in between:

  • CharacterBody2D + move_and_slide drive the runner. A character is KINEMATIC: it is never pushed by the simulation, so the demo owns its velocity outright and move_and_slide sweeps it, resolves the hits and deflects what is left along the surfaces it touched.

  • is_on_floor (classified against the character’s up_direction) gates the jump, so it only fires with ground underfoot.

  • Ledges are PhysicsBody2D in BodyMode.STATIC with a RectangleShape2D collider that is rebuilt whenever their size changes, so what is drawn is exactly what is collided with.

  • Named input actions bound on the root, so keyboard and pointer feed the same movement code.

This is a screen-space (Y-down) world: gravity points at +Y and the character’s up_direction is flipped to -Y so “up” on screen is up for the floor classifier. World gravity only accelerates DYNAMIC bodies, so the character integrates it itself, reading it off the world to keep one source of truth.

Controls: A/D or Left/Right = move, Space = jump. Mouse/touch: hold the left or right half of the screen to move, press the top half to jump.

Run: uv run python examples/features/physics/character_platformer.py Headless self-check: uv run python examples/features/physics/character_platformer.py –test

Source

  1#!/usr/bin/env python3
  2"""Character Platformer: CharacterBody2D with gravity, jump, and platforms.
  3
  4# /// simvx
  5# tags = ["physics", "2d", "character-body", "gravity", "input-actions"]
  6# web = { root = "PlatformerDemo" }
  7# ///
  8
  9A blue block runs and jumps across seven floating ledges above a full-width
 10ground; fall off the bottom of the screen and it respawns near the top. Every
 11piece of it is engine API, with no game framework in between:
 12
 13* ``CharacterBody2D`` + ``move_and_slide`` drive the runner. A character is
 14  KINEMATIC: it is never pushed by the simulation, so the demo owns its
 15  ``velocity`` outright and ``move_and_slide`` sweeps it, resolves the hits and
 16  deflects what is left along the surfaces it touched.
 17* ``is_on_floor`` (classified against the character's ``up_direction``) gates
 18  the jump, so it only fires with ground underfoot.
 19* Ledges are ``PhysicsBody2D`` in ``BodyMode.STATIC`` with a
 20  ``RectangleShape2D`` collider that is rebuilt whenever their size changes,
 21  so what is drawn is exactly what is collided with.
 22* Named input actions bound on the root, so keyboard and pointer feed the same
 23  movement code.
 24
 25This is a screen-space (Y-down) world: gravity points at +Y and the character's
 26``up_direction`` is flipped to -Y so "up" on screen is up for the floor
 27classifier. World gravity only accelerates DYNAMIC bodies, so the character
 28integrates it itself, reading it off the world to keep one source of truth.
 29
 30Controls: A/D or Left/Right = move, Space = jump. Mouse/touch: hold the left
 31or right half of the screen to move, press the top half to jump.
 32
 33Run: uv run python examples/features/physics/character_platformer.py
 34Headless self-check: uv run python examples/features/physics/character_platformer.py --test
 35"""
 36
 37from simvx.core import (
 38    BodyMode,
 39    CharacterBody2D,
 40    Input,
 41    Key,
 42    MouseButton,
 43    Node2D,
 44    PhysicsBody2D,
 45    PhysicsRoot2D,
 46    Property,
 47    RectangleShape2D,
 48    Vec2,
 49)
 50from simvx.graphics import App
 51
 52WIDTH, HEIGHT = 800, 600
 53GRAVITY = 800.0
 54# Screen-space (Y-down) world: gravity is +Y, "up" for the character is -Y.
 55SCREEN_UP = Vec2(0.0, -1.0)
 56# Half-width/half-height of the runner: one source for the collider and the sprite.
 57PLAYER_HALF = Vec2(10.0, 14.0)
 58
 59
 60class Platform(PhysicsBody2D):
 61    """Static platform that the character collides with and slides along.
 62
 63    ``w`` and ``h`` are live Properties: assigning either one swaps in a matching
 64    collider, so a tuned platform can never draw at one size and collide at another.
 65    """
 66
 67    w = Property(120.0, range=(20.0, float(WIDTH)), on_change="_rebuild_collider")
 68    h = Property(16.0, range=(8.0, 60.0), on_change="_rebuild_collider")
 69
 70    def __init__(self, w: float = 120.0, h: float = 16.0, **kwargs):
 71        super().__init__(mode=BodyMode.STATIC, **kwargs)
 72        self.w = w
 73        self.h = h
 74        # Build from the stored (validated) sizes rather than the raw arguments.
 75        self._rebuild_collider()
 76
 77    def _rebuild_collider(self):
 78        """Match the collider to the current size (assigning ``shape`` is live)."""
 79        self.shape = RectangleShape2D(half_extents=Vec2(self.w / 2, self.h / 2))
 80
 81    def on_draw(self, renderer):
 82        x = self.position.x - self.w / 2
 83        y = self.position.y - self.h / 2
 84        renderer.draw_rect((x, y), (self.w, self.h), colour=(0.39, 0.71, 0.31, 1.0), filled=True)
 85
 86
 87class Player(CharacterBody2D):
 88    speed = Property(250.0, range=(100, 500))
 89    jump_force = Property(450.0, range=(200, 800))
 90
 91    def __init__(self, **kwargs):
 92        super().__init__(shape=RectangleShape2D(half_extents=PLAYER_HALF), **kwargs)
 93        self.up_direction = SCREEN_UP  # screen-space: "up" is -Y
 94
 95    def on_update(self, dt: float):
 96        # Horizontal movement (keyboard)
 97        move = Input.get_strength("right") - Input.get_strength("left")
 98        jump = Input.is_action_just_pressed("jump")
 99
100        # Pointer/touch (touch arrives as MouseButton.LEFT on web): hold the
101        # left/right half of the screen to move, press the top half to jump.
102        if Input.is_mouse_button_pressed(MouseButton.LEFT):
103            win_w, win_h = self.app.width, self.app.height
104            pointer = Input.mouse_position
105            move += -1.0 if pointer.x < win_w / 2 else 1.0
106            if Input.is_mouse_button_just_pressed(MouseButton.LEFT) and pointer.y < win_h / 2:
107                jump = True
108        vx = max(-1.0, min(1.0, move)) * self.speed
109
110        # Gravity (toward +Y on screen). A KINEMATIC character is immune to world
111        # gravity, so integrate it here; reading it off the world keeps one source
112        # of truth. move_and_slide zeroes the into-floor component on landing, so
113        # standing still does not accumulate fall speed.
114        vy = self.velocity.y + self.world.gravity.y * dt
115
116        # Jump (a -Y, upward-on-screen kick)
117        if jump and self.is_on_floor():
118            vy = -self.jump_force
119
120        self.velocity = Vec2(vx, vy)
121        self.move_and_slide(dt)
122
123        # Reset if fallen off screen
124        if self.position.y > HEIGHT + 50:
125            self.position = Vec2(WIDTH / 2, 100)
126            self.velocity = Vec2()
127
128    def on_draw(self, renderer):
129        px, py = self.position.x, self.position.y
130        hw, hh = float(PLAYER_HALF.x), float(PLAYER_HALF.y)
131        renderer.draw_rect((px - hw, py - hh), (hw * 2, hh * 2), colour=(0.24, 0.55, 1.0, 1.0), filled=True)
132        # Eyes, just below the top of the body
133        eye_y = py - hh + 4
134        renderer.draw_rect((px - 5, eye_y), (4, 4), colour=(1.0, 1.0, 1.0, 1.0), filled=True)
135        renderer.draw_rect((px + 1, eye_y), (4, 4), colour=(1.0, 1.0, 1.0, 1.0), filled=True)
136
137
138class PlatformerDemo(Node2D):
139    # The canonical, web-safe registration path: the scene tree reads this at mount
140    # and re-applies it on every scene swap.
141    input_actions = {
142        "left": [Key.A, Key.LEFT],
143        "right": [Key.D, Key.RIGHT],
144        "jump": [Key.SPACE],
145    }
146
147    def on_ready(self):
148        # Screen-space (Y-down) world: gravity points at +Y so falling reads as
149        # falling on screen. It accelerates DYNAMIC bodies only; the character
150        # integrates it itself. Every physics node below resolves to this world.
151        world = self.add_child(PhysicsRoot2D(name="World", gravity=Vec2(0.0, GRAVITY)))
152
153        world.add_child(Player(name="Player", position=Vec2(WIDTH / 2, 100)))
154
155        # Ground
156        world.add_child(Platform(name="Ground", w=WIDTH, h=20, position=Vec2(WIDTH / 2, HEIGHT - 10)))
157
158        # Platforms
159        platforms = [
160            (200, 450, 150),
161            (400, 350, 120),
162            (600, 450, 150),
163            (300, 250, 100),
164            (500, 250, 100),
165            (150, 150, 130),
166            (650, 150, 130),
167        ]
168        for i, (x, y, w) in enumerate(platforms):
169            world.add_child(Platform(name=f"Plat{i}", w=w, position=Vec2(x, y)))
170
171    def on_draw(self, renderer):
172        renderer.draw_text("Platformer Demo", (10, 10), scale=2, colour=(1.0, 1.0, 1.0))
173        renderer.draw_text(
174            "A/D: move  SPACE: jump  |  touch: hold a side to move, tap top to jump",
175            (10, 45),
176            scale=1,
177            colour=(0.71, 0.71, 0.71),
178        )
179
180
181def _selftest() -> bool:
182    """Headless: play the scene through the real input path and check what it claims.
183
184    One offscreen run of the real scene with the keys held on the frames a player
185    would hold them, so the movement code is reached the way the game reaches it:
186    named actions, ``is_action_just_pressed`` edges and all. Frames are 1/60 s.
187    """
188    from simvx.core.testing import InputSimulator
189    from simvx.graphics.testing import assert_not_blank, save_png
190
191    # The script. The spawn falls onto the ledge beneath it before anything is
192    # pressed, so every later phase starts from a known rest.
193    LANDED = 69  # settled on the ledge under the spawn
194    JUMP = 70  # a single tap, from that rest
195    REJUMP = 82  # and another in mid-air, which the floor gate must refuse
196    RE_LANDED = 165  # down again, having risen and fallen
197    WALK = 170  # hold "right"
198    WALK_QUARTER = 185  # a quarter second in: far enough to measure the speed
199    WALK_END = 230  # by now it has run off the ledge and landed on the next one
200    RESTED = 280
201    DROP = 290  # dropped below the bottom of the screen
202    RESPAWNED = 291
203
204    app = App(title="Platformer", width=WIDTH, height=HEIGHT, visible=False)
205    scene = PlatformerDemo(name="PlatformerDemo")
206    sim = InputSimulator()
207    seen: dict[str, object] = {}
208    player = None
209    peak_y = float("inf")
210
211    def on_frame(idx: int, _t: float) -> bool:
212        nonlocal player, peak_y
213        if player is None:
214            player = scene.node_at("World/Player")
215        if JUMP < idx <= RE_LANDED:
216            peak_y = min(peak_y, float(player.position.y))
217
218        if idx == LANDED:
219            seen["landed"] = (float(player.position.y), player.is_on_floor())
220        elif idx == JUMP:
221            sim.press_key(Key.SPACE)
222        elif idx == JUMP + 1:
223            sim.release_key(Key.SPACE)
224            seen["launched"] = (float(player.velocity.y), player.is_on_floor())
225        elif idx == REJUMP:
226            sim.press_key(Key.SPACE)
227        elif idx == REJUMP + 1:
228            sim.release_key(Key.SPACE)
229        elif idx == RE_LANDED:
230            seen["re_landed"] = (float(player.position.y), player.is_on_floor())
231        elif idx == WALK:
232            seen["walk_from"] = float(player.position.x)
233            sim.press_key(Key.D)
234        elif idx == WALK_QUARTER:
235            seen["walk_to"] = float(player.position.x)
236        elif idx == WALK_END:
237            sim.release_key(Key.D)
238        elif idx == RESTED:
239            seen["rested"] = (float(player.position.y), player.is_on_floor())
240            seen["floor_normal"] = tuple(float(v) for v in player.floor_normal)
241            seen["contacts"] = len(player.collisions)
242        elif idx == DROP:
243            player.position = Vec2(player.position.x, HEIGHT + 60.0)
244        elif idx == RESPAWNED:
245            seen["respawn"] = tuple(float(v) for v in player.position)
246        return True
247
248    frames = app.run_headless(scene, frames=320, on_frame=on_frame, capture_frames=[319])
249    assert_not_blank(frames[0])
250    save_png(frames[0], "/tmp/character_platformer_test.png")
251
252    # Rest heights are read off the ledges rather than written down, so moving one
253    # in the scene above moves what this expects rather than breaking it.
254    def rest_on(name: str) -> float:
255        ledge = scene.node_at(f"World/{name}")
256        return float(ledge.position.y) - ledge.h / 2 - float(PLAYER_HALF.y)
257
258    ok = True
259
260    def check(label: str, passed: bool, detail: str) -> None:
261        nonlocal ok
262        ok = ok and passed
263        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
264
265    # Falls onto the ledge it spawned above and stops exactly on its top face:
266    # move_and_slide resolved the hit, and the floor classifier agrees.
267    y, on_floor = seen["landed"]
268    check(
269        "lands on the ledge below the spawn",
270        abs(y - rest_on("Plat1")) < 1.0 and on_floor,
271        f"y={y:.2f} on_floor={on_floor}",
272    )
273
274    # A tap from that rest launches it at exactly jump_force, upward on screen.
275    vy, on_floor = seen["launched"]
276    check("the jump fires at jump_force", abs(vy + player.jump_force) < 1.0 and not on_floor, f"vy={vy:.1f}")
277
278    # It rises the height that velocity buys and no more. The second tap at REJUMP
279    # happened in mid-air, so a floor gate that had stopped working would show up
280    # here as a second launch and roughly twice the rise.
281    apex = player.jump_force**2 / (2 * GRAVITY)
282    rise = seen["landed"][0] - peak_y
283    check(
284        "rises one jump's worth, and no second jump in mid-air",
285        apex * 0.9 < rise < apex * 1.15,
286        f"rose {rise:.1f}px (one jump buys {apex:.1f}px)",
287    )
288
289    y, on_floor = seen["re_landed"]
290    check(
291        "comes back down to the same ledge",
292        abs(y - rest_on("Plat1")) < 1.0 and on_floor,
293        f"y={y:.2f} on_floor={on_floor}",
294    )
295
296    # Held "right" moves it at the speed the Property says, through the action map.
297    walked = seen["walk_to"] - seen["walk_from"]
298    expected = player.speed * (WALK_QUARTER - WALK) / 60.0
299    check(
300        "walks right at `speed`",
301        abs(walked - expected) < 5.0,
302        f"{walked:.1f}px in {(WALK_QUARTER - WALK) / 60:.2f}s (expected {expected:.1f})",
303    )
304
305    # Off the end of that ledge, down, and stopped on the next one: a second
306    # collision resolved somewhere the demo never spelled out.
307    y, on_floor = seen["rested"]
308    check(
309        "runs off the edge and lands on the next ledge",
310        abs(y - rest_on("Plat2")) < 1.0 and on_floor,
311        f"y={y:.2f} on_floor={on_floor}",
312    )
313    check("blocking contact reported", seen["contacts"] >= 1, f"{seen['contacts']}")
314
315    # Screen-space world: the floor it is standing on faces -Y, which is only a
316    # floor at all because up_direction was flipped to match.
317    normal = seen["floor_normal"]
318    check(
319        "floor classified against the flipped up_direction",
320        abs(normal[1] + 1.0) < 0.01,
321        f"floor_normal={normal[0]:.2f},{normal[1]:.2f}",
322    )
323
324    check("respawns after falling off the bottom", seen["respawn"] == (WIDTH / 2, 100.0), f"{seen['respawn']}")
325
326    # Both size Properties are live: assigning either swaps in a matching collider,
327    # so a ledge can never draw at one size and collide at another.
328    ground = scene.node_at("World/Ground")
329    ground.w, ground.h = 300.0, 40.0
330    half = ground.shape.half_extents
331    check(
332        "resizing a ledge rebuilds its collider",
333        abs(float(half.x) - 150.0) < 0.01 and abs(float(half.y) - 20.0) < 0.01,
334        f"half_extents={float(half.x):.1f},{float(half.y):.1f}",
335    )
336
337    print("screenshot: /tmp/character_platformer_test.png")
338    print("SELFTEST:", "PASS" if ok else "FAIL")
339    return ok
340
341
342if __name__ == "__main__":
343    import sys
344
345    if "--test" in sys.argv:
346        sys.exit(0 if _selftest() else 1)
347    App(title="Platformer Demo", width=WIDTH, height=HEIGHT).run(PlatformerDemo())