2D Physics Playground¶

rigid bodies, a character, one-way platforms, and joints.

â–¶ Run in browser

Tags: 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())