Character Push

a character shoves what it walks into, as hard as you say.

▶ Run in browser

Tags: 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_factor and .mass: the whole feature, and the factor’s 1.0 default.

  • 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())