Character Push¶
a character shoves what it walks into, as hard as you say.
▶ Run in browserTags: physics 3d character
A character body is position-driven, so nothing about the collision itself moves
what it walks into. push_factor is the knob that turns the block into a shove,
and it ships ON at 1.0: walk into a crate and the crate goes. Godot’s character
body and Unity’s stock controller leave this to the game; Unreal pushes out of the
box, and so do we.
The impulse per blocking contact is push_factor * (M * m / (M + m)) * approach_speed, M being the character’s mass and m the mass of what
it hit. That middle term is the pair’s reduced mass, and times the approach speed
it is the arrest impulse of a perfectly inelastic collision – which is exactly
why 1.0 is safe to ship: one contact hands the crate M / (M + m) of your
approach speed and never more, so at the 1 kg character here the 1 kg crate leaves
its first contact at 2.00 m/s of a 4 m/s walk and the 200 kg one at 0.02 m/s. That
is a bound per contact, not a speed limit: catch a light crate up, hit it again,
and the kicks stack – the 1 kg one tops out at 5.15 m/s over the walk below. Set
the factor to 0 for the pure block; the floor under your feet is exempt at any
setting, so jumping onto a crate never drives it into the ground.
The character here weighs 1 kg rather than the 70 kg default, because what a crate
takes is M / (M + m) of the approach speed and a light character is what makes
three crates of different mass react three different ways.
Walk along the row and watch them: a second and a half of walking at the default sends the 1 kg crate 4.16 m, touching it on only 14 of those 90 frames because it skitters ahead and you have to catch it up; carries the 20 kg one 2.17 m over 44 frames of contact; and moves the 200 kg one 0.03 m while you lean on it for 76, which is a shrug. Drive into the wall instead and nothing happens at all, to the wall or to the character: an immovable body has no finite mass to share, so the push skips it.
The stack at the far end has been left alone long enough to fall asleep. Sleep is per island, so shoving the bottom crate wakes the whole stack rather than sliding one crate out from under two that stay put.
Shows:
CharacterBody3D.push_factorand.mass: the whole feature, and the factor’s1.0default.The knob read PER MOVE, not frozen at enter-tree: the +/- keys change it mid-walk, like a “carrying something heavy” state would.
Mass on both sides of the contact deciding the outcome.
Floor contacts exempt: standing on a crate imparts nothing to it.
collisions: the blocking contacts, still handed back, so a game can add physics of its own on top (here: a readout of what was hit last move).
Controls: W A S D - walk the character (arrows also work) Space - jump (land on a crate: standing on it shoves nothing) + / - - raise / lower push_factor (starts at the default, 1.0) 0 - push_factor = 0, the opt-out (pure block) R - rebuild the scene Escape - quit
Run: uv run python examples/features/physics/character_push.py Headless self-check: uv run python examples/features/physics/character_push.py –test
Source¶
1"""Character Push: a character shoves what it walks into, as hard as you say.
2
3A character body is position-driven, so nothing about the collision itself moves
4what it walks into. ``push_factor`` is the knob that turns the block into a shove,
5and it ships ON at ``1.0``: walk into a crate and the crate goes. Godot's character
6body and Unity's stock controller leave this to the game; Unreal pushes out of the
7box, and so do we.
8
9The impulse per blocking contact is ``push_factor * (M * m / (M + m)) *
10approach_speed``, ``M`` being the character's ``mass`` and ``m`` the mass of what
11it hit. That middle term is the pair's reduced mass, and times the approach speed
12it is the arrest impulse of a perfectly inelastic collision -- which is exactly
13why ``1.0`` is safe to ship: one contact hands the crate ``M / (M + m)`` of your
14approach speed and never more, so at the 1 kg character here the 1 kg crate leaves
15its first contact at 2.00 m/s of a 4 m/s walk and the 200 kg one at 0.02 m/s. That
16is a bound per contact, not a speed limit: catch a light crate up, hit it again,
17and the kicks stack -- the 1 kg one tops out at 5.15 m/s over the walk below. Set
18the factor to ``0`` for the pure block; the floor under your feet is exempt at any
19setting, so jumping onto a crate never drives it into the ground.
20
21The character here weighs 1 kg rather than the 70 kg default, because what a crate
22takes is ``M / (M + m)`` of the approach speed and a light character is what makes
23three crates of different mass react three different ways.
24
25Walk along the row and watch them: a second and a half of walking at the default
26sends the 1 kg crate 4.16 m, touching it on only 14 of those 90 frames because it
27skitters ahead and you have to catch it up; carries the 20 kg one 2.17 m over 44
28frames of contact; and moves the 200 kg one 0.03 m while you lean on it for 76,
29which is a shrug. Drive into the wall instead and nothing happens at all, to the
30wall or to the character: an immovable body has no finite mass to share, so the
31push skips it.
32
33The stack at the far end has been left alone long enough to fall asleep. Sleep is
34per island, so shoving the bottom crate wakes the whole stack rather than sliding
35one crate out from under two that stay put.
36
37Shows:
38 - ``CharacterBody3D.push_factor`` and ``.mass``: the whole feature, and the
39 factor's ``1.0`` default.
40 - The knob read PER MOVE, not frozen at enter-tree: the +/- keys change it
41 mid-walk, like a "carrying something heavy" state would.
42 - Mass on both sides of the contact deciding the outcome.
43 - Floor contacts exempt: standing on a crate imparts nothing to it.
44 - ``collisions``: the blocking contacts, still handed back, so a game can add
45 physics of its own on top (here: a readout of what was hit last move).
46
47Controls:
48 W A S D - walk the character (arrows also work)
49 Space - jump (land on a crate: standing on it shoves nothing)
50 + / - - raise / lower push_factor (starts at the default, 1.0)
51 0 - push_factor = 0, the opt-out (pure block)
52 R - rebuild the scene
53 Escape - quit
54
55Run: uv run python examples/features/physics/character_push.py
56Headless self-check: uv run python examples/features/physics/character_push.py --test
57
58# /// simvx
59# tags = ["3d", "physics", "character"]
60# ///
61"""
62
63from __future__ import annotations
64
65from simvx.core import (
66 BodyMode,
67 BoxShape3D,
68 Camera3D,
69 CharacterBody3D,
70 CollisionShape3D,
71 DirectionalLight3D,
72 Input,
73 InputMap,
74 Key,
75 Material,
76 Mesh,
77 MeshInstance3D,
78 Node,
79 PhysicsBody3D,
80 Text2D,
81 Vec3,
82)
83from simvx.graphics import App
84
85#: Walking speed, world units per second, and the take-off speed of a jump:
86#: enough to clear a 1 m crate with a little to spare.
87_SPEED = 4.0
88_JUMP_SPEED = 7.0
89#: How much one press of + or - moves the knob, and how far it may travel here.
90#: The range brackets the shipped default of 1.0 on both sides.
91_PUSH_STEP = 0.1
92_PUSH_MAX = 2.0
93#: The character's mass, in kg. Deliberately not the 70 kg default: the transfer
94#: is ``mass / (mass + crate mass)`` of the approach speed, so a light character
95#: is what makes three crates of different mass react three different ways.
96_CHAR_MASS = 1.0
97#: Half-extents of the character's collider, and the height its centre rides at.
98_CHAR_HALF = Vec3(0.35, 0.9, 0.35)
99#: The three crates, as (mass, z): identical in every way but their mass, and
100#: tinted darker as they get heavier so the row reads without the HUD.
101_CRATES = ((1.0, -2.5), (20.0, 0.0), (200.0, 2.5))
102_CRATE_HALF = 0.5
103_CRATE_X = 2.0
104#: Where the sleeping stack sits, and how many crates are in it.
105_STACK_X = 6.5
106_STACK_Z = -2.5
107_STACK_COUNT = 3
108#: The wall the character can lean on all day without either of them moving.
109_WALL_X = 6.5
110_WALL_Z = 2.5
111
112_GROUND = Material(colour=(0.20, 0.22, 0.26, 1.0), roughness=0.95)
113_WALL = Material(colour=(0.42, 0.40, 0.46, 1.0), roughness=0.9)
114_CRATE_TINTS = (
115 Material(colour=(0.95, 0.78, 0.30, 1.0), roughness=0.5),
116 Material(colour=(0.85, 0.48, 0.20, 1.0), roughness=0.6),
117 Material(colour=(0.55, 0.22, 0.16, 1.0), roughness=0.75),
118)
119#: Alternating tints down the stack, so three crates read as three and not as one column.
120_STACK = (
121 Material(colour=(0.35, 0.55, 0.80, 1.0), roughness=0.6),
122 Material(colour=(0.55, 0.70, 0.90, 1.0), roughness=0.6),
123)
124_CHARACTER = Material(colour=(0.45, 0.85, 0.55, 1.0), emissive_colour=(0.08, 0.25, 0.12, 0.6), roughness=0.4)
125
126
127class CharacterPushScene(Node):
128 def on_ready(self):
129 InputMap.add_action("walk_forward", [Key.W, Key.UP])
130 InputMap.add_action("walk_back", [Key.S, Key.DOWN])
131 InputMap.add_action("walk_left", [Key.A, Key.LEFT])
132 InputMap.add_action("walk_right", [Key.D, Key.RIGHT])
133 InputMap.add_action("jump", [Key.SPACE])
134 InputMap.add_action("push_up", [Key.EQUAL, Key.KP_ADD])
135 InputMap.add_action("push_down", [Key.MINUS, Key.KP_SUBTRACT])
136 InputMap.add_action("push_off", [Key.KEY_0])
137 InputMap.add_action("rebuild", [Key.R])
138 InputMap.add_action("quit", [Key.ESCAPE])
139
140 cam = self.add_child(Camera3D(position=(-3.5, 7.0, 9.5)))
141 cam.look_at((2.5, 0.5, 0.0))
142
143 sun = DirectionalLight3D(position=(6, 12, 8))
144 sun.colour = (1.0, 0.96, 0.85)
145 sun.intensity = 3.0
146 sun.look_at((0, 0, 0))
147 self.add_child(sun)
148 # A dim fill from the camera side: without it the faces turned toward the
149 # viewer are the ones the sun never reaches, and the wall reads as a hole.
150 fill = DirectionalLight3D(position=(-6, 6, 10))
151 fill.colour = (0.60, 0.68, 0.85)
152 fill.intensity = 1.0
153 fill.look_at((2, 0, 0))
154 self.add_child(fill)
155
156 self._cube = Mesh.cube()
157 self._props: list[PhysicsBody3D] = []
158 self._crates: list[PhysicsBody3D] = []
159 self._stack: list[PhysicsBody3D] = []
160 self._character: CharacterBody3D | None = None
161 self._push = float(CharacterBody3D.push_factor.default)
162 self._build()
163
164 self._hud = self.add_child(
165 Text2D(
166 text="WASD walk | +/- push_factor | 0 no push | R reset",
167 position=(10, 10),
168 font_scale=1.3,
169 )
170 )
171 self._status = self.add_child(Text2D(text="", position=(10, 34), font_scale=1.1))
172 self._note = self.add_child(Text2D(text="", position=(10, 58), font_scale=1.0))
173
174 # -- scene construction -------------------------------------------------
175
176 def _box(self, *, mode, position, half, material, mass=1.0, scale=None) -> PhysicsBody3D:
177 body = PhysicsBody3D(mode=mode, position=position, mass=mass)
178 body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=half)))
179 body.add_child(MeshInstance3D(mesh=self._cube, material=material, scale=scale or (half * 2)))
180 self.add_child(body)
181 self._props.append(body)
182 return body
183
184 def _build(self):
185 for body in self._props:
186 body.destroy()
187 if self._character is not None:
188 self._character.destroy()
189 self._props, self._crates, self._stack = [], [], []
190
191 self._box(
192 mode=BodyMode.STATIC,
193 position=(3.0, -0.5, 0.0),
194 half=Vec3(14.0, 0.5, 14.0),
195 material=_GROUND,
196 )
197 self._box(
198 mode=BodyMode.STATIC,
199 position=(_WALL_X, 1.5, _WALL_Z),
200 half=Vec3(0.5, 1.5, 3.0),
201 material=_WALL,
202 )
203 for tint, (mass, z) in zip(_CRATE_TINTS, _CRATES, strict=True):
204 crate = self._box(
205 mode=BodyMode.DYNAMIC,
206 position=(_CRATE_X, _CRATE_HALF, z),
207 half=Vec3(_CRATE_HALF, _CRATE_HALF, _CRATE_HALF),
208 material=tint,
209 mass=mass,
210 )
211 self._crates.append(crate)
212 for i in range(_STACK_COUNT):
213 self._stack.append(
214 self._box(
215 mode=BodyMode.DYNAMIC,
216 position=(_STACK_X, _CRATE_HALF + i * (_CRATE_HALF * 2 + 0.02), _STACK_Z),
217 half=Vec3(_CRATE_HALF, _CRATE_HALF, _CRATE_HALF),
218 material=_STACK[i % len(_STACK)],
219 mass=2.0,
220 )
221 )
222
223 character = CharacterBody3D(position=(-2.0, _CHAR_HALF.y, 0.0))
224 character.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=_CHAR_HALF)))
225 character.add_child(MeshInstance3D(mesh=self._cube, material=_CHARACTER, scale=_CHAR_HALF * 2))
226 character.push_factor = self._push
227 character.mass = _CHAR_MASS
228 self.add_child(character)
229 self._character = character
230 self._start_x = [float(c.world_position.x) for c in self._crates]
231
232 # -- frame --------------------------------------------------------------
233
234 def on_update(self, dt: float):
235 if Input.is_action_just_pressed("quit"):
236 self.app.quit()
237 return
238 if Input.is_action_just_pressed("rebuild"):
239 self._build()
240 return
241 if Input.is_action_just_pressed("push_up"):
242 self._push = min(_PUSH_MAX, self._push + _PUSH_STEP)
243 if Input.is_action_just_pressed("push_down"):
244 self._push = max(0.0, self._push - _PUSH_STEP)
245 if Input.is_action_just_pressed("push_off"):
246 self._push = 0.0
247
248 character = self._character
249 # Written every frame, not only when it changes: the knob is read per move,
250 # so this is all a "carrying something heavy" state would have to do.
251 character.push_factor = self._push
252
253 x = Input.get_strength("walk_right") - Input.get_strength("walk_left")
254 z = Input.get_strength("walk_back") - Input.get_strength("walk_forward")
255 # Gravity is the caller's job: move_and_slide never integrates it. Applied
256 # every frame, grounded or not, which is the usual character loop: standing
257 # on something therefore presses into it at 18 * dt every step, and the
258 # crate under your feet still does not sink, because a floor contact takes
259 # no push.
260 fall = (0.0 if character.is_on_floor() and character.velocity.y < 0.0 else character.velocity.y) - 18.0 * dt
261 if Input.is_action_just_pressed("jump") and character.is_on_floor():
262 fall = _JUMP_SPEED
263 character.velocity = Vec3(x * _SPEED, fall, z * _SPEED)
264 character.move_and_slide(dt)
265
266 travel = " ".join(
267 f"{mass:g}kg {float(c.world_position.x) - x0:+.2f}m"
268 for (mass, _), c, x0 in zip(_CRATES, self._crates, self._start_x, strict=True)
269 )
270 self._status.text = f"push_factor {self._push:.2f} crate travel: {travel}"
271 asleep = sum(1 for c in self._stack if c.is_sleeping)
272 hit = len(character.collisions)
273 self._note.text = f"stack asleep {asleep}/{len(self._stack)} | blocking contacts last move: {hit}"
274
275
276def _selftest() -> bool:
277 """Headless: the default shoves by mass, the opt-out shoves nothing, walls do not move."""
278 from simvx.graphics.testing import assert_not_blank, save_png
279
280 app = App(title="Character Push", width=1280, height=720, visible=False)
281 scene = CharacterPushScene(name="CharacterPushScene")
282 frames = app.run_headless(scene, frames=120, capture_frames=[119])
283 assert_not_blank(frames[0])
284 save_png(frames[0], "/tmp/physics_character_push_test.png")
285
286 world = scene._character.world
287 character = scene._character
288
289 def place(x: float, z: float) -> None:
290 """Teleport through the seam: nothing is stepping the tree in here, so a
291 node-level pose write would not reach the simulated body."""
292 pos, rot = world.body_transform(character.handle)
293 world.set_body_transform(character.handle, (Vec3(x, _CHAR_HALF.y, z), rot))
294
295 def walk_into(target_z: float, push_factor: float, steps: int = 90) -> None:
296 """Put the character behind the crate at ``target_z`` and drive it forwards."""
297 character.push_factor = push_factor
298 place(_CRATE_X - 1.8, target_z)
299 for _ in range(steps):
300 character.velocity = Vec3(_SPEED, 0.0, 0.0)
301 character.move_and_slide(1.0 / 60.0)
302 world.step(1.0 / 60.0)
303 world.drain_contact_events()
304 world.drain_overlap_events()
305
306 def crate_x(body) -> float:
307 """The simulated pose, not the node's: the tree is not syncing in here."""
308 return float(world.body_transform(body.handle)[0][0])
309
310 def crate_y(body) -> float:
311 return float(world.body_transform(body.handle)[0][1])
312
313 light, medium, heavy = scene._crates
314 start = [crate_x(c) for c in scene._crates]
315
316 # Standing on a crate, at the strongest push this example offers, before
317 # anything has been shoved: the contact under the feet is classified as floor
318 # and exempted, so a 6 m/s landing is not an impulse into the crate. Ends
319 # with the crate exactly where it began, so the shoves below are unaffected.
320 light_y0 = crate_y(light)
321 character.push_factor = _PUSH_MAX
322 pos, rot = world.body_transform(character.handle)
323 world.set_body_transform(character.handle, (Vec3(start[0], 3.0, _CRATES[0][1]), rot))
324 character.velocity = Vec3()
325 for _ in range(180):
326 # The scene's own loop: gravity every step, so a grounded character keeps
327 # pressing down at 18/60 m/s rather than going still.
328 grounded = character.is_on_floor() and character.velocity.y < 0.0
329 character.velocity = Vec3(0.0, (0.0 if grounded else character.velocity.y) - 18.0 / 60.0, 0.0)
330 character.move_and_slide(1.0 / 60.0)
331 world.step(1.0 / 60.0)
332 stood_moved = crate_y(light) - light_y0
333 print(f"landing on the 1 kg crate at push_factor {_PUSH_MAX:g} moved it {stood_moved:+.4f} m vertically")
334
335 walk_into(_CRATES[0][1], 0.0)
336 optout_travel = crate_x(light) - start[0]
337 print(f"push_factor 0.0 against the 1 kg crate: {optout_travel:+.4f} m")
338
339 # The default, against all three, so the order the intro claims is measured.
340 default_factor = float(CharacterBody3D.push_factor.default)
341 travel = []
342 for (mass, z), crate, x0 in zip(_CRATES, scene._crates, start, strict=True):
343 walk_into(z, default_factor)
344 travel.append(crate_x(crate) - x0)
345 print(f"push_factor {default_factor:g} against {mass:g} kg: {travel[-1]:+.4f} m")
346 light_travel, medium_travel, heavy_travel = travel
347
348 # The wall: same shove, into something with infinite mass.
349 wall = scene._props[1]
350 wall_x = crate_x(wall)
351 character.push_factor = default_factor
352 place(_WALL_X - 2.0, _WALL_Z)
353 for _ in range(180):
354 character.velocity = Vec3(_SPEED, 0.0, 0.0)
355 character.move_and_slide(1.0 / 60.0)
356 world.step(1.0 / 60.0)
357 wall_moved = crate_x(wall) - wall_x
358 print(f"walking into the wall for three seconds moved it {wall_moved:+.4f} m")
359
360 # The sleeping stack: one island, so shoving the bottom crate wakes all of it.
361 stack_asleep = sum(1 for c in scene._stack if c.is_sleeping)
362 top_x0 = crate_x(scene._stack[-1])
363 character.push_factor = default_factor
364 place(_STACK_X - 2.0, _STACK_Z)
365 for _ in range(180):
366 character.velocity = Vec3(_SPEED, 0.0, 0.0)
367 character.move_and_slide(1.0 / 60.0)
368 world.step(1.0 / 60.0)
369 top_moved = crate_x(scene._stack[-1]) - top_x0
370 print(f"stack asleep before the shove: {stack_asleep}/{len(scene._stack)}; top crate moved {top_moved:+.4f} m")
371
372 print("screenshot: /tmp/physics_character_push_test.png")
373
374 ok = (
375 abs(optout_travel) < 0.05 # push_factor 0 imparts nothing
376 and light_travel > 0.5 # the shipped default shoves
377 and heavy_travel < medium_travel < light_travel # and mass decides how far
378 and abs(wall_moved) < 1e-6 # an immovable body absorbs it
379 and stack_asleep == len(scene._stack) # the stack really had settled
380 and abs(top_moved) > 0.1 # and the whole island woke, not just the bottom crate
381 and abs(stood_moved) < 0.02 # standing on a body is not a shove
382 )
383 print("SELFTEST:", "PASS" if ok else "FAIL")
384 return ok
385
386
387if __name__ == "__main__":
388 import sys
389
390 if "--test" in sys.argv:
391 sys.exit(0 if _selftest() else 1)
392 app = App(title="Character Push", width=1280, height=720)
393 app.run(CharacterPushScene())