Q1K3

first-person Quake-style FPS with three weapons and five enemy types.

▶ Run in browser

Upstream: 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)
uv run python examples/ports/q1k3/main.py

# Headless screenshot capture (8 stages, writes into screenshots/)
uv run python examples/ports/q1k3/main.py --test

# Smoke harness (no graphics)
uv run python examples/ports/q1k3/harness.py

# Web export (single HTML)
uv run simvx export web examples/ports/q1k3/main.py -o /tmp/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.py builds every 64x64 RGBA texture as a numpy array; nodes/audio.py bakes the sound effects with AudioSynth (the frequency sweeps stay hand-rolled numpy, since the synth’s oscillators hold a constant frequency); nodes/maps.py is a hand-authored list of cuboids. Nothing is loaded.

  • First-person input. InputMap actions plus MouseCaptureMode.CAPTURED for the look, with the title screen owning when capture happens.

  • Custom collision. nodes/physics.py is 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.py runs 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 PointLight3D nodes; 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 Control subclasses 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 AudioSynth rather than Sonant-X, and there is no soundtrack.

Source files

File

Summary

Lines

main.py

Q1K3: first-person Quake-style FPS with three weapons and five enemy types.

184

harness.py

Smoke harness for Q1K3: starts the game from the menu and drives the player.

76

nodes/__init__.py

Q1K3 SimVX port: node package.

1

nodes/audio.py

Synthesised SFX bank for the Q1K3 port: built on AudioSynth.

208

nodes/door.py

Sliding door for Q1K3 port.

83

nodes/enemy.py

Enemy entities with state-machine AI for Q1K3 port.

316

nodes/hud.py

Heads-up display: health, ammo, weapon name, message banner, crosshair.

149

nodes/light.py

Temporary fading PointLight3D: used for muzzle flash + explosions.

46

nodes/maps.py

Hand-authored Q1K3 maps.

99

nodes/mathutil.py

Yaw / pitch rotation helpers shared by the player, weapons, enemies and doors.

30

nodes/menu.py

Title screen: the port’s entry point and its pause screen.

99

nodes/meshes.py

Cached mesh primitives for Q1K3 port.

20

nodes/particle.py

Particle entities for blood, gibs, explosion fragments.

79

nodes/physics.py

AABB-vs-block collision integrator for Q1K3 port.

223

nodes/pickup.py

Pickups for Q1K3 port: health, weapons, ammo, key.

122

nodes/player.py

First-person player controller for Q1K3 port.

228

nodes/projectile.py

Projectile entities for Q1K3 port.

234

nodes/prop.py

Static props: barrels (explode on damage), torches (flickering lights),

119

nodes/root.py

Q1K3Root: the top-level scene node.

356

nodes/textures.py

Procedural textures for Q1K3 port.

265

nodes/touch.py

On-screen pointer controls, so the port is playable without a keyboard.

144

nodes/weapon.py

Weapons for Q1K3 port: Shotgun, Nailgun, GrenadeLauncher.

111

nodes/world.py

World / Map system for Q1K3 port.

125

Source

  1"""Q1K3: first-person Quake-style FPS with three weapons and five enemy types.
  2
  3# /// simvx
  4# tags = ["port", "tier-2"]
  5# upstream = "https://github.com/phoboslab/q1k3"
  6# web = { width = 1280, height = 720, responsive = true }
  7# ///
  8
  9A re-implementation of Dominic Szablewski's 13 KB js13k entry q1k3. Everything
 10is generated at boot, so the port ships no asset files at all: numpy procedural
 11textures, synthesised sound effects, and hand-authored blocky maps.
 12
 13Demonstrates: InputMap-driven movement with a MouseCaptureMode.CAPTURED
 14mouse-look, a voxel AABB collision integrator with step-up and sub-stepped
 15projectiles, state-machine enemy AI with line-of-sight tracing, dynamic point
 16lights (muzzle flashes, explosions, flickering torches), spatial audio, and an
 17anchor-based Control HUD. A title screen owns pointer capture, and on-screen
 18stick-and-buttons controls keep the game playable on a touch device.
 19
 20Run:
 21    uv run python examples/ports/q1k3/main.py            # interactive
 22    uv run python examples/ports/q1k3/main.py --test     # headless screenshot capture
 23"""
 24
 25from __future__ import annotations
 26
 27import math
 28import sys
 29from pathlib import Path
 30
 31_PORT_DIR = Path(__file__).parent
 32if str(_PORT_DIR) not in sys.path:
 33    sys.path.insert(0, str(_PORT_DIR))
 34
 35from nodes.root import Q1K3Root  # noqa: E402
 36
 37WIDTH = 1280
 38HEIGHT = 720
 39
 40
 41def _run_headless() -> None:
 42    """Capture a sequence of stage screenshots in one App.run_headless call."""
 43    from simvx.graphics import App, save_png
 44
 45    out_dir = _PORT_DIR / "screenshots"
 46    out_dir.mkdir(exist_ok=True)
 47
 48    # Each tuple = (name, frame_idx, mutator(root) -> None)
 49    boot_settle = 4
 50
 51    def stage_at(name: str, frame: int, fn=None):
 52        return (name, frame, fn or (lambda r: None))
 53
 54    # Schedule 8 stages.
 55    # Coordinates: cells xz=32 units, y=16 units. yaw=0 looks +Z (toward doorway).
 56    # Start room interior cells x=11..22, z=11..22. Corridor x=16..17, z=24..31.
 57    # Locked-door room z=32..41.
 58    from simvx.core import Vec3
 59
 60    stages = []
 61    stages.append(stage_at("01_spawn.png", boot_settle - 1))
 62
 63    def view_door(root):
 64        if root.player:
 65            root.player.p = Vec3(16 * 32 + 16, 24, 14 * 32)
 66            root.player.position = root.player.p
 67            root.player._yaw = 0.0  # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
 68            root.player._pitch = 0.0
 69
 70    stages.append(stage_at("02_room_interior.png", boot_settle + 4, view_door))
 71
 72    def look_corridor_enemy(root):
 73        # Place player IN the corridor, just past the lintel, and the grunt
 74        # close-ish ahead so the screenshot frames it clearly.
 75        if root.player:
 76            root.player.p = Vec3(16 * 32 + 16, 24, 25 * 32)
 77            root.player.position = root.player.p
 78            root.player._yaw = 0.0  # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
 79            root.player._pitch = 0.0
 80        # Place the grunt 2 cells ahead, centered, body facing the player.
 81        for e in root._enemies:
 82            if type(e).__name__ == "Grunt":
 83                e.p = Vec3(16 * 32 + 16, 24, 27 * 32)
 84                e.position = e.p
 85                e._target_yaw = 3.14159
 86                e._yaw = 3.14159
 87                e.v = Vec3(0, 0, 0)
 88                # Force IDLE so AI doesn't immediately rotate / move.
 89                e._set_state("IDLE")
 90                break
 91
 92    stages.append(stage_at("03_enemy_in_sight.png", boot_settle + 8, look_corridor_enemy))
 93
 94    def fire_weapon(root):
 95        if root.player:
 96            # Player's own SFX/light flash is timed with the firing call;
 97            # advance _can_shoot_at and call weapon.shoot directly.
 98            root.player._can_shoot_at = -1.0
 99            root.player.weapon.shoot(root, root.player.p, root.player._yaw, root.player._pitch)
