SNKRX

Wave-based arena, particles, screenshake, slow-motion, bloom.

▶ Run in browser

Upstream: 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.py keeps every particle in one flat numpy structured array, advances the whole buffer with vector operations, and draws the live slice as circles from a single on_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, and camera.shake(intensity=..., duration=...) fires on melee connects, kills and boss deaths. Aiming converts the cursor through camera.screen_to_world(...), so hit-testing and rendering agree under pan and shake.

  • Slow motion from a scaled dt. Arena.on_update multiplies gameplay dt by time_scale, drops it to a floor on a big kill and ramps it back up. Spawn pacing keeps using wall-clock dt, 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 CanvasLayer at 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_action calls live in SNKRXRoot.on_ready, not in main(), because InputMap belongs to a scene tree and none exists before run().

Upstream

The upstream Lua was used as a design reference only; all Python here was written from scratch.

Source files

File

Summary

Lines

main.py

SNKRX: Wave-based arena, particles, screenshake, slow-motion, bloom.

358

harness.py

Scripted headless harness: drive the SNKRX port through a full run and

103

nodes/__init__.py

SNKRX SimVX port: node modules.

1

nodes/arena.py

Arena round: snake vs enemy waves with juice (particles, screenshake, slow-mo).

342

nodes/buy_screen.py

Build / upgrade screen: between rounds.

234

nodes/colours.py

SNKRX palette: neon-on-dark.

24

nodes/enemies.py

Enemy types: chase the snake head, deal contact damage, drop XP on death.

109

nodes/hud.py

Arena HUD overlay: wave / kills / snake hp via on_draw.

117

nodes/menu.py

SNKRX title screen: neon block logo + start prompt.

82

nodes/particles.py

CPU 2D particle pool: SNKRX-style hit sparks, death bursts, projectile trails.

162

nodes/projectile.py

Player projectiles: pierce shots from archers, AOE bursts from mages.

88

nodes/units.py

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