Darkwood Blog Blog
  • Articles
  • Watch
  • Releases
  • Creators
en
  • de
  • fr
Login
  • Blog
  • Articles
  • Watch
  • Releases
  • Creators

👾 I asked an AI agent to port Diablo to PHP.

on August 12, 2026

Log in to add a reaction to this post

🚀 1

It actually opens a window.

You can walk the streets of town.

Click a door and it opens.

Swing at a skeleton in the cathedral.

Load the original game data from a file you already own.

But that is not the interesting part.

The interesting part is that the whole thing is a real game engine written in PHP: an MPQ archive reader, CEL and CL2 decoders, an isometric tile pipeline, A* pathfinding, item affixes, monster AI, inventory, vendors, missiles, lighting tables, and an SDL2 frontend glued together with FFI. Not a toy demo. Not a sprite slideshow. A runtime that boots, simulates, and draws.

This article is a technical tour of that github source code repository — https://github.com/matyo91/diablo-php — written for PHP developers, engine programmers, and anyone who thinks “game engine” and “Symfony-adjacent language” cannot share a sentence.

What is Diablo?

Diablo 1 is the classic isometric action RPG this project targets. The repository does not ship Blizzard’s artwork or levels. It ships a PHP runtime that expects you to point it at a legally obtained DIABDAT.MPQ — the original Diablo 1 data archive.

What the codebase does implement tells you what kind of game this is. There is a town hub with NPCs you can talk to and buy from. There are dungeon level types — cathedral, catacombs, caves, hell — generated from seeds through classes named DrlgL1, DrlgL2, DrlgL3, and DrlgL4. There are monsters with mode sheets (stand, walk, attack, hit, death), doors that swap tile pieces when opened, chests and barrels that drop items, firebolts and arrows as missiles, an inventory grid, belt potions, and a bottom control panel that eats 128 pixels of a 640×480 framebuffer.

Atmosphere in this runtime is not a mood board. It is Lighting remapping palette indices through light tables, Vision revealing automap cells, and Audio cues that pull WAV files out of the same MPQ you used for graphics. When the player dies, GameState::tick flips mode to 'dead', starts a death animation, and plays the death cue. That is the tone of the game, expressed as code.

The project is honest about maturity. The CLI help text still marks the runtime as not fully playable for a human review grade. Automated smoke and scenario passes exist; retail visual fidelity is still a moving target. That honesty matters. What follows is about what is in the tree, not a claim that every cathedral pixel matches a 1990s binary.

Why PHP?

If you only ever meet PHP behind nginx, this project looks like a dare.

Look closer.

PHP 8.5+ is the floor in composer.json. The runtime leans on typed properties, match, enums-as-constants, and a PSR-4 layout under the Diablo\ namespace. That is the same language evolution Symfony developers already live with — just pointed at a game loop instead of an HTTP kernel.

CLI is the delivery vehicle. bin/diablo.php is a long-running process with max_execution_time disabled and a 512M memory limit. There is no request cycle. There is a window, a tick, a render.

FFI is the bridge. Diablo\Platform\Sdl loads SDL2 through FFI::cdef, creates a window and renderer, pumps events, uploads RGBA textures, and presents frames. Native code stays in one class. The rest of the engine stays in PHP: decode, simulate, compose.

Portability follows from that split. The asset pipeline is pure PHP (MpqArchive, Cel, Cl2, DunMicro). The display layer is whatever SDL2 your machine can load. On macOS, optional sound even shells out to afplay for WAVs extracted from the MPQ — a pragmatic hack while SDL_mixer stays deferred.

Experimentation is why this shape is interesting for AI-assisted engineering. You can dump a palette, freeze the first frame, profile sim versus render versus decode, hide actors, or compose the world on a CPU framebuffer with --software-compose. The language that hosts WordPress can also host an isometric blit loop. Once you accept that, a lot of “PHP can’t” folklore collapses.

The renderer is not WebGL. It is mostly software decode of 8-bit art into RGBA, then GPU upload via SDL textures — with an optional full software compose path for diagnostics. That is a deliberate engineering choice: make the pixels inspectable in PHP before you trust the GPU.

Running Diablo in PHP

Requirements

From the repository README:

  • PHP 8.5+ with ext-ffi
  • SDL2 library installed on the host
  • A legally obtained DIABDAT.MPQ (not distributed with the project)

