Campaign Builder / v1.25.2

A developer guide to a codebase with no framework in it.

Campaign Builder is plain HTML, CSS, and ES modules, with no runtime dependencies and no build step for development. The conventions below keep a codebase of this size readable without a framework to enforce them. The counts, the mount order, and the code samples are read from the source tree at build time, so they match the commit you have checked out, and most of the panels respond to a click. LOC counts every line under src/. SLOC counts only the lines that are not blank and not comment.

95,920lines of source (LOC)
52,184lines of code (SLOC)
539source files
408test suites
0runtime dependencies
01

Pure logic and DOM glue

Every module is either pure logic, which takes its inputs as arguments, including the random number generator and the current time, returns new values, and never touches the DOM, or DOM glue, which connects that logic to elements and events.

Glue calls pure logic, and pure logic never calls glue. Because every import points the same way, most of the tree runs under node --test without a browser or a mock.

Import map: pick a directory to see what it imports

Counts and edges are read from the working tree. An edge is a real import statement, so a JSDoc type reference does not create one.

02

The composition root

src/main.js builds one AppContext and hands it to each wiring module in turn. The context bundles the engine objects, the campaign state a save serializes, and two registries that start empty: views for mounted panels and actions for cross-module operations.

The wiring modules fill those registries with 39 entries between them. A handler reads a registry when it runs rather than when it is wired, which lets an early module call a late one: partyWiring.js triggers an encounter without ever importing encounterWiring.js.

