Game feel

composing shake, hitstop, punch, flash and particles

▶ Run in browser

Tags: 2d juice camera coroutines particles

A target sits mid-screen; SPACE hits it. Each hit stacks five “juice” ingredients: camera shake, hitstop, a damped-sine punch on the target, a white flash, and a particle burst. Keys 1-5 toggle each ingredient so its individual contribution can be felt in isolation.

Why each ingredient works

  • Camera shake (Camera2D.shake): jolting the whole view sells the impact’s energy to the eye without moving anything in the scene.

  • Hitstop (App.time_scale): freezing time for a tenth of a second makes the frame of contact linger, which reads as weight. The restore timer runs on the wall clock: at a near-zero time scale coroutine dt is scaled to ~0, so a scaled wait() would never complete.

  • Punch (punch_position / punch_rotation): a damped-sine recoil on the target itself says the hit landed on THIS object, not on the camera.

  • White flash: a one-frame overlay marks the exact instant of contact, the visual equivalent of a click.

  • Particles: debris carries the energy outward and leaves an aftermath, so the hit has consequences beyond the frame it happened on.

Controls: SPACE - hit the target 1..5 - toggle shake / hitstop / punch / flash / particles ESC - quit

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

Source

  1"""Game feel: composing shake, hitstop, punch, flash and particles
  2
  3A target sits mid-screen; SPACE hits it. Each hit stacks five "juice"
  4ingredients: camera shake, hitstop, a damped-sine punch on the target,
  5a white flash, and a particle burst. Keys 1-5 toggle each ingredient
  6so its individual contribution can be felt in isolation.
  7
  8# /// simvx
  9# tags = ["2d", "juice", "camera", "coroutines", "particles"]
 10# web = { root = "JuiceDemo", width = 960, height = 540, responsive = true }
 11# ///
 12
 13## Why each ingredient works
 14
 15- Camera shake (`Camera2D.shake`): jolting the whole view sells the impact's
 16  energy to the eye without moving anything in the scene.
 17- Hitstop (`App.time_scale`): freezing time for a tenth of a second makes the
 18  frame of contact linger, which reads as weight. The restore timer runs on
 19  the wall clock: at a near-zero time scale coroutine dt is scaled to ~0, so
 20  a scaled `wait()` would never complete.
 21- Punch (`punch_position` / `punch_rotation`): a damped-sine recoil on the
 22  target itself says the hit landed on THIS object, not on the camera.
 23- White flash: a one-frame overlay marks the exact instant of contact, the
 24  visual equivalent of a click.
 25- Particles: debris carries the energy outward and leaves an aftermath, so
 26  the hit has consequences beyond the frame it happened on.
 27
 28Controls:
 29  SPACE - hit the target
 30  1..5  - toggle shake / hitstop / punch / flash / particles
 31  ESC   - quit
 32
 33Run: uv run python examples/features/2d/juice.py
 34Headless self-check: uv run python examples/features/2d/juice.py --test
 35"""
 36
 37import math
 38import random
 39import time
 40
 41import numpy as np
 42
 43from simvx.core import Camera2D, Input, Key, Node2D, Sprite2D, Vec2, punch_position, punch_rotation
 44from simvx.graphics import App
 45
 46WIDTH, HEIGHT = 960, 540
 47TARGET_SIZE = 96
 48
 49HITSTOP_SCALE = 0.05  # time scale during the freeze (0.05 = near-frozen slow-mo)
 50HITSTOP_SECONDS = 0.12  # wall-clock length of the freeze
 51FLASH_DECAY = 6.0  # flash alpha lost per (scaled) second
 52BURST_COUNT = 26
 53
 54#: The five ingredients, in the order the number keys toggle them.
 55INGREDIENTS = ("shake", "hitstop", "punch", "flash", "particles")
 56
 57
 58def _target_texture(size: int = TARGET_SIZE) -> np.ndarray:
 59    """A concentric-ring target board as an RGBA array (no asset files)."""
 60    yy, xx = np.mgrid[0:size, 0:size]
 61    r = np.hypot(xx - (size - 1) / 2, yy - (size - 1) / 2) / (size / 2)
 62    img = np.zeros((size, size, 4), np.uint8)
 63    rings = [(1.0, (209, 74, 74)), (0.74, (238, 233, 222)), (0.48, (209, 74, 74)), (0.22, (238, 233, 222))]
 64    for radius, colour in rings:
 65        mask = r <= radius
 66        img[mask, 0], img[mask, 1], img[mask, 2] = colour
 67        img[mask, 3] = 255
 68    return img
 69
 70
 71class JuiceDemo(Node2D):
 72    """Hit a target and layer the five classic game-feel ingredients."""
 73
 74    dynamic = True  # particles, flash and shake animate every frame
 75
 76    input_actions = {
 77        "hit": [Key.SPACE],
 78        "toggle_1": [Key.KEY_1],
 79        "toggle_2": [Key.KEY_2],
 80        "toggle_3": [Key.KEY_3],
 81        "toggle_4": [Key.KEY_4],
 82        "toggle_5": [Key.KEY_5],
 83        "quit": [Key.ESCAPE],
 84    }
 85
 86    def on_ready(self):
 87        self._on = dict.fromkeys(INGREDIENTS, True)
 88        self._hits = 0
 89        self._flash = 0.0  # 1.0 right after a hit, decays to 0
 90        self._particles: list[list[float]] = []  # [x, y, vx, vy, life, max_life]
 91        self._hitstop_handle = None
 92        self._punch_handles: list = []
 93
 94        # World origin sits at the screen centre once a camera is active.
 95        self._camera = self.add_child(Camera2D())
 96        self._target = self.add_child(Sprite2D(texture=_target_texture(), position=Vec2(0, 0)))
 97
 98    # -- the hit -------------------------------------------------------------
 99