Blizzard game assets are not included. You must own Diablo and take DIABDAT.MPQ from your own installation. The MPQ reader portions are adapted from mpqfs under the MIT license. The project’s LICENSE file is MIT.

Install

composer install

Autoloading maps Diablo\ to src/.

Launch

php -d ffi.enable=true bin/diablo.php --data="/path/to/DIABDAT.MPQ"

Or via environment:

DIABLO_DATA=/path/to/DIABDAT.MPQ php -d ffi.enable=true bin/diablo.php --smoke

Useful entry points:

php -d ffi.enable=true bin/diablo.php --data="/path/to/DIABDAT.MPQ" --town
php -d ffi.enable=true bin/diablo.php --data="/path/to/DIABDAT.MPQ" --level=1 --seed=12345

--smoke exercises town, cathedral, and save without asking you to click through a full session. --town skips the menu into a new game in town. --level and --seed drop you into a seeded cathedral. --help prints a long list of debug options (render layers, freeze-frame, tile coverage, combat capture, and more).

FFI must be enabled. The launcher checks ext-ffi and ini_get('ffi.enable') before constructing Diablo.

Controls

Input Action
↑/↓ Enter Menu
Click Walk / attack / operate / talk
Right-click Firebolt
1–4 Belt potion
I / C Inventory / character
E Equip
H / F Heal / Firebolt
S Save
Esc Save + menu

GameState::handleInput also wires quest, spellbook, and automap keys in play mode. Click targeting is the real interface: the cursor resolves a destination action — walk, attack monster, operate object, talk, pickup, spell, or ranged attack — then the sim executes it over subsequent ticks.

Repository architecture

At a glance:

Path Role
bin/diablo.php CLI entry, option parsing, MPQ probe / smoke
src/Diablo.php Application: MPQ → assets → SDL → GameState → loop
src/GameState.php ~14k lines: simulation + render orchestration
src/Engine/ Assets, path, lighting, vision, animation, dest actions
src/Engine/Render/ Scrollrt, DunMicro, IsoCoords, masks, software FB
src/Levels/ DungeonMap, DrlgL1–DrlgL4
src/Items/ ItemDat, inventory grid helpers
src/Monsters/ Monstdat, AiProc
src/DiabloUI/ Title dialog, menu list, selector
src/Platform/Sdl.php SDL2 FFI boundary
assets/txtdata/ Item and monster TSV tables
data/ Small JSON fixtures (items, spells, monsters)
maps/ Town map JSON
saves/ slot1.json save slot

Boot sequence

Diablo constructs the stack in one place:

$this->mpq = new MpqArchive($dataPath);
$this->assets = new AssetStore($this->mpq);
$this->sdl = new Sdl();
$this->state = new GameState($this->assets, new Audio($this->assets));

Logical window size comes from IsoCoords::SCREEN_W / SCREEN_H — 640×480. The game loop lives in Diablo::run: poll SDL events into GameState::handleInput, advance GameState::tick, call GameState::render, present.

Simulation spine

GameState::tick is the heartbeat when mode === 'play':

$this->processPlayer();
$this->processMonsters();
$this->processMissiles();
$this->processObjects();
$this->advanceTownerAnims();
$this->checkTriggers();
$this->updateLighting();

Death is handled in the same tick: clear path, set pmode to DEATH, switch UI mode to 'dead', message the player, play audio.

There are thinner helper classes (Combat, Player, Monster, Missile, Spell) in the tree. The live path concentrates behavior in GameState arrays and methods. When you read this codebase, start with GameState, then drill into Engine and Levels.

UI shell

DiabloUI\TitleDialog loads title background and a multi-frame logo PCX from the MPQ. Menu tracks states such as main, create, playing, vendor, and dead. UiList and DrawSelector draw focus. In-game HUD uses UiFont and control-panel CEL art (ctrlpan\panel8.cel is one of the smoke-test probes).

Rendering architecture

The production draw path is not the older diamond helper in Renderer / Iso. The live pipeline is GameState::render → dungeon draw → Scrollrt::drawGame.

Scrollrt documents its own contract:

DrawGame pipeline:
DrawFloor → DrawTileContent(DrawDungeon) → DrawOOB.
TILE 64×32, East +{1,-1}/+64px, zigzag rows, micro L/R, stack y-=32.

Coordinate spaces

