ENGINE

The LS ENGINE

How it is constructed—and how to make your own

This beautiful essay describes the making of the LS ENGINE and explores its inner workings: how it runs 2D, 3D, and assorted other-D games in a web browser, and how you might make an engine of your own. Assuming, of course, that you have too much free time and an inadequate fear of debugging.

Why would you want to make a game engine? The main reasons are:

  • Code reuse: that beautiful code you wrote for one game might also work in another—or, in a rare outbreak of good architecture, in many more.
  • Abstraction: you study linear algebra once, bury it inside tidy little methods, give those methods tidy little names, and never again worry about how any of it works. Until it doesn't.
  • An editor: you want a tool that helps you build the game world using the same systems as the game itself. Otherwise, the editor and the game will eventually develop incompatible interpretations of reality.
  • There are more reasons, naturally, but you get the gist. Right...?

For me, it started with Castle Haunt. Just before that, I had made Princess Wants Everything, and the map format, map-to-world routines, and map-display methods were already there, waiting patiently to be reused. Copy and paste worked, of course, but that is for pussies—and for people who enjoy fixing the same bug in seventeen different places.

At that point, it became clear that my real hobby was not making tons of retro games. It was building my own game engine, written predominantly in JavaScript and designed for developing and running web-based retro games. The games, apparently, were merely a side effect.

The Digger Flux.1D
WishingWell Flux.1D

Third-Party Code and Other Necessary Evils

The first rule of my hobby is to use only my own code; otherwise, what is the point of having a hobby? This rule is, naturally, enforced only as rigidly as remains convenient. Since I am not writing the whole thing in assembly merely to preserve the complete 1980s experience, I have allowed myself a few notable concessions:

  • Bootstrap: I once made my own slim responsive CSS framework, but eventually decided to focus on the game engine rather than personally reimplementing the entire internet. Bootstrap is used only in the outermost presentation layer—the webpage surrounding the game—and has nothing to do with the games themselves.
  • glMatrix: while making Crawl Master II, I started using WebGL and writing shaders in GLSL ES. I adopted glMatrix to accelerate the learning process, fully intending to replace it when the time came to write my own matrix-and-vector library. When that time arrived, I bowed my head and said, “Nope. I shall keep this one. The wheel is already sufficiently round.”
  • jQuery: vanilla JavaScript can make even a simple DOM lookup feel like a minor administrative procedure. To find an element whose ID is stored in id, it offers:
    document.getElementById(id);
    jQuery lets me type:
    $(`#${id}`);
    Strictly speaking, the first expression returns a DOM element and the second a jQuery object, but the latter is what I want at this layer. Brevity has won the appeal. Of course, jQuery is used only in the outermost presentation layer, where it manages the game window and its canvases. It has no part in the engine's inner workings. There must be limits, even to convenience.

Prototype.js: A Modest Amount of Heresy

As I hinted earlier, Brendan Eich famously wrote the first version of JavaScript in ten days. He is not a god after all, although ten days remains irritatingly close. Perhaps as a result, JavaScript can occasionally resemble an ugly duckling. We love it nonetheless; we merely subject it to a little corrective surgery.

Accordingly, I committed the unspeakable heresy of adding methods to several native JavaScript objects and browser interfaces: Array, Math, String, Set, the Canvas API, and several other innocent victims. Respectable software engineering has naturally filed a complaint. I add a method only if that name does not already exist, and I use the library solely inside my own games, where any resulting catastrophe remains reassuringly proprietary.

My Prototype library also provides a collection of foundational classes—Grid, Vector, Point, Angle, Area, and assorted relatives—with either integer-grid or floating-point semantics, in both 2D and 3D variants.

These classes, however, grew out of a 2D worldview in which x and y form the game-world plane, while z represents height. For the 3D engine, I adopted a y-up convention: x runs horizontally, y represents height, and z provides depth. OpenGL itself does not require this particular world-axis arrangement, but it is the convention used by my 3D renderer. Consequently, y and z had to exchange identities. Splendid.

LS_matrix.js to the Rescue

LS_matrix.js acts as an adapter layer between the rest of the 3D engine, my Prototype classes, and glMatrix. An LS matrix object therefore leads a double life: it exposes the familiar Grid- and Vector-style properties used by the engine while also maintaining a flat floating-point array suitable for glMatrix and for upload through WebGL to the shaders. At the boundary, y and z quietly exchange passports. The rest of the engine is spared the paperwork.

MysteRya Flux.1D
PinkNordica Flux.1D

ENGINE.js: The Reception Desk

ENGINE is the first component to wake up. Its first order of business is to preload graphics, audio, GLSL shader source, object definitions, models, and assorted other assets, then prepare everything for use by the game. Nothing undermines the majesty of a game engine quite like asking it to draw a sprite whose image has not yet arrived.

