SHRIKE¶

top-down 3D space survival roguelike.

â–¶ Run in browser

Tags: 3d game roguelike survival procedural

SHRIKE¶

A top-down 3D space survival roguelike on the SimVX engine. Strip-mine dying sectors under a signature meter that your own greed fills, juggle fuel, oxygen, ammo and a starving capacitor, bank your haul or smelt it aboard a Refinery that lights you up like a flare, and flee the Shrike when the klaxon sounds; behind you a red front swallows the star chart, and ahead lie richer sectors, louder deaths, and eventually the night your build is strong enough to turn around and hunt it back to its roost. Every tuning number that shapes the game is in shrike/balance.py.

Run¶

uv run python examples/demos/shrike/main.py          # play
uv run python examples/demos/shrike/main.py --test   # headless self-check, exits on its own

--test mounts the entry scene, flies a fixed-seed slice of a whole run (mine a deposit, provoke the signature lock, survive a wave and an arrival, flee through the star chart, extract at the Deep Gate) and prints SELFTEST: PASS before exiting. It writes its saves to a throwaway directory, so it never touches a real profile.

Controls¶

The nose aims independently of the thrust vector: you fly one way and shoot another. On a pad the shield arc mirrors the right stick by default; on a keyboard Q and E rotate it continuously.

Action

Keyboard and mouse

Gamepad

Thrust

W A S D

Left stick

Aim

Mouse

Right stick

Fire primary

Left mouse

Right trigger

Mining beam

Right mouse

Left trigger

Rotate shield arc

Q / E

Left / right bumper (60 degree nudge; both re-centres on aim)

Afterburner

Shift (hold)

A (hold)

Interact

F

X

Jettison scrap

G

B

Silent running

C (toggle)

Y (toggle)

Solar wings

X

D-pad up

Generator

Z

D-pad down

Warp spool

R (toggle)

Left stick click (toggle)

Tractor scoop

Tab (toggle)

Right stick click (toggle)

Star chart

M

Back

Show or hide the controls bar

H

D-pad left

Pause menu

Esc

Start

Warp spool is a toggle rather than a hold: press once to start the channel, press again to cancel it. Loose salvage is scooped by flying through it, so a mote you touch is a mote you keep.

Interact is one button with three lengths, and the ship draws the hold as a radial fill:

Hold

Effect

Tap

Dock at a depot, grab salvage, trigger a signal event

1 second

Patch the nearest hull breach (costs scrap)

3 seconds

Start a Refinery batch, or sell your scrap while docked

Saves¶

Two files, both under the XDG data directory:

Path

Contents

$XDG_DATA_HOME/simvx/shrike/profile.sav

Cores, Fleet Doctrine, quills, fletchings, lure fragments, hulls, Hunt Rank, codex, milestones, per-hull bests

$XDG_DATA_HOME/simvx/shrike/suspend.sav

The single suspend slot, deleted as it is loaded

XDG_DATA_HOME falls back to ~/.local/share when unset or not absolute, so the usual location is ~/.local/share/simvx/shrike/. Setting SHRIKE_DATA_DIR overrides both, which is what the self-check and portable installs use. Nothing is ever written into the game directory.

Tests¶

uv run --package simvx-core pytest examples/demos/shrike/tests/test_foundation.py

Each module has its own test file under examples/demos/shrike/tests/; run one file at a time. They are headless logic tests: scenes run under SceneRunner and every player verb goes through InputSimulator, so the real action map and input pipeline are exercised rather than handlers called directly. No GPU, no windows.

tests/test_balance_bands.py is the tuning contract: it measures the design’s time-to-kill bands (mite under 0.5 s, lancer about 2 s, warden about 45 s, one Shrike fin about 15 s) by feeding each reference loadout’s DPS through the real damage router. A balance change that moves a band fails there first.

Source files¶

File

Summary

Lines

main.py

SHRIKE: top-down 3D space survival roguelike.

441

shrike/__init__.py

SHRIKE: a top-down 3D space survival roguelike on the SimVX engine.

20

shrike/artkit.py

Procedural art kit for SHRIKE: every mesh, material, sky and grade is generated.

2168

shrike/audio.py

SHRIKE’s audio: every sound synthesised at load, and the mix the power state drives.

893

shrike/balance.py

Every tuning number in SHRIKE, as named constants and small specs.

780

