2D Physics Playground¶
rigid bodies, a character, one-way platforms, and joints.
â–¶ Run in browserTags: physics 2d character joints
One scene exercising the 2D physics stack end to end. Dynamic boxes and circles fall and pile on a static segment floor, a two-link pendulum swings from pin joints, a bead slides along a groove joint, and a player-controlled character walks, jumps, and lands on a one-way platform it can also jump up through from below. An Area2D trigger zone reports every body passing through it, the character included: a CharacterBody2D is an ordinary kinematic body, so it fires the same signals a falling box does.
This is a Y-down pixel world (gravity = Vec2(0, +g), +Y rendered downward), so
falling reads as falling on screen. The character’s up_direction and the one-way
platform’s solid side are flipped to -Y to match.
Engine features shown:
PhysicsBody2D in DYNAMIC and STATIC modes with PhysicsMaterial friction and restitution; each body carries a CollisionShape2D plus a visual child that inherits the body transform the physics sync writes each fixed step.
CharacterBody2D driven by velocity + move_and_slide, with is_on_floor gating jumps.
A one-way platform: solid from above, pass-through from below.
Area2D body_entered / body_exited signals updating the HUD live, including the body_exited a DESTROYED body still owes: press X and the zone names what it lost.
PinJoint2D (a pendulum chain) and GrooveJoint2D (a bead pinned to a slide rail and kicked along it at start).
Controls: A / D - move the character left / right Hold click or tap - walk the character toward the pointer (mouse / touch) Space, click, tap - jump R - respawn the falling batch X - destroy whatever is inside the trigger zone Escape - quit
Run: uv run python examples/features/physics/playground2d.py Headless self-check: uv run python examples/features/physics/playground2d.py –test
Source¶
1"""2D Physics Playground: rigid bodies, a character, one-way platforms, and joints.
2
3One scene exercising the 2D physics stack end to end. Dynamic boxes and circles fall
4and pile on a static segment floor, a two-link pendulum swings from pin joints, a bead
5slides along a groove joint, and a player-controlled character walks, jumps, and lands
6on a one-way platform it can also jump up through from below. An Area2D trigger zone
7reports every body passing through it, the character included: a CharacterBody2D is an
8ordinary kinematic body, so it fires the same signals a falling box does.
9
10This is a Y-down pixel world (``gravity = Vec2(0, +g)``, +Y rendered downward), so
11falling reads as falling on screen. The character's ``up_direction`` and the one-way
12platform's solid side are flipped to -Y to match.
13
14Engine features shown:
15 - PhysicsBody2D in DYNAMIC and STATIC modes with PhysicsMaterial friction and
16 restitution; each body carries a CollisionShape2D plus a visual child that
17 inherits the body transform the physics sync writes each fixed step.
18 - CharacterBody2D driven by velocity + move_and_slide, with is_on_floor gating jumps.
19 - A one-way platform: solid from above, pass-through from below.
20 - Area2D body_entered / body_exited signals updating the HUD live, including the
21 body_exited a DESTROYED body still owes: press X and the zone names what it lost.
22 - PinJoint2D (a pendulum chain) and GrooveJoint2D (a bead pinned to a slide rail and
23 kicked along it at start).
24
25Controls:
26 A / D - move the character left / right
27 Hold click or tap - walk the character toward the pointer (mouse / touch)
28 Space, click, tap - jump
29 R - respawn the falling batch
30 X - destroy whatever is inside the trigger zone
31 Escape - quit
32
33Run: uv run python examples/features/physics/playground2d.py
34Headless self-check: uv run python examples/features/physics/playground2d.py --test
35
36# /// simvx
37# tags = ["2d", "physics", "character", "joints"]
38# ///
39"""
40
41from __future__ import annotations
42
43import math
44
45from simvx.core import (
46 Area2D,
47 BodyMode,
48 Camera2D,
49 CharacterBody2D,
50 CircleShape2D,
51 CollisionShape2D,
52 GrooveJoint2D,
53 Input,
54 InputMap,
55 Key,
56 MouseButton,
57 Node,
58 Node2D,
59 PhysicsBody2D,
60 PhysicsMaterial,
61 PhysicsRoot2D,
62 PinJoint2D,
63 Polygon2D,
64 RectangleShape2D,
65 SegmentShape2D,
66 Text2D,
67 Vec2,
68)
69from simvx.graphics import App
70
71# Pixel-space, Y-DOWN world (the documented Y-down game path: gravity = Vec2(0, +g)
72# and +Y rendered downward, so falling reads as falling on screen). World units ARE
73# screen pixels and a Camera2D at the screen centre with zoom 1 gives the identity
74# view (the same convention as the spaceinvaders2d demo). The physics world itself is
75# axis-neutral: Y-down is this game's choice, carried by gravity and up_direction.
76_WIDTH, _HEIGHT = 1280, 720
77_GRAVITY = Vec2(0.0, 1400.0) # px/s^2 downward
78_FLOOR_Y = 660.0 # screen-y of the static floor (near the bottom)
79_SOLID_UP = Vec2(0.0, -1.0) # "up" on screen is -Y in this Y-down world
80
81_COLOURS = [
82 (0.90, 0.30, 0.25, 1.0),
83 (0.30, 0.70, 0.95, 1.0),
84 (0.95, 0.80, 0.25, 1.0),
85 (0.55, 0.85, 0.35, 1.0),
86 (0.80, 0.45, 0.90, 1.0),
87]
88
89
90class _DiscVisual(Node2D):
91 """A filled disc with a spoke so its spin is visible.
92
93 Added as a child of a PhysicsBody2D it inherits the body's pose: the physics sync
94 writes the body's transform each fixed step and ``transform_points`` composes the
95 whole parent chain, so the disc tracks the body it hangs off. Boxes need no custom
96 node at all: they are plain ``Polygon2D`` children (see :func:`_box`).
97 """
98
99 def __init__(self, *, radius: float, colour, segments: int = 24, **kwargs: object) -> None:
100 super().__init__(**kwargs)
101 self._radius = float(radius)
102 self._colour = colour
103 self._segments = segments
104
105 def on_draw(self, renderer):
106 centre, spoke = self.transform_points([Vec2(0.0, 0.0), Vec2(self._radius, 0.0)])
107 cx, cy = float(centre.x), float(centre.y)
108 renderer.draw_circle((cx, cy), self._radius, colour=self._colour, filled=True, segments=self._segments)
109 renderer.draw_line((cx, cy), (float(spoke.x), float(spoke.y)), colour=(0.1, 0.1, 0.1, 0.8), thickness=2.0)
110
111
112def _box(hx: float, hy: float, colour) -> Polygon2D:
113 return Polygon2D(polygon=[(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)], colour=colour)
114
115
116def _circle(radius: float, colour) -> _DiscVisual:
117 return _DiscVisual(radius=radius, colour=colour)
118
119
120class Physics2DScene(Node):
121 def on_ready(self):
122 InputMap.add_action("move_left", [Key.A])
123 InputMap.add_action("move_right", [Key.D])
124 # Left-click / tap doubles as jump (touch maps to MouseButton.LEFT on web),
125 # so the demo is playable with mouse or a finger, not keyboard-only.
126 InputMap.add_action("jump", [Key.SPACE, MouseButton.LEFT])
127 InputMap.add_action("respawn", [Key.R])
128 InputMap.add_action("vaporise", [Key.X])
129 InputMap.add_action("quit", [Key.ESCAPE])
130
131 # One isolated Y-down 2D world; every physics node below resolves to it.
132 self._root2d = self.add_child(PhysicsRoot2D(name="World2D", gravity=_GRAVITY))
133
134 # Camera at the centre of the authored frame. On a 1280x720 window that is
135 # the identity pixel-space view; on anything else the zoom below scales it.
136 self._cam = self._root2d.add_child(Camera2D())
137 self._cam.position = Vec2(_WIDTH / 2, _HEIGHT / 2)
138 self._fit_camera()
139
140 self._build_floor()
141 self._build_one_way_platform()
142 self._build_trigger_area()
143 self._build_pendulum()
144 self._build_groove()
145 self._build_character()
146
147 self._bodies: list[PhysicsBody2D] = []
148 self._spawn_batch()
149
150 # Two lines rather than one: the whole HUD has to stay inside the frame at
151 # the size the site publishes screenshots at, not just at the window size.
152 self._hud = Text2D(
153 text="A/D or hold click/tap to move | Space or click/tap to jump",
154 position=(10, 10),
155 font_scale=1.4,
156 )
157 self.add_child(self._hud)
158 self._hud_keys = Text2D(text="R respawn | X vaporise | ESC quit", position=(10, 38), font_scale=1.4)
159 self.add_child(self._hud_keys)
160 self._status = Text2D(text="", position=(10, 66), font_scale=1.0)
161 self.add_child(self._status)
162 self._last_trigger = ""
163 self._last_exit = ""
164 self._vaporised = 0
165 self._trigger_status = Text2D(text="trigger: empty", position=(10, 90), font_scale=1.0)
166 self.add_child(self._trigger_status)
167 self._inside = 0
168
169 def _fit_camera(self):
170 """Scale the VIEW so the whole authored frame is visible on any window.
171
172 Every world position here is part of the simulation setup -- the pendulum's
173 reach, where the drops land, where the trigger sits -- so they are authored
174 once at ``_WIDTH`` x ``_HEIGHT`` and never recomputed per window: the same
175 run has to produce the same physics whatever it is displayed on. A smaller
176 window therefore changes the camera, not the world, which is the difference
177 between seeing the scene shrunk and seeing a crop of it.
178 """
179 self._cam.zoom = min(self.app.width / _WIDTH, self.app.height / _HEIGHT)
180
181 # -- scene construction -------------------------------------------------
182
183 def _build_floor(self):
184 # A long STATIC segment floor (a thin 2D-only beam) with a slab visual.
185 floor = PhysicsBody2D(name="Floor", mode=BodyMode.STATIC, material=PhysicsMaterial(friction=0.7))
186 floor.position = Vec2(640.0, _FLOOR_Y)
187 floor.add_child(CollisionShape2D(shape=SegmentShape2D(a=(-560.0, 0.0), b=(560.0, 0.0), radius=6.0)))
188 floor.add_child(_box(560.0, 8.0, (0.24, 0.26, 0.30, 1.0)))
189 self._root2d.add_child(floor)
190
191 def _build_one_way_platform(self):
192 # A one-way platform: solid from above (-Y up on screen), pass-through from
193 # below. A box collider + slab visual; the character lands on it and can
194 # jump up THROUGH it from below.
195 plat = PhysicsBody2D(
196 name="OneWay",
197 mode=BodyMode.STATIC,
198 one_way=True,
199 one_way_normal=_SOLID_UP,
200 material=PhysicsMaterial(friction=0.6),
201 )
202 plat.position = Vec2(900.0, 470.0)
203 plat.add_child(CollisionShape2D(shape=RectangleShape2D(half_extents=Vec2(110.0, 8.0))))
204 plat.add_child(_box(110.0, 8.0, (0.45, 0.75, 0.45, 1.0)))
205 self._root2d.add_child(plat)
206
207 def _build_trigger_area(self):
208 # An Area2D zone that reports bodies entering / leaving via signals: the
209 # falling drops pass through it on their way down, and so does the
210 # character if it is walked into the zone (it is a kinematic body, not a
211 # separate kind of peer).
212 self._area = Area2D(name="Trigger")
213 self._area.position = Vec2(560.0, 300.0)
214 self._area.add_child(CollisionShape2D(shape=RectangleShape2D(half_extents=Vec2(120.0, 110.0))))
215 self._area.add_child(_box(120.0, 110.0, (0.95, 0.85, 0.20, 0.18)))
216 self._area.body_entered.connect(self._on_trigger_enter)
217 self._area.body_exited.connect(self._on_trigger_exit)
218 self._root2d.add_child(self._area)
219
220 def _build_pendulum(self):
221 # A two-link pendulum: a static anchor + two circles held by pin joints, so
222 # it swings under gravity (PinJoint2D chain).
223 anchor = PhysicsBody2D(name="PendAnchor", mode=BodyMode.STATIC)
224 anchor.position = Vec2(170.0, 140.0)
225 anchor.add_child(CollisionShape2D(shape=CircleShape2D(7.0)))
226 anchor.add_child(_circle(7.0, (0.7, 0.7, 0.7, 1.0)))
227 self._root2d.add_child(anchor)
228
229 link1 = PhysicsBody2D(name="PendLink1", mode=BodyMode.DYNAMIC, mass=1.0)
230 link1.position = Vec2(250.0, 140.0)
231 link1.add_child(CollisionShape2D(shape=CircleShape2D(20.0)))
232 link1.add_child(_circle(20.0, (0.30, 0.70, 0.95, 1.0)))
233 self._root2d.add_child(link1)
234
235 link2 = PhysicsBody2D(name="PendLink2", mode=BodyMode.DYNAMIC, mass=1.0)
236 link2.position = Vec2(330.0, 140.0)
237 link2.add_child(CollisionShape2D(shape=CircleShape2D(20.0)))
238 link2.add_child(_circle(20.0, (0.80, 0.45, 0.90, 1.0)))
239 self._root2d.add_child(link2)
240
241 # Joints are added AFTER their bodies so both endpoints already exist.
242 self._root2d.add_child(PinJoint2D(name="Pin1", body_a=anchor, body_b=link1, anchor=Vec2(170.0, 140.0)))
243 self._root2d.add_child(PinJoint2D(name="Pin2", body_a=link1, body_b=link2, anchor=Vec2(250.0, 140.0)))
244
245 def _build_groove(self):
246 # A GrooveJoint2D slider: a static carrier holds a dynamic bead on a
247 # horizontal groove; an initial sideways kick sends it sliding along the
248 # line (pinned perpendicular, clamped at the ends). Disjoint collision masks
249 # so the bead and carrier never collide as circles: ONLY the groove acts.
250 groove_y = 230.0
251 carrier = PhysicsBody2D(name="GrooveCarrier", mode=BodyMode.STATIC, collision_layer=0x1, collision_mask=0x2)
252 carrier.position = Vec2(640.0, groove_y)
253 carrier.add_child(CollisionShape2D(shape=CircleShape2D(7.0)))
254 carrier.add_child(_circle(7.0, (0.7, 0.7, 0.7, 1.0)))
255 # A visual line marking the groove segment (x in [-180, 180], carrier-local).
256 carrier.add_child(_box(180.0, 2.0, (0.5, 0.5, 0.55, 1.0)))
257 self._root2d.add_child(carrier)
258
259 self._groove_bead = PhysicsBody2D(
260 name="GrooveBead", mode=BodyMode.DYNAMIC, mass=1.0, collision_layer=0x1, collision_mask=0x4
261 )
262 self._groove_bead.position = Vec2(640.0 - 160.0, groove_y)
263 self._groove_bead.add_child(CollisionShape2D(shape=CircleShape2D(16.0)))
264 self._groove_bead.add_child(_circle(16.0, (0.95, 0.55, 0.20, 1.0)))
265 self._root2d.add_child(self._groove_bead)
266
267 self._root2d.add_child(
268 GrooveJoint2D(
269 name="Groove",
270 body_a=carrier,
271 body_b=self._groove_bead,
272 groove_a=Vec2(-180.0, 0.0),
273 groove_b=Vec2(180.0, 0.0),
274 anchor_b=Vec2(0.0, 0.0),
275 )
276 )
277 self._groove_y = groove_y
278 # Initial slide kick along the groove (+X).
279 self._groove_bead.velocity = Vec2(240.0, 0.0)
280
281 def _build_character(self):
282 self._char = CharacterBody2D(name="Player", shape=CircleShape2D(24.0))
283 self._char.up_direction = _SOLID_UP # up = -Y in this Y-down world
284 self._char.position = Vec2(900.0, 140.0)
285 self._char.add_child(_circle(24.0, (1.0, 0.55, 0.15, 1.0)))
286 self._root2d.add_child(self._char)
287
288 def _spawn_batch(self):
289 for b in self._bodies:
290 b.destroy()
291 self._bodies = []
292 for i in range(8):
293 x = 560.0 + (i % 4 - 1.5) * 55.0
294 y = 60.0 - (i // 4) * 65.0
295 colour = _COLOURS[i % len(_COLOURS)]
296 body = PhysicsBody2D(
297 name=f"Drop{i}",
298 mode=BodyMode.DYNAMIC,
299 mass=1.0,
300 material=PhysicsMaterial(friction=0.5, restitution=0.1),
301 )
302 body.position = Vec2(x, y)
303 if i % 2 == 0:
304 body.add_child(CollisionShape2D(shape=CircleShape2D(22.0)))
305 body.add_child(_circle(22.0, colour))
306 else:
307 body.add_child(CollisionShape2D(shape=RectangleShape2D(half_extents=Vec2(22.0, 22.0))))
308 body.add_child(_box(22.0, 22.0, colour))
309 self._root2d.add_child(body)
310 self._bodies.append(body)
311
312 # -- signals ------------------------------------------------------------
313
314 def _on_trigger_enter(self, node):
315 # The payload is a PhysicsObject2D: a falling box, or the character.
316 self._inside += 1
317 self._last_trigger = type(node).__name__
318
319 def _on_trigger_exit(self, node):
320 # Two ways to stop overlapping, and the payload tells them apart. A body
321 # that just fell out of the zone is still simulated; a body DESTROYED
322 # inside it is handed over detached (``handle is None``), so a scoring or
323 # cleanup handler can react to the kill without having cached anything.
324 self._inside = max(0, self._inside - 1)
325 if node.handle is None:
326 self._vaporised += 1
327 self._last_exit = f"{node.name} destroyed inside"
328 else:
329 self._last_exit = f"{node.name} left"
330
331 def _vaporise_inside(self):
332 """Destroy every body currently in the trigger (bound to X)."""
333 for body in self._area.get_overlapping_bodies():
334 body.destroy()
335
336 # -- per-frame ----------------------------------------------------------
337
338 def on_fixed_update(self, dt):
339 speed = 300.0 # px/s
340 vx = Input.get_axis("move_left", "move_right") * speed
341 # Touch / mouse steering: while the left button (or finger) is held and no key
342 # is driving, walk toward the pointer's x. Pixel-space identity view, so the
343 # mouse screen x is the world x. A small dead zone stops jitter over the body.
344 if vx == 0.0 and Input.is_mouse_button_pressed(MouseButton.LEFT):
345 dx = float(Input.mouse_position.x) - float(self._char.world_position.x)
346 if abs(dx) > 8.0:
347 vx = math.copysign(speed, dx)
348 # The fall speed lives on the node: move_and_slide writes the deflected
349 # post-slide velocity back to self.velocity, so landing on a surface already
350 # removes the component into it. Resting on the ground probe (no sweep hit)
351 # still needs the clamp, or gravity would keep piling up frame after frame.
352 # Y-down world: falling is +Y, so a jump is a -Y kick.
353 vy = float(self._char.velocity.y)
354 on_floor = self._char.is_on_floor()
355 if on_floor and vy > 0.0:
356 vy = 0.0
357 if on_floor and Input.is_action_just_pressed("jump"):
358 vy = -650.0
359 vy += _GRAVITY.y * dt
360 self._char.velocity = Vec2(vx, vy)
361 self._char.move_and_slide(dt)
362
363 def on_update(self, dt):
364 if Input.is_action_just_pressed("quit"):
365 self.app.quit()
366 return
367 if Input.is_action_just_pressed("respawn"):
368 self._spawn_batch()
369 if Input.is_action_just_pressed("vaporise"):
370 self._vaporise_inside()
371 self._fit_camera()
372 # A destroyed body keeps its node but loses its handle, so the list has to
373 # drop it or the count below would contradict the trigger line beside it,
374 # which has already reported every body it lost.
375 self._bodies = [b for b in self._bodies if b.handle is not None]
376 rested = sum(1 for b in self._bodies if float(b.world_position.y) > _FLOOR_Y - 80.0)
377 self._status.text = f"bodies: {len(self._bodies)} | rested: {rested} | on_floor: {self._char.is_on_floor()}"
378 last = f" (last in: {self._last_trigger})" if self._last_trigger else ""
379 out = f" | last out: {self._last_exit}" if self._last_exit else ""
380 self._trigger_status.text = f"trigger: {self._inside} inside{last}{out} | vaporised: {self._vaporised}"
381
382
383def _selftest() -> bool:
384 """Headless: run a couple of seconds, screenshot, assert bodies fell + rested."""
385 from simvx.graphics.testing import assert_not_blank, save_png
386
387 app = App(title="Physics2D", width=_WIDTH, height=_HEIGHT, visible=False)
388 scene = Physics2DScene(name="Physics2DScene")
389 settled: dict[str, object] = {}
390
391 def on_frame(index: int, _time: float) -> None:
392 # Frames 0-179 are the ordinary settle, snapshotted at 179. After that,
393 # replay what X does at runtime: respawn the batch, catch it mid-zone and
394 # destroy it there, so the trigger has to report each body it lost.
395 if index == 179:
396 settled["y"] = [float(b.world_position.y) for b in scene._bodies]
397 settled["char_y"] = float(scene._char.world_position.y)
398 settled["bead"] = (float(scene._groove_bead.world_position.x), float(scene._groove_bead.world_position.y))
399 elif index == 180:
400 scene._spawn_batch()
401 elif index > 180 and "caught" not in settled and scene._inside:
402 settled["caught"] = scene._inside
403 scene._vaporise_inside()
404
405 frames = app.run_headless(scene, frames=320, on_frame=on_frame, capture_frames=[179])
406 frame = frames[0]
407 assert_not_blank(frame)
408 save_png(frame, "/tmp/physics2d_new_test.png")
409
410 ys = settled["y"]
411 # Y-down: bodies START above (smaller y) and FALL toward the floor (larger y).
412 # They pile just above the floor segment (within ~one body-height + beam radius).
413 rested = [y for y in ys if _FLOOR_Y - 80.0 < y < _FLOOR_Y + 10.0]
414 char_y = settled["char_y"]
415 bead_x, bead_y = settled["bead"]
416 groove_y = scene._groove_y
417 print(f"drop body y after 3s: min={min(ys):.1f} max={max(ys):.1f} ; rested-on-floor: {len(rested)}/{len(ys)}")
418 print(f"character y={char_y:.1f} on_floor={scene._char.is_on_floor()}")
419 print(f"groove bead: x={bead_x:.1f} y={bead_y:.1f} (slid along, pinned to groove line at y={groove_y:.0f})")
420 print("screenshot: /tmp/physics2d_new_test.png")
421 ok = (
422 len(rested) >= len(ys) - 1 # all but at most one drop body settled on the floor
423 and char_y < _FLOOR_Y + 30.0 # the character is on / above the floor (did not fall through)
424 and abs(bead_y - groove_y) < 12.0 # the groove bead stayed on its line
425 and bead_x > 640.0 - 160.0 + 20.0 # the bead slid along the groove from its start (+X)
426 )
427
428 # The trigger is owed one body_exited per body destroyed inside it, naming
429 # each one, which is what distinguishes "destroyed inside" from "fell out".
430 # (Later drops fall through the zone and leave normally, so _last_exit has
431 # moved on by now: the count is what pins the destroyed-inside path.)
432 caught = settled.get("caught", 0)
433 print(f"bodies caught in the trigger: {caught}; destroyed inside: {scene._vaporised}")
434 ok = ok and caught > 0 and scene._vaporised == caught
435
436 # And the two HUD lines have to agree: a body count that still includes the
437 # ones the trigger just reported destroying is the contradiction a reader sees
438 # first, so it is asserted rather than left to the eye.
439 alive = sum(1 for b in scene._bodies if b.handle is not None)
440 print(f"bodies listed: {len(scene._bodies)}; alive: {alive}; status line: {scene._status.text!r}")
441 ok = ok and len(scene._bodies) == alive and scene._status.text.startswith(f"bodies: {alive} ")
442
443 print("SELFTEST:", "PASS" if ok else "FAIL")
444 return ok
445
446
447if __name__ == "__main__":
448 import sys
449
450 if "--test" in sys.argv:
451 sys.exit(0 if _selftest() else 1)
452 app = App(title="2D Physics Playground", width=_WIDTH, height=_HEIGHT)
453 app.run(Physics2DScene())