IsoCoords names the spaces the engine juggles:

/**
 * Spaces:
 * - mega: dungeon[x][y] 40×40 mega tiles (DRLG)
 * - dPiece: dPiece[x][y] 112×112 piece tiles (ViewPosition lives here)
 * - micro: 32×32 (or triangle) CEL frames stacked on a piece
 * - screen: logical 640×(480−panel) framebuffer pixels
 * - ui: 640×480 DiabloUI rectangle
 */

World to screen:

public static function worldToScreen(int $dx, int $dy): array
{
    return [
        ($dy - $dx) * 32,
        ($dy + $dx) * -16,
    ];
}

Viewport height is 480 - 128 = 352 — the panel owns the bottom strip.

Microtiles

A dungeon piece is not one bitmap. It is a stack of micros: 32×32 (or triangular) CEL chunks with types:

public const TYPE_SQUARE = 0;
public const TYPE_TRANSPARENT_SQUARE = 1;
public const TYPE_LEFT_TRIANGLE = 2;
public const TYPE_RIGHT_TRIANGLE = 3;
public const TYPE_LEFT_TRAPEZOID = 4;
public const TYPE_RIGHT_TRAPEZOID = 5;

DunMicro::decode turns raw micro bytes plus a Palette (and optional light table) into RGBA. Left triangles unpack with padding and widening rows:

private static function decodeLeftTriangle(string $src, array &$buf, int &$pos, int $len): void
{
    // Bottom-up 31 rows; widths 2,4,...32,...2 with 2 pad bytes before even rows
    for ($i = 0; $i < 31; $i++) {
        if (($i & 1) === 0) {
            $pos += 2; // padding
        }
        $width = $i < 16 ? ($i + 1) * 2 : (31 - $i) * 2;
        $x0 = 32 - $width;
        $y = 30 - $i;
        for ($x = 0; $x < $width && $pos < $len; $x++) {
            $buf[$y * 32 + $x0 + $x] = ord($src[$pos++]);
        }
    }
}

Masks and transparency

After decode, MaskType post-processes RGBA:

  • SOLID — leave opaque texels alone
  • TRANSPARENT — set alpha to 128 for blending
  • LEFT / RIGHT — blend a triangular prefix region so walls meet cleanly

Left prefix math grows from the bottom of the tile:

private static function leftTransparent(int $x, int $fromBottom, int $w): bool
{
    $prefix = -32 + 2 * $fromBottom;
    if ($prefix <= 0) {
        return false;
    }

    return $x < min($w, $prefix);
}

Drawing a cell

Scrollrt::drawCell decides foliage versus wall bases, picks left/right masks from SOL flags and room transparency (dTransVal / trans list), then blits micros. Floors can draw foliage micros offset by −16 Y. Walls blit micro 0 and micro 1 side by side (MICRO_WIDTH = 32), then stack upper micros upward by 32 pixels each.

Entities — players, towners, monsters, objects, ground items, missiles — draw in dungeon passes with their own blit helpers. Flags like hideActors / hideObjects / hideItems / hideHud exist so you can isolate geometry when debugging.

Two present paths

Normally, decoded frames become SDL textures (createTextureRGBA / updateTextureRGBA) and the renderer presents.

With --software-compose, blits land in SoftwareFramebuffer first. That path exists so you can reason about every pixel in PHP — including RenderTrace JSON of who touched which pixel — before one final upload.

World representation

From generator to grid

Dungeon generation starts in mega space (roughly 40×40 DRLG cells). Generators such as DrlgL1::createL5Dungeon place rooms, corridors, stairs, minisets (lamps, dirt, shadows), then pass through piece expansion into dPiece — a 112×112 grid of piece IDs. DungeonMap holds:

  • dPiece — which piece sits on each world tile
  • dPieceMicros — micro definitions per piece
  • SOL flags — solid, transparent, block-missile
  • dTransVal — room/sector transparency groups
  • dSpecial / lighting indices
  • helpers like isWalkable, isSolid, blocksMissile, isFloorTile

Town loads through DungeonMap::loadTown. Cathedral / catacombs / caves / hell have dedicated loaders that call the matching DrlgL* generator with a seed.

Occupancy and vision

Occupancy tracks who stands where — player, monster, item, object grids with signed IDs for movers in flight. Vision floods visibility for the automap. Lighting maintains a light list and builds LightTables: shade is a palette index remap, not an RGB multiply. That matches how the original 8-bit art was meant to darken — swap color indices, then look up RGB once.