shrike/bounty.py

Notoriety, the run-level reputation dial, and the two set pieces it buys.

1018

shrike/chart.py

The star chart: a branching route eaten from behind, and the warp that pays for it.

1664

shrike/combat.py

The damage router and the collision-layer contract.

452

shrike/enemies/__init__.py

SHRIKE’s hostiles: shared steering, the enemy base, and the archetypes.

53

shrike/enemies/advanced.py

The advanced half of the enemy roster: support, siege, alarm, emplacement and herald.

1474

shrike/enemies/basic.py

The four common hostiles: Mites, Skimmers, Lancers and Mag-mines.

781

shrike/enemies/steering.py

Flow-field steering and the shared enemy base.

815

shrike/events.py

The signal-event deck: twelve cards a sector can deal you on arrival.

185

shrike/flow.py

The run lifecycle: the menu, the run, the Gate, the collapse and the ledger.

7469

shrike/hud.py

SHRIKE’s heads-up display: foveal arcs, a warming border and diegetic rings.

4500

shrike/hunter.py

The Shrike: the segmented hunter the whole economy is priced against.

977

shrike/juice.py

The feel layer: hit-stop, trauma-budgeted shake, punches and the scoop magnet.

690

shrike/meta.py

Fleet Doctrine, the milestone bounties, the hull trophies and the Hunt Ranks.

1500

shrike/modules.py

Hull sockets, the thirty-module catalogue, and the modules that act.

1292

shrike/onboarding.py

The staged first three runs: the HUD subtraction schedule and the safe arrival.

934

shrike/power.py

Power generation, life support, and the silent-running posture.

1064

shrike/roost.py

The endgame campaign: Lure fragments, Fletching pity, and the Roost duel.

653

shrike/runtime.py

The shared runtime spine of SHRIKE.

541

shrike/save.py

Persistence: the permanent meta profile and the one-slot suspend save.

528

shrike/sector.py

What a warp-in drops you into: one sector’s harvestable content.

1736

shrike/ship.py

The player ship: drift flight, the shield arc, hull breaches and the warp spool.

1104

shrike/signature.py

The signature meter: the bill this sector charges for everything you do.

203

shrike/trading.py

The scrap ledger, the drift depots, and the Broker barge.

1121

shrike/vfx.py

SHRIKE’s effects library: pooled bursts, the two live emitters and the dust.

1509

shrike/waves.py

The wave composer: what arrives every 35 to 55 seconds, and from where.

1105

shrike/weapons.py

Hardpoints, the ten-weapon roster, and everything they put in the air.

1315

