All Articles

Do you want to play a game (again)?

Robert rebuilt his 8-year-old Vue D&D combat app in BoxLang, using SocketBox/WebSockets for real-time state. Features SRD 5.2 character creation, turn-based combat with fog-of-war, and a chunked map system with dual-layer tiles and POI markers. Encounter state lives server-side; ~300 TestBox tests cover it.

It seems I have been in a retro game kick lately. Last month I worked on a BoxLang CLI port of the class Zork I text adventure game, and I also managed to give The Hitchhiker's Guide to the Galaxy the same treatment (due to licensing issues, that port has not been made public). I enjoyed working on those command-line programs, but I thought I would try something a little bit different.

Eight years ago (I cannot believe it has been that long...) I was just learning Vue, and I thought a Dungeons and Dragons-style combat app would be a great learning experience. The result of that was VUE-RPG and it was not a complicated "game" by any stretch of the imagination.

Combat Log

Giant Crab rolled a 20 for attack roll. Critical Hit!
Giant Crab: Claw for 6 points of damage.
You attack the Giant Crab, but miss!
Giant Crab: Claw, but misses!
You attack the Giant Crab, but miss!
Giant Crab: Claw, but misses!
You attack the Giant Crab, but miss!
Giant Crab: Claw for 3 points of damage.
You have died!!!!

You had the ability to create a character of any race and class combination you wanted, as long as it was a human fighter. You did, however, have the ability to assign your attribute points however you wanted. The main play area is the combat screen, where you could decide which monster you wanted to fight from the DnD5e monster manual JSON data file of the day. The only action available to you is the "Attack" button; if you like clicking buttons, this game was made for you, but it does not offer much beyond that.

I thought now would be a good time to revisit that old code.


The Background

In 2023, Wizards of the Coast released the 5.1 System Reference Document under the Creative Commons Attribution 4.0 license, which allows anyone to share or adapt the source material. This makes it possible to use the SRD content freely without worrying about licensing as long as they are attributed.

Licensing no longer a concern, it was time for the fun stuff, so I decided to lay down some ground rules for the new project.

  • The user should be able to create their own character using races and classes available in the 5.2 SRD.
  • Attempt to implement game mechanics for any skill, feature, and class abilities where possible.
  • Most D&D adventure modules are designed for more than one character, so the player should be able to select up to four characters when going adventuring.
  • When going on an adventure, you do not want to just click an attack button. You want to be able to strategize your movement; casters want to be in the back attacking at range, you want your tanks up front soaking up aggression. Implement a visible map with turn-based mechanics.
  • The game should be as performant as possible.
  • I am not the most creative when it comes to world building so the user should have the ability to create their own maps to play and to share with other users.

Those were not the only criteria for the new project, but they offer a solid beginning.

Performance turned out to be the trickiest of those to satisfy. The first version of the game was written using ColdBox with CBWire for the reactive bits, but CBWire's snapshot mechanism didn't hold up against the volume of data required to transfer map state in real time, so that approach was scrapped in favor of WebSocket support using SocketBox.

I also aimed to make every service testable. As complexity grows, a single adjustment might trigger downstream effects, so I now use a TestBox harness with nearly 300 test cases to keep operations stable.


Character Creation

Manage Party
Manage Party

The supported classes include barbarian, bard, cleric, druid, fighter, monk, paladin, ranger, rogue, sorcerer, warlock, and wizard. Depending on your class, you may choose skill proficiencies, though many provide flavor text alone. I aimed to ensure support for skill proficiencies that might align with combat mechanics. Your mileage may vary.

The next step involves selecting your character origin (race). At present, your options include Dragonborn, dwarf, elf, gnome, half-elf, half-orc, halfling, human, and tiefling, with racial ability bonuses already included. There is only one background available: Acolyte. Additional backgrounds may be added later.

The third step is selecting your ability scores. Each class is different so your selected class's primary attribute is shown. Always put your highest score there.

Step four is choosing your alignment. It does not affect gameplay but I included it anyway.

