SNKRX¶
Wave-based arena, particles, screenshake, slow-motion, bloom.
▶ Run in browserUpstream: https://github.com/a327ex/SNKRX
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.
Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.
Tags: port tier-1
SNKRX (SimVX port)¶
SimVX port of a327ex/SNKRX, the snake auto-battler arena roguelite. Steer a snake made of your units around a walled arena: the units auto-attack whatever comes into range, enemies chase the head, and every cleared wave opens a shop where you add another unit to the tail. Five waves, with a boss on the last one.
Licensing: MIT, matching upstream. No upstream code, art or audio is reused:
every visual is drawn from primitives. See ATTRIBUTION.md and
UPSTREAM_LICENSE.md.
Run¶
simvx examples run ports_snkrx
simvx export web main.py -o snkrx.html
Headless smoke test (writes screenshots/frame_30.png and friends):
simvx examples run ports_snkrx --test
Scripted capture of a whole run (writes screenshots/stage_*.png):
Controls¶
Action |
Binding |
|---|---|
Aim the snake head |
Move the mouse, or drag on a touchscreen |
Rotate the snake head |
A / D, or Left / Right |
Start, restart, pick a shop card |
Enter / Space, or click |
Reroll the shop offers (2 gold) |
R, or click REROLL |
Skip the shop |
S, or click SKIP |
Quit |
Esc |
What this shows about SimVX¶
A pooled CPU particle system.
nodes/particles.pykeeps every particle in one flat numpy structured array, advances the whole buffer with vector operations, and draws the live slice as circles from a singleon_draw. Hit sparks, death bursts and muzzle flashes all come from that one pool, with no per-particle Node.Camera2D follow and shake. The arena camera targets the snake head with
smoothing=8.0, andcamera.shake(intensity=..., duration=...)fires on melee connects, kills and boss deaths. Aiming converts the cursor throughcamera.screen_to_world(...), so hit-testing and rendering agree under pan and shake.Slow motion from a scaled dt.
Arena.on_updatemultiplies gameplaydtbytime_scale, drops it to a floor on a big kill and ramps it back up. Spawn pacing keeps using wall-clockdt, so the wave schedule does not stretch with the effect.HDR bloom via WorldEnvironment. The root enables ACES tonemapping and bloom on a
WorldEnvironment, which is what gives the neon primitives their glow. The renderer is never touched directly.A screen-space HUD in a CanvasLayer. The HUD sits in a
CanvasLayerat layer 10, so it stays put while the camera moves, and it dirties its retained draw only when the state it shows actually changes.Signal-driven scene flow. Title, arena, shop and end screens are separate nodes swapped by the root; each announces what happened through a
Signal(finished,failed,chosen,skipped) rather than reaching into its parent.Actions registered in the root’s ready path.
InputMap.add_actioncalls live inSNKRXRoot.on_ready, not inmain(), becauseInputMapbelongs to a scene tree and none exists beforerun().
Upstream¶
The upstream Lua was used as a design reference only; all Python here was written from scratch.
Source files¶
File |
Summary |
Lines |
|---|---|---|
SNKRX: Wave-based arena, particles, screenshake, slow-motion, bloom. |
358 |
|
Scripted headless harness: drive the SNKRX port through a full run and |
103 |
|
SNKRX SimVX port: node modules. |
1 |
|
Arena round: snake vs enemy waves with juice (particles, screenshake, slow-mo). |
342 |
|
Build / upgrade screen: between rounds. |
234 |
|
SNKRX palette: neon-on-dark. |
24 |
|
Enemy types: chase the snake head, deal contact damage, drop XP on death. |
109 |
|
Arena HUD overlay: wave / kills / snake hp via on_draw. |
117 |
|
SNKRX title screen: neon block logo + start prompt. |
82 |
|
CPU 2D particle pool: SNKRX-style hit sparks, death bursts, projectile trails. |
162 |
|
Player projectiles: pierce shots from archers, AOE bursts from mages. |
88 |
|
Player snake: head plus a chain of follower units. |
277 |
Source¶
1#!/usr/bin/env python3
2# /// simvx
3# tags = ["port", "tier-1"]
4# upstream = "https://github.com/a327ex/SNKRX"
5# web = { width = 1280, height = 720, responsive = true }
6# ///
7"""SNKRX: Wave-based arena, particles, screenshake, slow-motion, bloom.
8
9A snake auto-battler arena roguelite: steer the snake head, your units
10auto-attack whatever comes into range, survive five waves and grow the build
11in the shop between rounds.
12
13The port exercises a pooled numpy particle system drawn from ``on_draw``,
14``Camera2D`` target-following plus ``shake()`` for impact feedback,
15slow-motion by scaling the gameplay ``dt``, HDR bloom and ACES tonemapping
16via ``WorldEnvironment``, a screen-space HUD in a ``CanvasLayer``, and
17signal-driven scene flow between title, arena, shop and end screens.
18
19Run interactively::
20
21 simvx examples run ports_snkrx
22
23Headless smoke test (captures frames 30 / 60 / 120)::
24
25 simvx examples run ports_snkrx --test
26
27Web export::
28
29 simvx export web main.py -o snkrx.html
30
31Controls
32--------
33- MOUSE / TOUCH: aim the snake head toward the cursor
34- A / D or LEFT / RIGHT: rotate the snake head
35- ENTER / SPACE or CLICK: start, restart, and pick a card in the shop
36- R: reroll the shop offers (costs 2 gold)
37- S: skip the shop
38- ESC: quit
39"""
40
41from __future__ import annotations
42
43import sys
44from pathlib import Path
45
46_PORT_DIR = Path(__file__).resolve().parent
47
48from nodes.arena import Arena
49from nodes.buy_screen import BuyScreen
50from nodes.colours import BG, FG, GREEN, RED, YELLOW
51from nodes.hud import HUD
52from nodes.menu import TitleScreen
53
54from simvx.core import CanvasLayer, Input, InputMap, Key, Mouse, Node, Signal
55from simvx.core.world_environment import WorldEnvironment
56from simvx.graphics import App
57
58WINDOW_W = 1280
59WINDOW_H = 720
60TOTAL_WAVES = 5
61
62
63# ---------------------------------------------------------------------------- root
64
65
66class SNKRXRoot(Node):
67 """Top-level scene: owns InputMap, the active sub-scene, and the WorldEnvironment.
68
69 Sub-scene swaps happen in-place via ``_set_phase`` so we don't tear down
70 the WorldEnvironment between rounds.
71 """
72
73 def __init__(self, **kwargs):
74 super().__init__(name="SNKRXRoot", **kwargs)
75 self.phase: str = "menu" # menu | arena | buy | gameover | victory
76 self.wave = 1
77 self.gold = 0
78 self.kills = 0
79 self.build: list[tuple[str, int]] = [
80 ("warrior", 1),
81 ("archer", 1),
82 ("mage", 1),
83 ]
84 self._sub: Node | None = None
85 self._mouse_aiming = False
86 # HUD lives in a CanvasLayer (layer=10) so it renders in screen-space,
87 # above the camera-transformed gameplay scene.
88 self._hud_layer = CanvasLayer(name="HUDLayer", layer=10)
89 self.add_child(self._hud_layer)
90 self._hud = HUD()
91 self._hud_layer.add_child(self._hud)
92
93 def on_ready(self):
94 # InputMap MUST live in on_ready: run_headless resets input state, so a
95 # --test mode loses anything registered before it.
96 InputMap.add_action("start", [Key.ENTER, Key.SPACE])
97 InputMap.add_action("left", [Key.A, Key.LEFT])
98 InputMap.add_action("right", [Key.D, Key.RIGHT])
99 InputMap.add_action("quit", [Key.ESCAPE])
100 InputMap.add_action("skip", [Key.S])
101 InputMap.add_action("reroll", [Key.R])
102
103 # Bloom + tonemap for the neon SNKRX feel.
104 # Sky is disabled so the 2D scene gets a true dark background.
105 env = self.add_child(WorldEnvironment())
106 env.sky_mode = "disabled"
107 env.tonemap_mode = "aces"
108 env.tonemap_exposure = 1.0
109 env.bloom_enabled = True
110 env.bloom_threshold = 0.85
111 env.bloom_intensity = 0.55
112 env.bloom_soft_knee = 0.4
113
114 self._enter_menu()
115
116 # ------------------------------------------------------------ phase swaps
117
118 def _set_phase(self, phase: str) -> None:
119 """Record the active phase and keep the HUD in step with it.
120
121 Phase changes can land mid-frame (an arena finishing inside its own
122 ``on_update``), so the HUD visibility has to move with the phase here
123 rather than being polled once per frame from ``on_update``.
124 """
125 self.phase = phase
126 self._hud.visible_hud = phase == "arena"
127
128 def _drop_sub(self):
129 if self._sub is not None:
130 self._sub.destroy()
131 self._sub = None
132
133 def _enter_menu(self):
134 self._set_phase("menu")
135 self._drop_sub()
136 self.wave = 1
137 self.gold = 0
138 self.kills = 0
139 self.build = [("warrior", 1), ("archer", 1), ("mage", 1)]
140 ts = TitleScreen()
141 ts.start.connect(self._enter_arena)
142 self.add_child(ts)
143 self._sub = ts
144
145 def _enter_arena(self):
146 self._set_phase("arena")
147 self._drop_sub()
148 arena = Arena(level=self.wave, build=self.build)
149 arena.finished.connect(self._on_arena_finished)
150 arena.failed.connect(self._on_arena_failed)
151 self.add_child(arena)
152 self._sub = arena
153
154 def _on_arena_finished(self, xp_gained: int, gold_gained: int):
155 self.gold += gold_gained
156 self.kills += getattr(self._sub, "kills", 0)
157 self.wave += 1
158 if self.wave > TOTAL_WAVES:
159 self._enter_victory()
160 else:
161 self._enter_buy()
162
163 def _on_arena_failed(self):
164 self._enter_gameover()
165
166 def _enter_buy(self):
167 self._set_phase("buy")
168 self._drop_sub()
169 bs = BuyScreen(level=self.wave - 1, gold=self.gold, build=self.build)
170 bs.chosen.connect(self._on_buy_chosen)
171 bs.skipped.connect(self._enter_arena)
172 self.add_child(bs)
173 self._sub = bs
174
175 def _on_buy_chosen(self, klass: str, lvl: int):
176 if len(self.build) < 8:
177 self.build.append((klass, lvl))
178 # Subtract gold from the BuyScreen's running total
179 self.gold = self._sub.gold if self._sub else self.gold
180 self._enter_arena()
181
182 def _enter_gameover(self):
183 self._set_phase("gameover")
184 self._drop_sub()
185 # Reuse TitleScreen-style overlay would be ideal, keep simple here
186 self._sub = _EndScreen(victory=False, wave=self.wave, kills=self.kills, gold=self.gold)
187 self._sub.start.connect(self._enter_menu)
188 self.add_child(self._sub)
189
190 def _enter_victory(self):
191 self._set_phase("victory")
192 self._drop_sub()
193 self._sub = _EndScreen(victory=True, wave=self.wave - 1, kills=self.kills, gold=self.gold)
194 self._sub.start.connect(self._enter_menu)
195 self.add_child(self._sub)
196
197 # ---------------------------------------------------------------- updates
198
199 def on_update(self, dt: float):
200 # Global ESC → quit. Easy to relocate to per-screen later.
201 if Input.just_pressed("quit"):
202 if self.app is not None:
203 self.app.quit()
204 return
205
206 # Arena phase: forward steering input + populate HUD
207 if self.phase == "arena" and isinstance(self._sub, Arena):
208 arena: Arena = self._sub # type: ignore[assignment]
209 # Mouse steering latches on the first cursor movement (or a click,
210 # which is how a touchscreen reports a drag) and releases again the
211 # moment a keyboard steering key is used.
212 if Input.pressed("left") or Input.pressed("right"):
213 self._mouse_aiming = False
214 elif Input.mouse_delta.length() > 0 or Input.is_mouse_button_pressed(Mouse.LEFT):
215 self._mouse_aiming = True
216
217 if self._mouse_aiming:
218 # ``Input.mouse_position`` is screen-space and the arena camera
219 # follows the snake head, so convert before aiming at a world point.
220 target = arena.camera.screen_to_world(Input.mouse_position, self.tree.screen_size)
221 arena.snake.aim_at(target)
222 else:
223 # Keyboard fallback steering
224 arena.snake.aim_at(None)
225 steer = 0.0
226 if Input.pressed("left"):
227 steer -= 1.0
228 if Input.pressed("right"):
229 steer += 1.0
230 arena.snake.steer(steer)
231
232 # HUD state
233 self._hud.set_state(
234 wave=self.wave,
235 total_waves=TOTAL_WAVES,
236 kills=arena.kills,
237 gold=self.gold + arena.gold_gained,
238 snake_hp=sum(u.hp for u in arena.snake.units if u.alive) if arena.snake.units else 0,
239 snake_max_hp=sum(u.max_hp for u in arena.snake.units) if arena.snake.units else 1,
240 time_scale=arena.time_scale,
241 )
242 else:
243 # Outside the arena the HUD is hidden by ``_set_phase``; reset its
244 # per-round counters so the next wave starts from a clean slate.
245 self._hud.set_state(
246 wave=self.wave,
247 total_waves=TOTAL_WAVES,
248 kills=0,
249 gold=self.gold,
250 snake_hp=0,
251 snake_max_hp=1,
252 time_scale=1.0,
253 )
254
255
256# ---------------------------------------------------------------- end screens
257
258
259class _EndScreen(Node):
260 """Game-over / victory scene with continue prompt."""
261
262 start = Signal()
263
264 # The continue prompt pulses every frame from ``self._t``, so ``on_draw``
265 # genuinely produces new geometry each frame: declare it dynamic.
266 dynamic = True
267
268 def __init__(self, *, victory: bool, wave: int, kills: int, gold: int, **kwargs):
269 super().__init__(name="EndScreen", **kwargs)
270 self.victory = victory
271 self.wave = wave
272 self.kills = kills
273 self.gold = gold
274 self._t = 0.0
275
276 def on_update(self, dt: float):
277 self._t += dt
278 # Keyboard or click/tap both restart, so the end screen is reachable
279 # for a mouse- or touch-only player.
280 if Input.just_pressed("start") or Input.is_mouse_button_just_pressed(Mouse.LEFT):
281 self.start.emit()
282
283 def on_draw(self, renderer):
284 w, h = self.viewport_size
285 w, h = int(w), int(h)
286 renderer.rect((0, 0), (w, h), fill=BG)
287 title = "VICTORY" if self.victory else "DEFEAT"
288 c = GREEN if self.victory else RED
289 tw = renderer.text_width(title, 12)
290 renderer.text(title, (w // 2 - tw // 2, int(h * 0.28)), fill=c, scale=12)
291
292 for i, line in enumerate(
293 [
294 f"WAVES SURVIVED {self.wave}",
295 f"KILLS {self.kills}",
296 f"GOLD {self.gold}",
297 ]
298 ):
299 lw = renderer.text_width(line, 3)
300 renderer.text(line, (w // 2 - lw // 2, int(h * 0.53) + i * 50), fill=FG, scale=3)
301
302 if int(self._t * 2) % 2 == 0:
303 prompt = "ENTER / CLICK RESTART"
304 pw = renderer.text_width(prompt, 3)
305 renderer.text(prompt, (w // 2 - pw // 2, int(h * 0.80)), fill=YELLOW, scale=3)
306
307
308# --------------------------------------------------------------------- entry
309
310
311def _capture(captures, frames):
312 out_dir = _PORT_DIR / "screenshots"
313 out_dir.mkdir(exist_ok=True)
314 from simvx.graphics import save_png
315
316 for idx, frame in zip(frames, captures, strict=False):
317 path = out_dir / f"frame_{idx}.png"
318 save_png(frame, path)
319 print(f"saved {path}")
320
321
322def _run_test():
323 """Headless smoke test: auto-advance into the arena; capture frames 30/60/120."""
324 from simvx.core import InputSimulator
325
326 app = App(title="SNKRX (test)", width=WINDOW_W, height=WINDOW_H, visible=False, bg_colour=BG)
327 root = SNKRXRoot()
328 sim = InputSimulator(root)
329
330 def _drive(idx, _t):
331 # Auto-press Enter after a few frames so capture frame 60 / 120 show
332 # actual arena gameplay rather than just the title screen.
333 if idx == 20:
334 sim.press_key(Key.ENTER)
335 if idx == 22:
336 sim.release_key(Key.ENTER)
337 return None
338
339 capture_frames = [30, 60, 120]
340 captures = app.run_headless(
341 root,
342 frames=130,
343 on_frame=_drive,
344 capture_frames=capture_frames,
345 )
346 _capture(captures, capture_frames)
347
348
349def main():
350 if "--test" in sys.argv:
351 _run_test()
352 return
353 app = App(title="SNKRX", width=WINDOW_W, height=WINDOW_H, bg_colour=BG)
354 app.run(SNKRXRoot())
355
356
357if __name__ == "__main__":
358 main()