Character Platformer¶
CharacterBody2D with gravity, jump, and platforms.
▶ Run in browserTags: 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_slidedrive the runner. A character is KINEMATIC: it is never pushed by the simulation, so the demo owns itsvelocityoutright andmove_and_slidesweeps it, resolves the hits and deflects what is left along the surfaces it touched.is_on_floor(classified against the character’sup_direction) gates the jump, so it only fires with ground underfoot.Ledges are
PhysicsBody2DinBodyMode.STATICwith aRectangleShape2Dcollider 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())