Finally, give your character a name and you are ready to manage your party and hop on to a map. Here's what's actually happening under the hood once you do.


The Combat Event System, Step by Step

Combat
Combat

1. Starting a fight

When a player picks a module/map and hits start, the client POSTs to public/api/combat.bxm. The server:

  • Verifies the requesting user actually owns every character in the party (403 if not).
  • Loads each character's full sheet, places them on the map at the spawn point, and spawns monsters appropriate to the party's average level.
  • Computes initial fog-of-war/visibility.
  • Builds one big state struct — HP, positions, whose turn it is, round number, action log, map tiles, etc. — and stores it server-side under a random encounterKey, in application.encounters.
  • If a monster is already visible on spawn, initiative is rolled immediately; otherwise the party starts in "exploring" mode (no turn order yet).

The response back to the browser is just a snapshot of that state plus the encounterKey, which the client holds onto for the rest of the fight.

Key idea: all the real state lives on the server, in memory, keyed by that encounterKey. The browser only ever holds a copy.

2. Connecting over WebSocket

Once in combat, the browser opens a WebSocket (/ws). When that socket connects, the server notes which logged-in user owns it. There's no separate "join this encounter" handshake — instead, every single message the client sends carries the encounterKey with it, and the server checks that the connected user matches that encounter's owner before doing anything. This is also why logging in after opening the app requires reconnecting the socket — otherwise it's permanently tied to "nobody."

3. Taking an action

Every button in the UI (Attack, Move, Cast Spell, Rest, End Turn, ...) does the same thing: send a small JSON message over the socket, e.g.

{ "type": "attack", "encounterKey": "..." }
{ "type": "move", "encounterKey": "...", "x": 4, "y": 7 }

If the socket happens to be reconnecting at that exact moment, the message is queued (up to 20) and flushed once the connection is back — so a click never just silently vanishes.

4. The server resolves it