ENGINE also organises the canvas layers, renders sprites onto them, handles conversions between bitmaps and canvases, and provides several flavours of MoveState and Motion classes, responsible for—surprise—moving objects.

Then come the various Actor classes, which encapsulate sprite properties and rendering routines. Timers live here as well, alongside inventory managers and methods for drawing HTML-like forms onto canvases. Once a file grows large enough, apparently, it develops its own gravitational field.

And, naturally, the magnificently global unit vectors used for directions. They are needed everywhere, so everywhere is precisely where they live.

Historically, more specialised functionality was gradually extracted from ENGINE into separate modules. This is why it is now a lean, disciplined 5,000 lines.

At that size, ENGINE is less a single-purpose module than the reception desk for the entire establishment. GAME arrives, announces what it needs and where it needs it, and ENGINE politely makes the arrangements while pretending that all of this was part of the original design.

GRID.js: The Nervous System

At a mere 3,000 lines, GRID.js is one of the engine's most essential components. It forms the next layer of abstraction above the foundational grid classes, connecting them to motion handlers, sprite-rendering routines, and several varieties of collision detection. It also houses pathfinding, including implementations of familiar algorithms such as A* (A-star) and Dijkstra's algorithm. Apparently, knowing where everything is was not enough; the engine must also know how to get there.

The following method calculates a 2D distance field using the engine's array-backed queue. Because every valid move costs exactly one, this is the unit-weight case of Dijkstra's algorithm and can be solved as breadth-first search. A binary heap is unnecessary here and would mostly contribute paperwork:


calcDistancesBFS_A(start, dungeon, mode = GROUND_MOVE_GRID_EXCLUSION, nodeMap = "nodeMap") {
    dungeon.setNodeMap(mode, nodeMap);
    let Q = new NodeQ("distance");

    dungeon.GA[nodeMap][start.x][start.y].distance = 0;
    dungeon.GA[nodeMap][start.x][start.y].goto = new Vector(0, 0);
    Q.queueSimple(dungeon.GA[nodeMap][start.x][start.y]);

    while (Q.size() > 0) {
        let node = Q.dequeue();

        for (let D = 0; D < ENGINE.directions.length; D++) {
            let x = (node.grid.x + ENGINE.directions[D].x);
            let y = (node.grid.y + ENGINE.directions[D].y);
            let nextNode = dungeon.GA[nodeMap][x][y];

            if (nextNode) {
                if (nextNode.distance > node.distance + 1) {
                    nextNode.distance = node.distance + 1;
                    nextNode.prev = node.grid;
                    nextNode.goto = ENGINE.directions[D].mirror();
                    Q.queueSimple(nextNode);
                }
            }
        }
    }
},
        
SkullRider Flux.1D
Wolverine1 Flux.1D

GridArray: The Dungeon in Bits

One of the central concepts inside GRID.js is the aptly named GridArray. It wraps a flat typed array in several helper methods, allowing its contents to be accessed either by linear index or by grid coordinates. Each array element represents one cell: a value of zero denotes empty space, while individual bits encode properties such as walls, corridors, closed rooms, doors, staircases, flooded terrain, and whatever fresh indignity the dungeon invents next.

GridArray, affectionately shortened to GA because three syllables were clearly excessive, stores the mostly static topology of the dungeon. Mostly static, naturally—a fragile wall can always be demolished. Permanence in games is generally just a temporary implementation detail.

Depending on its purpose, a GA can use 8, 16, or 32 bits per cell. This provides constant-time access to a cell's flags and suitably cheap occupancy checks for the wall-collision system.

The following method performs A* over a 3D node map, restricting movement to the XY plane unless fly is enabled. A* does not simply favour whichever node appears closest to the destination; it selects the node with the lowest estimated total cost: f(n) = g(n) + h(n). Here, path supplies g(n), distance supplies h(n), and setPriority() combines them.

In benchmarks on my own mazes, this array-backed priority queue outperformed my binary-heap implementation. That is not a new theorem of computer science, merely the welcome result of testing the code I actually run.


