SHRIKE¶
top-down 3D space survival roguelike.
â–¶ Run in browserTags: 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 |
|---|---|
|
Cores, Fleet Doctrine, quills, fletchings, lure fragments, hulls, Hunt Rank, codex, milestones, per-hull bests |
|
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 |
|---|---|---|
SHRIKE: top-down 3D space survival roguelike. |
441 |
|
SHRIKE: a top-down 3D space survival roguelike on the SimVX engine. |
20 |
|
Procedural art kit for SHRIKE: every mesh, material, sky and grade is generated. |
2168 |
|
SHRIKE’s audio: every sound synthesised at load, and the mix the power state drives. |
893 |
|
Every tuning number in SHRIKE, as named constants and small specs. |
780 |
|
Notoriety, the run-level reputation dial, and the two set pieces it buys. |
1018 |
|
The star chart: a branching route eaten from behind, and the warp that pays for it. |
1664 |
|
The damage router and the collision-layer contract. |
452 |
|
SHRIKE’s hostiles: shared steering, the enemy base, and the archetypes. |
53 |
|
The advanced half of the enemy roster: support, siege, alarm, emplacement and herald. |
1474 |
|
The four common hostiles: Mites, Skimmers, Lancers and Mag-mines. |
781 |
|
Flow-field steering and the shared enemy base. |
815 |
|
The signal-event deck: twelve cards a sector can deal you on arrival. |
185 |
|
The run lifecycle: the menu, the run, the Gate, the collapse and the ledger. |
7469 |
|
SHRIKE’s heads-up display: foveal arcs, a warming border and diegetic rings. |
4500 |
|
The Shrike: the segmented hunter the whole economy is priced against. |
977 |
|
The feel layer: hit-stop, trauma-budgeted shake, punches and the scoop magnet. |
690 |
|
Fleet Doctrine, the milestone bounties, the hull trophies and the Hunt Ranks. |
1500 |
|
Hull sockets, the thirty-module catalogue, and the modules that act. |
1292 |
|
The staged first three runs: the HUD subtraction schedule and the safe arrival. |
934 |
|
Power generation, life support, and the silent-running posture. |
1064 |
|
The endgame campaign: Lure fragments, Fletching pity, and the Roost duel. |
653 |
|
The shared runtime spine of SHRIKE. |
541 |
|
Persistence: the permanent meta profile and the one-slot suspend save. |
528 |
|
What a warp-in drops you into: one sector’s harvestable content. |
1736 |
|
The player ship: drift flight, the shield arc, hull breaches and the warp spool. |
1104 |
|
The signature meter: the bill this sector charges for everything you do. |
203 |
|
The scrap ledger, the drift depots, and the Broker barge. |
1121 |
|
SHRIKE’s effects library: pooled bursts, the two live emitters and the dust. |
1509 |
|
The wave composer: what arrives every 35 to 55 seconds, and from where. |
1105 |
|
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()