Camera and walk scroll

The camera follows the player’s dPiece position. During walks, WalkOffset interpolates a pixel offset from animation progress so the sprite (and optionally the camera) slides between tiles over eight walk frames:

private const MOVING_OFFSET = [
    Direction::S => [0, 32],
    Direction::SW => [-32, 16],
    Direction::W => [-64, 0],
    // ...
    Direction::E => [64, 0],
    Direction::SE => [32, 16],
];

public static function fromAnimInfo(AnimationInfo $anim, int $dir, bool $cameraMode = false): array
{
    $progress = $anim->getAnimationProgress();
    [$ox, $oy] = self::MOVING_OFFSET[$dir] ?? [0, 0];
    $x = (int) intdiv($ox * $progress, self::BASE_VALUE_FRACTION);
    $y = (int) intdiv($oy * $progress, self::BASE_VALUE_FRACTION);
    if ($cameraMode) {
        return [-$x, -$y];
    }

    return [$x, $y];
}

Scrollrt expands the drawn tile window with overscan so walking does not reveal empty edges. --camera-fixed freezes the view when you want to isolate actor motion from scrolling.

Asset loading

Everything visual and most audio starts as a path inside DIABDAT.MPQ.

MPQ

MpqArchive is an MPQ v1 reader: find header, load hash and block tables, decrypt sectors with MpqCrypto, decompress with MpqExplode (PKWARE) or zlib as flagged. Public API: hasFile, readFile, info. Smoke/inspect probes known paths such as:

  • levels\towndata\town.pal
  • ctrlpan\panel8.cel
  • towners\butch\deadguy.cel
  • plrgfx\warrior\wld\wldas.cl2

AssetStore

final class AssetStore
{
    /** @var array<string,string> */
    private array $cache = [];

    public function read(string $path): string
    {
        $key = strtolower(str_replace('/', '\\', $path));
        if (!isset($this->cache[$key])) {
            $this->cache[$key] = $this->mpq->readFile($path);
        }
        return $this->cache[$key];
    }

    public function loadPalette(string $path): Palette { return Palette::fromBytes($this->read($path)); }
    public function loadCel(string $path): Cel { return Cel::parse($this->read($path)); }
}

Path separators normalize to backslash; caching is by lowercased key. Palettes are 256 RGB entries. CELs and CL2s decode to RGBA strings the renderer uploads.

CEL

CEL is the workhorse for UI panels, objects, and many world graphics. Cel::parse reads a frame table (and handles grouped CELs by peeling a group header when offsets disagree with file size). decodeFrame walks RLE:

  • bytes ≥ 0x80 — transparent run (signed length)
  • otherwise — literal run of palette indices

Frames are stored bottom-up and flipped when building RGBA. Index 0 and nulls become fully transparent. Optional lightTable remaps indices before Palette::rgb.

CL2

CL2 is the animation sheet format for players and monsters. Files can be multi-group (commonly eight facing directions). Cl2::parse detects grouping; selectGroup switches the active direction; decodeFrame uses a control-byte RLE distinct from CEL’s. Light tables apply the same way.

PCX and fonts

Pcx decodes title and UI art, including sprite lists. UiFont loads artfont strips at multiple sizes (load42, load24, load16), measures strings, and renders glyphs for menus and HUD text.

From bytes to textures

Pipeline in one sentence: MPQ read → (decrypt/decompress) → cache → parse → decode with palette/light → optional mask → SDL texture or software blit.

Data tables outside the MPQ live under assets/txtdata/ as TSV: itemdat.tsv, prefix/suffix tables, unique_itemdat.tsv, monstdat.tsv. Those are game data the PHP runtime owns; art stays in the user MPQ.

Animation system

Timing

AnimationInfo is the shared clock:

public const BASE_VALUE_FRACTION = 128;

public function setNewAnimation(int $numberOfFrames, int $ticksPerFrame = 1, int $numSkippedFrames = 0): void
{
    $this->numberOfFrames = max(1, $numberOfFrames);
    $this->ticksPerFrame = $ticksPerFrame;
    $this->currentFrame = max(0, min($this->numberOfFrames - 1, $numSkippedFrames));
    $this->tickCounterOfCurrentFrame = 0;
}