100    def _hit(self):
101        self._hits += 1
102        if self._on["shake"]:
103            self._camera.shake(10.0, 0.3)
104        if self._on["hitstop"]:
105            if self._hitstop_handle is not None:
106                self.stop_coroutine(self._hitstop_handle)
107            self._hitstop_handle = self.start_coroutine(self._hitstop_co(HITSTOP_SCALE, HITSTOP_SECONDS))
108        if self._on["punch"]:
109            # Stopping a punch runs its cleanup, which snaps the attribute back
110            # to its starting value, so re-triggering never accumulates drift.
111            for handle in self._punch_handles:
112                self.stop_coroutine(handle)
113            self._punch_handles = [
114                self.start_coroutine(punch_position(self._target, Vec2(16, 10), 0.3)),
115                self.start_coroutine(punch_rotation(self._target, 0.18, 0.35)),
116            ]
117        if self._on["flash"]:
118            self._flash = 1.0
119        if self._on["particles"]:
120            self._spawn_burst()
121
122    def _hitstop_co(self, scale: float, duration: float):
123        # Coroutines keep ticking while time_scale is low, but the dt they
124        # receive is the SCALED dt: at a near-zero scale it is ~0, so the
125        # freeze must time itself on the wall clock or it would never lift.
126        self.app.time_scale = scale
127        end = time.perf_counter() + duration
128        try:
129            while time.perf_counter() < end:
130                yield
131        finally:
132            self.app.time_scale = 1.0
133            self._hitstop_handle = None
134
135    def _spawn_burst(self):
136        base = self._target.position
137        for _ in range(BURST_COUNT):
138            angle = random.uniform(0.0, math.tau)
139            speed = random.uniform(180.0, 420.0)
140            life = random.uniform(0.35, 0.7)
141            self._particles.append([base.x, base.y, math.cos(angle) * speed, math.sin(angle) * speed, life, life])
142
143    # -- per-frame -----------------------------------------------------------
144
145    def on_update(self, dt: float):
146        if Input.is_action_just_pressed("quit"):
147            self.app.quit()
148            return
149        if Input.is_action_just_pressed("hit"):
150            self._hit()
151        for i, name in enumerate(INGREDIENTS, start=1):
152            if Input.is_action_just_pressed(f"toggle_{i}"):
153                self._on[name] = not self._on[name]
154
155        # dt here is the scaled dt, so the flash holds and the debris hangs
156        # mid-air during hitstop; the ingredients compose for free.
157        self._flash = max(0.0, self._flash - FLASH_DECAY * dt)
158        drag = math.exp(-2.2 * dt)
159        alive = []
160        for p in self._particles:
161            p[4] -= dt
162            if p[4] <= 0.0:
163                continue
164            p[2] *= drag
165            p[3] = p[3] * drag + 640.0 * dt  # gravity (y grows downward)
166            p[0] += p[2] * dt
167            p[1] += p[3] * dt
168            alive.append(p)
169        self._particles = alive
170
171    def on_draw(self, renderer):
172        # A dot grid in world space, so camera shake has something to read against.
173        for gx in range(-560, 561, 80):
174            for gy in range(-320, 321, 80):
175                renderer.draw_circle((gx, gy), 2, colour=(0.28, 0.28, 0.34, 1.0), filled=True)
176
177        # Debris: warm sparks that shrink and fade as their life runs out.
178        for x, y, _vx, _vy, life, max_life in self._particles:
179            t = life / max_life
180            renderer.draw_circle((x, y), 1.5 + 4.0 * t, colour=(1.0, 0.78, 0.32, t), filled=True)
181
182        # The flash: a screen-space overlay, unaffected by the shaking camera.
183        if self._flash > 0.0:
184            colour = (1.0, 1.0, 1.0, 0.55 * self._flash)
185            renderer.draw_rect((0, 0), (self.app.width, self.app.height), colour=colour, filled=True, screen_space=True)
186
187        # HUD (screen-space, so it stays legible while the world shakes).
188        renderer.draw_text(
189            "Game feel: SPACE hits the target", (16, 12), colour=(1.0, 1.0, 1.0, 1.0), scale=2, screen_space=True
190        )
191        for i, name in enumerate(INGREDIENTS, start=1):
192            on = self._on[name]
193            colour = (0.65, 0.92, 0.65, 1.0) if on else (0.48, 0.48, 0.48, 1.0)
194            label = f"{i}  {name}: {'on' if on else 'off'}"
195            renderer.draw_text(label, (16, 34 + 20 * i), colour=colour, screen_space=True)
196        renderer.draw_text(
197            f"hits: {self._hits}   time scale: {self.app.time_scale:.2f}",
198            (16, HEIGHT - 52),
199            colour=(0.75, 0.75, 0.75, 1.0),
200            screen_space=True,
201        )
202        renderer.draw_text("ESC: quit", (16, HEIGHT - 28), colour=(0.6, 0.6, 0.6, 1.0), screen_space=True)
203
204
205def _selftest() -> bool:
206    """Headless: hit the target, watch every ingredient rise and fully restore.
207
208    The hitstop check is the interesting one: the freeze timer must run on the
209    wall clock, so the pass condition is that ``App.time_scale`` drops on the
210    hit and is back at 1.0 by the end EVEN THOUGH the scaled dt during the
211    freeze is ~0. Phase two toggles all five ingredients off and hits again,
212    proving each toggle really disconnects its ingredient.
213    """
214    from simvx.core.testing import InputSimulator
215    from simvx.graphics.testing import assert_not_blank, save_png
216
217    app = App(title="Juice", width=WIDTH, height=HEIGHT, visible=False)
218    scene = JuiceDemo(name="JuiceDemo")
219    sim = InputSimulator()
220
221    st = {
222        "phase": 0,  # 0 warm-up, 1 first hit settling, 2 toggling off, 3 second hit, 4 done
223        "base_pos": None,
224        "base_rot": 0.0,
225        "settled_at": None,
226        "script": [],  # (frame, fn) pairs still to run
227        "min_ts": 1.0,
228        "max_flash": 0.0,
229        "max_particles": 0,
230        "max_offset": 0.0,
231        "phase3_clean": True,
232        "phase3_frames": 0,
233        "done": False,
234    }
235
236    def tap(frame: int, key) -> list:
237        return [(frame, lambda: sim.press_key(key)), (frame + 1, lambda: sim.release_key(key))]
238
239    def on_frame(idx: int, _t: float) -> bool:
240        for frame, fn in list(st["script"]):
241            if idx >= frame:
242                fn()
243                st["script"].remove((frame, fn))
244
245        if st["phase"] == 0 and idx == 4:
246            st["base_pos"] = Vec2(scene._target.position)
247            st["base_rot"] = float(scene._target.rotation)
248            st["script"] += tap(5, Key.SPACE)
249            st["phase"] = 1
250        elif st["phase"] == 1 and idx >= 6:
251            st["min_ts"] = min(st["min_ts"], app.time_scale)
252            st["max_flash"] = max(st["max_flash"], scene._flash)
253            st["max_particles"] = max(st["max_particles"], len(scene._particles))
254            offset = scene._target.position - st["base_pos"]
255            st["max_offset"] = max(st["max_offset"], math.hypot(offset.x, offset.y))
256            # Only count as settled once the hit has visibly landed (min_ts dropped).
257            settled = st["min_ts"] < 1.0 and app.time_scale == 1.0 and scene._flash == 0.0 and not scene._particles
258            if settled and st["settled_at"] is None:
259                st["settled_at"] = idx
260            # 90 settled frames of scale-1 time is well past both punch durations.
261            if st["settled_at"] is not None and idx >= st["settled_at"] + 90:
262                frame = idx + 1
263                for key in (Key.KEY_1, Key.KEY_2, Key.KEY_3, Key.KEY_4, Key.KEY_5):
264                    st["script"] += tap(frame, key)
265                    frame += 2
266                st["script"] += tap(frame + 2, Key.SPACE)
267                st["hit2_at"] = frame + 3
268                st["phase"] = 3
269        elif st["phase"] == 3 and idx > st["hit2_at"]:
270            clean = (
271                app.time_scale == 1.0
272                and scene._flash == 0.0
273                and not scene._particles
274                and math.hypot(*(scene._target.position - st["base_pos"])) < 1e-3
275            )
276            st["phase3_clean"] = st["phase3_clean"] and clean
277            st["phase3_frames"] += 1
278            if st["phase3_frames"] >= 120:
279                st["done"] = True
280                return False
281        return True
282
283    frames = app.run_headless(scene, frames=2000, on_frame=on_frame, capture_frames=[7])
284    assert_not_blank(frames[0])
285    save_png(frames[0], "/tmp/juice_test.png")
286
287    ok = True
288
289    def check(label: str, passed: bool, detail: str) -> None:
290        nonlocal ok
291        ok = ok and passed
292        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
293
294    check("the run completed both phases", st["done"], f"phase {st['phase']}")
295    check("hits landed in both phases", scene._hits == 2, f"{scene._hits} hits")
296    check("hitstop dropped the time scale", st["min_ts"] <= HITSTOP_SCALE + 1e-6, f"min {st['min_ts']:.3f}")
297    check("the wall-clock timer lifted the freeze", app.time_scale == 1.0, f"time_scale {app.time_scale}")
298    check("the flash fired and decayed", st["max_flash"] > 0.5 and scene._flash == 0.0, f"peak {st['max_flash']:.2f}")
299    check(
300        "particles burst and died out",
301        st["max_particles"] >= BURST_COUNT and not scene._particles,
302        f"peak {st['max_particles']}, {len(scene._particles)} left",
303    )
304    check("the punch displaced the target", st["max_offset"] > 2.0, f"peak offset {st['max_offset']:.1f}px")
305    if st["base_pos"] is not None:
306        drift = math.hypot(*(scene._target.position - st["base_pos"]))
307        rot = abs(float(scene._target.rotation) - st["base_rot"])
308        restored = drift < 1e-3 and rot < 1e-3
309        check("the punch restored the target exactly", restored, f"drift {drift:.4f}px, {rot:.4f}rad")
310    check("keys 1-5 switched every ingredient off", not any(scene._on.values()), f"{scene._on}")
311    check("a hit with everything off changes nothing", st["phase3_clean"], "phase-3 frames all clean")
312
313    print("screenshot: /tmp/juice_test.png")
314    print("SELFTEST:", "PASS" if ok else "FAIL")
315    return ok
316
317
318if __name__ == "__main__":
319    import sys
320
321    if "--test" in sys.argv:
322        sys.exit(0 if _selftest() else 1)
323    App(title="Game Feel: Juice", width=WIDTH, height=HEIGHT).run(JuiceDemo())