Collision Shapes

A circle and a box body falling onto a static floor.

▶ Run in browser

Tags: 2d physics collision rigidbody

Two dynamic PhysicsBody2D nodes (one with a CircleShape2D collider, one with a RectangleShape2D collider) fall under gravity and come to rest on a STATIC PhysicsBody2D floor. The Physics2DWorld resolves the contacts automatically, so each body settles exactly on top of the floor.

What it demonstrates

  • PhysicsBody2D(DYNAMIC) with two collider kinds: CircleShape2D and RectangleShape2D.

  • PhysicsBody2D(STATIC) as an immovable floor the dynamic bodies rest against.

  • Both ways to give a body its geometry: the single-collider shape= shortcut (live-swappable) and the equivalent CollisionShape2D child node.

  • Automatic gravity + impulse-based contact response from the Physics2DWorld.

  • Different shapes settling at the correct height (floor top minus their extent).

Controls: R / click (tap) - Reset the bodies back to their drop positions ESC - Quit

Run: uv run python examples/features/2d/collision_shapes.py Headless self-check: uv run python examples/features/2d/collision_shapes.py –test

Source

  1"""Collision Shapes: A circle and a box body falling onto a static floor.
  2
  3Two dynamic PhysicsBody2D nodes (one with a CircleShape2D collider, one with a
  4RectangleShape2D collider) fall under gravity and come to rest on a STATIC
  5PhysicsBody2D floor. The Physics2DWorld resolves the contacts automatically, so
  6each body settles exactly on top of the floor.
  7
  8# /// simvx
  9# tags = ["2d", "physics", "collision", "rigidbody"]
 10# web = { root = "CollisionShapesDemo", width = 800, height = 600, responsive = true }
 11# ///
 12
 13## What it demonstrates
 14- PhysicsBody2D(DYNAMIC) with two collider kinds: CircleShape2D and RectangleShape2D.
 15- PhysicsBody2D(STATIC) as an immovable floor the dynamic bodies rest against.
 16- Both ways to give a body its geometry: the single-collider `shape=` shortcut
 17  (live-swappable) and the equivalent CollisionShape2D child node.
 18- Automatic gravity + impulse-based contact response from the Physics2DWorld.
 19- Different shapes settling at the correct height (floor top minus their extent).
 20
 21Controls:
 22  R / click (tap) - Reset the bodies back to their drop positions
 23  ESC             - Quit
 24
 25Run: uv run python examples/features/2d/collision_shapes.py
 26Headless self-check: uv run python examples/features/2d/collision_shapes.py --test
 27"""
 28
 29from simvx.core import (
 30    BodyMode,
 31    CircleShape2D,
 32    CollisionShape2D,
 33    Input,
 34    InputMap,
 35    Key,
 36    MouseButton,
 37    Node2D,
 38    PhysicsBody2D,
 39    PhysicsMaterial,
 40    PhysicsRoot2D,
 41    RectangleShape2D,
 42    Vec2,
 43)
 44from simvx.graphics import App
 45
 46WIDTH, HEIGHT = 800, 600
 47
 48FLOOR_Y = 500.0
 49FLOOR_HALF_W = 320.0
 50FLOOR_HALF_H = 20.0
 51FLOOR_TOP = FLOOR_Y - FLOOR_HALF_H
 52
 53BALL_RADIUS = 30.0
 54BOX_HALF = 35.0
 55
 56# 2D physics here runs in screen pixels, Y-down (gravity = +Y, falling reads as
 57# falling on screen). Continuous collision keeps the box from tunnelling the floor
 58# at the fall speeds this gravity produces.
 59GRAVITY = Vec2(0.0, 1600.0)
 60
 61BALL_DROP = Vec2(WIDTH / 2 - 110, 100)
 62BOX_DROP = Vec2(WIDTH / 2 + 110, 100)
 63
 64
 65class CollisionShapesDemo(Node2D):
 66    """Dynamic circle + box bodies resting on a static floor."""
 67
 68    dynamic = True  # bodies fall + settle every frame (physics world positions)
 69
 70    def on_ready(self):
 71        # Left-click (touch on web) also resets, so the demo stays replayable
 72        # without a keyboard.
 73        InputMap.add_action("reset", [Key.R, MouseButton.LEFT])
 74        InputMap.add_action("quit", [Key.ESCAPE])
 75
 76        # One isolated Y-down 2D world; every physics node below resolves to it.
 77        self._root = self.add_child(PhysicsRoot2D(name="World", gravity=GRAVITY))
 78
 79        # Static floor: a wide, thin box. Immovable, infinite effective mass. The
 80        # `shape=` Property is the short form for a body with one collider, and
 81        # assigning a new shape to it swaps the live body's geometry in place.
 82        floor = PhysicsBody2D(
 83            name="Floor",
 84            mode=BodyMode.STATIC,
 85            position=Vec2(WIDTH / 2, FLOOR_Y),
 86            material=PhysicsMaterial(friction=0.7),
 87            shape=RectangleShape2D(half_extents=Vec2(FLOOR_HALF_W, FLOOR_HALF_H)),
 88        )
 89        self._root.add_child(floor)
 90
 91        # Dynamic ball: a circular collider. Continuous CCD avoids tunnelling.
 92        self._ball = PhysicsBody2D(
 93            name="Ball",
 94            mode=BodyMode.DYNAMIC,
 95            mass=1.0,
 96            continuous=True,
 97            position=Vec2(BALL_DROP.x, BALL_DROP.y),
 98            material=PhysicsMaterial(friction=0.5),
 99            shape=CircleShape2D(BALL_RADIUS),
100        )
101        self._root.add_child(self._ball)
102
103        # Dynamic box: the same collider attached the other way, as a CollisionShape2D
104        # child. Equivalent to `shape=`, and it is what a scene built in the editor
105        # looks like, since a child is a node in the hierarchy rather than a resource
106        # on the body. A body resolves to `shape=` first, then its first such child.
107        self._box = PhysicsBody2D(
108            name="Box",
109            mode=BodyMode.DYNAMIC,
110            mass=1.0,
111            continuous=True,
112            position=Vec2(BOX_DROP.x, BOX_DROP.y),
113            material=PhysicsMaterial(friction=0.5),
114        )
115        self._box.add_child(CollisionShape2D(shape=RectangleShape2D(half_extents=Vec2(BOX_HALF, BOX_HALF))))
116        self._root.add_child(self._box)
117
118    def _reset(self):
119        self._ball.position = Vec2(BALL_DROP.x, BALL_DROP.y)
120        self._ball.velocity = Vec2()
121        self._box.position = Vec2(BOX_DROP.x, BOX_DROP.y)
122        self._box.velocity = Vec2()
123
124    def on_update(self, dt: float):
125        if Input.is_action_just_pressed("quit"):
126            self.app.quit()
127        elif Input.is_action_just_pressed("reset"):
128            self._reset()
129
130    def on_draw(self, renderer):
131        # Floor.
132        renderer.draw_rect(
133            (WIDTH / 2 - FLOOR_HALF_W, FLOOR_Y - FLOOR_HALF_H),
134            (FLOOR_HALF_W * 2, FLOOR_HALF_H * 2),
135            colour=(0.4, 0.4, 0.45, 1.0),
136            filled=True,
137        )
138
139        # Ball.
140        bp = self._ball.world_position
141        renderer.draw_circle((bp.x, bp.y), BALL_RADIUS, colour=(0.95, 0.45, 0.3, 1.0), filled=True)
142
143        # Box (axis-aligned; the demo bodies stay upright as they drop).
144        xp = self._box.world_position
145        renderer.draw_rect(
146            (xp.x - BOX_HALF, xp.y - BOX_HALF),
147            (BOX_HALF * 2, BOX_HALF * 2),
148            colour=(0.4, 0.7, 0.95, 1.0),
149            filled=True,
150        )
151
152        # HUD.
153        renderer.draw_text("Collision Shapes", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
154        renderer.draw_text("R / click: reset   ESC: quit", (10, 40), colour=(0.75, 0.75, 0.75))
155
156
157def _selftest() -> bool:
158    """Headless: drop both bodies, check where they came to rest, then reset with the real R key."""
159    from simvx.core.testing import InputSimulator
160    from simvx.graphics.testing import assert_not_blank, save_png
161
162    SETTLED = 150  # both bodies down and at rest
163    RESET = 160  # the R key, through the action map
164    RESET_SEEN = 162
165    RESETTLED = 300
166
167    app = App(title="CollisionShapes", width=WIDTH, height=HEIGHT, visible=False)
168    scene = CollisionShapesDemo(name="CollisionShapesDemo")
169    sim = InputSimulator()
170    seen: dict[str, object] = {}
171
172    def sample() -> dict[str, tuple[float, float]]:
173        return {
174            "ball": (float(scene._ball.world_position.x), float(scene._ball.world_position.y)),
175            "box": (float(scene._box.world_position.x), float(scene._box.world_position.y)),
176            "floor": (float(scene._root.node_at("Floor").world_position.y), 0.0),
177        }
178
179    def on_frame(idx: int, _t: float) -> bool:
180        if idx == SETTLED:
181            seen["settled"] = sample()
182        elif idx == RESET:
183            sim.press_key(Key.R)
184        elif idx == RESET + 1:
185            sim.release_key(Key.R)
186        elif idx == RESET_SEEN:
187            seen["reset"] = sample()
188        elif idx == RESETTLED:
189            seen["resettled"] = sample()
190        return True
191
192    frames = app.run_headless(scene, frames=320, on_frame=on_frame, capture_frames=[319])
193    assert_not_blank(frames[0])
194    save_png(frames[0], "/tmp/collision_shapes_test.png")
195
196    ok = True
197
198    def check(label: str, passed: bool, detail: str) -> None:
199        nonlocal ok
200        ok = ok and passed
201        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
202
203    # Each shape stops with its own extent clear of the floor's top face, which is
204    # the whole claim: the world resolved a circle and a box against the same plane
205    # and neither sank into it nor tunnelled through at this gravity.
206    settled = seen["settled"]
207    ball_x, ball_y = settled["ball"]
208    box_x, box_y = settled["box"]
209    check(
210        "the circle rests one radius above the floor",
211        abs(ball_y - (FLOOR_TOP - BALL_RADIUS)) < 1.5,
212        f"y={ball_y:.2f} (floor top {FLOOR_TOP:.0f} - r {BALL_RADIUS:.0f})",
213    )
214    check(
215        "the box rests one half-extent above the floor",
216        abs(box_y - (FLOOR_TOP - BOX_HALF)) < 1.5,
217        f"y={box_y:.2f} (floor top {FLOOR_TOP:.0f} - half {BOX_HALF:.0f})",
218    )
219
220    # The box got its collider from a CollisionShape2D child rather than `shape=`;
221    # it landed at all, so that second attachment style reached the world too.
222    check(
223        "both bodies fell straight down",
224        abs(ball_x - BALL_DROP.x) < 1.0 and abs(box_x - BOX_DROP.x) < 1.0,
225        f"ball x={ball_x:.1f} box x={box_x:.1f}",
226    )
227    check(
228        "the static floor did not move under them",
229        abs(settled["floor"][0] - FLOOR_Y) < 0.01,
230        f"y={settled['floor'][0]:.2f}",
231    )
232
233    # R is bound to an action, so this goes through the same edge the player's
234    # keypress does rather than calling _reset directly.
235    reset = seen["reset"]
236    check(
237        "R puts both bodies back at their drop heights",
238        abs(reset["ball"][1] - BALL_DROP.y) < 30.0 and abs(reset["box"][1] - BOX_DROP.y) < 30.0,
239        f"ball y={reset['ball'][1]:.1f} box y={reset['box'][1]:.1f}",
240    )
241    check(
242        "and they fall and settle again",
243        abs(seen["resettled"]["ball"][1] - ball_y) < 1.5 and abs(seen["resettled"]["box"][1] - box_y) < 1.5,
244        f"ball y={seen['resettled']['ball'][1]:.2f} box y={seen['resettled']['box'][1]:.2f}",
245    )
246
247    print("screenshot: /tmp/collision_shapes_test.png")
248    print("SELFTEST:", "PASS" if ok else "FAIL")
249    return ok
250
251
252if __name__ == "__main__":
253    import sys
254
255    if "--test" in sys.argv:
256        sys.exit(0 if _selftest() else 1)
257    App(title="2D Collision Shapes", width=WIDTH, height=HEIGHT).run(CollisionShapesDemo())