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