findPath_AStar_fast(start, finish, path = [0], type = "value", fly = 0, block = []) {
    if (GRID.same(start, finish)) return 0;

    var Q = new NodeQ("priority");
    let NodeMap = this.setNodeMap("AStar", path, type, block);
    const DIR = fly > 0.0 ? [...ENGINE.directions3D] : [...ENGINE.directions3D_XY_plane];

    NodeMap[start.x][start.y][start.z].distance = start.distance(finish);
    NodeMap[start.x][start.y][start.z].path = 0;
    NodeMap[start.x][start.y][start.z].setPriority();

    Q.queueSimple(NodeMap[start.x][start.y][start.z]);

    while (Q.size() > 0) {
        let node = Q.dequeue();

        for (let D = 0; D < DIR.length; D++) {
            let x = (node.grid.x + DIR[D].x);
            let y = (node.grid.y + DIR[D].y);
            let z = (node.grid.z + DIR[D].z);

            let nextNode = NodeMap[x][y][z];

            if (nextNode) {
                if (nextNode.path > node.path + 1) {
                    nextNode.path = node.path + 1;
                    nextNode.prev = node.grid;
                    nextNode.distance = nextNode.grid.distance(finish);
                    nextNode.setPriority();
                    Q.queueSimple(nextNode);

                    if (nextNode.distance === 0) return NodeMap;
                }
            }
        }
    }

    return null;
}
WhichWitch Flux.1D
Tigress Flux.1D

IndexArray: The Moving Parts

What GA is to mostly static cell properties, IndexArray—or IA—is to runtime objects. It provides constant-time access to the packed contents of any grid cell, allowing the engine to locate moving actors, items, fireplaces, torches, and other objects currently inconveniencing the dungeon.

A dungeon generally needs only one GA, but it may maintain several IAs. Each one covers a related family of object instances that the engine may need to query together. Apparently, even data structures eventually form cliques.

Like GA, IA wraps a flat typed array. Each array element corresponds to one grid cell and is divided into fixed-width bit banks. Every bank stores a compact object index, with 0 reserved to mean “vacant.” Packing several banks into one element allows a cell to contain references to multiple objects without allocating a separate array for every coordinate.

Bank width trades index range against maximum occupancy. A 32-bit element can contain four 8-bit banks, allowing four indices between 1 and 255, or eight 4-bit banks, allowing eight indices between 1 and 15. There is no free lunch, merely lunch compressed into bit fields.

The following method unpacks—or, in the established dialect of the engine, “unrolls”—all non-zero indices from a cell, beginning with the least-significant bank:


unroll(grid) {
    const items = [];
    if (this.isOutOfBounds(grid)) return items;
    const index = this.gridToIndex(grid);
    let layerValue = this.map[index];
    for (let i = 0; i < this.banks; i++) {
        const current = layerValue & this.layerSize;
        if (current) items.push(current);
        layerValue >>>= this.bankBitWidth;
    }

    return items;
}

IndexArray Managers: The Guest List and the Seating Plan

IndexArrays are organised into a higher abstraction: IndexArray Managers, or IAMs. GAME never deals with an IA directly; that would be beneath its dignity and, more importantly, far too low-level.

Each IAM combines its spatial index with an iterable collection of object instances, affectionately called POOL. The POOL is the guest list; the IA is the seating plan. Together, they answer two valuable questions:

  • What exists? The engine can iterate over every object in POOL to update its position and behaviour, draw it, or otherwise remind it that another frame has begun.
  • What is here? The IA identifies the objects occupying a particular cell. Which enemy is HERO touching? Which item? Which burning fireplace has become an immediate career concern?

If we step back for a moment, where does the responsibility of ENGINE end and that of GAME begin? ENGINE answers, “What just happened?” GAME answers, “What are we going to do about it?” A collision is geometry; losing a life is editorial policy.

Sometimes ENGINE can relieve GAME of a common burden. Preventing ordinary actors from walking through solid walls is reusable enough to become the engine's default behaviour. Individual games may still override it, naturally, because ghosts, secret passages, and debugging modes have never respected architecture.

Jumping can be more game-specific. ENGINE can provide the underlying Motion machinery, but GAME decides the jump rules: its initial impulse, gravity, permitted states, animation, and the precise degree to which the player should blame the controls.

Returning to IAM: it moves as much reusable bookkeeping as possible into ENGINE, allowing GAME to make one pleasantly simple call from its game loop:

ENEMY2D.manage(lapsedTime, HERO.player);

One polite call above the waterline; several thousand lines paddling furiously beneath it.

ZaraGraft Flux.1D
CorneredBabe Flux.1D

AI: Behaviour and Other Calculated Malice

The AI module is accessed only by IAMs. GAME never touches it directly. Not with a stick. By then they are fourth-degree architectural relatives: technically family, but no longer invited to the same function call.

Remember Pac-Man? Each ghost followed a different targeting rule and consequently displayed a recognisable personality. That is the territory of the AI module. It governs the behaviour of movable entities and their transitions between active and passive modes. Here, “AI” means authored decision logic, not machine learning or an enemy achieving self-awareness five minutes before release.

