Physics Sandbox

stack-toppling cubes and bouncy balls in a walled 3D arena.

▶ Run in browser

Tags: 3d physics rigid-body

A pyramid of cubes and a handful of spheres tumble inside a walled arena. Spawn extra balls with a random impulse and watch the pile react, while a live counter tracks how many bodies the world is simulating.

Engine features showcased: - A PhysicsRoot owning one isolated PhysicsWorld (per-scene gravity and body_count) - PhysicsBody3D in DYNAMIC mode for the cubes and spheres, STATIC for the ground and the walls - PhysicsMaterial tuning friction and restitution per object - Impulse-driven interaction via PhysicsBody3D.push - Instant scene reset via SceneTree.change_scene

Controls: SPACE / click / tap : Spawn a new ball with random impulse R : Reset the scene ESC : Quit

Run with: uv run python examples/demos/physics_sandbox.py

Source

  1#!/usr/bin/env python3
  2"""Physics Sandbox: stack-toppling cubes and bouncy balls in a walled 3D arena.
  3
  4# /// simvx
  5# tags = ["3d", "physics", "rigid-body"]
  6# web = { width = 1280, height = 720, root = "PhysicsSandbox" }
  7# ///
  8
  9A pyramid of cubes and a handful of spheres tumble inside a walled arena.
 10Spawn extra balls with a random impulse and watch the pile react, while a live
 11counter tracks how many bodies the world is simulating.
 12
 13Engine features showcased:
 14    - A ``PhysicsRoot`` owning one isolated ``PhysicsWorld`` (per-scene gravity
 15      and ``body_count``)
 16    - ``PhysicsBody3D`` in DYNAMIC mode for the cubes and spheres, STATIC for
 17      the ground and the walls
 18    - ``PhysicsMaterial`` tuning friction and restitution per object
 19    - Impulse-driven interaction via ``PhysicsBody3D.push``
 20    - Instant scene reset via ``SceneTree.change_scene``
 21
 22Controls:
 23    SPACE / click / tap : Spawn a new ball with random impulse
 24    R                   : Reset the scene
 25    ESC                 : Quit
 26
 27Run with:
 28    uv run python examples/demos/physics_sandbox.py
 29"""
 30
 31import random
 32
 33from simvx.core import (
 34    BodyMode,
 35    BoxShape3D,
 36    Camera3D,
 37    Input,
 38    Key,
 39    Material,
 40    Mesh,
 41    MeshInstance3D,
 42    MouseButton,
 43    Node,
 44    PhysicsBody3D,
 45    PhysicsMaterial,
 46    PhysicsRoot,
 47    SphereShape3D,
 48    Text2D,
 49    Vec3,
 50)
 51from simvx.graphics import App
 52
 53# ============================================================================
 54# Physics-aware mesh node helpers
 55# ============================================================================
 56
 57
 58class PhysicsCube(PhysicsBody3D):
 59    """A falling cube with physics (DYNAMIC PhysicsBody3D)."""
 60
 61    def __init__(self, size: float = 1.0, colour: tuple = (0.8, 0.3, 0.2, 1.0), **kwargs):
 62        half = size / 2
 63        super().__init__(
 64            mode=BodyMode.DYNAMIC,
 65            shape=BoxShape3D(half_extents=Vec3(half, half, half)),
 66            material=PhysicsMaterial(friction=0.6, restitution=0.3),
 67            **kwargs,
 68        )
 69        self._size = size
 70        self._colour = colour
 71
 72    def on_ready(self):
 73        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 74        mesh_node.mesh = Mesh.cube(size=self._size)
 75        mesh_node.material = Material(colour=self._colour)
 76
 77
 78class PhysicsBall(PhysicsBody3D):
 79    """A bouncing sphere with physics (DYNAMIC PhysicsBody3D)."""
 80
 81    def __init__(self, radius: float = 0.5, colour: tuple = (0.2, 0.6, 0.9, 1.0), **kwargs):
 82        super().__init__(
 83            mode=BodyMode.DYNAMIC,
 84            shape=SphereShape3D(radius=radius),
 85            material=PhysicsMaterial(friction=0.3, restitution=0.8),
 86            **kwargs,
 87        )
 88        self._radius = radius
 89        self._colour = colour
 90
 91    def on_ready(self):
 92        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
 93        mesh_node.mesh = Mesh.sphere(radius=self._radius)
 94        mesh_node.material = Material(colour=self._colour)
 95
 96
 97class Ground(PhysicsBody3D):
 98    """Static ground plane (STATIC PhysicsBody3D)."""
 99