public function processAnimation(bool $reverse = false): void
{
    $this->tickCounterOfCurrentFrame++;
    if ($this->tickCounterOfCurrentFrame >= $this->ticksPerFrame) {
        $this->tickCounterOfCurrentFrame = 0;
        ++$this->currentFrame; // or wrap / reverse
    }
}

getAnimationProgress returns 0..128 used by walk offsets and smooth scrolling. Fast attack / fast recover gear can skip frames when starting hit or attack animations — the affix system feeds that through GameState.

Player sheets

Player graphics resolve under plrgfx\{class}\… with armor and weapon characters encoded into the path prefix, then mode suffixes such as stand/walk in town versus dungeon, attack, hit, death, cast, block. GameState keeps separate CL2 handles for those modes and caches decoded frames per direction. warmRenderCaches pre-decodes stand, walk, attack, hit, block, spell, and death for all eight directions after load so the first combat swing does not hitch on decode.

Monsters

Monstdat ingests monstdat.tsv and builds CL2 paths like monsters\{suffix}{n|w|a|h|d}.cl2 for stand/walk/attack/hit/death. GameState::tryLoadMonsterCl2 swaps the mode letter and caches sheets on the monster array. Frame counts and attack hit frames come from the TSV’s frame/rate columns.

Towners and objects

Town NPCs and many interactive objects use CEL animations advanced in advanceTownerAnims / object processing. Doors are special: opening often swaps the underlying piece rather than only playing a decorative CEL — setDoorStateOpen / setDoorStateClosed keep world collision honest.

State machine

Player modes include stand, walk variants, attack, ranged attack, hit, block, spell, death. Monsters mirror a similar set. DestAction sits above movement: the high-level intent (attack that monster, operate that door) survives across ticks while walk paths and animation states churn underneath.

final class DestAction
{
    public const NONE = 0;
    public const WALK = 1;
    public const ATTACK_MON = 2;
    public const OPERATE = 3;
    public const TALK = 4;
    public const PICKUP = 5;
    public const SPELL = 6;
    public const RATTACK_MON = 7;
}

Gameplay systems

Movement and pathfinding

Click-to-move builds a path with Diablo\Engine\Path — A* with axis cost 100, diagonal 101, max length 25, optional corner-cut callback:

public function findPath(
    int $sx, int $sy, int $gx, int $gy,
    callable $isWalkable,
    ?callable $canStep = null,
    int $maxPath = self::MAX_PATH,
): array {
    // open set sorted by g+h, eight neighbors, reconstruct when goal reached
}

GameState converts the tile list into a walkpath, starts walk animations, commits the player to the future tile on the last frame in doWalk, then tries pickup and the next queued destination action. Chase logic refreshes paths when a targeted monster moves.

Combat

Melee: startAttack → attack animation → hit frame → hitMonster with to-hit versus armor (including piercing from TARGAC / plEnAc), damage roll, leech, knockback, weapon durability. Ranged: startRangeAttack / RATTACK_MON spawns arrow missiles. Monsters hit back through applyMeleeHitToPlayer, with block animations and mana shield absorption when active.

Resists cap at 75 in recalc. Fire, lightning, and magic resists reduce incoming elemental damage. Half-trap damage is an equipped effect check.

Objects and doors

Cathedral (and other themes) place door objects that occupy tiles and block paths when closed. Operating a door flips piece state and plays a door audio cue. Chests, barrels, shrines, traps, and décor have placement helpers and operateObject dispatch. Triggers on stairs move between town and dungeon levels (checkTriggers).

Items and inventory

ItemDat loads TSV tables and implements drop generation:

  • Filter by drop rate and monster level
  • Rare unique rolls against unique_itemdat.tsv
  • Otherwise base items; chance of magic prefix or suffix
  • applyAffixPower maps power names onto pl* fields and effects[] (fire resist, to-hit, damage %, attributes, steal life, knockback, fast attack, staff spells, and more)

GameState::recalc sums identified (or normal-quality) bonuses into player stats, caps resists, adjusts light radius, and zeroes mana when NOMANA is equipped. Inventory is a 40-slot bag plus belt and equipment slots; InventoryGrid understands item footprints. Cursor holds an item graphic from objcurs.cel via Cursor.

Spells and missiles

