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

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.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 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

main.py

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

369

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

353

nodes/buy_screen.py

Build / upgrade screen: between rounds.

247

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.

131

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.

85

nodes/particles.py

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

168

nodes/projectile.py

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

104

nodes/units.py

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