src/types/app.ts:239-253 | AppContext
export interface AppContext {  palette: TilePalette;  grid: TileGrid;  navigator: MapNavigator;  partyTracker: PartyTracker;  toasts: {    show(      message: string,      options?: { level?: 'status' | 'error'; action?: { label: string; onClick: () => void } },    ): void;  };  state: AppState;  views: AppViews;  actions: AppActions;}
Mount order: step through the 11 calls in main.js

    Order, roles, and reasons are read from the call site. Each registry entry is found by scanning src/app/ for what the module assigns.

    src/main.js:102-128
    wireCampaignActions(app); // dirty flag + header campaign controls; provides markDirty// The library loads before anything that offers its presets (the item form,// the enemy gear pickers), so the merged lists are live from the first open.wireLibrary(app); // Library mode: equipment/bestiary/NPC templates + custom-library file// The combat screen mounts before the encounters module, because that// module's refresh paths reach `views.combatScreen` while it is still// mounting.wireCombatScreen(app); // combat mode's full-width boardwireEncounters(app); // encounter + initiative panels, bestiarywireStory(app); // travelogue (logEvent), NPCs, quests, handouts// An Undo or Redo reloads the page and keeps the selected character and the// open tabs for this start.const reloadView = takeReloadView(sessionStorage);wireParty(app, reloadView); // roster, sheet, inventory, time// This call draws the first map, which also marks the encounter and NPC// tiles and rebuilds the Build-rail lists those markers share a node scope// with. The two modules that own those lists are wired above.const mapEnv = wireMapView(app); // canvas, trees, inspector, palette, fog, map toolswireGenerateAction(app, mapEnv); // shares the map's context rather than routing through actionswireDiceTray(app); // dice tray + the roll entries it writes to the traveloguewireHeaderMenu();// This must run last: mounting the role switch applies the starting role// straight away. That refreshes four panels and re-points the character// sheet, so everything it touches must already be registered.wireSessionControls(app); // mode/role switches (applies the initial role), tabs, sidebarwirePhoneViews(); // phone bottom bar; follows the sidebar tabs wired just abovewireShortcuts(app);

    The same lines, straight out of the composition root.

    03

    The panel contract

    A panel is a function mount<Name>(container, callbacks) that creates its own root, draws once, and returns { update }. A wiring module stores that handle on app.views, and from then on callers only ever call .update().

    A panel keeps no campaign data of its own. Everything it draws comes from a get* callback that runs at render time, so update() always reads current state, and every change it wants to make goes back out through a callback. A panel never writes state and never opens a dialog itself.

    src/ui/TimePanel.js:22-54 | mountTimePanel
    export function mountTimePanel(container, callbacks) {  const root = el('div', 'time-panel');  container.appendChild(root);  /** Every control here changes the clock, so each one rerenders the readout.   * @param {string} label @param {() => void} onClick @param {import('./icons.js').IconName} [glyph] */  const button = (label, onClick, glyph) =>    textButton(      label,      () => {        onClick();        render();      },      { icon: glyph, className: 'time-panel__btn' },    );  function render() {    root.innerHTML = '';    root.append(      el('div', 'time-panel__readout u-row u-g2', icon('clock'), formatClock(callbacks.getClock())),      el(        'div',        'time-panel__actions',        button('Advance', callbacks.onAdvance),        button('Short rest', callbacks.onShortRest),        button('Long rest', callbacks.onLongRest),      ),    );  }  render();  return { update: render };}

    Most list panels get this pattern from mountListPanel in src/ui/listPanel.js instead of hand-rolling it.

    Why identity comparison is enough

    A repaint is skipped when the rows are the same objects in the same order. That check is correct only because the entity layer never mutates in place: every writer returns a new object, so a changed row is always a different object.

    src/ui/listPanel.js:378-383 | repaintNeeded
    export function repaintNeeded(last, next) {  if (!last) return true;  if (last.gm !== next.gm) return true;  if (!Object.is(last.dependsOn, next.dependsOn)) return true;  return !sameRows(last.rows, next.rows);}
    src/ui/listPanel.js:392-396 | sameRows
    function sameRows(a, b) {  if (a.length !== b.length) return false;  for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;  return true;}

    The same rule elsewhere

    • Tile lookups. src/map/TileIndex.js gives O(1) tileAt(node, id), which is safe because a tile write replaces the node.
    • Derived map data. findRegionGroups and spanBlocks cache in a WeakMap keyed on the node. A mutated node would serve a stale answer forever.
    • Frozen catalogs. The built-in tables run through deepFreeze, so any code that copies one into campaign state has to do so explicitly, as CreatureTemplate.fromTemplate does.
    src/util/memoize.js:15-26 | memoizeByIdentity
    export function memoizeByIdentity(compute) {  /** @type {WeakMap<T, { value: R }>} */  const cache = new WeakMap();  return (input) => {    let entry = cache.get(input);    if (!entry) {      entry = { value: compute(input) };      cache.set(input, entry);    }    return entry.value;  };}
    04

    The map is one node type

    There is no world type, region type, or dungeon type, because every map is a MapNode. A node points up through parentId and a tile points down through childNodeId, and several tiles can share one childNodeId when a landmark covers more than one cell.

    The tile id is the position, so a tile has no x and y fields. Fog is one boolean per tile, and only a party move clears it. The example campaign has 149 nodes and 28,438 tiles.

    src/types/map.ts:15-33 | Tile
    export interface Tile {  id: string;  imageRef: string;  /** Image or images drawn on top of imageRef, for example a road or path.   * This lets path pieces layer over the terrain beneath instead of   * replacing it. Null means no overlay. An array draws in order, with the   * first entry at the bottom, so overlays can stack, for example a river   * channel over the shoreline where it drains into a lake. */  overlayRef: string | string[] | null;  metadata: TileMetadata;  revealed: boolean;  /** Id of the MapNode this tile zooms into, if any. */  childNodeId: string | null;  /** Side length, in tiles, of the block that this tile's image draws   * scaled across, anchored here and extending right and down. This is   * purely visual, and implies no region link. Absent or 1 means a normal   * one-cell image. */  span?: number;}
    Move the party: click a cell

    The demo starts at the party tracker's own default radius of 2.

    src/map/FogOfWar.js:27-54 | revealAround
    export function revealAround(node, centerId, radius) {  const center = parseCoords(centerId);  if (!center) return node;  // This code walks the bounding square of the disc by coordinate instead of  // mapping the whole tile array. This method costs O(radius^2) per party  // step instead of O(total tiles) and builds no id string per cell. A step  // that reveals nothing new returns the same node. This keeps the WeakMap  // caches for tile layout, region groups, and span blocks warm.  const r = Math.ceil(radius);  const radiusSq = radius * radius;  /** @type {Map<number, import('../types/map.js').Tile> | null} */  let changed = null;  for (let y = center.y - r; y <= center.y + r; y++) {    for (let x = center.x - r; x <= center.x + r; x++) {      const dx = x - center.x;      const dy = y - center.y;      if (dx * dx + dy * dy > radiusSq) continue;      const pos = cellPosition(node, x, y);      if (pos === undefined) continue;      const tile = node.tiles[pos];      if (tile.revealed) continue;      (changed ??= new Map()).set(pos, { ...tile, revealed: true });    }  }  if (!changed) return node;  return withTilesReplaced(node, changed);}

    Reveal is monotonic, and the function returns the same node when nothing new was revealed.

    05

    A campaign is one string

    Saving flattens live state into a CampaignState, runs it through five packing layers, and only then calls JSON.stringify. Loading reverses the chain, with migrations first. The layers exist because localStorage refuses writes near 5 MB, and on the example campaign they remove 97% of the string.

    Packing layers, measured on the example campaign

    Measured at build time by running the real packing functions over buildExampleCampaign. Layers 1 and 2 are measured with the generic packEntity against the same defaults the loader restores. serialize uses the specialized tile packer and comes out at 191,322 characters. On the densest node (The Marches, 48x48, 2,304 tiles) the codec alone saves 228,904 characters.

    src/storage/SaveManager.js:168-177 | packTile
    function packTile(tile) {  return copyPacked(tile, (key, value) => {    if (key === 'overlayRef' || key === 'childNodeId') return value == null ? SKIP : value;    if (key === 'revealed') return value === true ? value : SKIP;    // An absent span and a span of 1 mean the same one-cell image, per `Tile`.    if (key === 'span') return typeof value === 'number' && value > 1 ? value : SKIP;    if (key === 'metadata') return packMetadata(value);    return value;  });}

    Fields are deleted from a copy, so a field added after this function was written still survives the round trip.

    Undo without snapshots

    History stores one invertible delta per step rather than a copy of the campaign. diffState records the old and the new value of each change, invertOps swaps them, and the deltas apply on top of the saved campaign, so no snapshot is ever needed.

    A history write always follows the campaign write, and deltas are never migrated, so an index written by an older version is discarded whole.

    localStorage keys, with where each one is defined

    All file input and output is confined to src/storage/fileIO.js.

    06

    Where does my change go?

    Start
    07

    Working on the codebase

    Tests run against the source files, not the build. Run one test file while you iterate and the whole suite before you commit. The pre-commit hook runs the formatter, the linter, the suite, and the typecheck, and it blocks the commit on any failure.

    Commands

    Anything with a package script is read out of package.json.

    Checking the DOM and the canvas

    Code that touches the DOM or the canvas is checked in a real browser rather than a mock: serve the project, open the change, and read the console for 404s on asset paths. Playwright can drive and screenshot that check when you want it automated. The preview pages in tests/ do not end in .test.js, so the automated run skips them: map-canvas-preview.html, save-manager-preview.html, tile-preview.html, ui-panels-preview.html. Each one mounts the real modules the way main.js does.

    Coverage counts every module, because tests/moduleLoad.test.js imports all of src/ except main.js. A low row under src/ui/ or src/app/ is expected, since those directories are glue, but a low row anywhere else means the module needs tests.

    08

    Pre-flight

    Read this list before you commit or open a pull request.

    Conventions checklist

    docs/architecture/conventions.md explains each rule. docs/architecture.md is authoritative for design questions.