How a Wolfenstein 3D HTML5 port worked in April-May 2012
In April-May 2012, I ported Wolfenstein 3D to HTML5. The port is still available at wolf3d.wadcmd.com.
Not as a small raycasting demo. Not as a framework exercise. Not as a canvas toy that borrowed the feeling of the original. It was a browser port built from the platform that existed at the time: JavaScript, HTML, CSS, Canvas, XHR, audio elements, a little localStorage, and the patience to make those parts behave like a game.
There was no engine underneath it.
That sentence sounds simple now, but it is the most important architectural fact about the project. No game engine meant there was no inherited world model. No package manager meant there was no dependency graph quietly deciding what the program wanted to become. No framework meant there was no lifecycle to adapt to, no plugin interface to satisfy, no build-time abstraction standing between the game and the browser.
The browser was the engine.
That did not make the implementation easier. A complete game does not become simple because you avoid dependencies. You still need a renderer, resource loading, maps, collision, enemies, animation, input, sound, save games, menus, HUD, transitions, and timing. The complexity does not disappear. It moves closer.
In this port, that was the point. Every piece of the implementation could be shaped around one problem: make Wolfenstein 3D run in the browser that existed in 2012.
Internet Explorer 9 mattered. Chrome and Firefox mattered. Current browsers still run it today, but the architecture was formed by the constraints of that earlier browser landscape. Canvas 2D was the stable rendering target. Audio was fragmented. JavaScript engines were fast, but not generous enough to waste work casually. WebGL could not be the foundation if the goal was broad compatibility. The project had to be static, direct, and careful.
So it became a game made of ordinary browser parts.
Before Browser Ports Felt Normal
It is easy to forget how unusual this felt at the time.
Today, seeing an old game run in a browser is not surprising. We have WebAssembly, mature WebGL, better audio APIs, faster JavaScript engines, and a decade of examples proving that the browser can host serious ports. In 2012, that expectation was not really there yet.
Classic game ports to the browser were not a common thing in my memory. I remember a great Lemmings port from that period, but not a long list of comparable projects. The browser was becoming capable, but the idea that you could take the structure of an old game and make it feel at home in HTML5 still had a little bit of improbability around it.
That was part of the attraction.
The question was not only "can I draw a raycaster?" The question was whether the browser could carry the whole feeling of a game: the menus, the timing, the input, the sound, the little transitions, the data loading, the save slots, the sense that you were not looking at a web demo but playing something complete.
That question stayed with me after this port. Since then I have also ported DOOM and Another World, partially ported other smaller games, and I have been occasionally working for years on a Morrowind game port. The projects are different, but the underlying fascination is the same: what does it take to move an existing game into the browser without losing the thing that made it feel like itself?
Wolfenstein 3D was the project where I first answered that question seriously.
Static Files as an Architecture
The entry point is just index.html.
It loads a stylesheet and then a list of JavaScript files with script tags. The order of those tags is the dependency graph. There is no module loader to explain, no bundler contract, no runtime manifest. The files appear in the page in the order the program needs them.
That may look old-fashioned now, but it was also liberating. The deployment model was almost impossible to simplify further: serve the folder and open the page. The application did not need a server-side process to define itself. It did not need a compilation step before it could be understood. A browser could load it because the browser already knew the primitives it was made from.
The code is organized around global subsystem objects: game, raycast, maps, resources, player, ai, audio, controller, and a few smaller systems around animation, doors, secrets, scripts, fonts, and menus. Each file adds a part to the whole.
The pattern is plain:
game.extend({
newGame: function(map, difficulty, speed, callback){
...
}
});
Today, we would usually reach for imports. In 2012, for a static browser game targeting IE9, this was the smallest reliable module system available: objects, files, and load order. It was not elegant in the modern packaging sense, but it was concrete. You could read the HTML and know what the program was made of.
That concreteness shows up everywhere else.
Screens are not generated by a UI framework. They are small HTML fragments under html/. When the game needs the menu, the difficulty screen, the game view, the high score table, the sound settings, or a message dialog, it loads the fragment with XMLHttpRequest, puts it into the document, converts its text into the game’s bitmap-style text, and fades it in.
The game shell is therefore static, but not monolithic. It is a set of browser-native pieces, loaded only when the game asks for them.
That is the kind of architecture that comes from trusting the platform directly. There is no router. There is no component tree. There is no templating runtime. There is an HTML file, a request, a DOM node, and a screen.
The View Is Layered, Not Abstracted
The game screen is a stack of canvases and DOM elements.
At the back there is a canvas for the background. Above it there is the main view, where the raycaster draws walls and sprites. Above that is the weapon hand. Another canvas handles the fizzle-fade mask. A transparent control layer catches touch and mouse input. The HUD sits at the front with small canvases for score, floor, lives, face, health, ammo, keys, and weapon state.
This is not a general renderer. It is a layout for this game.
The separation matters because not every part of the screen changes for the same reason. The 3D view changes every frame. The HUD changes when a value changes. The weapon canvas changes when the weapon animation advances. The mask changes during transitions. By giving those concerns their own surfaces, the code avoids pretending that the whole screen is one thing.
That is a recurring theme in the port. There are abstractions, but they are local. They exist where the game has repeated behavior, not where a future engine might want an extension point.
The raycaster does not know about menus. The HUD does not know about wall rendering. The mask does not know about enemies. The DOM can still be used for layout where layout is the problem, and Canvas can be used where pixels are the problem.
This was one of the advantages of writing directly against the browser. The browser already had multiple materials. HTML was good for screens and menus. CSS was good for layering and transitions. Canvas was good for the game view. There was no need to force all of it through one abstraction.
Turning Images Into Game Data
The resource loader is small but important.
Wolfenstein-style rendering wants textures in predictable square cells. The port uses sprite sheets for walls and objects, then cuts them into 128-by-128 images at load time. An offscreen canvas draws one cell from the sheet, turns it into a data URL, and stores it in a lookup table.
After that, the renderer can ask for things in the shape it needs:
resources.walls[textureIndex][side]
resources.sprites[itemIndex][frameIndex]
That preprocessing step keeps the inner loop simple. The renderer does not want to think about sprite sheet layout every time it draws a wall column. It wants a texture image, a texture coordinate, and a destination column.
This is one of those small decisions that tells you what kind of program it is. The code does not build a generic asset pipeline. It prepares the assets into the exact form the renderer wants. Work that can happen once at startup is moved out of the frame loop. The frame loop stays narrow.
Again, the implementation is direct: generate the pixels, cache the pixels, put the pixels on screen.
Bitmap Font Rendering
The text rendering deserves its own place because it is one of those details that makes the port feel like a game instead of a web page.
The menus and HUD could not simply use browser fonts. A browser font would have been easy, but it would also have been wrong in a very visible way. Font rendering differed between platforms, antialiasing differed between browsers, and the original game had a very specific bitmap character. If the menus used normal DOM text, the illusion would break before the player even reached the first level.
So the port renders text as images.
There are two paths in the font system. font.BitmapFont uses a bitmap image that contains glyphs laid out horizontally. A small map tells the renderer where each character begins and how wide it is. When text is needed, the font renderer draws the matching glyph slices into an offscreen canvas, then turns that canvas into a data URL and inserts it into the DOM as an image.
The other path, font.Font, is even more direct. The small and large menu fonts are stored as compact bitmap data in JavaScript. Each glyph is a set of integer rows. Rendering a character means walking those bits, painting colored pixels into an ImageData buffer, and writing the result to the offscreen canvas.
That gives the code several useful properties.
Text can be colored without needing separate image files for every color. The same glyph data can become grey menu text, selected menu text, disabled text, read-this text, or high score text. It also means the game can process ordinary HTML fragments after loading them. A screen can contain readable text in the source, and game.processFont() replaces that text with generated bitmap images when the screen enters the game.
The renderer also caches the result by font and text value. Once a phrase has been turned into pixels, the same generated image can be reused. That matters because menus redraw and update, but the words themselves usually do not change. The port pays the text-rendering cost when it has to, not every frame.
It is a small system, but it captures the spirit of the whole port. The browser is still doing the work. There is still no external text rendering library. But the program refuses to let the browser's default presentation leak into the game. It takes the platform primitive, Canvas, and uses it to produce the exact kind of pixels the game needs.
Getting the Game Data Into Browser Shape
The browser port also needed the original game data in a form the JavaScript runtime could use.
That part did not happen by hand. The assets were converted from a Wolfenstein 3D editor format using a custom C# converter utility. The converter extracted the images and maps and turned them into browser-friendly files: PNG sprite sheets and JavaScript map data that the static app could load directly.
This was an important part of keeping the runtime simple. The browser code did not need to understand the original game data formats. It did not need a binary parser for every source file. It did not need to do expensive conversion work during startup. By the time the browser saw the assets, they were already shaped for the port.
Images became image files the browser could load normally. Wall textures and sprites became sheets the resource loader could slice into 128-by-128 cells. Maps became JavaScript files that populated wolf3d.maps, which meant a level could be loaded with a simple XHR request and evaluated into the same data structures the engine already expected.
That split made the architecture cleaner. The C# converter was an offline tool. The browser runtime was the game. The conversion step absorbed the awkwardness of extraction, while the game stayed static and dependency-free.
The open-source repository intentionally does not include those extracted game assets. The point of mentioning the converter is architectural: the port was not loading original game files directly in the browser. It used an offline conversion step to transform the data into web-native assets, then kept the runtime focused on playing the game.
The Raycaster at the Center
The heart of the port is the raycaster.
Wolfenstein 3D is built on a grid. The world is not arbitrary polygons. It is cells: empty space, walls, doors, secrets, objects, enemies. That makes it a perfect fit for a column renderer. For every vertical column of the screen, the engine casts a ray from the player into the grid, walks cell by cell until it hits something solid, computes the distance, and draws one vertical slice of texture.
The camera is just two vectors: the direction the player is facing, and the camera plane used to spread rays across the screen.
var cameraX = 2 * x / width - 1;
var rayDirectionX = player.direction.x + player.plane.x * cameraX;
var rayDirectionY = player.direction.y + player.plane.y * cameraX;
That is the beautiful thing about this kind of renderer. Once the math is in place, the world becomes very cheap to describe. A wall is a number in a map array. A ray walks the array. A texture slice becomes a one-pixel crop stretched vertically with drawImage().
The renderer does this hundreds of times per frame, once per screen column. It also records the distance of each wall hit in a z-buffer. That z-buffer becomes the truth for everything drawn after the walls. Sprites are projected into screen space, sorted from far to near, and each visible stripe is tested against the wall distance for that column.
This is how enemies disappear behind walls. It is also how weapons know what they are aiming at.
When an enemy sprite is visible in a column, the renderer stores that enemy in the player’s fireBuffer for that stripe. A hitscan weapon does not need a separate picking engine. It looks near the center of the screen, checks the same visibility information the renderer already computed, and damages the first visible target.
That reuse is one of my favorite parts of the implementation. The renderer is not only drawing. It is producing spatial knowledge. The weapon system uses that knowledge instead of inventing a parallel world.
Doors Are Not Walls
In a simple raycaster, a wall is just the first solid cell the ray hits.
Wolfenstein doors complicate that. A door is visually inside a grid cell, but it slides open. It can be partly open, fully open, closing, blocked by the player, blocked by an enemy, or locked behind a key. Treating it as a normal wall would be too crude.
The port gives doors their own state: position, texture, slide amount, action, delay, and optional key. During raycasting, doors encountered before the final wall hit are kept in a buffer. If a door is closer than the wall and its slide amount still blocks the ray, the renderer draws the visible door slice and writes that distance into the z-buffer.
The door has not moved through the map. Its texture has moved inside the cell.
Secret walls are the opposite. They really move through the map. When pushed, the starting cell becomes empty. The secret wall advances by a fractional state until it crosses into the next cell, then continues until it has moved two cells or is blocked. The map keeps separate secret-wall state so collision and rendering can both understand the wall while it is in motion.
This is where a lot of game code lives: not in grand abstractions, but in the difference between two things that look similar until you implement them. A door and a secret wall are both moving obstructions. Architecturally, they are not the same thing at all.
The implementation lets them be different.
The Main Loop Is a Set of Small Clocks
The game loop is driven by requestAnimationFrame, with vendor-prefixed fallbacks and finally setTimeout. The target tick is roughly 33 updates per second. Each frame calculates how much time has passed and turns that into a capped timeFactor.
The cap matters. If the browser stalls for a moment, the game should not respond by letting a guard sprint through the map or a door jump through its entire animation. Time compensation is useful only until it becomes a bug.
Inside the frame, the code updates the world in a straightforward order. Doors process. Secret walls process. Animations process. AI processes. One-frame script events are cleared. Positional audio events are played at a volume based on distance. Controls are processed. The player HUD updates. The raycaster renders.
There is no big scheduler.
There are arrays:
doors.process
secret.process
animation.process
ai.process
audio.process
If a door is moving, it is in doors.process. If an animation is active, it is in animation.process. If a sound should be played from a map position, it is queued in audio.process until the frame loop turns distance into volume.
This is the kind of simplicity that is easy to underestimate. A more general engine might have a unified entity lifecycle. This port does not need one. A door is not an enemy. A sound event is not a sprite. An animation is not a map script. Each small system gets the loop it needs.
That does not make the game less complete. It makes the completeness local.
Enemies Are Sprites With Intent
The enemy system starts from sprites and adds intent.
An enemy has a position, a texture resource, an animation set, hit points, a direction, a current action, and a process method. The current action is just a function reference: ready, moving, attack, fire, dying, dead, or a specialized behavior for a boss or projectile.
That turns each enemy into a small state machine.
The shared base handles the things every enemy needs: calculating which sprite direction should face the player, testing line of sight, choosing movement directions, walking through the grid, opening doors, taking damage, and advancing the current action.
The visibility check is not magic. It walks a line through the grid between enemy and player. If a wall or a closed door blocks the line, the player is not visible. If the enemy sees the player, or hears activity in the same floor area, it can wake up and begin attacking.
Movement is similarly local. Enemies choose chase, dodge, or run directions from nearby cells. They avoid blocked tiles, avoid reversing direction when appropriate, consider doors, and use a bit of randomness. It is not a general pathfinding library. It is the kind of movement model a Wolfenstein 3D enemy needs.
That is the point. The AI code is not trying to be generally intelligent. It is trying to create the pressure the game needs: guards noticing you, moving through corridors, opening doors, firing when visible, reacting to damage, and becoming part of the scene the raycaster knows how to draw.
The enemy is both a visual thing and a behavioral thing. It is a sprite with intent.
Input Is State, Not Events
Keyboard input is translated into game actions and stored as active or inactive key state.
That distinction matters because games do not usually want to respond to a key event once. They want to know what is currently true. Is the player holding forward? Is run active? Is strafe active? Was fire released? Should left/right mean rotation, or should they mean strafing because Alt is down?
The controller maps key codes to named actions, then the game loop processes those actions. Some actions are continuous. Some are single-fire. Some cancel each other. Some have release behavior.
Touch input uses the same idea. The game view contains a transparent table of control regions. Entering or touching a region activates actions like forward, left, right, or combinations. The input surface is crude, but it has one important property: it feeds the same controller state as the keyboard.
The rest of the game does not need to care where the input came from.
That is the right kind of abstraction. It does not invent a framework. It simply normalizes the one fact the game needs: which actions are active right now.
Audio Had to Be Pragmatic
Browser audio in 2012 was not one clean API.
The port uses WebKit AudioContext when it is available. In that path, sound effects are requested as array buffers and decoded into audio buffers. If that API is not available, the code falls back to HTML audio elements and checks whether the browser can play MP4 or OGG.
Music is handled as a looping audio element with multiple sources. Sound effects are loaded lazily and cached. Positional sound is deliberately simple: game systems queue a sound with map coordinates, and the main loop compares that position to the player. The farther away the event is, the lower the volume.
That is enough.
The game does not need a full audio engine. It needs doors to sound like they are nearby or far away. It needs enemies to make noise from their position. It needs music to loop. It needs guns, pickups, doors, secrets, and death sounds to happen at the right time.
The implementation solves exactly that.
State Lives in the Browser
A static game still needs persistence.
The port uses localStorage for settings, controls, graphics detail, sound volume, music volume, save slots, highscores, and zoom state. Save games serialize enough information to reconstruct the level: map name, difficulty, player position and direction, health, ammo, lives, score, keys, weapons, map and hit state, sprites removed or added, secrets, doors, and other level progress.
That choice preserved the static deployment model. No backend. No account. No database. No server session. The browser already had a small persistence layer, and the game used it.
This is another place where directness helped. The storage format did not have to serve multiple clients, sync across devices, support migrations for a live service, or survive hostile input. It had to remember a local game. The shape of the solution matched the shape of the problem.
Why No Dependencies Worked
No dependencies worked because the project was narrow.
It was not trying to create a reusable game engine. It was not trying to support arbitrary maps from arbitrary games. It was not trying to become a framework for browser shooters. It was trying to port one game to the browser, with enough fidelity that the browser disappeared and the game remained.
That narrowness made the absence of dependencies practical.
Canvas handled pixels. XHR handled files. HTML handled screens. CSS handled layout and fades. Audio APIs handled sound. localStorage handled persistence. JavaScript objects handled systems. The missing part was not a library; it was the code that made those browser materials behave like Wolfenstein 3D.
This is the line I still find important.
Dependencies are often useful when they provide a material or a stable primitive. But the core shape of a game is not a generic problem. The renderer, loop, collision, enemy behavior, resource model, and input feel define the game. If those layers come from a general engine too early, the game begins by negotiating with someone else’s idea of what a game should be.
Here, the negotiation was with the browser itself.
That is a very different relationship. The browser gives you primitives, not a game theory. Canvas does not tell you what an enemy is. XHR does not tell you what a map is. localStorage does not tell you how saving should work. Those decisions remain yours.
The port feels old in some ways because it belongs to its time. But it also feels unusually durable because there is so little around it that can expire. There is no abandoned framework version trapped underneath it. There is no engine upgrade path to follow. There is no transitive dependency tree to audit before the source can be understood.
It is just the program and the platform.
What I Would Preserve
If I were polishing the repository today, I would not rewrite it into modern JavaScript.
That would make the code look more familiar to current readers, but it would also remove part of what makes the project valuable. The source shows how a complete browser game was structured when the platform had become capable enough, but before the modern frontend stack became the default answer to every problem.
The globals matter. The script order matters. The direct XHR matters. The vendor-prefixed requestAnimationFrame fallback matters. The audio fallback matters. The little local process arrays matter. They show the shape of the browser as it was, and the shape of a program built close to it.
What I would add is explanation around the code, not a new identity inside the code.
Because the interesting thing is not that the implementation is perfect. It is not. The interesting thing is that the game is complete, understandable, and still runnable as static files. It owns its important layers. It can be opened without resurrecting a vanished ecosystem.
That is rare now.
What I Would Use Today
If I were building the same port from scratch today, I would not reproduce the 2012 structure exactly.
I would still keep the project small. I would still avoid a game engine. I would still avoid a framework. The core of the game would remain mine: the raycaster, the loop, the map representation, the resource model, the input layer, the enemy behavior, the save format. Those are the parts that define the game, and I would still want them shaped by the game rather than by a general-purpose engine.
But I would use modern packaging where it helps without changing the architecture.
I would use Vite. Not because the game needs a framework, but because Vite is a good way to run a local development server, use native-style imports, and produce a clean static output. The important distinction is that Vite would be tooling around the source, not the architecture inside the source. The game should still be understandable as browser code. The build tool should make development smoother, not become the thing the program is written for.
I would use imported modules instead of global objects and script order. In 2012, script tags were the simplest cross-browser module system available for this target. Today, import is the obvious way to make dependencies explicit. The same subsystems would probably still exist: renderer, resources, maps, player, AI, audio, input, screens. They would just be connected by imports instead of shared globals.
I would also use a formatter and a linter.
That was not really part of the workflow when this port was written. The code carries the habits and unevenness of that time: manual formatting, local conventions, and whatever discipline I brought to each file by hand. Today I would not rely on that. A formatter would remove style decisions from the work, and a linter would catch the boring mistakes before they became debugging sessions. That kind of tooling would not change the architecture of the game. It would simply make the source easier to maintain.
I would not use TypeScript.
That is not because TypeScript is bad. For many projects, especially larger application codebases, it is valuable. But for this kind of small, self-contained game, I would rather keep the source close to the runtime language. The important correctness boundaries here are not mostly type boundaries. They are behavioral and spatial: does the ray hit the right wall, does the sprite sort correctly, does the door block the player, does the enemy see through a closed door, does the save state restore the map exactly? TypeScript can document some shapes, but it would not be the thing that makes those systems true.
Plain JavaScript would also keep the source closer to the spirit of the port. The browser is the material. Vite and modules would clean up the development experience, but I would avoid turning the project into a modern stack demonstration.
The version I would build today would be more modular, easier to navigate, and easier to serve locally. But it would still be a static browser game. It would still be Canvas. It would still have no runtime dependencies. It would still choose ownership over generality at the layers where the game becomes itself.
The Port Still Runs
The open-source release does not include copyrighted game data. The source references images, maps, sounds, and music by relative path, but those files are intentionally excluded from the repository.
The source is available on GitHub at lazarv/wolf3d.
For local testing, the folder can still be served with a static server that proxies missing files to the hosted copy:
npx http-server . -p 8080 -c-1 --proxy https://wolf3d.wadcmd.com/
That keeps the source boundary clean. The repository contains the browser port. The game data stays outside it.
And the architecture still works because it was always just static files and browser APIs.
The Thing I Still Like About It
Looking back at this port, the thing I like most is not the raycaster, although I still like the raycaster. It is not the no-dependency choice by itself. It is not the fact that the code still runs.
It is the relationship between the program and its materials.
The implementation does not float above the browser. It touches it directly. It asks Canvas for pixels, HTML for screens, CSS for layering, XHR for files, audio for sound, localStorage for persistence, and JavaScript for the small machines that hold the game together.
There is very little pretending.
That kind of software has a particular feeling. You can follow it down. You can see where time is spent. You can see where state lives. You can see why a door opens, why a wall renders, why an enemy sees you, why a shot hits, why a sound is quiet, why a save game restores the room you left behind.
Modern software often hides those answers behind tools that are powerful, useful, and sometimes necessary. But there is still value in a program where the answers are close to the surface.
This port is one of those programs.
It is a game built from the browser, not from a stack around the browser. It belongs to April-May 2012, but it still says something I believe now: sometimes the best architecture is not the one with the most reusable layers. Sometimes it is the one that lets the thing you are building stay closest to the material it is made from.
For this port, that material was the browser.
And the browser was enough.
Top comments (0)