Q1K3¶
first-person Quake-style FPS with three weapons and five enemy types.
▶ Run in browserUpstream: https://github.com/phoboslab/q1k3
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-2
Q1K3: SimVX port¶
A first-person shooter ported from phoboslab/q1k3, Dominic Szablewski’s MIT-licensed js13k 2021 entry: a Quake clone in 13 KB.
Upstream’s size budget forced everything to be generated at runtime, and this port keeps that property: it ships no asset files. Textures, sounds, meshes and maps are all built at boot.
Run¶
# Interactive (Vulkan window)
simvx examples run ports_q1k3
# Headless screenshot capture (8 stages, writes into screenshots/)
simvx examples run ports_q1k3 --test
# Web export (single HTML)
simvx export web main.py -o q1k3.html
Controls¶
Action |
Keys |
|---|---|
Move |
W A S D or arrows |
Look |
Mouse |
Fire |
Left mouse |
Jump |
Space or right mouse |
Switch weapon |
Q / E or mousewheel |
Menu (releases the pointer) |
Esc |
The title screen also offers a pointer-only mode: an on-screen stick, plus FIRE / JUMP / WPN buttons and drag-to-look, so the game is playable with a mouse or a finger alone.
What it exercises in SimVX¶
Procedural content.
nodes/textures.pybuilds every 64x64 RGBA texture as a numpy array;nodes/audio.pybakes the sound effects withAudioSynth(the frequency sweeps stay hand-rolled numpy, since the synth’s oscillators hold a constant frequency);nodes/maps.pyis a hand-authored list of cuboids. Nothing is loaded.First-person input.
InputMapactions plusMouseCaptureMode.CAPTUREDfor the look, with the title screen owning when capture happens.Custom collision.
nodes/physics.pyis a voxel AABB integrator with step-up, sub-stepped fast projectiles, and a 16-unit line-of-sight trace the enemy AI queries.State-machine AI.
nodes/enemy.pyruns the upstream eight-state machine (idle, patrol, follow, evade, and the four attack phases) across five enemy types.Dynamic lighting. Muzzle flashes, explosions and grenade glow are short-lived
PointLight3Dnodes; torches flicker per frame.Spatial audio. Enemy hits and explosions play through
AudioPlayer3D, attenuated and panned against the player camera as listener.Anchor-based UI. The HUD, title screen and on-screen controls are
Controlsubclasses positioned entirely with anchors and margins.
Deliberate deviations from upstream¶
Enemies are single textured cubes: upstream’s animated mesh blending is dropped, since the port is about the AI and the collision integrator.
One hand-authored map instead of upstream’s two compiled levels, and far smaller with it: 24 blocks against the 230-330 brushes of an upstream level.
Sounds are synthesised with
AudioSynthrather than Sonant-X, and there is no soundtrack.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Q1K3: first-person Quake-style FPS with three weapons and five enemy types. |
181 |
|
Smoke harness for Q1K3: starts the game from the menu and drives the player. |
76 |
|
Q1K3 SimVX port: node package. |
1 |
|
Synthesised SFX bank for the Q1K3 port: built on AudioSynth. |
208 |
|
Sliding door for Q1K3 port. |
83 |
|
Enemy entities with state-machine AI for Q1K3 port. |
316 |
|
Heads-up display: health, ammo, weapon name, message banner, crosshair. |
138 |
|
Temporary fading PointLight3D: used for muzzle flash + explosions. |
46 |
|
Hand-authored Q1K3 maps. |
99 |
|
Yaw / pitch rotation helpers shared by the player, weapons, enemies and doors. |
30 |
|
Title screen: the port’s entry point and its pause screen. |
88 |
|
Cached mesh primitives for Q1K3 port. |
20 |
|
Particle entities for blood, gibs, explosion fragments. |
79 |
|
AABB-vs-block collision integrator for Q1K3 port. |
223 |
|
Pickups for Q1K3 port: health, weapons, ammo, key. |
122 |
|
First-person player controller for Q1K3 port. |
220 |
|
Projectile entities for Q1K3 port. |
234 |
|
Static props: barrels (explode on damage), torches (flickering lights), |
119 |
|
Q1K3Root: the top-level scene node. |
358 |
|
Procedural textures for Q1K3 port. |
265 |
|
On-screen pointer controls, so the port is playable without a keyboard. |
164 |
|
Weapons for Q1K3 port: Shotgun, Nailgun, GrenadeLauncher. |
111 |
|
World / Map system for Q1K3 port. |
125 |
Source¶
1# /// simvx
2# tags = ["port", "tier-2"]
3# upstream = "https://github.com/phoboslab/q1k3"
4# web = { width = 1280, height = 720, responsive = true }
5# ///
6"""Q1K3: first-person Quake-style FPS with three weapons and five enemy types.
7
8A re-implementation of Dominic Szablewski's 13 KB js13k entry q1k3. Everything
9is generated at boot, so the port ships no asset files at all: numpy procedural
10textures, synthesised sound effects, and hand-authored blocky maps.
11
12Demonstrates: InputMap-driven movement with a MouseCaptureMode.CAPTURED
13mouse-look, a voxel AABB collision integrator with step-up and sub-stepped
14projectiles, state-machine enemy AI with line-of-sight tracing, dynamic point
15lights (muzzle flashes, explosions, flickering torches), spatial audio, and an
16anchor-based Control HUD. A title screen owns pointer capture, and on-screen
17stick-and-buttons controls keep the game playable on a touch device.
18
19Run:
20 simvx examples run ports_q1k3 # interactive
21 simvx examples run ports_q1k3 --test # headless screenshot capture
22"""
23
24from __future__ import annotations
25
26import math
27import sys
28from pathlib import Path
29
30_PORT_DIR = Path(__file__).parent
31
32from nodes.root import Q1K3Root # noqa: E402
33
34WIDTH = 1280
35HEIGHT = 720
36
37
38def _run_headless() -> None:
39 """Capture a sequence of stage screenshots in one App.run_headless call."""
40 from simvx.graphics import App, save_png
41
42 out_dir = _PORT_DIR / "screenshots"
43 out_dir.mkdir(exist_ok=True)
44
45 # Each tuple = (name, frame_idx, mutator(root) -> None)
46 boot_settle = 4
47
48 def stage_at(name: str, frame: int, fn=None):
49 return (name, frame, fn or (lambda r: None))
50
51 # Schedule 8 stages.
52 # Coordinates: cells xz=32 units, y=16 units. yaw=0 looks +Z (toward doorway).
53 # Start room interior cells x=11..22, z=11..22. Corridor x=16..17, z=24..31.
54 # Locked-door room z=32..41.
55 from simvx.core import Vec3
56
57 stages = []
58 stages.append(stage_at("01_spawn.png", boot_settle - 1))
59
60 def view_door(root):
61 if root.player:
62 root.player.p = Vec3(16 * 32 + 16, 24, 14 * 32)
63 root.player.position = root.player.p
64 root.player._yaw = 0.0 # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
65 root.player._pitch = 0.0
66
67 stages.append(stage_at("02_room_interior.png", boot_settle + 4, view_door))
68
69 def look_corridor_enemy(root):
70 # Place player IN the corridor, just past the lintel, and the grunt
71 # close-ish ahead so the screenshot frames it clearly.
72 if root.player:
73 root.player.p = Vec3(16 * 32 + 16, 24, 25 * 32)
74 root.player.position = root.player.p
75 root.player._yaw = 0.0 # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
76 root.player._pitch = 0.0
77 # Place the grunt 2 cells ahead, centered, body facing the player.
78 for e in root._enemies:
79 if type(e).__name__ == "Grunt":
80 e.p = Vec3(16 * 32 + 16, 24, 27 * 32)
81 e.position = e.p
82 e._target_yaw = 3.14159
83 e._yaw = 3.14159
84 e.v = Vec3(0, 0, 0)
85 # Force IDLE so AI doesn't immediately rotate / move.
86 e._set_state("IDLE")
87 break
88
89 stages.append(stage_at("03_enemy_in_sight.png", boot_settle + 8, look_corridor_enemy))
90
91 def fire_weapon(root):
92 if root.player:
93 # Player's own SFX/light flash is timed with the firing call;
94 # advance _can_shoot_at and call weapon.shoot directly.
95 root.player._can_shoot_at = -1.0
96 root.player.weapon.shoot(root, root.player.p, root.player._yaw, root.player._pitch)
97 # Also spawn the muzzle flash light (normally done in Player.on_update):
98 root.spawn_temp_light(root.player.p + Vec3(0, 8, 0), 4.0, (1.0, 0.9, 0.3), 0.2)
99
100 stages.append(stage_at("04_weapon_fire.png", boot_settle + 12, fire_weapon))
101
102 def kill_grunt(root):
103 # Force kill the corridor grunt
104 for e in list(root._enemies):
105 if hasattr(e, "_kill") and not getattr(e, "_dead", False):
106 e._kill()
107 break
108
109 stages.append(stage_at("05_enemy_killed.png", boot_settle + 16, kill_grunt))
110
111 def near_locked_door(root):
112 if root.player:
113 # Approach the locked door
114 root.player.p = Vec3(16 * 32 + 16, 24, 30 * 32)
115 root.player.position = root.player.p
116 root.player._yaw = 0.0 # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
117 root.player._pitch = 0.0
118
119 stages.append(stage_at("06_locked_door.png", boot_settle + 20, near_locked_door))
120
121 def have_key(root):
122 # Pretend we picked up the key; banner changes; door will open
123 root.has_key = True
124
125 stages.append(stage_at("07_door_unlocked.png", boot_settle + 24, have_key))
126
127 def cinematic(root):
128 if root.player:
129 # Stand inside the locked room, looking back at the door (door at z=32, room
130 # centre z=37). JS-frame yaw=π → look -Z toward the door.
131 root.player.p = Vec3(16 * 32 + 16, 24, 38 * 32)
132 root.player.position = root.player.p
133 root.player._yaw = math.pi
134 root.player._pitch = 0.0
135
136 stages.append(stage_at("08_locked_room.png", boot_settle + 28, cinematic))
137
138 capture_indices = sorted({s[1] for s in stages})
139 schedule = {s[1]: s[2] for s in stages}
140 total_frames = max(capture_indices) + 2
141
142 app = App(width=WIDTH, height=HEIGHT, title="Q1K3 (test)", visible=False)
143 root = Q1K3Root()
144 root.autostart = True
145
146 def on_frame(idx, _t):
147 if idx in schedule:
148 try:
149 schedule[idx](root)
150 except Exception as e:
151 print(f"[--test] stage at frame {idx} failed: {e!r}")
152 return None
153
154 captured = app.run_headless(
155 root,
156 frames=total_frames,
157 capture_frames=capture_indices,
158 on_frame=on_frame,
159 )
160
161 # Match each capture index back to a stage name (multiple stages may share
162 # an index if scheduling collapses them; here they're distinct).
163 by_frame = {f: name for name, f, _ in stages}
164 for frame_idx, img in zip(capture_indices, captured, strict=False):
165 name = by_frame.get(frame_idx, f"frame_{frame_idx:03d}.png")
166 save_png(img, out_dir / name)
167 print(f"saved {out_dir / name}")
168
169
170def main() -> None:
171 if "--test" in sys.argv:
172 _run_headless()
173 return
174 from simvx.graphics import App
175
176 app = App(width=WIDTH, height=HEIGHT, title="Q1K3 (SimVX)")
177 app.run(Q1K3Root())
178
179
180if __name__ == "__main__":
181 main()