The server looks up the encounter by key, checks it's actually your turn and that you're allowed to do this (not incapacitated, haven't already used your attack, etc.), then runs the actual game logic. For an attack, that's: figure out advantage/disadvantage → roll to hit → roll damage → apply it → check if the target dropped to 0 HP → handle death saves / defeat / loot / victory as needed. Enemy turns run through the same underlying attack machinery, just driven by AI target-selection instead of a player's click.

All of this happens against the one shared state struct in memory — there's no separate "commit" step, the struct is just mutated in place.

5. The server replies

After resolving the action, the server sends a state_update message back — but only to the connection that sent the action, not broadcast to every device in the party. To keep messages small, it only sends what actually changed (e.g. just the fields on your character that changed, not your whole sheet every time; map tiles only if you changed maps; newly-explored fog-of-war tiles rather than the whole map).

6. The browser re-renders

The client merges that update into one shared reactive object that the whole combat screen is built on top of. Because everything in the UI — HP bars, the turn banner, the map grid, the action log — reads from that same object, updating it is enough; nothing needs to be manually re-painted.

7. Turns and rounds

  • Turn order is decided once, via an initiative roll, the first time a monster becomes visible.
  • Ending your turn hands control to the enemies: they all act in sequence using the same attack logic described above.
  • Once every enemy has acted, the round number increments, everyone's movement/actions/attacks reset, and it's the player's turn again.
  • The fight ends in victory the moment the last enemy is defeated, or heads toward defeat as party members go down.

8. If the connection drops

The socket reconnects automatically with a backoff (1s, 2s, 4s... up to 30s), and re-subscribes to the same messages once reconnected. Because the actual combat state lives on the server (not in the socket itself), reconnecting doesn't lose anything — the client just resumes sending messages against the same encounterKey. The one caveat: a full page reload doesn't resume that server-side encounter — it starts a brand new one from scratch.


None of that combat logic matters much without a map to run it on, so that's the other half of the build.

How the Map System Works

Map Editor
Map Editor

1. Modules, maps, and why maps are "chunked"

A module is an adventure (e.g. "The Shadowed Hollow"). Inside it, a map is one distinct level or area — a module can have several maps linked together. Each map's tile data is split into rectangular chunks (32×32 by default) rather than stored as one giant blob, so a big outdoor map never has to be fully decoded just to read one corner of it — only the chunks a given view actually touches get decoded.

Each tile row inside a chunk is stored compressed (e.g. 4:#,4:. means "4 walls, then 4 floor tiles") rather than one character per tile, which keeps even a 128×128 map's data compact.

Separately, each map has a list of POIs (points of interest) — specific (x, y) locations flagged as a monster spawn, a loot drop, or a transition to another map.

2. Two layers per tile: walkability vs. decoration

Every tile is really two overlapping things:

  • A structural layer — just "wall" or "floor." This is the only thing that matters for pathing and line of sight.
  • A decoration layer — torches, doors, chests, banners, grass, water, etc. Purely cosmetic (with one exception: a closed door still blocks sight even though you can walk through it).

Keeping these separate means the person painting a map can dress up a floor tile with a torch or rubble without it ever accidentally becoming impassable.

3. The map editor

The editor is a straightforward click-to-paint tool: pick a tile from a palette (floor, wall, door, torch, chest, outdoor terrain like grass/water/mountain, etc.) and click a tile to stamp it. Separate placement modes let you drop the party's spawn point, a monster-spawn marker (with how many/what kind), or a transition marker (you type in which module + map it should lead to).

Saving a map doesn't try to cleverly patch just what changed — it wipes and rewrites the whole thing from what's currently painted in memory. That's a deliberate simplicity choice: the editor already holds the full grid in memory, so a full rewrite is simpler and safer than diffing.

Checking "Set as Entry Map" marks that map as where players land when they start that module — exactly one map per module can hold that flag.

4. Fog of war (this is combat-only — the editor shows everything)

During an actual adventure, the map isn't just "walls and floor" anymore — it's filtered through what the party can actually see. For every living party member, the game traces line-of-sight out to about 60 feet and marks which tiles are currently visible. Walls block sight, and — subtly — so does a closed door, even though you could walk through it.

Two sets are tracked separately:

  • Currently visible tiles — recalculated fresh whenever someone moves.
  • Explored tiles — once seen, a tile stays "known" (dimmed on the map) even after it drops out of line of sight, the same way old-school CRPGs dim explored-but-not-visible areas.

To keep this fast with a full party, each character's sightline sweep is cached and only redone when that specific character actually moves — not recalculated for the whole party on every single action.

5. What you actually see on screen

The combat map doesn't render the entire level at once — it shows a 19×19 tile window centered on whoever's currently acting, scrolling to follow them as the turn order changes. Tiles you haven't explored yet render blank; explored-but-not-currently-visible tiles are dimmed; visible allies/enemies/loot/transition markers get their own icon.

Clicking a tile to move doesn't path-find in the browser — it just tells the server "I want to go here." The server checks that the tile is reachable within your remaining movement and actually walkable, then simply places you there (no walking animation along the way).

6. Walking between maps

Transitions are just POIs sitting on specific tiles. When you step onto one mid-adventure, the server:

  1. Swaps in the new map's data,
  2. Drops your party at either a specific spot the transition specifies, or that map's default spawn point,
  3. Rebuilds the monster roster for the new map,
  4. Resets initiative/turn state, and
  5. Throws away the old fog-of-war cache and re-sweeps visibility fresh — since cached sightlines from the old map's walls would be meaningless on the new one.

The client only needs to re-download the full tile grid at that exact moment (every other action just gets small deltas) — since that's the one moment the map genuinely changes out from under it.


Other Stuff

The game does require a database backend because characters and modules/maps belong to the player that created them. I generally use mySQL so that is the format of the schema creation script as well as the seed data for the game.

I do not currently have this available on the web anywhere, but the full source code is available on GitHub.

None of this would have been worth starting without the SRD going CC-licensed — turns out "you can just build the thing" is a pretty good motivator. There's more to do (the map editor in particular still has some rough edges I'm chipping away at), but it's playable now, and that's more than the old Vue version could say for itself.