👾 I Ported Quake III Arena to PHP — From PK3 Files to a Playable Map
on August 14, 2026
Quake III Arena is not a toy codebase. It is a full arena shooter with a ZIP-based filesystem, a binary map format, a shader language that is not a GLSL program, collision that is not the same mesh you draw, and movement code that still feels distinctive twenty-five years later. Porting a slice of that stack into PHP sounds like a dare. It was, partly. It was also a deliberate way to learn an engine by reimplementing its seams, not by skimming a wiki.
This article is about that experiment: a progressive PHP port that can launch a native window, load retail or demo PK3 data, parse an IBSP map, build draw batches, run Quake-style player movement against BSP collision, fire a small weapon set, open doors, and make maps/q3dm0.bsp feel coherent enough to walk, fight, and die in. It is not a claim that all of Quake III has been rewritten in PHP. There is no QVM, no multiplayer, no bot AI, no menu system, and the world materials are still incomplete. What exists is a real playable slice, with the engine logic living in PHP and the window living behind FFI.
If you only want the run command, skip to How to run it. Everything else is the path that made that command meaningful.
Why port Quake III to PHP?
PHP spends most of its life answering HTTP requests. That shape of work is short-lived: parse a request, talk to a database, emit HTML or JSON, exit. Game engines are the opposite. They keep state for minutes, tick dozens of times per second, decode binary formats, and care about the difference between a render mesh and a clip brush.
I already knew PHP could do more than web pages. The Darkwood line of work includes heavy offline tools and FFI-backed native windows. Quake III was a harder target: not “draw some triangles,” but “respect enough of an id Tech 3 pipeline that muscle memory from the original game still applies.”
The goals were concrete:
- Treat the GPL engine source as a specification, not as inspiration posters
- Keep game data out of the repository and load it the way Quake does: through PK3 archives
- Prefer fidelity on the seams that make the game feel like Quake (filesystem order, BSP lumps, pmove numbers, spawn rules) over a flashy but invented architecture
- Accept that PHP will be slower than C, and still ask whether it can be correct enough to play
I was not trying to prove that PHP should replace C for shipping shooters. I was trying to see how far a modern PHP runtime can go when you stop pretending every program is a request handler.
What id Software actually released
In 2005, id Software released the Quake III Arena engine source under the GPL. That release is the legal and technical foundation of this port. It includes the C code for the client, server, renderer, collision, and game modules — the machinery.
It does not include the commercial game assets. The maps, textures, sounds, models, and PK3 packages that make Quake III look and sound like Quake III remain proprietary. Development used locally owned or demo data pointed at with a CLI flag. Those files never belong in the git tree.
That distinction matters in every sentence of this article. When I say “the port loads pak0.pk3,” I mean your legal baseq3, not a redistributed game. When I say “based on Quake III Arena,” I mean algorithms and data layouts from the GPL sources, reimplemented in PHP.
Getting the game data into PHP
Quake III does not ship a loose directory of TGA files as the primary content model. Content lives in PK3 files: ZIP archives with a Quake path convention inside. A typical baseq3 directory contains pak0.pk3 through later point-release packs. Later packs override earlier ones when they contain the same internal path.
The PHP port mirrors that idea in src/Filesystem/FileSystem.php. You pass --basepath pointing at an install root (with a baseq3 child), at baseq3 itself, or at a directory of PK3s. The loader discovers *.pk3, sorts names the way Quake’s paksort does, and prepends each archive so alphabetically later packs win — then a lookup walks that list and stops at the first hit (FS_FOpenFileRead semantics):
// FileSystem::addGameDirectory + resolve — src/Filesystem/FileSystem.php
usort($names, static fn (string $a, string $b): int => Path::compare($a, $b));
foreach ($names as $name) {
$loaded[] = $this->addPak($real . DIRECTORY_SEPARATOR . $name); // prepend
}
public function resolve(string $qpath): ?ResolvedFile
{
// ...
foreach ($this->packs as $pack) { // head = highest priority
$entry = $pack->getEntry($qpath);
if ($entry !== null) {
return new ResolvedFile($entry, $pack);
}
}
return null;
}
That is the Quake-specific part — not “open one ZIP,” but “present several archives as one virtual filesystem with override order.” pak1.pk3 winning over pak0.pk3 for the same qpath is how point releases and patches work.
Pk3Archive indexes each ZIP once. Path::normalize in src/Filesystem/Path.php strips leading separators, folds case, and rejects .. escapes so Maps/Q3DM0.BSP and maps/q3dm0.bsp resolve the same way. There is no loose-directory search path in this port; that narrowing is deliberate.
CLI tools like fs:which, pk3:list, and data:inspect exist because filesystem mistakes look like renderer bugs. If the wrong pack wins, you will swear the shader parser is broken. When bsp:mesh or play startup prints where a BSP came from, it attributes the qpath and the winning archive — that single line has saved more debugging time than any profiler output.
Understanding IBSP
Maps are BSP files: Binary Space Partitioning trees plus a pile of related lumps. Quake III Arena uses the IBSP identity and version 46, with 17 lumps in the header.
BspLoader (src/Bsp/BspLoader.php) does not turn the file into a clever object graph first. It treats the bytes as a layout that must match Quake’s header: identity, version, then seventeen {fileofs, filelen} pairs. That is slightly stricter than some historical C paths that trusted version alone — bad offsets are rejected before anyone indexes into the buffer.
// BspLoader::loadFromString — src/Bsp/BspLoader.php
$head = unpack('Vident/Vversion', substr($bytes, 0, 8));
$ident = $head['ident'];
$version = $head['version'];
if ($ident !== BspIdent::IDENT) { /* expect IBSP */ }
if ($version !== BspIdent::VERSION) { /* expect 46 */ }
$lumps = [];
$offset = 8;
for ($i = 0; $i < BspIdent::HEADER_LUMPS; $i++) {
$chunk = unpack('Vfileofs/Vfilelen', substr($bytes, $offset, 8));
// ... bounds-check fileofs/filelen against strlen($bytes) ...
$lumps[] = new BspLump(
index: $i,
name: BspIdent::LUMP_NAMES[$i],
fileofs: $chunk['fileofs'],
filelen: $chunk['filelen'],
);
$offset += 8;
}
unpack('V…') is little-endian unsigned 32-bit — the same width and endianness as Quake’s on-disk integers on the platforms that mattered. Idiomatic PHP modelling comes after that fidelity. Misreading a lump length by four bytes does not produce a nice domain exception later; it produces a map that looks haunted.
The lumps are not interchangeable. The port cares about different ones for different jobs:
- Draw geometry: shaders, drawverts, drawindexes, surfaces, lightmaps, lightgrid, visibility, fogs (partially)
- Collision: planes, nodes, leafs, leafbrushes, brushes, brushsides, shaders (for contents flags)
- Gameplay entities: the entities lump — a text block of brace-delimited key/value dictionaries
- Models / movers: models lump indices that
func_doorand friends reference as*Nbrush models
Render geometry answers “what do I draw?” Collision answers “where can a box go?” Entities answer “where does the player start, and what are the items?” Confusing those three is the classic way to get a beautiful map you fall through.
From BSP surfaces to something drawable
Once the map is in memory, WorldBatchBuilder walks surfaces and builds static draw batches keyed by shader and lightmap. Planar faces and triangle soups become indexed meshes. Bezier patches are tessellated on the CPU. Sky and flare surfaces are classified and often skipped as solid geometry, then handled by dedicated paths.
The surprise for a typical PHP developer is how unglamorous the vertex path is. Quake’s drawVert_t is a fixed 44-byte record. BspDrawVert::fromBytes in src/Bsp/BspDrawVert.php peels it apart field by field:
// BspDrawVert::fromBytes — src/Bsp/BspDrawVert.php (44-byte drawVert_t)
return new self(
xyz: [
BspBinary::float32($bytes, $offset),
BspBinary::float32($bytes, $offset + 4),
BspBinary::float32($bytes, $offset + 8),
],
st: [
BspBinary::float32($bytes, $offset + 12),
BspBinary::float32($bytes, $offset + 16),
],
lightmap: [
BspBinary::float32($bytes, $offset + 20),
BspBinary::float32($bytes, $offset + 24),
],
normal: [
BspBinary::float32($bytes, $offset + 28),
BspBinary::float32($bytes, $offset + 32),
BspBinary::float32($bytes, $offset + 36),
],
color: [
ord($bytes[$offset + 40]),
ord($bytes[$offset + 41]),
ord($bytes[$offset + 42]),
ord($bytes[$offset + 43]),
],
);
That is the whole joke: a string of BSP bytes becomes $xyz, $st, lightmap UVs, a normal, and four colour bytes, then surfaces index those verts into triangles, then WorldBatchBuilder groups them for OpenGL. No ORM. No “VertexRepository.” Binary layout fidelity again.
At launch for q3dm0, a typical stderr summary looks like tens of thousands of triangles across roughly a hundred batches, multiple lightmap pages, patch collision triangles for the clip side of curved surfaces, and a PVS cluster count in the thousands. Those numbers are not a benchmark claim; they are a reminder that even a “small” Quake map is a real dataset.
The renderer does not stream the original C refEntity_t scene graph. PHP prepares batches and state; OpenGL (via FFI) submits fixed-function draws. That is a pragmatic boundary: PHP owns correctness of asset interpretation; the GPU still does the raster work.
Early mistakes were educational. Treating lightmap UVs with an unnecessary vertical flip made every lit surface look subtly wrong. Averaging portal plane normals instead of using the first triangle’s plane flipped portal facing. Caching a shader name as “already loaded” before sibling stage maps were bound skipped environment and blend textures. Soup surfaces that should use vertex colours were briefly treated like lightmapped faces and went fullbright. None of those bugs feel dramatic in isolation. Together they are why a port must debug intermediate values, not only final pixels.
Visibility (PVS) is loaded and used to limit which batches matter for a camera cluster. It is graded coherent in the status log, which means it is good enough for q3dm0 play, not that every edge case of Quake’s cluster packing has been re-proven. Turning PVS off with a flag is still useful when you suspect culling rather than materials.
Choosing the rendering boundary
A natural question: why not compile the original C with Emscripten and call it a PHP project? That would be a packaging exercise, not a port.
This project keeps Quake’s logic in PHP:
- PK3 search order
- BSP decoding
- Shader script parsing
- Collision traces
- Player movement
- Entity/game rules for the supported slice
- Render orchestration (what to draw, with which textures and blends)
The platform is thin: SdlGl opens a window, creates a GL context, polls input, swaps buffers. Gl is a large fixed-function OpenGL helper — world, MD3 items, view weapon, HUD, effects — still driven by PHP-owned state.
There is also a serve command that exposes a browser WebGL orbit viewer for mesh dumps. It is useful for diagnostics. It is not the game. The playable path is native:
PK3 archives
↓ FileSystem (override order)
BSP bytes
↓ BspLoader / WorldBatchBuilder / CollisionModel
world + collision + entities
↓ Application play loop
input → Pmove → MapGame → view
↓ Gl (FFI) + SdlGl::swap
native OpenGL window
Or, as a call chain:
php bin/quake3.php play → Application::cmdPlay
→ FileSystem + BspLoader + CollisionModel + WorldBatchBuilder
→ MapGame + Player + Pmove
→ loop: SdlGl input → pmove/game → Gl draw → swap
PHP owns persistent game state for as long as the window lives. It is not serving one HTTP request and exiting. The WebGL serve path serializes a static mesh for orbit viewing; it does not host this loop.
That split matches an older Darkwood pattern: PHP as the durable brain, FFI as the hardware door.
Textures, shaders, and lightmaps
Quake III “shaders” are not modern GPU shader programs. They are material scripts in scripts/*.shader: stages with maps, blend modes, alpha tests, tcMod transforms, animMaps, environment mapping, and sort keys. The GPU of 1999 executed a much simpler pipeline; the script described how to feed it.
The PHP port parses those scripts (ShaderParser, Q3Shader, stages) and executes a partial subset at draw time: lightmap modulation, many blend modes, scroll/rotate/turb-ish effects, environment stages on some surfaces and MD3s, sky boxes, portals/mirrors, flares. Texture loading covers the formats Quake actually used in practice for this data (TGA/JPEG paths through ImageLoader).
What is not claimed: full shader compatibility. The live status grade for shader execution is partial. Some world materials on q3dm0 still appear black or wrong even when the image files resolve. That is the current visual blocker: not missing files, but incomplete or incorrect stage behaviour. Tools like --debug-material= and --debug-surface-pick exist so a human can aim the camera at a bad surface and see which shader name is involved, instead of guessing from a screenshot.
Lightmaps are loaded as pages and applied in the classic Quake way: second texture stage modulating the diffuse look of the world. Getting linear upload and UV orientation right mattered more than any clever PHP optimization.
Finding the player spawn
The entities lump is plain text embedded in the BSP — Quake does not need a separate database to know where players and items live. EntityParser::deathmatchSpawns in src/Game/EntityParser.php walks the parsed dictionaries and lifts deathmatch pads (with info_player_start treated as an alias, as in g_client.c):
// EntityParser::deathmatchSpawns — src/Game/EntityParser.php
if ($classname === 'info_player_start') {
$classname = 'info_player_deathmatch';
}
if ($classname !== 'info_player_deathmatch') {
continue;
}
$origin = $this->parseVec3($ent['origin'] ?? '0 0 0');
$angles = [0.0, 0.0, 0.0];
if (isset($ent['angles'])) {
$angles = $this->parseVec3($ent['angles']);
} elseif (isset($ent['angle'])) {
$angles[1] = (float) $ent['angle']; // F_ANGLEHACK → yaw only
}
$spawns[] = new SpawnPoint($classname, $origin, $angles, $i, (int) ($ent['spawnflags'] ?? 0));
That only gets you a list. Quake’s first spawn prefers a pad with spawnflags & 1. On q3dm0 that is the intro-hall pad, not entity order zero. The port mirrors SelectInitialSpawnPoint / SelectRandomFurthestSpawnPoint in src/Game/SpawnPoint.php — this is a faithful rule for the supported slice, not a “pick [0]” simplification:
// SpawnPoint::selectInitial — src/Game/SpawnPoint.php
foreach ($spawns as $s) {
if (($s->spawnflags & 1) !== 0) {
return $s;
}
}
return self::selectRandomFurthest($spawns, [0.0, 0.0, 0.0]);
Respawns sort by distance from the death origin and pick randomly among the farthest half. --spawn=N still overrides for debugging.
Spawn origin gets the classic +9 on Z (playerOrigin()) before the player is dropped to the floor with a box trace. If you forget the lift, you spawn in the ground. If you skip the drop, you spawn in the air and the first frames look haunted.
MapGame then wires the rest of the supported entity set for this map: items with MD3 models, a teleporter, doors (including teamed doors that must move together), ambient speakers, timers, rotating map objects, portal surfaces. Intro VO chains are deliberately skipped; the goal was a playable arena, not a cutscene player.
Building a real game loop
bin/quake3.php raises memory and time limits, loads Composer’s autoloader, and hands argv to Application. The play command is not a framework of plugins. It is an explicit setup phase followed by a while ($running) loop.
Setup loads the BSP, builds collision and batches, constructs the game world, creates the player, initializes SDL/GL, uploads GPU resources, and prints a short inventory of what was found. The loop then:
- Measures a clamped frame delta
- Polls SDL events into a structured input snapshot
- Builds a
Usercmd(forward/side/up, buttons, angles, weapon wishes) - Runs
Pmoveagainst the collision model (with door solids injected as entity boxes) - Updates
MapGame(pickups, movers, projectiles, timers) - Handles firing and view-weapon state
- Computes first-person view offsets (bob, land, damage kick, duck lerp)
- Draws the world, entities, effects, view weapon, and HUD
- Swaps buffers
That is ordinary for a game. It is unusual for PHP only because PHP culture rarely writes this shape of program. The loop lives in Application::cmdPlay (src/Console/Application.php) as a long-lived process — not a request cycle:
// Application::cmdPlay — frame head (src/Console/Application.php)
while ($running) {
$now = $sdl->ticks();
$dt = max(0.001, min(0.05, ($now - $last) / 1000.0));
$last = $now;
$frame++;
$gameTime += $dt;
$ev = $sdl->pollEvents();
if ($ev['quit'] || $ev['escape']) {
$running = false;
}
// ... zoom FOV, mouse look / +strafe, Usercmd, Pmove, MapGame, draw, swap ...
}
Nothing about that excerpt is fictional middleware. Delta time is clamped so a hitch does not teleport the player through a wall. Input is polled into a snapshot. The rest of the frame — building a Usercmd, running Pmove, updating entities, drawing — is the complexity the loop calls, not the loop itself.
Mouse look and keyboard input
Input arrives through SdlGl::pollEvents. Keys are mapped to small string names (w, shift, pgdn, …). Mouse deltas accumulate per frame. Buttons set attack, strafe, and zoom.
Look uses Quake’s default feel: m_yaw * sensitivity with defaults that amount to 0.022 × 5 degrees per mouse step. Arrow keys turn at cl_yawspeed 140 (and pitch keys at cl_pitchspeed 140), with Shift applying the classic cl_anglespeedkey 1.5 multiplier for “speed.”
Hold right mouse or Alt for +strafe: mouse slides you instead of turning, and arrows become sidestep. Middle mouse holds +zoom, lerping horizontal FOV toward cg_zoomfov 22.5 over 150 ms and scaling look sensitivity by fov_y / 75 while zoomed — the same relationships Quake’s cgame uses.
Weapon keys follow Quake’s weapon N numbering: 2 machinegun, 3 shotgun, 8 plasma. The wheel and brackets cycle owned weapons that still have ammo. Fire is mouse1, Ctrl, or F. Crouch is C. Walk is Shift. None of this required a browser pointer lock, because the window is native and uses relative mouse mode.
Porting movement
Pmove is a subset of Quake’s bg_pmove.c / bg_slidemove.c. It is not a Unity CharacterController with Quake cosmetics. The constants are the Quake ones: jump velocity 270, friction 6, ground accelerate 10, air accelerate 1, stop speed 100, duck scale 0.25, step size 18, minimum walk normal 0.7.
Each command frame:
- Applies friction when grounded
- Accelerates wish velocity from forward/side move scaled by run or walk speeds
- Integrates gravity
- Performs slide moves with plane clipping
- Steps up stairs
- Handles jump, duck, crash land events, and bob cycle for view and footsteps
Air control is Quake’s weak air accelerate, which is exactly why rocket jumps and plasma bumps feel like Quake when splash knockback is also faithful. The heart of that feel is not $pos += $vel * $dt. It is PM_Accelerate-shaped wish-speed logic in Pmove::accelerate (src/Game/Pmove.php) — a faithful port of the Quake acceleration step for the ground/air paths that are implemented (water/spectator remain deferred):
// Pmove::accelerate — mirrors PM_Accelerate (src/Game/Pmove.php)
private function accelerate(array $wishdir, float $wishspeed, float $accel): void
{
$currentspeed = $this->dot($this->ps->velocity, $wishdir);
$addspeed = $wishspeed - $currentspeed;
if ($addspeed <= 0) {
return;
}
$accelspeed = $accel * $this->frametime * $wishspeed;
if ($accelspeed > $addspeed) {
$accelspeed = $addspeed;
}
$this->ps->velocity[0] += $accelspeed * $wishdir[0];
$this->ps->velocity[1] += $accelspeed * $wishdir[1];
$this->ps->velocity[2] += $accelspeed * $wishdir[2];
}
Wish direction and wish speed come from the usercmd and run/walk scale; current speed is the projection of velocity onto that wish; acceleration is capped so you do not overshoot the wish in one frame. Change the air accel constant from 1 to something “nicer” and the game stops feeling like Quake even if friction still works.
Dead bodies keep velocity and use dead-move friction instead of freezing in place. Teleporters set knockback time so you spit out without sticky ground friction eating the impulse.
There is a regression command, regress:pmove, that drops onto a floor, walks, jumps, and brushes a wall. It is a gate, not a full physics suite. When movement changes, that gate must still pass. A recent run on q3dm0 reported a stable floor drop height, a walk distance, a jump velz of 270-scale Quake (257.2 after the first integrate step in the test harness), and a finite wall-walk delta — enough to catch “I inverted gravity” class mistakes before anyone launches the window.
View presentation sits on top of pmove, not inside it. ViewOffset adds bob, landing dip, stair step smoothing, duck height lerp, and directional damage kick. The view weapon adds its own bob and land offsets so the gun does not float like a HUD sticker. Those layers are easy to skip when you only care about “WASD works,” and they are exactly what makes a port feel finished or cheap.
Water and spectator movement are deferred. For q3dm0, that is acceptable.
Collision against Quake’s BSP
You cannot collide against the render mesh and call it Quake. Render surfaces are optimized for drawing. Clip data lives in brushes hung off leafs in the BSP tree.
CollisionModel loads planes, nodes, leafs, brushes, and brush sides, then implements box traces in the spirit of CM_BoxTrace. Player movement uses a player-solid contents mask. Bullets and plasma use a shot mask that ignores playerclip. Patch surfaces contribute extra collision triangles built during batch generation, because curved clip geometry is not always a trivial brush story.
Doors are brush models. Their solid is not always sitting in the world leaf brush lists the way static world brushes are, so the play loop injects mover AABBs into the collision model each frame. That is a pragmatic bridge: full submodel brush clipping can come later; preventing the player from walking through a closed door cannot.
Traces return end position, plane normal, fraction, and surface flags. Those flags drive footsteps on metal, sky “no impact” for bullets, and fall damage exemptions where appropriate.
The brush clip step itself is where the port stops looking like a mesh demo. CollisionModel::traceThroughBrush (src/Collision/CollisionModel.php) walks brush sides, computes start/end plane distances (with the player AABB offset baked into the plane distance), and tracks enter/leave fractions — the CM_TraceThroughBrush idea:
// CollisionModel::traceThroughBrush — src/Collision/CollisionModel.php
for ($i = 0; $i < $brush['numsides']; $i++) {
$side = $this->brushsides[$brush['firstSide'] + $i];
$plane = $this->planes[$side['plane']];
$n = $plane['normal'];
$off = $tw['offsets'][$plane['signbits']];
$dist = $plane['dist'] - ($off[0] * $n[0] + $off[1] * $n[1] + $off[2] * $n[2]);
$d1 = $tw['start'][0] * $n[0] + $tw['start'][1] * $n[1] + $tw['start'][2] * $n[2] - $dist;
$d2 = $tw['end'][0] * $n[0] + $tw['end'][1] * $n[1] + $tw['end'][2] * $n[2] - $dist;
// ... miss / startout / getout bookkeeping ...
if ($d1 > $d2) {
$f = ($d1 - self::SURFACE_CLIP_EPSILON) / ($d1 - $d2);
if ($f > $enterFrac) {
$enterFrac = $f;
$clipplane = $plane;
$leadSurfaceFlags = $side['surfaceFlags'];
}
} else {
$f = ($d1 + self::SURFACE_CLIP_EPSILON) / ($d1 - $d2);
if ($f < $leaveFrac) {
$leaveFrac = $f;
}
}
}
if ($enterFrac < $leaveFrac && $enterFrac < $tw['fraction']) {
$tw['fraction'] = $enterFrac < 0 ? 0.0 : $enterFrac;
// ... store planeNormal / surfaceFlags ...
}
That is why colliding against visible triangles is the wrong abstraction: the BSP stores planes and brushes intended for these spatial queries. The excerpt above is a partial collision stack overall (entity movers still use injected AABBs; some patch cases use extra tris), but the brush fraction math is the real Quake algorithm, not a temporary sphere-vs-mesh hack.
The moment it became playable
The project did not arrive as a playable game in one leap. The useful story is a ladder of evidence:
- PK3 readable — list and extract paths; prove pack override order
- BSP recognized — IBSP 46, seventeen lumps, bounds checks
- First triangles — decode faces into CPU meshes; later upload batches
- First textured world — images bind; lightmaps modulate; sky appears
- First spawn — entity parse, +9 Z, drop to floor, camera at eyes
- Mouse look and WASD — initially freecam, then real usercmds
- Gravity and collision — pmove against brushes; no more floor clipping
- Items, doors, teleporter — the map becomes a place, not a museum
- Weapons and HUD — machinegun, shotgun, plasma; status bar; pain and death
- Coherent slice — enough of Quake’s rules that playing
q3dm0feels intentional
Status language matters. The port tracks a milestone like Q3DM0_PLAYABLE_COHERENT and refuses to self-promote to “reference close” without a human visual OK. That is discipline, not marketing. A map can be mechanically playable while pewter and portals still look wrong.
How to run it
Requirements: PHP 8.5+ with ext-zip and ext-ffi, SDL2 installed, and a legal Quake III baseq3 (or install root) on disk.
composer install
php -d ffi.enable=true bin/quake3.php play \
--basepath=/path/to/quake3-or-baseq3 \
--map=maps/q3dm0.bsp
--basepath may be:
- the game install directory that contains
baseq3/, or - the
baseq3directory itself, or - a directory that already contains
*.pk3files
This opens a native SDL window. It does not open a browser tab.
Controls
| Input | Action |
|---|---|
| WASD | Move |
| Arrows | Turn (strafe while +strafe); up/down walk |
| Mouse | Look |
| RMB / Alt | +strafe |
| Middle mouse | +zoom |
| Space | Jump |
| C | Crouch |
| Shift | Walk |
| Click / Ctrl / F | Fire |
| 2 / 3 / 8 | Machinegun / shotgun / plasma |
Wheel, [ ] |
Previous / next weapon |
| PgDn / Delete | Look up / down |
| End | Center pitch |
| R | Respawn after death delay |
| Esc | Quit |
Useful flags include --freecam, --debug-lighting, --debug-surface-pick, and --spawn=N.
Pmove regression:
php bin/quake3.php regress:pmove \
--basepath=/path/to/quake3-or-baseq3 \
maps/q3dm0.bsp
Diagnostic browser mesh viewer (not the game):
php bin/quake3.php serve \
--basepath=/path/to/quake3-or-baseq3 \
--map=maps/q3dm0.bsp
What the implementation looks like today
On maps/q3dm0.bsp, the port supports a solo deathmatch-shaped session:
- First-person movement with Quake numbers, duck, walk, jump, step, slide
- Machinegun, shotgun, and plasma with raise/drop timing, ammo rules, and impact FX
- Pickups (health, armor, ammo, weapons) with Quake grab pads and respawn waits
- Doors (including matched teams), a teleporter, ambient speakers, some timers
- HUD status bar, crosshair, weapon select strip, pickup names, damage feedback
- View weapon models with muzzle flash and machinegun barrel spin
- Portals/mirrors, sky, flares, player shadow blob
- Thin audio via platform players for a subset of events
Architecturally, the important PHP types are boring in the best way:
| Area | Types |
|---|---|
| FS | FileSystem, Pk3Archive, Path |
| BSP | BspLoader, BspMap, lump helpers, VisData |
| Render prep | WorldBatchBuilder, ShaderParser, MaterialResolver, ImageLoader |
| Platform | SdlGl, Gl, ThinSound |
| Move/collide | Pmove, Player, Usercmd, CollisionModel |
| Game | MapGame, EntityParser, SpawnPoint, MoverSystem, WeaponState |
| Shell | Application |
The console application is large because it currently owns the play loop. That is honest. A future cleanup could extract a GameSession class; it would not change the data flow.
What is still missing
Be explicit.
World materials remain the highest visual gap. Some surfaces are black or wrong despite textures resolving. Full Quake shader execution is not done.
Content scope is a solo q3dm0 slice. Other maps may boot, but they are not the fidelity target.
Weapons beyond MG/SG/plasma are absent. Gauntlet is numbered but unused. No railgun lightning BFG rocket grenade path in this slice.
Systems not in scope: bots, networking, QVM bytecode, menus/cvar UI, savegames, full sound spatialization, full dynamic light infrastructure, complete mapobject parity, water volumes.
Collision for complex movers is AABB-assisted rather than full brush-model tracing.
Audio is partial: important events play; it is not a mixer.
Browser serve is not a second game client.
Calling this “Quake III in PHP” without those caveats would be dishonest. Calling it “a playable Quake III map slice in PHP with a real engine pipeline” is accurate.
A related honesty check: comparison projects exist in JavaScript and other languages that render Quake maps in a browser. They are valuable references for visual expectations. This PHP port did not become those projects. It kept a native window and a PHP game loop on purpose. When fidelity work compared against a WebGL viewer, the authority order was still Quake’s C sources first, then the viewer as an oracle for “what should this surface look like,” then the PHP behaviour under test. That ordering prevents the port from converging on someone else’s approximations.
Performance and where PHP hurts
I am not going to invent frame-time numbers. Correctness came first. Subjectively, on a modern machine the q3dm0 slice is interactive; it is not a 1999 baseline on 1999 hardware, and it is not trying to be.
Likely cost centers, labeled as engineering judgment rather than measurements:
- PHP arrays and objects for vertices, batches, and traces allocate more than tight C structs
- Per-frame work in the play loop (entity updates, HUD, effects) is convenient to write and easy to make chatty
- Shader stage setup on the fixed-function path has more validation and branching than a baked material
- Image and MD3 uploads are front-loaded at start; that is good for play, harsh on startup latency
- Collision traces are algorithmic; PHP constant factors matter when many traces run per jump
None of that was a reason to stop. It was a reason to keep the architecture boring: decode once, batch statically where possible, and avoid rewriting Quake’s math into a cleverer but wrong form.
If this project ever needs serious speed, the honest options are profiling, reducing per-frame allocations, moving hot traces behind a tighter representation, or accepting FFI for selected kernels — not pretending PHP’s interpreter will match vm_x86.c.
Debugging and observability
Engine ports fail quietly. The triangles look almost right. The spawn is almost right. The gun fires into playerclip and explodes on an invisible wall.
So the CLI grew inspection tools:
data:inspect— what packs did--basepathactually attach?fs:which— which archive provides a qpath?bsp:info,bsp:entities,bsp:spawns,bsp:surfaces,bsp:lightmapsshader:missing,shader:audit,render:audittrace:test— drop a player box onto the floorregress:pmove— movement gate- play flags such as
--debug-surface-pick,--debug-lighting,--lightmap-only,--no-pvs
The point is always the same: inspect the intermediate world. Quake’s own developers lived in that habit. A PHP port needs it more, not less, because the runtime will not segfault politely on a bad lump offset every time; sometimes it will just draw nonsense.
--debug-surface-pick is the clearest example of that philosophy. Aim at a surface, press P, and dumpSurfacePick in src/Console/Application.php fires a collision trace from the eye, then walks render batches to name the shader under the crosshair:
// Application::dumpSurfacePick — src/Console/Application.php
[$fwd] = QuakeMath::angleVectors($angles);
$end = [
$eye[0] + $fwd[0] * 8192.0,
$eye[1] + $fwd[1] * 8192.0,
$eye[2] + $fwd[2] * 8192.0,
];
$tr = $cm->trace($eye, $end, [0, 0, 0], [0, 0, 0], CollisionModel::MASK_PLAYERSOLID);
fwrite(STDERR, sprintf(
"screen hit world=(%.1f %.1f %.1f) fraction=%.4f ...\n",
$tr->endPos[0], $tr->endPos[1], $tr->endPos[2], $tr->fraction,
));
// ... ray vs batches → shader name / lightmap / PVS cluster ...
When pewter still looks wrong, you need the shader name, not another screenshot. That is also why the status log still lists world materials as a human visual gate rather than a machine-closed task.
What this experiment taught me
Fidelity is a sequence of seams. Pack order, lump roles, spawnflags, contents masks, weapon numbering — each seam is small. Miss three and the game feels “off” in a way players notice immediately and debuggers explain slowly.
The render mesh is a liar. Beautiful geometry is not a collision API. Once pmove spoke brush traces, the map stopped being a film set.
PHP can host a game loop. The language is not the obstacle. The obstacle is respecting binary formats and real-time state without the comfort of a framework.
Partial shader support is still a cliff. You can be “mostly textured” and still look broken, because Quake materials are layered. A missing env stage or wrong blend does not degrade gracefully; it turns metal into void.
Documentation belongs next to the work, not inside the runtime tree forever. Status logs and format audits are invaluable while porting and distracting once someone only wants to run the game. Separating them keeps the code repository honest.
Do not self-promote milestones. PLAYABLE_COHERENT is a claim about a slice. REFERENCE_CLOSE needs eyes on pewter and portals. Machines should not rubber-stamp taste.
Conclusion
Quake III Arena’s GPL engine source is a gift: a complete, opinionated specification for how a late-90s arena shooter thinks. Porting a slice of it to PHP forced every assumption into the open — about filesystems, maps, materials, movement, and the boundary between language and GPU.
The result is not a product that replaces Quake III. It is a working laboratory: php bin/quake3.php play against a real baseq3, walking q3dm0 with Quake’s jump velocity still set to 270, plasma still asking the shot contents mask for permission, and the HUD still flashing low health on a 256 ms clock because that is what (cg.time >> 8) & 1 means.
If you have legal game data, a PHP 8.5 toolchain, and SDL2, you can run it. If you are curious about engines, you can read the PHP the way people once read the C — not as a framework tutorial, but as a map of decisions.
That was the point. Not to make PHP fashionable for games, but to take an engine seriously enough to reimplement its seams until a map became a place again.
Source Code: https://github.com/matyo91/quake-III-arena-php