100    def __init__(self, **kwargs):
101        super().__init__(
102            mode=BodyMode.STATIC,
103            shape=BoxShape3D(half_extents=Vec3(25, 0.5, 25)),
104            material=PhysicsMaterial(friction=0.8, restitution=0.5),
105            **kwargs,
106        )
107
108    def on_ready(self):
109        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
110        mesh_node.mesh = Mesh.cube(size=1)
111        mesh_node.material = Material(colour=(0.4, 0.5, 0.4, 1.0))
112        mesh_node.scale = Vec3(50, 1, 50)
113
114
115class Wall(PhysicsBody3D):
116    """Static wall for containing objects (STATIC PhysicsBody3D)."""
117
118    def __init__(self, half_extents=(0.5, 5, 25), colour=(0.5, 0.5, 0.6, 0.5), **kwargs):
119        super().__init__(
120            mode=BodyMode.STATIC,
121            shape=BoxShape3D(half_extents=Vec3(*half_extents)),
122            material=PhysicsMaterial(friction=0.5, restitution=0.7),
123            **kwargs,
124        )
125        self._half_extents = half_extents
126        self._colour = colour
127
128    def on_ready(self):
129        mesh_node = self.add_child(MeshInstance3D(name="Mesh"))
130        mesh_node.mesh = Mesh.cube(size=1)
131        # The walls are translucent so the front one never hides the arena.
132        mesh_node.material = Material(colour=self._colour, blend="alpha")
133        he = self._half_extents
134        mesh_node.scale = Vec3(he[0] * 2, he[1] * 2, he[2] * 2)
135
136
137# ============================================================================
138# Main Scene
139# ============================================================================
140
141
142class PhysicsSandbox(Node):
143    """Main physics sandbox scene."""
144
145    input_actions = {
146        "spawn": [Key.SPACE, MouseButton.LEFT],
147        "reset": [Key.R],
148        "quit": [Key.ESCAPE],
149    }
150
151    def on_ready(self):
152        # One isolated physics world for the whole sandbox.
153        self._world_root = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -9.8, 0)))
154
155        # Camera
156        camera = self.add_child(Camera3D(name="Camera"))
157        camera.position = Vec3(0, 15, 25)
158        camera.look_at(Vec3(0, 3, 0))
159        camera.fov = 60.0
160
161        # Ground
162        self._world_root.add_child(Ground(name="Ground", position=Vec3(0, -0.5, 0)))
163
164        # Side walls
165        self._world_root.add_child(Wall(name="WallLeft", position=Vec3(-12, 5, 0), half_extents=(0.5, 5, 12)))
166        self._world_root.add_child(Wall(name="WallRight", position=Vec3(12, 5, 0), half_extents=(0.5, 5, 12)))
167        self._world_root.add_child(Wall(name="WallBack", position=Vec3(0, 5, -12), half_extents=(12, 5, 0.5)))
168        self._world_root.add_child(Wall(name="WallFront", position=Vec3(0, 5, 12), half_extents=(12, 5, 0.5)))
169
170        # Initial stack of cubes
171        colours = [
172            (0.9, 0.2, 0.2, 1),
173            (0.2, 0.9, 0.2, 1),
174            (0.2, 0.2, 0.9, 1),
175            (0.9, 0.9, 0.2, 1),
176            (0.9, 0.2, 0.9, 1),
177            (0.2, 0.9, 0.9, 1),
178        ]
179        for i in range(3):
180            for j in range(3 - i):
181                colour = colours[(i * 3 + j) % len(colours)]
182                self._world_root.add_child(
183                    PhysicsCube(
184                        name=f"Cube_{i}_{j}",
185                        position=Vec3(-2 + j * 1.5, 1.5 + i * 1.5, 0),
186                        size=1.2,
187                        colour=colour,
188                    )
189                )
190
191        # A couple of bouncy balls
192        for i in range(3):
193            x = -3 + i * 3
194            self._world_root.add_child(
195                PhysicsBall(
196                    name=f"Ball_{i}",
197                    position=Vec3(x, 8 + i * 2, 2),
198                    radius=0.6,
199                    colour=(0.1 + i * 0.3, 0.5, 0.9 - i * 0.2, 1),
200                )
201            )
202
203        # UI
204        self._spawn_count = 0
205        self.add_child(
206            Text2D(
207                name="Title",
208                text="Physics Sandbox",
209                position=(20, 20),
210                font_scale=2.0,
211                colour=(1.0, 1.0, 1.0, 1.0),
212            )
213        )
214        self._info_text = self.add_child(
215            Text2D(
216                name="Info",
217                text="SPACE / click: spawn ball | R: reset | ESC: quit",
218                position=(20, 60),
219                font_scale=1.0,
220                colour=(0.78, 0.78, 0.78, 1.0),
221            )
222        )
223        self._count_text = self.add_child(
224            Text2D(
225                name="Count",
226                text="Bodies: 0",
227                position=(20, 90),
228                font_scale=1.0,
229                colour=(0.71, 0.71, 0.71, 1.0),
230            )
231        )
232
233    def on_update(self, dt: float):
234        # Quit on ESC
235        if Input.is_action_just_pressed("quit"):
236            self.app.quit()
237            return
238
239        # Spawn ball on space or click/tap
240        if Input.is_action_just_pressed("spawn"):
241            self._spawn_ball()
242
243        # Reset on R
244        if Input.is_action_just_pressed("reset"):
245            self.tree.change_scene(PhysicsSandbox())
246            return
247
248        # Live body count from this scene's world.
249        self._count_text.text = f"Bodies: {self._world_root.world.body_count}"
250
251        # Clean up fallen objects
252        for child in list(self._world_root.children):
253            if isinstance(child, PhysicsBody3D) and child.position.y < -20:
254                child.destroy()
255
256    def _spawn_ball(self):
257        self._spawn_count += 1
258        colour = (random.random(), random.random(), random.random(), 1.0)
259        ball = self._world_root.add_child(
260            PhysicsBall(
261                name=f"SpawnBall_{self._spawn_count}",
262                position=Vec3(random.uniform(-5, 5), 12, random.uniform(-5, 5)),
263                radius=random.uniform(0.3, 0.8),
264                colour=colour,
265            )
266        )
267        # Random impulse
268        ball.push(
269            Vec3(
270                random.uniform(-5, 5),
271                random.uniform(0, 5),
272                random.uniform(-5, 5),
273            )
274        )
275
276
277# ============================================================================
278# Entry Point
279# ============================================================================
280
281
282if __name__ == "__main__":
283    App(title="Physics Sandbox: SimVX", width=1280, height=720, target_fps=60, physics_fps=60).run(PhysicsSandbox())