Right-click and F cast firebolt; H heals. Staff charges can override the primary cast with castPrimarySpell / tryCastNamedSpell. Missiles (arrow, firebolt, fireball, lightning, …) move each tick with line-clear checks against solid and block-missile tiles. Apocalypse, inferno, stone curse, flash, phasing, and related helpers exist on GameState for books and scrolls.

Vendors and town

Vendor mode routes acts for healer, merchant, blacksmith, witch, Cain identify, and tavern NPCs — repair, recharge, buy/sell style interactions. Towners animate in place while you walk the isometric streets.

Monster AI

AiProc::tick only thinks when a monster is not already walking:

return match ($ai) {
    'Skeleton', 'SkeletonBow', 'BoneDemon' => self::skeletonAi(...),
    'GoatMc', 'GoatBow', 'GoatLord' => self::goatAi(...),
    'Fallen' => self::fallenAi(...),
    'Scavenger' => self::scavengerAi(...),
    default => self::zombieAi(...),
};

Sense checks: same transparency room or clear line of sight via LineClear::notSolid. Ranged AIs require missile-clear lines. Walk planning uses Path with a short max length; walk lasts WALK_FRAMES (8) before the tile commits.

Save and audio

saveGame writes version 2 JSON to saves/slot1.json: name, class, position, vitals, gold, XP, stats, inventory, belt, equipment, dungeon seed, level type, quest flags, mana shield, infra/search timers, spell levels. Load restores and re-enters the appropriate map.

Audio maps cues to MPQ WAV paths (sfx\misc\walk1.wav, swing.wav, bfire.wav, …), caches temp files, and on Darwin may afplay them with a 50ms throttle so combat does not fork a hundred players.

Rendering challenges

None of these are exotic once you have lived inside an isometric engine. They are still easy to get wrong in PHP.

Palette and light. Art is 8-bit. Beauty and darkness both live in index space. Lighting::makeLightTables builds remaps; blit paths must thread the correct row or everything looks flat (--fullbright exists exactly to debug geometry without shade).

Microtile topology. Squares, transparent squares, left/right triangles, trapezoids, foliage passes, upper stacks — one wrong pad byte and a wall grows a black tooth. ReencodeDungeonCels exists to normalize triangle data before decode.

Layer order. Floor first, then walls and entities, then specials, then OOB fill. Draw a player before the wall they stand “behind” and the scene collapses. Scrollrt pass counters (DrawFloor_tiles, DrawDungeon_ents, …) exist because order bugs are subtle.

Transparency. Room transparency and left/right masks are not the same as CEL’s RLE zeros. Alpha 128 blending approximates the original’s blended blit. Disable masks with --debug-disable-masks when isolating stripes.

Animation versus camera. Walk offsets move sprites and optionally invert for camera mode. Desync them and feet slide through tiles or the world rubber-bands. AnimationInfo progress in 128ths is the shared language between sim and render.

Actor uniqueness. Trace flags assert an actor is not drawn twice in one frame. Overdraw looks like flicker; underdraw looks like teleporting.

Panel versus world. UI is 640×480; the world camera only owns 352 pixels of height. Click mapping must use Scrollrt::screenToTile math that respects scroll offsets and walk overscan, or your “click that door” hits the wrong tile.

These are ordinary engine problems. The unusual part is solving them in a language whose standard toolkit is HTTP and SQL.

Performance

PHP is not C. The project treats that as an engineering constraint, not a personality flaw.

Byte cache. AssetStore memoizes MPQ reads. Opening the same CEL twice is free after the first hit.

Decode cache. GameState keeps $playerFrameCache and $microCache (with metadata). warmRenderCaches pays decode cost up front for player modes. Monster CL2 sheets stick on the monster array once loaded.

Light tables. Built once, reused as index remaps — cheaper than per-pixel RGB shading.

FFI upload. The expensive edge is often texture upload and present, not PHP arithmetic. --profile-frame prints periodic FPS and breaks out sim / render / decode / upload style stats (including high percentiles) so you can see which bucket hurts.

Software compose. Slower, but turns “what touched this pixel?” into a PHP-side answer via RenderTrace. Use it to debug, not to ship the hot path.

Memory. bin/diablo.php sets memory_limit to 512M. Decoded RGBA for a busy cathedral scene adds up; caches are a trade of RAM for frame time.