Depending on its behaviour pattern, an entity may wander blindly through the maze, guard a golden key, find the shortest route to the crossroads you are about to reach, or head towards the one you just left in anticipation of a cowardly reversal. It may quietly shadow your movements from just outside your field of view while steering you into a corner. It may even consider whether to shoot you now or wait for a better opportunity. Restraint has finally entered the engine, regrettably on the enemy's side.

All of this remains properly deterministic: the same state and the same inputs produce the same decisions. Well, except when not ...

And we have not yet mentioned the distance fields calculated by BFS or Dijkstra, nor all the paths found by A*. All of that happens within the same frame. On a 60 Hz display, an entire frame lasts about 16.7 milliseconds, and the game must share that time with browser event handling, rendering, compositing, and assorted invisible bureaucracy. The browser does not mercifully allocate the whole budget to us; it merely schedules the next repaint and waits to see whether we embarrass ourselves.

MAZE.js: Architecture Without Architects

MAZE is a dungeon generator that uses binary space partitioning (BSP) to divide the map into regions and place rooms. Corridors are then carved using a directionally biased recursive-backtracker, implemented iteratively with an explicit stack. Optional maze braiding removes selected dead ends and introduces loops. In short, it imposes order with BSP, becomes deliberately lost with depth-first search, and then removes some of the evidence.

It first powered my early Rogue-like games: initially the 2D Deep Down Into The Darkness, and later the pseudo-3D raycasting game Crawl Master. Both used fully procedurally generated dungeons, allowing the player and the developer to be equally uncertain about what waited around the next corner.

In later games, MAZE produced an initial dungeon template, which I then refined in MapEditor to better serve the level design. Algorithms, like writers, benefit from an editor, although neither enjoys being told so.

A version of the generator was also ported to MOS 6510 assembly and became part of a half-finished Commodore 64 game. The maze generator completed its work. The rest of the project showed less discipline.

MAZE can generate configurable, self-contained dungeon levels with multiple lock-and-key pairs arranged into a dependency chain guaranteed to be solvable: the first key is reachable and unlocks a room containing the next key, and so on. It can also designate a temple, a starting room, and an exit room. The dungeon may be cruel, but it is never fraudulent.

Teacher3 Flux.1D
Teacher2 Flux.1D

WebGL.js: The Behemoth

At roughly 8,000 lines, this behemoth took over most of the duties previously handled by ENGINE.js when I moved from raycasting to polygonal 3D rendering with WebGL and GLSL for Crawl Master II. Apparently, adding a third dimension also adds several thousand lines of code. Who could have foreseen this?

It constructs 3D worlds from GridArray data using pleasantly obedient cubic geometry, imports glTF models into LS ENGINE's internal object format, and adapts ENGINE's entity-motion classes to the needs of a 3D world. It replaces sprite-rendering classes with model-rendering classes and provides the machinery for cameras, dungeon decals, lights, world objects, items, traps, missiles, static dungeon dwellers, fire, and explosions. It is less a module than a small government, although marginally better organized.

It also generates GLSL shader source dynamically from the dungeon configuration, then compiles the shaders and links them into WebGL programs before rendering. Merely writing the shaders was apparently not ambitious enough; they also had to assemble themselves.

WebGL.js brings game mechanics and visual representation together under one roof. GLSL shaders paint everything visible on the screen, while its buffers and supporting data structures connect the rendered world to game-world interaction. This is the soul of LS ENGINE—a rather large, GPU-accelerated soul with an alarming number of responsibilities.

Honorable Mentions and the Inevitable Epilogue

LS ENGINE is more than its major modules. Beneath them toil smaller components that rarely receive applause: SaveGame, Speech, Terrain, Turn, Lights, Materials, and many other tiny but indispensable pieces of the puzzle. They work quietly under the hood, which is fortunate—if they started demanding recognition, the architecture diagram would become unbearable.

LS ENGINE does not have a GitHub repository of its own. Anyone interested in the details can visit the main LaughingSkull game page. Each listed game includes a link to its GitHub repository, where the relevant version of ENGINE can be studied in its natural habitat, surrounded by whichever game was abusing it at the time. Some versions have already become pleasantly retro. Software ages faster than programmers—at least according to programmers.

With persistence, small steps, continuous learning, and the masochistic correction of bugs I had thoughtfully created myself, I progressed from the modest Space Invaders-style shooter GalactiX to the fully 3D role-playing adventure Haunting the Hauntessa—a masterpiece, if I may say so myself. I may. This is my website.

But this is not GAME OVER. LS ENGINE is not finished, and neither am I. There are still games to build, ideas to overengineer, and entirely new classes of bugs waiting to be invented.

AtariGeek Flux.1D

LS-Web v1.16.5