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¶
uv run python examples/ports/snkrx/main.py
uv run simvx export web examples/ports/snkrx/main.py -o /tmp/snkrx.html
Headless smoke test (writes screenshots/frame_30.png and friends):
uv run python examples/ports/snkrx/main.py --test
Scripted capture of a whole run (writes screenshots/stage_*.png):
uv run python examples/ports/snkrx/harness.py
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(), because the web export instantiates the root directly.
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. |
369 |
|
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). |
353 |
|
Build / upgrade screen: between rounds. |
247 |
|
SNKRX palette: neon-on-dark. |
24 |
|
Enemy types: chase the snake head, deal contact damage, drop XP on death. |
131 |
|
Arena HUD overlay: wave / kills / snake hp via on_draw. |
117 |
|
SNKRX title screen: neon block logo + start prompt. |
85 |
|
CPU 2D particle pool: SNKRX-style hit sparks, death bursts, projectile trails. |
168 |
|
Player projectiles: pierce shots from archers, AOE bursts from mages. |
104 |
|
Player snake: head plus a chain of follower units. |
295 |
Source¶
1#!/usr/bin/env python3
2"""SNKRX: Wave-based arena, particles, screenshake, slow-motion, bloom.
3
4# /// simvx
5# tags = ["port", "tier-1"]
6# upstream = "https://github.com/a327ex/SNKRX"
7# web = { width = 1280, height = 720, responsive = true }
8# ///
9
10A snake auto-battler arena roguelite: steer the snake head, your units
11auto-attack whatever comes into range, survive five waves and grow the build
12in the shop between rounds.
13
14The port exercises a pooled numpy particle system drawn from ``on_draw``,
15``Camera2D`` target-following plus ``shake()`` for impact feedback,
16slow-motion by scaling the gameplay ``dt``, HDR bloom and ACES tonemapping
17via ``WorldEnvironment``, a screen-space HUD in a ``CanvasLayer``, and
18signal-driven scene flow between title, arena, shop and end screens.
19
20Run interactively::
21
22 uv run python examples/ports/snkrx/main.py
23
24Headless smoke test (captures frames 30 / 60 / 120)::
25
26 uv run python examples/ports/snkrx/main.py --test
27
28Web export::
29
30 uv run simvx export web examples/ports/snkrx/main.py -o /tmp/snkrx.html
31
32Controls
33--------
34- MOUSE / TOUCH: aim the snake head toward the cursor
35- A / D or LEFT / RIGHT: rotate the snake head
36- ENTER / SPACE or CLICK: start, restart, and pick a card in the shop
37- R: reroll the shop offers (costs 2 gold)
38- S: skip the shop
39- ESC: quit
40"""
41
42from __future__ import annotations
43
44import sys
45from pathlib import Path
46
47# Make the port folder importable in --test and direct runs alike
48_PORT_DIR = Path(__file__).resolve().parent
49if str(_PORT_DIR) not in sys.path:
50 sys.path.insert(0, str(_PORT_DIR))
51
52from nodes.arena import Arena
53from nodes.buy_screen import BuyScreen
54from nodes.colours import BG, FG, GREEN, RED, YELLOW
55from nodes.hud import HUD
56from nodes.menu import TitleScreen
57
58from simvx.core import (
59 CanvasLayer,
60 Input,
61 InputMap,
62 Key,
63 MouseButton,
64 Node,
65 Signal,
66)
67from simvx.core.world_environment import WorldEnvironment
68from simvx.graphics import App
69
70WINDOW_W = 1280
71WINDOW_H = 720
72TOTAL_WAVES = 5
73
74
75# ---------------------------------------------------------------------------- root
76
77
78class SNKRXRoot(Node):
79 """Top-level scene: owns InputMap, the active sub-scene, and the WorldEnvironment.
80
81 Sub-scene swaps happen in-place via ``_set_phase`` so we don't tear down
82 the WorldEnvironment between rounds.
83 """
84
85 def __init__(self, **kwargs):
86 super().__init__(name="SNKRXRoot", **kwargs)
87 self.phase: str = "menu" # menu | arena | buy | gameover | victory
88 self.wave = 1
89 self.gold = 0
90 self.kills = 0
91 self.build: list[tuple[str, int]] = [
92 ("warrior", 1),
93 ("archer", 1),
94 ("mage", 1),
95 ]
96 self._sub: Node | None = None
97 self._mouse_aiming = False
98 # HUD lives in a CanvasLayer (layer=10) so it renders in screen-space,
99 # above the camera-transformed gameplay scene.
100 self._hud_layer = CanvasLayer(name="HUDLayer", layer=10)
101 self.add_child(self._hud_layer)
102 self._hud = HUD()
103 self._hud_layer.add_child(self._hud)
104
105 def on_ready(self):
106 # InputMap MUST live in on_ready (web exporter skips main()).
107 InputMap.add_action("start", [Key.ENTER, Key.SPACE])
108 InputMap.add_action("left", [Key.A, Key.LEFT])
109 InputMap.add_action("right", [Key.D, Key.RIGHT])
110 InputMap.add_action("quit", [Key.ESCAPE])
111 InputMap.add_action("skip", [Key.S])
112 InputMap.add_action("reroll", [Key.R])
113
114 # Bloom + tonemap for the neon SNKRX feel.
115 # Sky is disabled so the 2D scene gets a true dark background.
116 env = self.add_child(WorldEnvironment())
117 env.sky_mode = "disabled"
118 env.tonemap_mode = "aces"
119 env.tonemap_exposure = 1.0
120 env.bloom_enabled = True
121 env.bloom_threshold = 0.85
122 env.bloom_intensity = 0.55
123 env.bloom_soft_knee = 0.4
124
125 self._enter_menu()
126
127 # ------------------------------------------------------------ phase swaps
128
129 def _set_phase(self, phase: str) -> None:
130 """Record the active phase and keep the HUD in step with it.
131
132 Phase changes can land mid-frame (an arena finishing inside its own
133 ``on_update``), so the HUD visibility has to move with the phase here
134 rather than being polled once per frame from ``on_update``.
135 """
136 self.phase = phase
137 self._hud.visible_hud = phase == "arena"
138
139 def _drop_sub(self):
140 if self._sub is not None:
141 self._sub.destroy()
142 self._sub = None
143
144 def _enter_menu(self):
145 self._set_phase("menu")
146 self._drop_sub()
147 self.wave = 1
148 self.gold = 0
149 self.kills = 0
150 self.build = [("warrior", 1), ("archer", 1), ("mage", 1)]
151 ts = TitleScreen()
152 ts.start.connect(self._enter_arena)
153 self.add_child(ts)
154 self._sub = ts
155
156 def _enter_arena(self):
157 self._set_phase("arena")
158 self._drop_sub()
159 arena = Arena(level=self.wave, build=self.build)
160 arena.finished.connect(self._on_arena_finished)
161 arena.failed.connect(self._on_arena_failed)
162 self.add_child(arena)
163 self._sub = arena
164
165 def _on_arena_finished(self, xp_gained: int, gold_gained: int):
166 self.gold += gold_gained
167 self.kills += getattr(self._sub, "kills", 0)
168 self.wave += 1
169 if self.wave > TOTAL_WAVES:
170 self._enter_victory()
171 else:
172 self._enter_buy()
173
174 def _on_arena_failed(self):
175 self._enter_gameover()
176
177 def _enter_buy(self):
178 self._set_phase("buy")
179 self._drop_sub()
180 bs = BuyScreen(level=self.wave - 1, gold=self.gold, build=self.build)
181 bs.chosen.connect(self._on_buy_chosen)
182 bs.skipped.connect(self._enter_arena)
183 self.add_child(bs)
184 self._sub = bs
185
186 def _on_buy_chosen(self, klass: str, lvl: int):
187 if len(self.build) < 8:
188 self.build.append((klass, lvl))
189 # Subtract gold from the BuyScreen's running total
190 self.gold = self._sub.gold if self._sub else self.gold
191 self._enter_arena()
192
193 def _enter_gameover(self):
194 self._set_phase("gameover")
195 self._drop_sub()
196 # Reuse TitleScreen-style overlay would be ideal, keep simple here
197 self._sub = _EndScreen(victory=False, wave=self.wave, kills=self.kills, gold=self.gold)
198 self._sub.start.connect(self._enter_menu)
199 self.add_child(self._sub)
200
201 def _enter_victory(self):
202 self._set_phase("victory")
203 self._drop_sub()
204 self._sub = _EndScreen(victory=True, wave=self.wave - 1, kills=self.kills, gold=self.gold)
205 self._sub.start.connect(self._enter_menu)
206 self.add_child(self._sub)
207
208 # ---------------------------------------------------------------- updates
209
210 def on_update(self, dt: float):
211 # Global ESC → quit. Easy to relocate to per-screen later.
212 if Input.is_action_just_pressed("quit"):
213 if self.app is not None:
214 self.app.quit()
215 return
216
217 # Arena phase: forward steering input + populate HUD
218 if self.phase == "arena" and isinstance(self._sub, Arena):
219 arena: Arena = self._sub # type: ignore[assignment]
220 # Mouse steering latches on the first cursor movement (or a click,
221 # which is how a touchscreen reports a drag) and releases again the
222 # moment a keyboard steering key is used.
223 if Input.is_action_pressed("left") or Input.is_action_pressed("right"):
224 self._mouse_aiming = False
225 elif Input.mouse_delta.length() > 0 or Input.is_mouse_button_pressed(MouseButton.LEFT):
226 self._mouse_aiming = True
227
228 if self._mouse_aiming:
229 # ``Input.mouse_position`` is screen-space and the arena camera
230 # follows the snake head, so convert before aiming at a world point.
231 target = arena.camera.screen_to_world(Input.mouse_position, self.tree.screen_size)
232 arena.snake.aim_at(target)
233 else:
234 # Keyboard fallback steering
235 arena.snake.aim_at(None)
236 steer = 0.0
237 if Input.is_action_pressed("left"):
238 steer -= 1.0
239 if Input.is_action_pressed("right"):
240 steer += 1.0
241 arena.snake.steer(steer)
242
243 # HUD state
244 self._hud.set_state(
245 wave=self.wave,
246 total_waves=TOTAL_WAVES,
247 kills=arena.kills,
248 gold=self.gold + arena.gold_gained,
249 snake_hp=sum(u.hp for u in arena.snake.units if u.alive) if arena.snake.units else 0,
250 snake_max_hp=sum(u.max_hp for u in arena.snake.units) if arena.snake.units else 1,
251 time_scale=arena.time_scale,
252 )
253 else:
254 # Outside the arena the HUD is hidden by ``_set_phase``; reset its
255 # per-round counters so the next wave starts from a clean slate.
256 self._hud.set_state(
257 wave=self.wave,
258 total_waves=TOTAL_WAVES,
259 kills=0,
260 gold=self.gold,
261 snake_hp=0,
262 snake_max_hp=1,
263 time_scale=1.0,
264 )
265
266
267# ---------------------------------------------------------------- end screens
268
269
270class _EndScreen(Node):
271 """Game-over / victory scene with continue prompt."""
272
273 start = Signal()
274
275 # The continue prompt pulses every frame from ``self._t``, so ``on_draw``
276 # genuinely produces new geometry each frame: declare it dynamic.
277 dynamic = True
278
279 def __init__(self, *, victory: bool, wave: int, kills: int, gold: int, **kwargs):
280 super().__init__(name="EndScreen", **kwargs)
281 self.victory = victory
282 self.wave = wave
283 self.kills = kills
284 self.gold = gold
285 self._t = 0.0
286
287 def on_update(self, dt: float):
288 self._t += dt
289 # Keyboard or click/tap both restart, so the end screen is reachable
290 # for a mouse- or touch-only player.
291 if Input.is_action_just_pressed("start") or Input.is_mouse_button_just_pressed(MouseButton.LEFT):
292 self.start.emit()
293
294 def on_draw(self, renderer):
295 w, h = self.tree.screen_size if self.tree is not None else (WINDOW_W, WINDOW_H)
296 w, h = int(w), int(h)
297 renderer.draw_rect((0, 0), (w, h), colour=BG, filled=True)
298 title = "VICTORY" if self.victory else "DEFEAT"
299 c = GREEN if self.victory else RED
300 tw = renderer.text_width(title, 12)
301 renderer.draw_text(title, (w // 2 - tw // 2, int(h * 0.28)), scale=12, colour=c)
302
303 for i, line in enumerate(
304 [
305 f"WAVES SURVIVED {self.wave}",
306 f"KILLS {self.kills}",
307 f"GOLD {self.gold}",
308 ]
309 ):
310 lw = renderer.text_width(line, 3)
311 renderer.draw_text(line, (w // 2 - lw // 2, int(h * 0.53) + i * 50), scale=3, colour=FG)
312
313 if int(self._t * 2) % 2 == 0:
314 prompt = "ENTER / CLICK RESTART"
315 pw = renderer.text_width(prompt, 3)
316 renderer.draw_text(prompt, (w // 2 - pw // 2, int(h * 0.80)), scale=3, colour=YELLOW)
317
318
319# --------------------------------------------------------------------- entry
320
321
322def _capture(captures, frames):
323 out_dir = _PORT_DIR / "screenshots"
324 out_dir.mkdir(exist_ok=True)
325 from simvx.graphics import save_png
326
327 for idx, frame in zip(frames, captures, strict=False):
328 path = out_dir / f"frame_{idx}.png"
329 save_png(frame, path)
330 print(f"saved {path}")
331
332
333def _run_test():
334 """Headless smoke test: auto-advance into the arena; capture frames 30/60/120."""
335 from simvx.core import InputSimulator
336
337 app = App(title="SNKRX (test)", width=WINDOW_W, height=WINDOW_H, visible=False, bg_colour=BG)
338 sim = InputSimulator()
339 root = SNKRXRoot()
340
341 def _drive(idx, _t):
342 # Auto-press Enter after a few frames so capture frame 60 / 120 show
343 # actual arena gameplay rather than just the title screen.
344 if idx == 20:
345 sim.press_key(Key.ENTER)
346 if idx == 22:
347 sim.release_key(Key.ENTER)
348 return None
349
350 capture_frames = [30, 60, 120]
351 captures = app.run_headless(
352 root,
353 frames=130,
354 on_frame=_drive,
355 capture_frames=capture_frames,
356 )
357 _capture(captures, capture_frames)
358
359
360def main():
361 if "--test" in sys.argv:
362 _run_test()
363 return
364 app = App(title="SNKRX", width=WINDOW_W, height=WINDOW_H, bg_colour=BG)
365 app.run(SNKRXRoot())
366
367
368if __name__ == "__main__":
369 main()