Logic cadence. One tick advances player, monsters, missiles, objects, triggers, lights. Render may run more often than logic depending on the loop timing in Diablo::run, but walk and combat correctness are tick-based.

Interesting implementation details

CEL transparent runs are signed

The CEL decoder does not treat ≥ 0x80 as a simple “skip N”. It reinterprets the byte as a signed length:

if ($val >= 0x80) {
    $n = -$this->toInt8($val);
    for ($i = 0; $i < $n; $i++) {
        $row[] = null;
        // ...
    }
} else {
    for ($i = 0; $i < $val; $i++) {
        $idx = ord($src[$pos++]);
        $row[] = $idx;
        // ...
    }
}

Get the sign wrong and every sprite grows polka-dot holes — or worse, eats the next frame’s bytes.

Pathfinding prices diagonals slightly higher

public const AXIS_COST = 100;
public const DIAG_COST = 101;

That one-point diagonal penalty biases paths toward axis steps without forbidding diagonals, and pairs with a canStep corner rule so you do not cut through solid corners. Monster AI uses the same Path class with a shorter max length so packs do not plan cross-map routes every tick.

Affixes are data, combat is recalc

Prefixes and suffixes are TSV rows. ItemDat::applyAffixPower writes fields like plFireRes, plToHit, plEnAc, plFastAttack. GameState::recalc is the single aggregation point — the place Symfony developers might think of as a “player stats view model rebuild”. Unidentified magic items withhold pl* until identified; normals always apply. That one rule prevents a class of “I equipped a mystery sword and my damage jumped” bugs.

Audio is a cue table, not a mixer graph

private const CUES = [
    'death' => 'sfx\\misc\\dead.wav',
    'player_hit' => 'sfx\\misc\\swing2.wav',
    'swing' => 'sfx\\misc\\swing.wav',
    'door' => 'sfx\\items\\invgrab.wav',
    'pickup' => 'sfx\\items\\invpot.wav',
    'missile' => 'sfx\\misc\\bfire.wav',
    'cast' => 'sfx\\misc\\cast1.wav',
    'heal' => 'sfx\\misc\\healing.wav',
    'walk' => 'sfx\\misc\\walk1.wav',
];

Gameplay calls play('swing'). Platforms that can make noise do; others still get a cue log. The engine does not block the sim on audio.

The FFI boundary is intentionally thin

Platform\Sdl owns library resolution, cdef, window, renderer, events, and texture uploads. GameState never calls SDL_* directly. That is the same instinct as keeping Doctrine behind a repository — replace or mock the edge without rewriting the cathedral generator.

Saves are boring JSON on purpose

Versioned, pretty-printed, one slot file. No binary hero format. You can diff a save, corrupt it on purpose, or script loaders. For a research runtime, boring is a feature.

Scenario harnesses as executable specs

--scenario=ALL runs deterministic movement, combat, door, pickup, stairs, and ranged checks that print PASS / FAIL. They are not a substitute for looking at pixels, but they keep refactors from silently breaking “click door, door opens”. Smoke tests probe the MPQ and exercise town → cathedral → save. Together they form a CI-shaped safety net around a 14k-line state object.

Conclusion

PHP can run a lot more than typical web applications.

This repository demonstrates a full vertical slice of a 2D isometric ARPG runtime: archive I/O, sprite codecs, procedural dungeon wiring, pathfinding, combat, items, AI, UI, and an SDL2 frontend — assembled as ordinary PSR-4 classes you can read with the same eyes you use on a Symfony codebase.

It also demonstrates something quieter. Old games survive when someone can still load their data and explain their systems in a modern language. You bring your own DIABDAT.MPQ. The project brings the decoder, the simulator, and the window. Legal ownership stays with you; technical understanding becomes shareable source.

Is it finished? The help text declines to grade it ready without human confirmation. Is it interesting? Walk into town, open a door, descend a seeded cathedral, and watch PHP blit microtiles at 640×480.

That was the ask: port Diablo to PHP.

The surprise is not that an AI agent helped write thousands of lines.

The surprise is that the lines form an engine — and the engine runs.

Code source of the game

Source code is here : github.com/matyo91/diablo-php

Entry: bin/diablo.php · Namespace: Diablo\ · License: MIT (code) · Assets: bring your own DIABDAT.MPQ*

Log in to add a reaction to this post

🚀 1

Site

  • Sitemap
  • Contact
  • Legal mentions

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, all rights reserved