Game feel¶
composing shake, hitstop, punch, flash and particles
▶ Run in browserTags: 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 scaledwait()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())