100            # Also spawn the muzzle flash light (normally done in Player.on_update):
101            root.spawn_temp_light(root.player.p + Vec3(0, 8, 0), 4.0, (1.0, 0.9, 0.3), 0.2)
102
103    stages.append(stage_at("04_weapon_fire.png", boot_settle + 12, fire_weapon))
104
105    def kill_grunt(root):
106        # Force kill the corridor grunt
107        for e in list(root._enemies):
108            if hasattr(e, "_kill") and not getattr(e, "_dead", False):
109                e._kill()
110                break
111
112    stages.append(stage_at("05_enemy_killed.png", boot_settle + 16, kill_grunt))
113
114    def near_locked_door(root):
115        if root.player:
116            # Approach the locked door
117            root.player.p = Vec3(16 * 32 + 16, 24, 30 * 32)
118            root.player.position = root.player.p
119            root.player._yaw = 0.0  # JS-frame yaw=0 → face +Z (toward enemies, doorway, door)
120            root.player._pitch = 0.0
121
122    stages.append(stage_at("06_locked_door.png", boot_settle + 20, near_locked_door))
123
124    def have_key(root):
125        # Pretend we picked up the key; banner changes; door will open
126        root.has_key = True
127
128    stages.append(stage_at("07_door_unlocked.png", boot_settle + 24, have_key))
129
130    def cinematic(root):
131        if root.player:
132            # Stand inside the locked room, looking back at the door (door at z=32, room
133            # centre z=37). JS-frame yaw=π → look -Z toward the door.
134            root.player.p = Vec3(16 * 32 + 16, 24, 38 * 32)
135            root.player.position = root.player.p
136            root.player._yaw = math.pi
137            root.player._pitch = 0.0
138
139    stages.append(stage_at("08_locked_room.png", boot_settle + 28, cinematic))
140
141    capture_indices = sorted({s[1] for s in stages})
142    schedule = {s[1]: s[2] for s in stages}
143    total_frames = max(capture_indices) + 2
144
145    app = App(width=WIDTH, height=HEIGHT, title="Q1K3 (test)", visible=False)
146    root = Q1K3Root()
147    root.autostart = True
148
149    def on_frame(idx, _t):
150        if idx in schedule:
151            try:
152                schedule[idx](root)
153            except Exception as e:
154                print(f"[--test] stage at frame {idx} failed: {e!r}")
155        return None
156
157    captured = app.run_headless(
158        root,
159        frames=total_frames,
160        capture_frames=capture_indices,
161        on_frame=on_frame,
162    )
163
164    # Match each capture index back to a stage name (multiple stages may share
165    # an index if scheduling collapses them; here they're distinct).
166    by_frame = {f: name for name, f, _ in stages}
167    for frame_idx, img in zip(capture_indices, captured, strict=False):
168        name = by_frame.get(frame_idx, f"frame_{frame_idx:03d}.png")
169        save_png(img, out_dir / name)
170        print(f"saved {out_dir / name}")
171
172
173def main() -> None:
174    if "--test" in sys.argv:
175        _run_headless()
176        return
177    from simvx.graphics import App
178
179    app = App(width=WIDTH, height=HEIGHT, title="Q1K3 (SimVX)")
180    app.run(Q1K3Root())
181
182
183if __name__ == "__main__":
184    main()