Source¶

  1"""SHRIKE: top-down 3D space survival roguelike.
  2
  3# /// simvx
  4# tags = ["3d", "game", "roguelike", "survival", "procedural"]
  5# screenshot_frame = 40
  6# web = { width = 1280, height = 720, root = "MainMenuScene", responsive = true }
  7# ///
  8
  9Run:            uv run python examples/demos/shrike/main.py
 10Frame cap:      uv run python examples/demos/shrike/main.py --fps 60
 11Headless check: uv run python examples/demos/shrike/main.py --test
 12
 13Controls (keyboard and mouse): WASD thrust, mouse aims the nose, LMB fires,
 14RMB mines, Q/E steer the shield arc, Shift afterburner, C silent running,
 15X solar wings, Z generator, F interact, G jettison, R warp spool, Tab scoop,
 16M star chart, Esc pause. Full gamepad mapping in shrike/runtime.py.
 17"""
 18
 19import importlib.util
 20import math
 21import os
 22import sys
 23import tempfile
 24
 25from shrike import balance
 26from shrike.runtime import (
 27    CAMERA_DISTANCE,
 28    CAMERA_PITCH_RADIANS,
 29    INPUT_ACTIONS,
 30    PLANE_Y,
 31    WINDOW_HEIGHT,
 32    WINDOW_WIDTH,
 33    CameraRig,
 34    Groups,
 35    move_input,
 36)
 37
 38from simvx.core import (
 39    DirectionalLight3D,
 40    Input,
 41    Material,
 42    Mesh,
 43    MeshInstance3D,
 44    Node,
 45    Node3D,
 46    Quat,
 47    Vec2,
 48    Vec3,
 49    WorldEnvironment,
 50)
 51
 52#: Re-exported so that the screenshot and web-export pipelines can reach the
 53#: front door by name: both resolve ``web.root`` as an attribute of this module,
 54#: and the run lifecycle otherwise only ever arrives through
 55#: :func:`create_entry_scene`. Guarded for the same reason that function is: a
 56#: tree without ``shrike.flow`` still has to import, and there ``FoundationScene``
 57#: is the entry and no menu exists to point a capture at.
 58try:
 59    from shrike.flow import MainMenuScene
 60except ImportError:  # pragma: no cover - only reachable without shrike.flow
 61    MainMenuScene = None
 62
 63FOUNDATION_SHIP_SPEED = 26.0
 64
 65
 66class FoundationShip(Node3D):
 67    """Minimal flight probe: dampened drift movement on the flight plane.
 68
 69    Stands in for the real player ship until ``shrike.ship`` provides one, and
 70    keeps the boot path and the headless self-check exercising real input,
 71    movement and camera-follow behaviour. The full flight model (nose-to-aim,
 72    afterburner, breaches) belongs to ``shrike.ship.PlayerShip``.
 73    """
 74
 75    def __init__(self, **kwargs):
 76        super().__init__(**kwargs)
 77        self.velocity = Vec2(0.0, 0.0)
 78
 79    def on_enter_tree(self):
 80        super().on_enter_tree()
 81        self.add_to_group(Groups.SHIP)
 82
 83    def on_ready(self):
 84        self.add_child(
 85            MeshInstance3D(
 86                name="Hull",
 87                mesh=Mesh.cone(radius=0.6, height=1.8, segments=12),
 88                material=Material(colour=(0.75, 0.78, 0.82, 1.0), metallic=0.8, roughness=0.35),
 89                rotation=Quat.from_euler(math.radians(90), 0.0, 0.0),
 90            )
 91        )
 92
 93    def on_fixed_update(self, dt: float):
 94        thrust = move_input()
 95        ax = float(thrust.x) * FOUNDATION_SHIP_SPEED
 96        az = float(thrust.y) * FOUNDATION_SHIP_SPEED
 97        damp = balance.INERTIAL_DAMPENING ** (dt * 60.0)
 98        self.velocity = Vec2(
 99            (float(self.velocity.x) + ax * dt) * damp,
100            (float(self.velocity.y) + az * dt) * damp,
101        )
102        self.position = Vec3(
103            float(self.position.x) + float(self.velocity.x) * dt,
104            PLANE_Y,
105            float(self.position.z) + float(self.velocity.y) * dt,
106        )
107
108
109class FoundationScene(Node):
110    """The minimal run scene the game boots until ``shrike.flow`` takes over.
111
112    Environment, sun, a flight probe on the plane, and the camera rig. Quits
113    on the ``star_chart`` action; the flow module replaces that with the real
114    pause and chart screens.
115    """
116
117    input_actions = INPUT_ACTIONS
118
119    def on_ready(self):
120        env = self.add_child(WorldEnvironment(name="Environment"))
121        env.ambient_light_colour = (0.10, 0.12, 0.18, 1.0)
122        env.ambient_light_energy = 1.0
123        env.bloom_enabled = True
124
125        sun = self.add_child(DirectionalLight3D(name="Sun"))
126        sun.direction = Vec3(-0.5, -1.0, -0.4)
127        sun.colour = (0.9, 0.92, 1.0)
128        sun.intensity = 1.2
129
130        self.ship = self.add_child(FoundationShip(name="Ship", position=Vec3(0.0, PLANE_Y, 0.0)))
131        self.rig = self.add_child(CameraRig(name="CameraRig"))
132        self.rig.set_target(self.ship)
133
134    def on_update(self, dt: float):
135        self.rig.set_aim(self._mouse_aim_point())
136        if Input.is_action_just_pressed("star_chart"):
137            self.app.quit()
138
139    def _mouse_aim_point(self) -> Vec3:
140        """Approximate flight-plane point under the mouse, relative to the ship.
141
142        A screen-centre-relative mapping, good enough for the camera lead;
143        the real ship module replaces this with a proper camera ray cast.
144        """
145        w, h = self.tree.screen_size
146        m = Input.mouse_position
147        dx = (float(m.x) - w / 2.0) / max(w, 1.0)
148        dz = (float(m.y) - h / 2.0) / max(h, 1.0)
149        reach = 30.0
150        p = self.ship.position
151        return Vec3(float(p.x) + dx * reach, PLANE_Y, float(p.z) + dz * reach)
152
153
154def create_entry_scene() -> Node:
155    """The scene the app boots into.
156
157    ``shrike.flow`` owns the run lifecycle (main menu, run setup, the run
158    itself) and is what boots in a complete tree. The foundation scene is the
159    fallback for a tree without that module, where it keeps the game bootable
160    and the ``--test`` smoke gate meaningful.
161    """
162    if importlib.util.find_spec("shrike.flow") is not None:
163        from shrike.flow import create_entry_scene as flow_entry
164
165        return flow_entry()
166    return FoundationScene(name="FoundationScene")
167
168
169#: Fixed seed for the scripted run slice, so the smoke gate is repeatable.
170SELFTEST_SEED = 20260823
171#: Run number past the onboarding schedule, so the chart answers the player.
172SELFTEST_RUN_NUMBER = 4
173
174
175def _fail(reason: str) -> bool:
176    print(f"SELFTEST: FAIL ({reason})")
177    return False
178
179
180def _boot_check() -> bool:
181    """Boot the entry scene headlessly and verify the front door.
182
183    Runs without a GPU or a window: the scene is stepped by ``SceneRunner``.
184    The game opens on the main menu, so the check is that the menu mounts,
185    draws its centred panel inside the screen, and offers entries. Flight is
186    checked by the run slice, which drives a live ship.
187    """
188    from shrike.flow import MainMenuScene
189
190    from simvx.core.testing import SceneRunner
191
192    runner = SceneRunner(screen_size=(WINDOW_WIDTH, WINDOW_HEIGHT))
193    runner.load(create_entry_scene())
194    runner.advance_frames(60)
195    root = runner.root
196    if root is None or not root.children:
197        return _fail("entry scene mounted empty")
198    if not isinstance(root, MainMenuScene):
199        return _fail(f"entry scene is {type(root).__name__}, not the main menu")
200    if root.menu is None or not root.menu.entries:
201        return _fail("the main menu has no entries")
202    panel = root.menu_panel()
203    if panel is None:
204        return _fail("the main menu drew no panel")
205    x, y, width, height = panel.get_global_rect()
206    if x < 0.0 or y < 0.0:
207        return _fail("the menu panel hangs off the top-left of the screen")
208    if x + width > WINDOW_WIDTH or y + height > WINDOW_HEIGHT:
209        return _fail("the menu panel hangs off the bottom-right of the screen")
210    entries = [text for text, _ in root.menu.entries]
211    print(f"SELFTEST: boot ok ({runner.frame_count} frames, menu entries: {', '.join(entries)})")
212    return True
213
214
215def _run_slice() -> bool:
216    """Fly one scripted slice of a real run, end to end, headlessly.
217
218    Boot into a fixed-seed run, mine a deposit and grab the motes, provoke
219    the signature lock, ride out a wave and the Shrike's arrival on the move,
220    flee through the star chart once the lockout lifts, then extract at the
221    Deep Gate and check the ledger that comes back.
222    """
223    from shrike.flow import LedgerScene, RunConfig, RunScene
224
225    from simvx.core import JoyAxis, Key
226    from simvx.core.testing import InputSimulator, SceneRunner
227
228    runner = SceneRunner(screen_size=(WINDOW_WIDTH, WINDOW_HEIGHT))
229    sim = InputSimulator(runner.tree)
230    sim.reset()
231    scene = RunScene(RunConfig(seed=SELFTEST_SEED, run_number=SELFTEST_RUN_NUMBER).as_dict())
232    runner.load(scene)
233    runner.advance_frames(10)
234    if not scene.running or scene.ship is None or scene.sector is None:
235        return _fail("run scene did not assemble")
236    if not scene.sector.deposits:
237        return _fail("start sector generated no deposits")
238
239    # The spine first: thrust moves the ship on the flight plane and the
240    # camera rig follows it. This used to live in the boot check, but the boot
241    # scene is now the menu, which mounts no ship.
242    ship = scene.ship
243    start = Vec3(*ship.position)
244    sim.press_key(Key.W)
245    runner.advance_frames(90)
246    sim.release_key(Key.W)
247    runner.advance_frames(30)
248    moved = float(abs(ship.position.z - start.z)) + float(abs(ship.position.x - start.x))
249    if moved < 1.0:
250        return _fail(f"thrust did not move the ship; moved {moved:.3f}")
251    if abs(float(ship.position.y) - PLANE_Y) > 1e-4:
252        return _fail(f"ship left the flight plane: y={float(ship.position.y):.4f}")
253    rig = runner.find(CameraRig)
254    if rig is None or rig.camera is None:
255        return _fail("the run mounted no camera rig")
256    cam_focus_z = float(rig.camera.position.z) - CAMERA_DISTANCE * math.cos(CAMERA_PITCH_RADIANS)
257    if abs(cam_focus_z - float(ship.position.z)) > 20.0:
258        return _fail("camera rig did not follow the ship")
259
260    seen: set[str] = set()
261    scene.waves.wave_spawned.connect(lambda count: seen.add("wave"))
262
263    # Harvest: park west of a deposit, aim the pad stick at it, hold the beam,
264    # then tap-grab the motes it sheds.
265    deposit = scene.sector.deposits[0]
266    ship.position = Vec3(float(deposit.position.x) - 6.0, PLANE_Y, float(deposit.position.z))
267    sim.set_gamepad_axis(JoyAxis.RIGHT_X, 1.0)
268    sim.set_gamepad_axis(JoyAxis.LEFT_TRIGGER, 1.0)
269    runner.advance_time(6.0)
270    sim.set_gamepad_axis(JoyAxis.LEFT_TRIGGER, 0.0)
271    for _ in range(10):
272        sim.tap_key(Key.F)
273        runner.advance_frames(6)
274    if float(scene.economy.scrap) <= 0.0:
275        return _fail("mining and grabbing banked no scrap")
276
277    # Provoke the Shrike, then flee the warning: burner lit, shield astern,
278    # weaving so the wave that lands mid-telegraph cannot line up a shot.
279    scene.signature.add(balance.SIGNATURE_MAX, "provocation")
280    runner.advance_frames(3)
281    hunter = scene.hunter_director.hunter
282    if hunter is None:
283        return _fail("signature lock raised no hunter")
284    hunter.hunter_telegraph.connect(lambda stage: seen.add(f"telegraph_{stage}"))
285    hunter.hunter_arrived.connect(lambda index: seen.add("arrived"))
286    sim.set_gamepad_axis(JoyAxis.RIGHT_X, 0.0)
287    sim.set_gamepad_axis(JoyAxis.RIGHT_Y, 1.0)
288    sim.press_key(Key.W)
289    sim.press_key(Key.LEFT_SHIFT)
290    weave = (Key.A, Key.D)
291    for step in range(60):
292        side = weave[step % 2]
293        sim.press_key(side)
294        runner.advance_time(1.5)
295        sim.release_key(side)
296        if "arrived" in seen or scene.ended:
297            break
298    if "arrived" not in seen:
299        return _fail("the Shrike never arrived")
300    if "wave" not in seen:
301        return _fail("no wave spawned before the arrival")
302    for step in range(30):
303        side = weave[step % 2]
304        sim.press_key(side)
305        runner.advance_time(1.0)
306        sim.release_key(side)
307        if not ship.warp_scrambled or scene.ended:
308            break
309    sim.release_key(Key.LEFT_SHIFT)
310    if scene.ended or float(ship.hull) <= 0.0:
311        return _fail("the ship did not survive the arrival")
312    if ship.warp_scrambled:
313        return _fail("the arrival lockout never lifted")
314
315    # Flee through the star chart.
316    sim.tap_key(Key.M)
317    runner.advance_frames(2)
318    chart = scene.chart_screen
319    if not chart.is_open:
320        return _fail("the star chart did not open")
321    offered = [d for d in chart.destinations() if d.available]
322    if not offered:
323        return _fail("no affordable escape route on the chart")
324    target = min(offered, key=lambda d: d.quote.total).node_id
325    if not (chart.select(target) and chart.confirm()):
326        return _fail("the chart refused the escape jump")
327    if not runner.simulate_until(lambda _: scene.state.current_node_id == target, max_ticks=30 * 60):
328        return _fail("the warp never completed")
329    if not runner.simulate_until(lambda _: scene.hunter_director.hunter is None, max_ticks=30 * 60):
330        return _fail("the Shrike did not depart after the jump")
331    runner.advance_frames(30)
332    sim.release_key(Key.W)
333    if scene.sector is None or scene.state.ledger.arrivals_survived < 1:
334        return _fail("the fled-to sector did not come up")
335
336    # Extract at the Deep Gate and read the ledger back.
337    gate = next((nid for nid, node in scene.state.chart.nodes.items() if node.is_deep_gate), "")
338    if not gate:
339        return _fail("the chart has no Deep Gate")
340    scene.enter_sector(gate)
341    runner.advance_frames(5)
342    root = runner.root
343    if not isinstance(root, LedgerScene):
344        return _fail("extraction did not raise the ledger screen")
345    ledger = root.ledger
346    if ledger.outcome != "extraction" or not ledger.conversion or ledger.cores_total <= 0.0:
347        return _fail("the extraction ledger did not settle")
348    if not ledger.recap or not root.lines():
349        return _fail("the ledger screen has nothing to say")
350
351    stages = ", ".join(sorted(seen))
352    print(f"SELFTEST: slice ok ({runner.frame_count} frames; {stages}; {ledger.cores_total:.1f} Cores)")
353    return True
354
355
356def _selftest() -> bool:
357    """The full headless gate: the boot check, then the scripted run slice.
358
359    Saves land in a throwaway directory unless ``SHRIKE_DATA_DIR`` is already
360    set, so the gate never touches a real profile.
361    """
362    os.environ.setdefault("SHRIKE_DATA_DIR", tempfile.mkdtemp(prefix="shrike-selftest-"))
363    if not _boot_check():
364        return False
365    if not _run_slice():
366        return False
367    print("SELFTEST: PASS")
368    return True
369
370
371#: The settings key holding the player's frame cap, in frames per second. Zero
372#: (and an absent key) means "follow the display", which is what the game does
373#: with no cap set: vsync-gated and otherwise uncapped.
374FRAME_CAP_SETTING = "frame_cap"
375#: The settings key holding vertical sync. Unlike the cap this one is live
376#: (``App.set_vsync``), and the settings screen flips it there and then; it is
377#: still read here so the window opens the way the player left it.
378VSYNC_SETTING = "vsync"
379
380
381def resolve_frame_cap(argv: list[str] | None = None) -> int:
382    """The frame cap to run at: ``--fps`` if given, else the saved setting.
383
384    Uncapped is not free. Without a cap the loop runs at whatever the panel
385    refreshes at, so a 144 Hz laptop does two and a half times the work of a
386    sixty-frame cap for a twin-stick game that reads no better for it, and
387    spends the difference in heat and battery.
388
389    Read from disk here so the window opens the way the player left it. That is
390    all this does now: ``App.target_fps`` became writable mid-run, so the
391    settings screen applies a new cap on the next frame and no longer has to
392    wait for a relaunch to be honoured. This is the boot half of the same
393    setting, not a workaround for a missing setter.
394    """
395    argv = list(sys.argv[1:] if argv is None else argv)
396    if "--fps" in argv:
397        index = argv.index("--fps")
398        value = argv[index + 1] if index + 1 < len(argv) else ""
399        if not value.isdigit():
400            raise SystemExit("--fps expects a frame rate, for example --fps 60 (0 follows the display)")
401        return int(value)
402    from shrike import save
403
404    try:
405        return max(0, int(save.stored_settings().get(FRAME_CAP_SETTING, 0)))
406    except (TypeError, ValueError):
407        return 0
408
409
410def resolve_vsync() -> bool:
411    """Whether to open the window with vertical sync, off the saved setting.
412
413    On by default and on for any profile that has never been asked, which is
414    the setting a laptop panel wants; the settings screen can turn it off
415    mid-session because this one the engine does expose a setter for.
416    """
417    from shrike import save
418
419    return bool(save.stored_settings().get(VSYNC_SETTING, True))
420
421
422def main(argv: list[str] | None = None) -> None:
423    from simvx.graphics import App
424
425    cap = resolve_frame_cap(argv)
426    app = App(
427        title="SHRIKE",
428        width=WINDOW_WIDTH,
429        height=WINDOW_HEIGHT,
430        target_fps=cap or None,
431        vsync=resolve_vsync(),
432    )
433    app.run(create_entry_scene())
434
435
436if __name__ == "__main__":
437    if "--test" in sys.argv:
438        from simvx.core.testing import run_selftest
439
440        sys.exit(run_selftest(_selftest))
441    main()