mdbergmann/lambda-tale

Lambda's Tale, Bard's Tale like game engine in Common Lisp

9

stars

128

commits

Common Lisp

primary language

Sep 7, 2026

updated

README

Lambda's Tale (engine)

A Bard's Tale-style dungeon-crawler engine written in Common Lisp for cl-amiga. Separate repo: the engine is pure Lisp and runs on the host clamiga for development; the Amiga front-end renders in an Intuition window or on an own custom screen.

The engine ships no story: a game is a sibling repo holding its worlds (maps + campaign) and loading the engine — the town of Closure next door is the playable example. The engine's own test suite plays a minimal fixture world (tests/world/).

Using the engine

Build clamiga in the cl-amiga checkout first (make host there), then from this directory:

make test    # run the engine test suite (plays tests/world/)
make assets  # regenerate the default tile packs (data/gfx*, tools/gen-walls.lisp)

A game loads the engine from wherever it lives and names its starting map — the engine is self-locating (it finds its own sources and default tile packs through *load-truename*, never through the working directory; the working directory belongs to the game):

(load "lambda-tale/src/load.lisp")     ; engine vendored as the game repo's
                                       ; lambda-tale/ submodule; or self-locate
                                       ; via *LOAD-TRUENAME* like
                                       ; Closure's src/load.lisp
(tale:play "mygame/village.map")               ; host front-end
(tale:play-amiga "mygame/village.map")         ; AmigaOS front-end

The test suite runs on the Amiga the same way (it is not part of the cl-amiga test-amiga run — the engine is a separate repo, mounted as its own FS-UAE volume; see CLAUDE.md for the launch command):

cd LambdaTale:
stack 128000
CLAmiga:build/amiga/clamiga --heap 8M --non-interactive --load tests/run-tests.lisp

On AmigaOS the suite additionally runs GUI smoke tests (both display profiles) and three unattended *autoplay* sessions through the fixture world.

Races

Races follow the engine's usual split: the engine knows what a race is — ability-score modifiers plus the list of hero classes the race may take — and a campaign registers the concrete ones with define-race in its campaign.lisp (the Closure game next door ships its own canon, specs/canon-data.md there). make-hero rolls the abilities (3d6 each), then adds the racial modifiers in place — no extra dice — and rejects a race/class pairing the race does not allow:

(define-race :dwarf :str 2 :con 2 :iq -2
  :classes '(:warrior :paladin :rogue :bard :hunter :monk)
  :description "Amazingly strong and healthy, but not always bright.")

(make-hero "Grod" :warrior :race :dwarf)   ; a stout dwarf warrior
(make-hero "Grod" :conjurer :race :dwarf)  ; error: dwarves cast no spells
(make-hero "Nym"  :rogue)                  ; raceless is still fine
(make-hero "Mab"  :rogue :woman t)         ; wears the class's woman's
                                           ; portrait (:image-woman)

The race rules are exercised end to end in tests/run-tests.lisp (search "Races") — the executable reference.

Layout

src/package.lisp     package TALE
src/version.lisp     the engine's version + the slots a game fills in
                     with its own (see "Version" below)
src/profiles.lisp    display profiles (:lores / :hires — screen geometry,
                     viewport, tile pack, layout tuning per target) and
                     the self-located *ENGINE-DIR* / ENGINE-PATH
src/dice.lisp        dice notation ("2d6+1") and the scriptable *RNG*
src/ilbm.lisp        IFF ILBM image reader/writer (pure CL, ByteRun1)
src/map.lisp         dungeon map model + ASCII map parser + story layer
src/knowledge.lisp   the party's automap knowledge (explored cells, seen walls)
src/view.lisp        first-person view geometry (view cone, perspective
                     planes, backend-independent display list)
src/time.lisp        the game clock: day/night, darkness, timed effects
src/game.lisp        game state, movement, automap observation
src/events.lisp      engine event bus + story flags
src/races.lisp       races: ability-score modifiers + which classes each
                     race may take (mechanics; DEFINE-RACE)
src/party.lisp       heroes, classes, races, xp/levels, party queries
src/items.lisp       item types, packs and equipment
src/spells.lisp      spell types, spell points, casting + the cast menu
src/combat.lisp      monster types, round-based combat
src/specials.lisp    cell-special interpreter (the story op vocabulary)
src/locations.lisp   locations (shops): mechanics + shared menu model
src/save.lisp        save games (readable Lisp data, never evaluated)
src/save-menu.lisp   named saves: the saves/ dir + the slot-picker menu
src/render.lisp      ASCII automap renderer (player view + omniscient debug view)
src/render-fp.lisp   ASCII wireframe first-person renderer
src/host-ui.lisp     host front-end (interactive ASCII walkabout, PLAY)
src/amiga-ui.lisp    AmigaOS front-end (Intuition window, graphics.library)
data/gfx/*.iff       the default tile pack for :lores (wall pieces +
                     floor/ceiling ILBM assets; regenerate: make assets)
data/gfx-hires/*.iff the same pack drawn for the :hires viewport
tools/gen-walls.lisp procedural wall-art generator
tests/run-tests.lisp engine test suite (make test)
tests/world/         the minimal fixture world the suite plays (a keep,
                     a dark crypt, a 30-line campaign)
specs/               design constraints (UI layout, map scale, screens)

Both front-ends draw the Bard's Tale split screen (see specs/ui-and-engine.md): the first-person view with the location plaque under it on the left, the scrolling message log on the right (in the engine's condensed bold microfont on the Amiga) with a slim strip of active-effect icons below it, laid out in effect order — an effect granting a compass shows the live rose in its slot — and the numbered party roster (# CHARACTER AC HIT PTS SPL PTS CL, two-letter class codes, the number columns right-aligned under their headings) at the bottom, its rows set solid — one glyph box each, no leading — the way a printed roster reads. Locations (shops, taverns) and the character sheet take over the message area: their menu renders at the top of the log page with the trailing log lines still scrolling underneath, while the view column shows the location's picture or the hero's portrait when the campaign ships one ((location ... :image FILE), define-hero-class ... :image FILE — resolved relative to the map file like effect icons; without one the live first-person view stays). A location also has a face from the street: facing its door puts its facade in the view column before the party ever steps in, so a city's houses have faces (the Bard's Tale building-front look). An optional :facade FILE names that street face; without one the :image picture shows from the street too — so a location can pair an exterior with a distinct interior, or ship one picture for both. A fight reads the same way: the round-orders page — one hero at a time, then the review — takes over the message area and the view column carries the enemy's portrait (define-monster ... :image FILE), so the party sees what it is fighting while it picks the round; the picture belongs to the leading group and passes to the next one as groups fall. The pickers — save and load, cast a spell, play a song, use an item — open as a centered dialog over the page instead, all four the same size: each of them lists names, a name has no room in the view column, and nothing worth watching happens beside a picker while it is open. The key reference lives on the help page under h/? — set in the same condensed small face as the message page, and scrolling (u/d, or its scrollbar) when the reference outgrows the page. The full automap lives under m — black ink on the grey page, doors and the party amber, with a legend beside the map listing the special places the party has found (shops, taverns and the like, each marker also drawn on its map cell — plain houses are scenery and carry no marker, or a city's front doors would bury the places that matter) and a footer showing the zone, position and game clock; maps can be large (30x30 like Bard's Tale I, up to 128x128). The page shows the map whole while it can do so legibly, and windows it when it cannot: cells below the glyph size carry no stairs, doors or markers at all, so rather than shrink past that the page keeps the cell its width affords and scrollsu/d, or the scrollbar down the map's right edge — opening centered on the party each time. The legend's column is reserved before the cell size is chosen, so the map never grows over it. The overlay pages, the takeover and the whole map page set the same face as the log — the engine's 5x7-on-6px condensed bold cut, the metrics of the actual Bard's Tale II text — so the Amiga UI carries one type size throughout. The key bindings are listed in the Closure README. On the Amiga the window uses the same geometry as the custom screen, so both displays lay out identically; the custom screen's geometry comes from a display profile (play-amiga's :profile argument):

  • :lores (the default) — a 320x200 layout, 32 colors, the ECS target: half the chip-RAM/DMA cost of hires and near-square pixels for the art. 200 lines rather than PAL's 256 so one layout serves PAL and NTSC alike — an NTSC machine has no 256-line mode to fall back on. That budget sizes the 120x100 viewport: the chrome pads, view, plaque and the seven solid-set roster rows fill it exactly — roster row 7 ends on the last usable line, with the chrome ring's clearance below it. The effect strip at the message column's foot is 20 pixels bought from dead seams in the layout — page slack, flush strip bottom — so the message page above it still clears eleven rows of text: enough that the shop's and the character sheet's last option stays on the page.

    The screen is a separate question from the layout, and it grows to fit the display it landed on: play-amiga asks the display database how tall the chosen mode really is (amiga.intuition:display-mode-height) and opens the screen at that height, capped by the profile's screen-max-height (256 here). So PAL opens 320x256 and NTSC 320x200, with the backdrop window clamped to the 200-line layout either way — the game lays out identically on both, and only the background below it differs. There is no PAL/NTSC test in the engine; the database answers for whatever the machine actually is, RTG included. A screen shorter than its display is one an emulator's auto-zoom crops and rescales and a real monitor letterboxes, which is the point of asking.

    Which display it lands on is a third question, and the profile's screen-max-height answers that one too: BestModeIDA is asked for 256 rows here, not the layout's 200. It matches on the size it is handed, and on a machine carrying both a chipset display and an RTG board 320x200 is an exact hit on an RTG mode — so asking for the layout would put the game on the graphics card, where its pixels are resampled to the monitor instead of shown. Asking for the tallest display the profile would accept keeps the native mode in front there and changes nothing elsewhere: a machine with no 320x256 returns its own best fit, which the clamp above brings back to the layout.

  • :hires — 640x256 PAL hires, 16 colors, the classic presentation with the larger 240x130 viewport.

Draw distance (slower machines)

The first-person view draws up to +view-depth+ (4) distance levels ahead. play-amiga's :draw-depth argument (1-4) trades some of that distance for frames on a slower machine:

(tale:play-amiga "mygame/village.map" :draw-depth 2)

Each level dropped is up to three fewer wall blits per frame, and ten fewer piece images (plus their style variants) decoded into bitmaps at load time; the corridor then ends that much nearer, fading into the ceiling/floor backdrop. Note that the far levels are the small ones — the win is mostly in per-blit overhead, not blitter time, so measure on the target rather than assuming.

A tile pack is unaffected: it must always ship the full set for all four depths (print-tile-manifest still lists every piece, and the loader still errors on a missing one), because a pack is data that has to work on any machine — the draw distance of whoever built it must not shape it. Draw depth decides which of those images are loaded, never which must be provided.

It is a rendering cap only: the automap still records everything the party could see, and darkness (a (zone :dark N) or nightfall) still shortens the view further when it is the tighter of the two. Each display profile carries a default, since a profile describes a screen while draw distance tracks the CPU: :lores draws the full 4, :hires draws 3 — it blits roughly twice the pixel area per frame, and the deepest level spends a blit on an 8x8 far wall. :draw-depth overrides the default either way (:draw-depth 4 buys the last level back on a hires machine that can afford it).

Note that :draw-depth is the only way in: binding tale:*draw-depth* around play-amiga has no effect, because with-display-profile rebinds it from the profile on the way in.

Draw width (faster machines)

:draw-depth's sideways twin, :draw-flanks (0-8, default 1), sets how many cells to each open side the view draws of a facing row of houses. The classic view shows at most the corridor's immediate neighbors — three houses where a whole street front stands (the original Bard's Tale drew just the one). Raising the knob repeats the already-loaded flank pieces one cell width further out per step, each with its own building's style, until the row, the viewport edge or a nearer wall ends it:

(tale:play-amiga "mygame/village.map" :draw-flanks 8)  ; fill the horizon
(tale:play-amiga "mygame/village.map" :draw-flanks 0)  ; the lone BT house

Each step costs one more blit per visible row cell per frame and nothing else — no extra piece files, no load time, so a tile pack is again unaffected. Like draw depth it is a rendering knob only: the automap records the same walls whatever the machine draws. Each display profile carries the default (both ship 1, today's look), and play-amiga's argument overrides it per machine.

Both profiles give the first-person view about 2/5 of the screen and the message log the other 3/5 — the text carries the game. The split is a profile knob, not engine code: the view column is exactly the profile's fp-width and the log takes the remainder, so a custom target with a different balance is a new profile plus a matching tile pack (see print-tile-manifest).

Both are picked RTG-aware through graphics.library/BestModeIDA (so Picasso96/CyberGraphX/MorphOS promote them to a suitable RTG mode), with the tile pack's palette, a borderless backdrop window and an Intuition menu strip on the right mouse button (GadTools). Quitting — from the menu, from q or from Esc — always asks first: a small confirmation box takes over until y ends the session or n/Esc (or a click beside it) returns to the game. For development there is also a window view on the Workbench screen (no custom palette): (tale:play-amiga "mygame/village.map" :display :window).

The whole game also plays by mouse: clicking the first-person view walks (left/right quarters turn, the middle steps forward, its bottom band steps back), clicking a roster row opens that character sheet, clicking a menu's numbered rows or its Sell-style option rows (the first letter is the key) acts as those keys, and the map/help/sheet pages close on a click elsewhere. The pages that open out of nothing have no row to click, so on the Amiga the menu strip carries them all: Game holds Save, Load and Quit, Screens holds Map, Help, Cast, Play and Use. No item shows a shortcut — Intuition can only ever offer right-Amiga+key there, and the game's own keys are Shift-S, Shift-L, Q, M, H, C, P and U, so the help page stays the one place that says what they are. The Screens items only ever open a page (asked for twice, a page starts over rather than closing), and they decline while a picker, a shop or a combat round owns the keys. The strip is data — *menu-strip* in src/keys.lisp, where the host suite checks both its layout and the pick decode; the Amiga front-end turns it into the GadTools NewMenu array. Menu option rows carry their pick key (menu-option / menu-numbered in src/events.lisp), so front-ends map clicks to keys without parsing the text. A page emits one option per row, the Bard's Tale look; a page with fewer rows than that needs squeezes in order — spacer rows go first, then the options themselves pack onto shared rows (fit-menu-lines), each keeping its own click target on the packed row (menu-line-spans). So a short page loses its blank lines and its vertical listing before it loses an option, and the last row — the sheet carousel's NEXT, the shop's Pool gold — stays on the page and stays clickable. A menu list deeper than a page — a big shop stock, a full pack on the sell page or the character sheet, a fat spell book — scrolls: u/d (or the scrollbar at the page's right edge — a click above the thumb is a window up, below it down) move the window and digits pick within it, so every item stays reachable with single-digit keys (menu-window in src/events.lisp; the scroll walks live in the model tests in tests/run-tests.lisp). Because those digits are the keys, they count from 1 again in every window — so a scrolled list says which entries it is showing, Spells: 9-16 of 24, on the head standing over it where the column can hold both and on a row of its own where it cannot (menu-scroll-head). Without it the second window of a long book reads as the same eight entries over again. The pointer is an open hand that turns into a pointing finger whenever it rests on something clickable; over the first-person view's walk zones it becomes the arrow of the move a click would make — left/right turn arrows on the side quarters, an up arrow on the middle, a down arrow on its back-step band (all campaign-replaceable — see pointer.iff and friends under "Custom tile packs"), and a busy hourglass shows during the loads that take real seconds at 14MHz: tile packs, save games, first-sight location pictures and icons. See the hotspot tests in tests/run-tests.lisp (amiga-ui autoplay drives the game by mouse clicks) for the full contract.

The first-person view never looks around corners (Bard's Tale rules): compute-view walks the cells straight ahead, stopping at walls, doors and the view-depth cap, and view-display-list flattens the result into line/door primitives that both the ASCII renderer and the Amiga draw-line renderer consume. Walking and turning call observe, which records every wall the party can currently see into the automap.

Wall graphics

On the Amiga the first-person view is drawn with blitted wall graphics (M3): every wall the view can show falls into a fixed Bard's Tale-style screen slot (view-blit-list — front walls, receding side walls, walls seen through open sides, each with a door variant, at four depths), and each slot is filled by a pre-rendered bitmap piece. The pieces are IFF ILBM files — one pack per display profile, data/gfx/ for :lores and data/gfx-hires/ for :hires (each profile's viewport dictates the piece sizes) — loaded by the pure-Lisp reader in src/ilbm.lisp and drawn by the procedural generator in tools/gen-walls.lisp (make assets regenerates both; the test suite compares the checked-in files pixel-for-pixel against the generator, so art and code cannot drift apart).

The rendering path is RTG-safe — no chipset or planar assumptions, so it works unchanged under Picasso96/CyberGraphX/MorphOS: pieces are uploaded once into AllocBitMap bitmaps in the display's native format (chunky pens through WriteChunkyPixels). Each frame draws the ceiling/floor backdrop first, then composites the walls back-to-front over it — the receding side pieces cookie-cut through a 1-bit mask (BltMaskBitMapRastPort, transparent where the piece uses pen 0), so the backdrop shows through the corners they don't cover instead of black wedges. When the assets are missing the view falls back to the wireframe renderer.

Custom tile packs

The default art is replaceable: a tile pack is a directory of IFF ILBM files. A zone declares its own pack in its map file — (zone :kind :city :gfx "gfx/") — and travel swaps packs as the party crosses zones. The directory resolves relative to the map file when the pack lives in the world directory, else relative to the game directory (a pack the game ships beside its worlds). play-amiga's :gfx-dir argument overrides everything for the session; without either, the active profile's pack applies (the engine's own data/gfx/ for :lores). A pack is drawn for one profile's viewport; a mis-sized pack is rejected at load time and the view falls back to the wireframe.

The generator ships two wall styles: the dungeon brick the default packs are drawn with (draw-wall-piece), and a city house style (draw-city-wall-piece in tools/gen-walls.lisp) that renders every piece as a timber-framed house — thatch roof band, plaster with dark framing, lit amber windows, stone foundation — so a city street reads as rows of houses, Bard's Tale style. A city pack using it must carry *house-colors* in pens 7–9 of its palette; Closure's worlds/closure/gfx/make-pack.lisp is the worked example.

A pack holds the 40 wall pieces plus optional extras:

  • front-0-v1.iff, side-2-l-v2.iff, ... — per-building style variants of any wall piece, probed in order (-v1, -v2, ...) until one is missing. The view deals them out deterministically per building — one walled-in mass of cells is one house and wears one look all the way along its front — so a street reads as a row of different houses instead of one repeated front; a (location ... :style N) op anywhere in the mass pins that building's look explicitly (Closure matches each block's street pieces to its houses' facade pictures this way). A pack ships as many looks — for as many pieces — as it wants to pay load time for: variants are per-piece, so trimming the far depths' files is a valid budget cut. draw-city-wall-piece's :style argument (:timber/:stone/:townhouse) draws the engine's three house looks.

  • floor.iff / ceiling.iff and ground.iff / sky.iff — the Bard's Tale split backdrop: one image fills the view above the horizon, one below, and the walls blit on top, carving the perspective. A zone takes one pair — ceiling/floor when it is :dark, sky/ground when it has a sky — because the two are painted to different rules. A pack that ships ground.iff paints its street instead of settling for one flat colour; only pens 5 and 6 follow the day bands, so whatever in sky.iff/ground.iff should darken with the hour must be one of those two pens. A missing file leaves a flat fill — pens 5 and 6 in the open, black underground. The default packs paint the floor as one flat color and the ceiling as solid distance bands darkening toward the horizon, split at the perspective-plane rows so each band lines up with a corridor depth — a pack can use the same trick, since the bands sit at fixed screen rows.

    Put the ceiling on pen 5 and the floor on pen 6, whichever pair a pack ships. The names are the contract, and a zone's :sky/:ground now reaches those two registers underground as well as out of doors (see "Every zone can declare its own colours"), so a dungeon pack laid out this way can be recoloured per zone for free — one pack dressing many dungeons. The demo packs predate that and do not: draw-backdrop-piece spends pen 6 on its near ceiling band and paints the floor in a fixed mid grey, which means a zone colour on one of them repaints the ceiling and calls it a floor. They are a drawing sample, not the layout to copy; Closure's worlds/closure/make-dungeon-packs.lisp (three dungeon packs, one painted stone each, the same flagged floor in all of them) is the worked example that keeps the contract.

  • palette.iff — any ILBM whose CMAP provides the pack's colors. Only the pens a pack owns are read from it (see the pen contract); entries for the engine's pens are ignored, so a stale palette cannot recolor the UI or the monsters.

  • pointer.iff / pointer-click.iff / pointer-forward.iff / pointer-back.iff / pointer-turn-left.iff / pointer-turn-right.iff — optional mouse-pointer art: the neutral pointer, the one shown over a click target, and the four directional cursors shown over the first-person view's walk zones. At most 16 pixels wide (a hardware sprite), pens 0–3 only — pen 0 transparent, pens 1–3 show as screen colors 17–19, taken from pointer.iff's CMAP entries 1–3 (the sprite has one palette; all pointers share it). The hot spot is the topmost-leftmost inked pixel. Missing files get the engine's built-in art — an open hand, a pointing finger and four arrows (*hand-pointer-art* and friends in src/ilbm.lisp).

(tale:print-tile-manifest) prints the full contract — every filename with its exact pixel size for the active profile (wrap it in tale:with-display-profile for another one) — so custom art can be drawn to spec; mis-sized pieces are rejected at load time with a message naming the file and both sizes.

The pen contract

The game runs on one screen with one palette, and walking into another zone swaps the tile pack under it. A bitmap, though, is nothing but pen indices — so an image loaded in one zone and still cached in the next re-colors the moment the new pack's CMAP lands. The screen's pens are therefore split in two, and src/palette.lisp is where the line is drawn:

pensowner
0enginetransparent key in wall pieces
1–3engineUI white, grey, amber
4engineopaque black
5–6packsky / ceiling, ground / floor
7–16packart
17–19enginemouse-pointer sprite registers
20–23packart
24–31enginethe shared figure core

Engine pens hold the same color in every zone. Pack pens are the pack's own and change under the player's feet on zone travel, which is the point: the night street and the cellar are the same pens in different colors. Only pack pens are loaded from a pack's palette.iff — a stale or hand-edited palette therefore cannot recolor the UI text, the mouse pointer, or the monsters standing in front of its walls.

A fixed pen is shared, not lost. A wall may paint in the figure core freely — the quantizer is offered it — it just cannot re-color it. So a pack has 16 pens of its own plus 12 more to draw with.

:hires is 16 colors, has no pen 24, and so has no figure core and no pointer pens: 0–4 engine, 5–15 pack, exactly as before. It is a wall-pack target only. :lores is where the contract lives and where new art should be drawn.

Pack colors need :display :screen — a window on the Workbench screen keeps the Workbench palette.

Transparency: in a wall piece pen 0 is transparent — the ceiling/floor backdrop shows through it, so the receding side walls don't stamp black wedges over the sky/floor. Paint solid black inside a wall (mortar, joints, door frames) with pen 4, not pen 0. The walls are composited over the backdrop with BltMaskBitMapRastPort (cookie-cut, RTG-safe); the backdrops are opaque, so pen 0 there is plain black.

The Closure game ships worked examples, all declared map-relative and all generated: worlds/closure/gfx/ — the town's painted houses under a day-banded sky, from its own make-pack.lisp — and three dungeon packs, gfx-rubble/, gfx-ashlar/ and gfx-rock/, one painted stone each over the same flagged floor and ceiling, from one make-dungeon-packs.lisp; each dungeon zone names the stone it wears. See also the "Backdrop slots" and wall-art sections of tests/run-tests.lisp for executable examples of the contract.

Packs from one hand-drawn facade

Drawing 40 pieces to spec by hand is a lot of work, and the procedural generators only make the looks they were coded for. The third route is tools/gen-pack-from-art.lisp: give it one flat, front-on picture of a wall and it derives the whole pack.

make pack ART=art/house.iff OUT=data/gfx-town/
make preview PACK=data/gfx-town/ OUT=street.iff

The source can be an IFF ILBM of any size and any depth — indexed art is expanded through its CMAP, 24/32-bit "deep" ILBMs are read directly — or a binary PPM (P6), the bridge for art that was never an Amiga file:

ffmpeg -i house.png -pix_fmt rgb24 house.ppm

The format is sniffed, not guessed from the name. write-deep-ilbm turns any of them back into a 24-bit IFF, so art that arrived as a PNG becomes a source you can keep editing in DPaint.

The natural size is the viewport (120×100 at :lores, 240×130 at :hires), which is exactly one wall cell at the nearest plane, so every piece comes out of a downscale. The perspective is pure geometry: front and flank slots are rectangles cut at the front slot's scale, side slots are the trapezoid between two perspective planes with the wall compressed into each column's visible span and the ceiling/floor corners left transparent.

More than one house. :variants takes further wall pictures, each a whole extra look, written as the -v1, -v2, … files the view deals out per building — so the street becomes a row of different houses rather than one facade repeated. :style N on a location pins a building's look, counting the base source as 0:

(generate-pack-from-art "art/house-1.iff"
  :out "worlds/closure/gfx/"
  :variants '("art/house-2.ppm" "art/house-3.ppm" "art/house-4.ppm")
  :pictures '(("art/house-1.iff" . "house-1.iff")))

Because a pack is one shared CMAP and pictures blit with the live screen palette rather than their own, every source above — base, variants and pictures — is quantized together, which is what makes a shop's takeover art belong to the same street it stands in. It also means the pens are a budget: four looks sharing 22 colors get noticeably less each than one look with all of them.

The pen layout follows the contract above, with the pointer's pens and the figure core held back — 14 art colors at :lores, 9 at :hires. The core is not a loss on top of that: the quantizer matches against those eight guaranteed colors too, it just may not redefine them.

Quantization works on the 12-bit grid the screen can actually show (set-rgb4, four bits a channel, on RTG as much as on ECS), and no two art pens are allowed to land on the same screen color or duplicate a fixed one — otherwise pens are spent on colors the machine cannot tell apart.

Every pack gets a palette.gpl beside its palette.iff: the same colors as a GIMP palette, which GIMP, Aseprite, Krita and Inkscape all import, each entry named with its pen number and role. That closes the loop — draw the next house against the pack's palette, then pass the pack's palette.iff back as :palette-source and quantization becomes a lossless lookup instead of a re-derivation:

(generate-pack-from-art "art/house-5.ppm"
  :out "worlds/closure/gfx/"
  :palette-source "worlds/closure/gfx/palette.iff")

Every piece kind has a -door- twin. By default both come from the same source, which suits a dense street whose facade already has a door in it; pass :door-source to give the door pieces their own art as soon as the player needs to see which walls can be entered.

tools/preview-view.lisp composites a pack the way the Amiga front end does — same blit list, same order, same cookie-cut rule — and hands back an ILBM, so art can be judged on the host without booting an emulator. make preview renders a fixture street that shows every piece kind at more than one depth. It is a preview, not a second renderer: where it and the Amiga disagree, the Amiga is right.

The art-pack tests in tests/run-tests.lisp are the executable spec — deep-ILBM reading, the box filter, median cut, the pen contract, and a whole pack built, reloaded through the loader's own size checks and composited.

Figures: art that travels between zones

A monster sprite, a hero portrait or an effect icon is not part of any pack. It is cached by the path it was loaded from and keeps rendering after the player walks into a zone with a different palette, so it must be drawn in pens no pack can move: pen 0 for transparency, plus 1–4 and 24–31 — twelve solid colors and a cookie-cut key, which is a Bard's Tale bestiary's worth.

generate-figure is the build step, and it enforces that rather than trusting it:

(tale::generate-figure "art/skeleton.png" "worlds/closure/gfx/skeleton.iff"
                       :transparent '(255 0 255))

:transparent names the source color meaning "nothing here"; those pixels become pen 0. Every other pixel is quantized into the figure palette, and the written image is then audited pen by pen — a pixel on a pack pen is an error naming the file, the pen, its role and the coordinate. The check runs on the host, where a per-pixel scan is free; a 68020 doing it at load time would not be.

The figure core is eight hand-picked constants, deliberately not derived from the art that uses it: a core computed over the bestiary would mean monster #17 changes the core and every pack in every world goes stale. Fixed means a new monster is a new file and nothing else moves. Given white, grey, amber and black come free from the UI pens, the core covers what hangs off a figure — a three-step flesh ramp (also wood and leather), two steels (armour and cold shadow), a red and a green (cloth, blood, scales, slime), and a bone highlight.

Every pack's palette.gpl marks each entry [FIXED] or as the pack's own, so this contract is visible in the file an artist opens.

Animated images

Any image the view column or the effects band shows — a monster portrait, a location picture, an effect icon — may ship animation frames beside it: mon-kobold.iff is frame 0, mon-kobold-f1.iff, mon-kobold-f2.iff, … the frames after it, probed in order until one is missing (the wall pieces' -v1/-v2 variant convention applied to time instead of style). Frames must match the base image's size and follow the same pen contract; the placeholder generators (draw-monster-portrait, draw-effect-icon :flame) take a frame argument and draw a two-frame pulse.

On the Amiga the frames cycle in place at ~3 steps a second on the window's INTUITICKS heartbeat — never through a full redraw. At load time the frames are diffed against the base and only the rectangle where they actually differ is re-blitted per step, so a portrait that only moves its eyes costs an eyes-sized blit, not a viewport-sized one. Identical frames are dropped at load; a mis-sized frame is a loud error like any mis-sized pack piece. The host renderer ignores frames entirely.

How a pack loads

Pack art is plain IFF ILBM — planar, ByteRun1-compressed, exactly what Deluxe Paint and friends read and write, so pieces can be edited in any Amiga paint program.

On the Amiga the pieces load without ever becoming chunky: an ILBM plane row and an Amiga BitMap plane row have the same layout, so the rows are poked straight into a scratch planar BitMap and the blitter moves them into the piece's own display-format bitmap (tale:*wall-load-planar*, on by default; bind it to NIL for the portable chunky path through WriteChunkyPixels). The cookie-cut mask comes from the same rows — for the usual pen-0 transparent key it is just the OR of the planes, folded once and reused both to decide whether a piece needs a mask at all and as the chip-RAM mask plane. Location pictures, portraits and effect icons load through the same planar recipe. All the per-byte and per-row work runs at C speed on clamiga: the file arrives through the bulk read-sequence path, the whole BODY is decoded in a single ext:unpack-byterun1 call, each plane's rows are gathered out of that interleaved buffer with one ext:copy-rows call per plane (the pure-CL loops are kept as the portable fallbacks), and the mask fold is a map-into #'logior over the packed plane bytes.

read-ilbm (chunky pens) remains the general reader and is what the host renderer, the pointer sprites and the asset generator use; read-ilbm-planar is the Amiga load path. The two are cross-checked pen for pen in tests/run-tests.lisp, and the Amiga suite blits a loaded piece back off the screen to confirm it carries the pens its ILBM declares.

Keeping packs loaded

Loading a pack decodes a directory of ILBMs into offscreen bitmaps — real seconds on a 14MHz 68020 — so a zone boundary between two packs (a town and its cellar) pays for the swap each way. tale:*gfx-cache-packs* trades memory for that time by keeping the pack the party just left, so walking back is instant:

valuebehavior
0never cache; reload on every swap (smallest footprint)
Nkeep up to N inactive packs, least-recently-used evicted first
:auto (default)keep one, but drop the cache when free memory falls below tale:*gfx-cache-min-free* (1 MB)

A :lores pack is roughly 40K of bitmaps plus 8K of chip-RAM masks, so :auto caches freely on a big machine and reloads on a small one. The cache lives inside a play-amiga session and is freed with it. Set it in a campaign, or bind it around the call:

(let ((tale:*gfx-cache-packs* 0))          ; a tight machine: never cache
  (tale:play-amiga "worlds/closure/town.map"))

Engine vs. story

Lambda's Tale is an engine; a game is one instance of it. The engine never hard-codes story facts. It emits events (:message, :enter-cell, :enter-zone, :enter-location, :blocked, :combat-start, :combat-end, :hero-died, :party-defeated, ...) that the front-end and the campaign subscribe to with on-event; story state lives in flags (set-flag/flag). A campaign is pure data on top of the engine: hero classes (define-hero-class), monsters (define-monster), items (define-item), spells (define-spell) and maps with cell specials. The Closure game is the shipped example; the engine suite's tests/world/ is the minimal one.

The world: cities, dungeons, shops

A world is a set of zones — ordinary map files linked by travel. Cities and dungeons are both first-class and both just maps: a (zone :kind :city :title "Frogmorton" :gfx "gfx/") form in the map file says what a zone is (and, optionally, which tile pack it wears), and the (travel FILE [X Y] [FACING]) special op links zones together — city gates, stairs and portals are all map data (a stair that asks before it is taken wraps its travel in the ask op, see "Asking first" below). A world can hold any number of cities and dungeons — every zone is its own file, and each keeps its own map and automap knowledge alive for the whole session; save games carry the whole world.

A location — a shop, or any enterable building — is the (location TITLE KIND ARG...) special op on a cell. The engine ships shop mechanics: items are campaign data (define-item — with prices, damage dice, AC bonuses, class restrictions, a player-facing :description and designer :notes carried as data for generated catalogues, in the Bard's Tale equipment kinds: weapon, armor, shield, helmet, gloves, bow, arrow, instrument, ring, wand, figurine, plus plain :misc), heroes carry up to 8 items and equip one item of each equipment kind — every worn piece's AC counts — combat uses the equipped gear, and shops sell their :stock and buy anything back at half price; p pools the party's gold onto the shopper, Bard's Tale style (the character sheet offers the same key, pooling onto the viewed hero), and t on the sheet trades gold back out: a digit picks who receives, then the sum is typed — digits, Backspace, Return — so a pooled purse splits again without a shop in sight. o on the sheet changes the marching order (move-hero): a digit names the viewed hero's new slot and the others close ranks — order matters, because the first three living members are the front ranks, and reach cuts both ways: they are the heroes monsters can hit and the only ones who can trade melee blows back. A hero behind them attacks only with a missile — an equipped bow and arrows (the arrows carry the damage dice, the shot aims by DEX instead of STR), or a weapon given a :reach of its own, the thrown kind, which needs no bow beside it. Bare of either, the attack action is out of reach and the orders page says so; carrying one, the hero shoots as far as its :reach allows (see the distance rules under Combat). A weapon defined :two-handed fills both hands, D&D-style: it will not go on beside a shield, nor a shield beside it, and the pack and shop pages mark it (2H); an instrument defined :tireless spends no tune (see Bard songs). Equipment is managed from the character sheet: the NEXT row (n, or a click — the sheet pages as a carousel: stat block, pack, a caster's or singer's spells/songs page, back around) turns to the hero's pack page, where a digit puts a pack item on or takes it off again; items a hero's class cannot use are marked (u) there, on the sheet and in the shop (the item card spells the full (unfit) out) — the shop still sells them (another hero may carry them), the marker just warns before the gold is gone. p on that page hands an item to another party member: a digit picks what to give, then a digit picks who receives it (each row showing the room left in their pack). Carrying is not using — an unfit item passes freely, so one hero can haul another's gear — and the fallen both give and receive, as with pooled gold; a full receiving pack refuses the item and leaves it whole with the giver — and the numbered rows are the gear, the quest pieces having a page of their own (see Quest pieces below). t on the pack page throws an item away for good: a digit picks it and a clickable yes/no stands guard — the one pack action that destroys, so it alone asks twice. i on the pack page — and on the shop's buy page, before any gold changes hands — inspects an item: a digit opens its card — kind, damage, AC bonus, price, class restriction, and the campaign's :description text when the item carries one. An item can also be usable (:use — a torch, a potion, a wand): using it (u, the use menu, in the open or as a combat-round order) heals a chosen hero, fires another non-battle instant (a figurine's :summon), installs a timed effect from the same vocabulary spells speak — or casts a registered spell outright: :use '(:cast SPELL) is the spell-trigger item (Bard's Tale's Wizhelm), casting for free with no spell points and no spellbook; a battle spell politely waits for a fight. A :consumed item is spent on use, and :image gives the effect its band icon. See the "Usable items" and "Spell-trigger items" test sections of tests/run-tests.lisp for the exact rules.

Quest pieces. A pack holds eight things, and a long campaign hands out more keys, tokens and proofs than a party has hands. :quest t on an item marks it a plot piece: it rides outside the eight-slot limit, so it never fits or fails to fit and never crowds out a sword. A quest piece has no price (define-item refuses one), no shop buys it and no hand throws it away — the way forward is not the player's to sell or destroy. It reads on the pack page's own quest page (r, offered only to a hero carrying one) rather than among the numbered gear rows: a title and the item's :description under it, scrolled like any long page. The gate ops (when-item, take-item) see it like any other item, so a piece is still something the party is carrying — visible, on a character sheet, spendable at the door it opens — and not a story flag wearing an item's name.

Two more location kinds spend gold on recovery, Bard's Tale style. A temple(location TITLE :temple :price N :raise M :raise-per-hp R :cures ((AILMENT PRICE) ...)) — heals for N gold per missing hit point (default 2), plus a fee to raise a fallen hero: the flat M (default 50) and R (default 0) for every one of the patient's full hit points, so a map may make a raising scale with the life restored. That fee buys the life, not the health: the hero comes back at a single hit point, and every hit point above it is a wound like any other at N — so making the fallen whole costs the fee plus the rest of their health. Its priests also lift conditions, at the flat price :cures names per ailment — and only the ones that table names: a condition this house does not treat walks out of the temple however much gold is offered. Its menu asks twice: Who wishes healing? — a digit per hero they have work for, wounded or merely ailing — then Who will pay? — any purse in the party, the patient's own included. The purse buys in one order: the raising first (there is no cleansing a corpse, so a purse short of the fee buys nothing at all), then each cure it can cover whole, then wounds one at a time with what is left. A purse that buys nothing leaves Not enough Gold as the menu's last line. An energy fount(location TITLE :energy :price N) — refills a living caster's spell points at N gold apiece (default 3), the Roscoe's of the piece; singers refill at the tavern instead. It asks the same two questions as the temple: Who wants to refresh spell points? — a bare prompt, since the roster pane below it already shows every hero's spell points and purse (a digit or a click on a roster row picks, and a hero the waters have no work for is told so) — then Who will pay? over the party's purses, with the same Not enough Gold notice for one that falls short. The "Temples" and "The energy fount" test sections of tests/run-tests.lisp are the executable specification.

The guild(location TITLE :guild :gold DICE) — is the Adventurers' Guild of Bard's Tale tradition: characters are made and parties formed there. Heroes not marching wait in the game's roster (saved with the game); the guild's pages move them — Create a character walks race, class, portrait where the class carries two (:image-woman), and a typed name (letters, digits, spaces, - and _, up to 12), then shows the 3d6 roll for keeping or rolling again, the fresh hero signing the roster with DICE gold (campaign data decides the purse; default 0); Add a member moves a waiting hero into the party, Remove a member sends one back to the hall, and Delete a character strikes a name for good behind a yes/no. Save game and Load game open the save/load picker right there, so the guild is where a tale is put down and taken up again — a save made inside any location now restores inside it. A campaign that starts at its guild puts the location on the map's start cell and defines default-roster (heroes waiting at boot) instead of default-party (heroes marching at boot; both hooks are optional) — the boot walks straight into the guild menu, and the guild will not send an empty party out onto the street. Leaving a location entered without a step (the boot's start cell, a TRAVEL arrival) still steps out the front door when the cell has exactly one passable side. The "The guild" test section of tests/run-tests.lisp is the executable specification.

Any location may keep hours(location ... :closed :night), a day-band or a list of them — and its door will not open while the clock stands in one: the party is told, an entering step is bounced back onto the street facing the shut door, and :location-closed fires for campaign scripts. The op stays top-level map data, so the closed shop keeps its street facade. See the "Opening hours" test section.

A location may also name a picture(location ... :image "gfx/shop.iff") — shown in the view column while its menu is up, plus an optional street-facing facade:facade "gfx/house-0.iff" — shown instead of the :image when the party faces the location's door from outside (see the "Facades from the street" test section), and a plaque name:plaque "The Guild" — a short form the view plaque under the first-person view (and the host header) shows while the party stands inside, where the full title would not fit; without one the plaque keeps the zone's name. A location page's title banner drops its closing *** when the full *** TITLE *** would overflow the narrowest takeover column — the ornament may not cost the page a row. For the same reason a location model may answer a key with (:notice TEXT) instead of a note row: a sentence wider than that column would wrap, and a wrapped row on a full page costs the menu its spacer and packs its options onto shared rows, so the page visibly reflows around the answer. The instruction puts TEXT alone under the banner (notice-lines), holds it *notice-linger* seconds — the Amiga front-end; the host speaks it through the log — and then the menu returns exactly as it was. A hero class names a portrait(define-hero-class ... :image "gfx/hero-warrior.iff") — shown beside the character sheet. A class open to both men and women may carry a second one (:image-woman); make-hero stamps the chosen portrait onto the hero at creation (:woman t picks the woman's), and the face stays theirs through every later class change. A monster type carries a portrait too — (define-monster ... :image "gfx/mon-rat.iff") — shown for as long as the fight lasts. All of them resolve relative to the map file, like effect icons and zone tile packs, so a world directory carries its own art. tools/gen-walls.lisp draws placeholder scenes and portraits (draw-location-scene, draw-portrait, draw-monster-portrait); Closure's worlds/closure/gfx/make-pack.lisp shows how a world generates and ships them.

Building your own world

A world is a directory: map files plus a campaign.lisp beside them — play and play-amiga load the campaign next to whatever map you start, so your world brings its own classes, monsters and items. Put a shop wherever you want it, stocked however you see fit:

mygame/campaign.lisp     (define-item 'rusty-dagger :kind :weapon
                           :price 5 :damage "1d4") ...
mygame/village.map       the art, then:
                         (zone :kind :city :title "Frogmorton"
                               :gfx "gfx/")   ; the world's own pack
                         (special (3 7)
                           (location "Bree's Bargains" :shop
                                     :stock (rusty-dagger torch)))
                         (special (9 2) (travel "warrens.map"))
mygame/warrens.map       (zone :kind :dungeon :title "the warrens") ...
mygame/gfx/*.iff         optional zone tile pack (see the manifest)
(tale:play "mygame/village.map")

Everything is data read with *read-eval* bound to NIL except campaign.lisp, which is a Lisp file of define-* calls (Closure's worlds/closure/campaign.lisp is the template; the engine suite's tests/world/campaign.lisp is the smallest possible one).

Map format

Maps are ASCII art on a (2W+1) x (2H+1) character grid — see the header of src/map.lisp for the exact rules and tests/world/crypt.map for a small example:

+-+-+-+
|@  | |
+ +D+ +
| |  <|
+-+-+-+

- | walls, D doors, @ party start, other cell characters are features (> stairs down, < stairs up by convention). Walls are stored per cell, so one-way (phantom) walls are expressible; maps can wrap Bard's Tale-style (:wrap t).

After the art a map file may carry its story layer as Lisp data forms (read with *read-eval* bound to NIL, never evaluated):

(zone :kind :dungeon :title "the cellar" :dark 3)

(special (1 2)
  (once (message "Something stirs in the darkness...")
        (encounter ("giant rat" "1d3+1"))))

The zone form's keywords are :kind, :title, :wrap, :start-facing, :gfx (the tile pack), :dark (see above), :sky / :ground — the outdoor noon colours the day-band tint works from (see "The day-bands and the sky") — and the wandering-monster keys :encounters / :encounter-chance and their :night-* counterparts (see "Wandering monsters").

The op vocabulary — message, set-flag/clear-flag, when-flag/unless-flag, at-night/at-day, once, teleport, travel, location, spin, damage, trap, heal, gold, give-item/take-item, when-item/unless-item, encounter, event, ask — is documented in src/specials.lisp.

Asking first. Every op acts the moment the party's foot lands, and for stairs that is too soon: a stair cell is a cell to walk across, and a descent is a decision. (ask TEXT... OP...) is the pause — Bard's Tale's "Stairs down. Take them?" — as map data: the leading strings are the question (free prose; the page wraps it), and the ops after them run only on a yes:

(special (3 2)
  (ask "Stone stairs spiral down into darkness.  Take them?"
       (travel "undervault.map" 1 1 :south)))

The question is a box both front-ends draw over the play page, the quit confirmation's shape (question-lines / question-act): Y takes the offer, N or Esc declines and the party simply stands on the cell, Q still asks to quit. It eats every other key until it is answered, pauses the Amiga's idle clock, and a step that ends on a question draws no wandering roll — the party has stopped to decide. Ops after the ask on the same cell run at once; a once belongs inside the ask, not around it, or a no would spend it.

The item ops. gold hands the party coin; (give-item NAME) hands it something with a name — a chest, a niche, a reward, anything no monster carries in for the party to kill it for. The first living hero with pack room takes it, the same rule a combat find follows, and with every pack full it is left where it lay. (take-item NAME) spends one copy — a key on the gate it opened — and carrying none is silent, not an error. (when-item NAME OP...) and its unless-item twin are when-flag's counterpart for things the party can hold, drop and hand around: a flag only remembers that the party once could, while these ask what it is carrying now. All four count a fallen hero's pack — a key its bearer died holding is still the party's — and all four signal an error on an unregistered item name, because that is a typo in map data.

Naming a hero in map text. Map text is literal — the ops carry strings, never forms — which leaves a line unable to say a hero's name, and that is exactly the line a parting scene wants. A text may therefore name the party in braces: {leader} is the hero walking in front (the first living one in marching order, the rank the gold op pays), so (message "{leader} stays behind.") reads Percival stays behind. damage and trap read their TEXT the same way. The vocabulary is small and closed on purpose: an unknown {token} is an error, as loud as an unknown op, and a brace with no closing twin is just a brace.

Traps. A (trap DICE [TEXT] [DIFFICULTY]) op is a floor trap with three layers of defence: a levitating party (a :levitate effect) floats over it; else a trap-skilled hero (the rogue's art, define-hero-class ... :trap-skill N) may spot and disarm it for this crossing; else it springs and every living hero rolls a saving throw — d20 + level + the LCK bonus + any :save-bonus effects against the trap's difficulty (default 14) — to halve the damage dice. The Trap Zap spell effect (:disarm-traps N) destroys traps up to N squares ahead for good; wrap a trap in once for a one-shot.

The character-by-character art parse scales with map area (a 30x30 city costs real seconds at 14MHz), so a successful parse writes a binary sidecar next to the map (town.maptown.mapc) holding the art-derived grid; while the sidecar is newer than the map it loads in one bulk read, and the story forms are read from the map file itself. Editing the map transparently reparses and rewrites the sidecar — delete .mapc files freely, they are pure cache (and the format is big-endian, so a sidecar written on the host works on the Amiga). See the map-cache tests in tests/run-tests.lisp for the contract.

Time, day and night

The game has a clock (shown in the map view's footer): every step, turn and combat round costs a minute, a fresh game starts at day 1, 08:00, and daylight runs 06:00–22:00 — :sunrise/:sunset events fire at the boundaries and the at-night/at-day special ops make map encounters time-dependent.

Darkness and light. A zone declared (zone ... :dark t) — a dungeon or cellar — is pitch black, the Bard's Tale dungeon: until a light effect burns the party sees nothing at all — the view is black, the automap learns only the cell the party stands on, and the door it faces (and any location behind it) goes unseen. :dark N (a positive integer) keeps it dark but grants N cells of sight there, a dimly glowing place. Outdoors at night there is no sun but there is a moon: sight falls to *moonlight-depth* cells (a few — dimmer than the daytime +view-depth+, but not the blind nothing of the underground). *moonlight-depth* is a plain special (default 3, capped at +view-depth+); set it to 1 for near-black nights.

A light effect — a torch, a lamp, a light spell or song — restores the full depth, then gutters: over its last minutes the circle of sight draws in a cell at a time (one cell per *light-fade-minutes*, default 4, remaining — so the last twelve minutes go three cells, two, one, out), and in a pitch-black zone the walls, ceiling and floor dim with it (light-brightness, a palette-only effect on the pack's own pens — portraits, monsters and text keep their colour), so a failing torch is read off the view itself before the "wears off" line. The view always shows whichever reaches further, the light or the zone's own glow: a guttering torch under the moon never shows less than the moon does. The schedule is in absolute minutes, not a share of the light's life — a four-hour lamp gutters over the same last minutes as a half-hour spell. Active effects can carry durations on the clock and wear off with a message. See the "Game time" sections of tests/run-tests.lisp for the exact rules.

The day-bands and the sky

The clock also names five bands of the day — morning, noon, afternoon, evening, night — and the map view prints the current one ("It's Morning."). Each band turn is announced in the message log ("The sun rises.", "The sun climbs high.", "The afternoon wears on.", "Dusk gathers.", "Night falls." — see *time-band-messages*), so the day reads as passing whether the party walks or stands. Outdoors, the first-person sky and ground take a different colour in each band: a bright blue that lifts toward dawn, softens through the afternoon, warms at dusk and sinks to near-black at night. It is a palette-only effect — two colour registers reloaded when the band turns (:time-band event), no new art and no redraw — so it is free even on a 14 MHz 020.

Every zone can declare its own colours:

(zone :kind :city :title "Closure" :gfx "gfx/"
      :sky (102 170 204) :ground (110 96 74))

:sky and :ground are (R G B) triples (a #(...) vector works too) giving the zone's noon colour; the engine derives the other bands by tinting that base, so a zone that paints a red alien sky still goes dark at nightfall. A zone that declares neither uses the engine defaults (*default-sky* / *default-ground*).

Indoor zones take their colours too, but not the clock. In a (zone ... :dark ...) the same two pens are the ceiling and the floor, and a ceiling does not brighten at dawn — so a dark zone's :sky and :ground are used exactly as declared, with no band tint. Declaring them is how several dungeons share one tile pack and still look like different places: the pack paints the stone, the zone line colours it. A dark zone that declares neither keeps whatever its pack loaded — there is no engine default underground, because substituting one would repaint art the pack meant to show. The two are independent, so a zone may colour its floor and leave its roof to the pack.

zone-pen-colors is the single function that decides all of this (src/map.lisp); the front end loads exactly what it returns and nothing else. The tint tables (*sky-band-tints* / *ground-band-tints*) and the pure sky-color-for / ground-color-for live in src/palette.lisp; the "Day-time sky and ground colour" section of tests/run-tests.lisp is the spec.

Wandering monsters

Classic Bard's Tale springs random fights on a walking party, and the streets get meaner after dark. A zone opts in by declaring a wandering-monster table and a per-step chance:

(zone :kind :city :title "Closure" :gfx "gfx/"
      :encounters (("giant rat" "1d3" 2) ("footpad" 1))
      :encounter-chance 2
      :night-encounters (("footpad" "1d2" 2) ("skeleton" "1d2"))
      :night-encounter-chance 8)

Every successful step rolls :encounter-chance percent; when it comes up, one table entry — (MONSTER COUNT-DICE [WEIGHT]), drawn by weight (default 1) — spawns as a group and combat starts exactly as from an (encounter ...) special. After dark (the daylight window, not the day-bands) an outdoor zone switches to its :night-encounters / :night-encounter-chance, each falling back to the base key it goes without — so "same monsters, more often" and "meaner monsters, same rate" are both one extra key. A :dark zone keeps its base pair at all hours: there is no night underground. The roll happens after the target cell's special has run — a scripted fight, an entered location or a travel to another zone all preempt it — and the step's own minute decides the table, so the sunset-crossing step already rolls against the night pair.

*encounter-rate* scales every zone's chance globally: 1 plays the authored numbers, 2 doubles them, 1/2 halves them, and NIL (or 0) disables wandering monsters entirely — combat then comes only from scripted specials. It is a plain special variable read on every step: a campaign sets its taste, a difficulty setting can rebind it later, the REPL can turn it live.

The living-world clock (time passes while you stand)

Classic Bard's Tale moves the clock only on an action, so standing still freezes the sky. The Amiga front-end instead drips time forward while the party stands idle in free exploration (not in combat, a location, a menu, or the map/help pages), on the window's idle heartbeat: the sky cycles, casters slowly regain magic outdoors, and timed effects burn down whether or not you walk. It is the same advance-time, so every consequence — spell-point regen, effect expiry, :sunrise/:sunset, :time-band — fires exactly as a step's would.

The pace is the special variable *idle-clock-rate* — game-minutes per real second — read on every tick, so a campaign, a display profile or the REPL can rebind or disable it live:

(setf tale:*idle-clock-rate* 4)    ; brisk (default): a day in ~6 real min
(setf tale:*idle-clock-rate* 1)    ; ambient: a day in ~24 real min
(setf tale:*idle-clock-rate* 20)   ; demo: a day in ~72 real sec
(setf tale:*idle-clock-rate* nil)  ; off: classic, time only moves on an action

The whole-minute/remainder arithmetic (idle-minutes-elapsed / idle-minutes-cost, driven off get-internal-real-time so the pace is independent of tick jitter and no sub-minute time is lost) is host tested in the "living-world idle clock" section of tests/run-tests.lisp.

The living world hunts, too. Idle time feeds the wandering-monster vigil: every full *idle-encounter-minutes* (default 30) of idle game time draws one roll of the zone's wandering check — the same chance and table a step pays per minute, spread far more thinly, since a loitering party draws less attention than a marching one. The remainder carries across heartbeats (maybe-idle-encounter), the usual guards apply (no fight starts inside a fight or a location, and *encounter-rate* scales or disables globally), and NIL turns idle encounters off while steps keep rolling as ever. A zone may keep its own vigil: (zone ... :idle-encounter-minutes M) overrides the global dial, and an explicit NIL there makes that zone's loitering safe — a friendly world can let its streets ambush walkers yet leave campers alone, or drop :encounters entirely and know no wandering monsters at all.

Party and combat

Heroes have Bard's Tale-ish stats (str/dex/iq/con/lck, descending AC, hit dice per class) and bank experience toward xp thresholds. The ladder those thresholds climb is the campaign's, not the engine's: how long a game runs, and how much of it a monster tier is worth, is content. (define-xp-table '(TOTAL ...) :growth N) registers the running totals to reach levels 2, 3, 4, ... in order, with :growth compounding the last of them once per level beyond the list so the ladder never simply stops; the form checks that the totals are positive, strictly increasing and that the growth exceeds 1, since a ladder that stalls would leave a hero for ever ready to rise. A game that registers none plays the engine's own gentle curve (50 x L x (L-1)), which is what the fixture world does. A crossed threshold flags the hero in the roster (a white up-arrow beside the name) and the rise itself is taken by hand on the character sheet — l, one level per press, each rise reporting its story (the new level, the stat gain, the spells learned) on a page of its own before the sheet returns. A level-up rolls the class hit dice again (plus the CON bonus) and gives every ability a Bard's Tale chance to rise by one — a d18 per stat, the score rises when the draw lands at or above it, so gains thin out toward the cap of 18 — though at most one ability rises per level: the first successful draw takes it. High CON pays into hit points and high DEX into the effective armor class (never the reverse: low scores cost nothing, the Bard's Tale kindness — see stat-gift). A class may carry a fighting art: define-hero-class ... :extra-attack-levels 4 grants an extra strike per four levels beyond the first (the warrior's way), :crit-chance N a percent chance — growing one point per level — that a landed blow fells the foe outright (the hunter's), :ac-per-level N one point of natural armor per N levels beyond the first, floored at -10 (the monk's), :trap-skill N a percent chance — growing one point per level — to spot and disarm a springing floor trap (the rogue's), and :description a lore line for the campaign to show.

A class may also be closed and conditional. :startable nil keeps it off the creation menu — make-hero refuses it, startable-hero-classes lists what is left — so the only way in is the second ladder: changing class. :change-at N is the level a hero must reach before they may leave that class, :change-group the family it changes within (a hero only ever moves between classes naming the same group, which is how "a mage moves up to another mage class" is said — a class missing either key is for life), and :requires ((:class . level) …) what they must have attained before they may enter this one. change-class (the sheet's c, offered only when hero-class-change-targets is non-empty) freezes the art being left at its current level and starts the new one over: level 1, no experience, but hit points, spell points, gold, abilities, armor and pack all carry across untouched, and the maximum spell points never fall. The frozen rating is the point — hero-class-level reads it, so a class left behind goes on granting every spell it had opened and never opens another, and a hero's book is the sum of every art they have worn. An art already worn cannot be taken up a second time. The roster holds up to 7 members (join-party): six regular heroes plus one guest slot for a summoned monster or story NPC. Combat is round-based, Bard's Tale style: every round opens on the engage pageFight or Run, the one choice that is the whole party's (everyone runs or nobody does, and only at the top of the round; a failed run costs a free monster round and asks again) — then the round-orders page asks one hero at a time (attack, defend, cast a spell, play a song, use an item; Esc undoes the previous pick), and when the last of them has picked it turns into the review — every hero with the order it gave, under Is this OK? — where y fights the round and n throws the orders away and asks again from the first hero. On the Amiga all these pages take over the message area, with the enemy's portrait in the view column. Then the round runs — heroes strike first, then every surviving monster swings at a random front-rank hero, or shoots from where it stands off if its type can (see the distance rules below).

The enemy also stands at a distance, in feet, on the scale the spellbooks already speak: ten feet is one dungeon square, and a group at +melee-distance+ (10') is toe to toe with the front rank — the only distance at which a melee blow lands, in either direction. start-combat's spec takes a distance per group, (MONSTER COUNT DISTANCE), and a zone's encounter table a fourth element for the same; name none and the groups line up from melee backwards, one *combat-group-spacing* apart (10', 30', 50' …), so a fight of one group opens at melee and plays as it always did. At the end of every round the lines still walking cover their ground and say so — one *combat-close-step*, or the type's own :speed where the campaign gave it a gait, so a runner crosses in one round what a shambler takes four over; set the dial to NIL for a campaign of fixed skirmish lines, and a type's :speed 0 nails that one line wherever the fight found it. Blows and bolts land on the nearest group (nearest-monster); a group spell breaks the group it lands among, and an all-foes word covers only what stands inside its reach (monsters-in-reach). A missile carries as far as its :reach says and a spell as far as its :reach, each measured against combat-distance — a spell that falls short is refused before it is paid for, and the orders page drops the Attack row for a hero with nothing in range. Both are campaign data: an item or a spell given no :reach goes unmeasured and carries however far the fight asks, which is what keeps a campaign written before distance existed playing unchanged.

The enemy answers in kind. A monster type given a :missile — the dice its arrow, its venom or its breath throws — and a :missile-reach shoots from where it stands while its group is still walking in, under the same to-hit roll, the same +4 for a defence and the same :inflicts as its blow; an unmeasured missile carries however far the fight asks, exactly as the hero's does. Melee still comes first on both sides: a group that has closed swings rather than shoots. A shot picks any living hero, whatever rank they stand in — rank is a melee line and a missile flies over it, the mirror of the back-rank hero shooting over the enemy's heads, and the only thing in the game that reaches the back ranks. :missile-verb is the transcript's word for a landed one (SHOOTS by default, SPITS AT, WAILS AT). A type without a missile is the plain fighter it always was: until its group reaches melee, its round is the ground it covers.

Each round opens with a -- Round N -- line and its transcript plays out one message at a time on a fresh page of its own; +/- set the pace (5 speeds, from a second per line — the starting pace — to instant). A won fight pays out each monster type's XP and gold: the sum over the fallen goes to each hero still standing, Bard's Tale style, rather than being divided among them — a hero who went down takes neither. The fight may also turn up an item a fallen monster carried (define-monster ... :item NAME :item-chance P, one find per fight at most). The :combat-end :victory event fires the moment the last foe falls, before the spoils are told, so the Amiga front end puts the campaign's *victory-image* treasure picture up first and the victory lines pace in under it; the page then lingers *victory-linger* seconds before play resumes. All randomness goes through *rng*, so the test suite scripts entire fights deterministically.

Ailments

Four conditions a hero carries about until something lifts them — none wears off with time, which is what makes a cure worth paying for:

AilmentIn play
:poisonbites for *poison-bite* hp on every party step and every combat round; it can kill
:insanitythe hero fights on its own — the round's order is void and the madness strikes a companion
:paralysisthe hero cannot act at all
:stonea statue: cannot act, and stands in the rank taking blows

afflict-hero lays one on (once — an ailment does not deepen), cure-ailment lifts one quietly, cure-hero lifts a list and says what it lifted. A hero's hero-ailments list is always in *ailments* order, so conditions read and cure in the same order however they arrived, and they stack: a poisoned hero can be paralysed too, and each is cured on its own. Saves carry them.

The paralysed and the stone are helpless: acting-heroes leaves them out, so the orders page never asks them and the round never spends an action on them (they are named once a round instead), while front-ranks still offers them to the monsters. A party where nobody can act — every hero frozen or petrified — loses the fight (party-can-act-p), which is the honest end for a fight that could otherwise run rounds forever with no one able to swing.

Who hands them out and what lifting one costs is campaign data:

  • define-monster ... :inflicts ((:poison 25) (:stone 5)) — a landed blow rolls each entry's percent chance on the hero it struck. This is how the Bard's Tale monster powers are expressed; a blow that misses, or that fells its target, carries nothing. A landed shot carries the same: a venom spat across thirty feet is the same venom.
  • a spell's :cure (:poison :insanity) lifts exactly the conditions it names — a word against poison leaves a madness where it found it. With :heal-party or :resurrect it reaches the whole roster.
  • a temple's :cures ((:poison 60) ...) prices each condition its priests know how to treat (see below).

Both front-ends read a hero's state from the engine, so they name the same things: hero-condition-titles gives the roster's parenthetical (poisoned, down, insane), hero-condition-code the Amiga table's four-letter code for the worst of it (DEAD, STON, PARA, INSA, POIS), and the character sheet lists the conditions one per line.

Spells

Spells are campaign data (define-spell); the engine knows the mechanics: casters (define-hero-class ... :caster t) carry spell points (2 per level plus the IQ bonus) and pay them per cast. A caster knows every registered spell of their class at or below the level they have reached in that class — no separate learning step; a fresh level's spells arrive with the rise, which names each one ("Zzal learns test flame!"). "In that class" matters only for a hero who has changed class (below): an art left behind keeps granting what it had opened. The character sheet's spells/songs page carries the spellbook as it stands — numbered, so a digit opens that spell's card: the tier, what the cast costs against the caster's own points, the four-letter incantation, the range and duration the campaign gave it, and what the spell does. That last line is derived from the effect spec ((:heal "4d4") reads "Heals 4-16"), so it cannot drift from the mechanics; a spell may carry its own :description where the derived words are too plain. The card casts too — c there spends the points without a trip through the c menu, asking for a target only when the spell needs one. A spell's effect is a plist over a shared vocabulary, and the keys combine freely — a restoration heals and cures, a batchspell installs five enchantments in one casting:

  • Instant keys resolve at cast time: :damage, :damage-per-level (the roll times the caster's level), :damage-group (the front group), :damage-all, :slay N (percent chance to fell the front monster) — all combat-only — plus :heal and :heal-party (dice or :full), :resurrect (the fallen rise at 1 hp), :scry (speaks the party's position), :disarm-traps N (destroys the traps up to N squares ahead, for good) and :teleport N — a real fold in space: the cast menu asks for a heading (N/E/S/W) and a count up to N, wrapping zones fold around the seam, a plain map's edge refuses (the spell is spent), and the destination cell's special fires on arrival. :teleport t is the other kind of flight: not a distance but a named destination — the cast menu lists the places the campaign registered (see Named destinations below) and a digit picks one. Neither teleport casts in combat: there is no walking away from a fight, and a cast that reaches the mechanism anyway (an item's free cast, a scripted cast-spell) finds the way shut. :cure (:poison ...) lifts the ailments it names (see Ailments above) and refuses anything that is not one. :summon and the foe-handling keys carry canonical data and speak their line today; their subsystems (allies) are still to come.
  • Timed keys merge into one effect record with a :duration in game minutes (or :indefinite): :buff-ac, :light, :night-vision and :reveal (all three defeat darkness), :compass (the party sees its facing only while one burns), :buff-damage, :regen-sp (multiplies the daylight trickle), :extra-attacks, :combat-heal (mends the party every round), :foes-ac and :foes-attack (the enemy fights worse), plus :save-bonus (weighs into every saving throw) and :levitate (the party floats over floor traps). A timed spell may name an :image, the icon the effect strip shows.

Beside the mechanics a spell keeps its lore: :code (the four-letter incantation), :range and :duration-text — display metadata the engine stores (spell-code, spell-range, spell-duration-text) and never interprets — plus :description, the player-facing line the card shows, and designer :notes carried as data for generated spellbooks (define-song takes both too). The two are deliberately separate: notes may record where an engine stand-in departs from canon, and a player should never read that, so only :description reaches a card. Spell points trickle back Bard's Tale style while walking outdoors in daylight. The "Spells" and "extended effect vocabulary" test sections of tests/run-tests.lisp are the executable specification.

Named destinations. A homing spell needs somewhere to home to, and where that is, is the campaign's business:

(define-destination 'testville-guild :title "The Guild at Testville"
                    :map "town.map" :x 14 :y 28 :facing :north)

The engine keeps the list and flies the party there; the game names the places worth flying to. :x/:y and :facing are optional and omitted means the map's own start, exactly as for the travel op — which is also how the map file is resolved, relative to the zone the party stands in when the flight begins. Registration order is menu order, and registering a name twice replaces the destination without moving it. The flight itself is a travel: the zone loads (or is remembered, automap and all) and the arrival cell's special fires, so a guild that greets its visitors greets these too. With nothing registered the spell is one with nowhere to go and says so on its card. An item that casts such a spell (:use '(:cast SPELL)) asks the same question from the use menu.

Bard songs and taverns

Songs are campaign data too (define-song): a song is always a timed effect over the same vocabulary — keys combine here as well, so one tune can quicken spell points on the road and grant extra attacks in a fight (:regen-sp 2 :extra-attacks 1 :duration 60). Singers (define-hero-class ... :singer t) pay tunes — one charge per song, one charge per level when rested — and only one song plays at a time: striking up a new one displaces the old, the Bard's Tale rule. A class may also need something in hand to play at all — :sings-with :instrument names an item kind (any of *item-kinds*) the singer must have equipped, the Bard's Tale rule that the music needs an instrument; carrying one in the pack is not enough, and a class without the option sings bare-handed. p opens the sing menu, and in combat (:sing SONG) is a party action beside attacking and casting. The songbook also lists on the character sheet's spells/songs page, where a digit opens the song's card — its level, the tunes in hand and what the tune does — and p there strikes it up on the spot, or says why it will not go (song-refusal: not in the book, no instrument in hand, no tunes left). Tunes come back with a drink at a tavern — a (location TITLE :tavern :price N) map special; a tavern may also hold the way below (:down FILE, the trapdoor to the cellar). An instrument registered :tireless t (define-item, instruments only) spends no tune at all: a singer holding one never runs dry, the menus say "Tireless" where they would count, and the count waits on the sheet for the day the instrument comes off. The "Bard songs" test section of tests/run-tests.lisp is the executable specification.

Magic at work

The effects band shows an icon per active working; the magic-at-work page spells them out — every active effect by name (a song marked as one), what it does in the same phrases the spell and song cards use, and how long it has ("43 minutes left", "until dispelled"). E opens it on the road and in a fight alike (over the round's orders, while no cast/play/use picker of theirs is up), E or Esc closes it, u/d turn a long page; on the Amiga the band itself clicks to it. workings-lines / workings-act in src/help.lisp are the model both front-ends draw.

Sound

Sound works like tiles: the engine names the cues, a sound pack ships them. The vocabulary (*sound-names*) covers the moments the game already announces — hit, slay, miss, hurt, death, combat, victory, defeat, door, blocked, cast, song, level, coin, drink — and a pack is a directory of IFF 8SVX samples named after them (hit.8svx, door.8svx, ...), declared per zone in the map right beside the tile pack:

(zone :kind :city :gfx "gfx/" :sfx "sfx/")

A pack may ship any subset; missing cues stay silent. hit.8svx is the pack's probe file (the front-0.iff of sound): zone-sfx-dir resolves the directory beside the map file first, then relative to the game directory.

Mechanics never play sounds — they emit events, and attach-sounds (wired by both front-ends, next to the message log) maps them to cues through play-sound. On the Amiga the cues are audible: the pack's samples are uploaded to chip RAM once and one audio.device channel plays them (cl-amiga's AMIGA.AUDIO module), a new cue cutting the one still sounding; travel swaps sound packs exactly like tile packs. The host front-end stays silent — *sound-backend* is the single hook, so a host player (or a test) can install its own. read-8svx / write-8svx round-trip the format (uncompressed and Fibonacci-delta), so asset generators need no second toolchain. The "Sound" test section of tests/run-tests.lisp is the executable specification.

A location may also name a tune(location ... :music "sfx/guild-theme.8svx"), resolved relative to the map file like :image. On the Amiga the 8SVX loops on a second, quieter channel for as long as the party stands inside (a save loaded inside the location picks the tune straight back up) and falls silent at the door; the cue channel keeps speaking over it. The host front-end ignores it like every cue.

Save games

Save games (save-game/load-game) are a single readable Lisp form: the current zone's map file, position, the game clock, active effects, every visited zone's automap knowledge, story flags and the party with packs, equipment and each hero's ailments. Up to 9 named saves live side by side as saves/NAME.sav: both front-ends share the same slot picker (Shift-S/Shift-L, and the Save/Load menu items on the Amiga) — pick a slot by number or type a new name (refused once 9 slots exist, so every slot stays reachable by its digit) — and saving is refused during combat.

The test suite (tests/run-tests.lisp) doubles as the executable specification for the map model, movement, knowledge tracking, renderers, events, specials, zones and travel, party, items, shops, combat and save games.

Version

src/version.lisp holds the engine's version — MAJOR.MINOR.PATCH plus a DD.MM.YYYY date, the same shape the clamiga runtime uses:

(tale:engine-version-string)   ; => "0.1.0"
(tale:engine-version)          ; => 0, 1, 0   (three values)
tale:*engine-name*             ; => "Lambda's Tale"
tale:*engine-version-date*     ; => "24.07.2026"

A game built on the engine has its own version, which moves independently of the engine's. The engine only declares the slots — tale:*game-name*, tale:*game-version*, tale:*game-version-date*, all NIL until a game sets them — and the game fills them in from a src/version.lisp of its own, loaded after the engine:

(in-package :tale)
(setf *game-name* "Closure" *game-version* "0.1"
      *game-version-date* "24.07.2026")

See ../closure-tale/src/version.lisp for the worked example, and the version sections of both test suites for the contract.

Debug log

(tale:debug-log-enable) opens a timestamped trace file (tale-debug.log by default, or pass a path) and (tale:debug-log-disable) closes it again; setting the environment variable TALE_DEBUG_LOG (a path, or 1 for the default) enables it as the engine loads. While enabled, the engine logs every image, map and campaign load with its duration, every emitted event with its handler count, and every key press — each line wall-clock timestamped with a millisecond fraction and flushed immediately, so a session that crashes still leaves the trace up to the moment it died. Off by default and free when off. Game code can write its own lines with (tale:dlog "..." args...) and time a block with tale:dlog-timed; see the debug-log section of tests/run-tests.lisp for usage.

The log doubles as a launch profiler: the engine loader logs each source file's load time and a launch -> loaded summary, and play-amiga marks new-game, display open and first frame up, each with milliseconds since clamiga started (get-internal-real-time counts from process launch). Together with clamiga's --boot-log (the pre-engine runtime/CLOS boot phases) one debug-log trace shows where every second between launch and the first rendered frame goes — see "Load times and memory" in the Closure README for measured results.

Seams for tooling

Three specials let a game hang its own tools off a running session — a debug console, a cheat menu, a recorder — without forking a front-end. The engine never reads any of them, and a release build simply never sets them.

tale:*game* is the game the running front-end is playing, or nil outside a session. Both play and play-amiga assign it as they wire a game up, for a fresh game and for one restored from a save alike, and leave it standing when the session ends, so a tool can still inspect what the party walked away from. It is assigned, never bound: dynamic bindings are per-thread in this runtime, so a let would be invisible to any other thread and would vanish the moment the front-end returned.

tale:*key-hook* is a function of (game char) that both front-ends offer every key before their own dispatch — ahead even of the quit confirmation, so a console stays reachable when a page has wedged. A true return means the hook consumed the key: the front-end redraws and no page sees it.

tale:*tick-hook* is a function of (game) that the Amiga front-end calls once per heartbeat, beside the *autoplay* step. It is where work arriving from outside the event loop gets run on the loop's own task: the front-end draws from that task and the game state carries no locks, so a channel fed by another thread posts its forms here rather than evaluating them wherever they were typed. A true return means the hook changed something the frame does not show yet, and the front-end redraws; an idle hook must return nil, because the heartbeat runs about ten times a second and a redraw at that rate is more than a 68020 has to give. The host front-end blocks on the keyboard and has no heartbeat, so it never calls this.

The Closure game's src/debug.lisp is the worked example: an in-game Lisp console, loaded only when :debug is on *features*.

Roadmap

  • M0 (done): map model, movement, automap knowledge, ASCII map view, interactive walkabout on the host
  • M1 (done): wireframe first-person view (shared geometry + display list, ASCII and Amiga renderers, automap fed by what the party sees)
  • M2 (done): events + cell-specials story layer, party and character system, round-based combat, save games
  • M2.5 (done): Bard's Tale screen layout (scrolling message log, active-spells strip, 7-slot party roster, full map under m), large maps (30x30 up to 128x128), custom screen via BestModeID (RTG-aware) — see specs/ui-and-engine.md
  • M3 (done): ILBM asset loading (src/ilbm.lisp), blitted wall graphics — RTG-aware (MorphOS / Picasso96 / CyberGraphX): no chipset or planar assumptions, bitmaps via AllocBitMap, blits through OS calls only; procedurally generated wall-piece assets (make assets)
  • M3.5 (done): swappable tile packs (:gfx-dir, print-tile-manifest), custom screen with per-pack palettes, ceiling/floor backdrop; display profiles (:profile) — 32-color lo-res ECS default, 16-color hi-res alternative, each with its own generated pack
  • M4 (in progress): the game proper — now the separate Closure subproject — plus engine support as it needs it: zones/travel/shops (done), day/night and darkness (done), spells (done), named saves (done), sound — 8SVX cue packs per zone, audio.device playback (done); next polish. The Bard's Tale II chrome and the day/night sky art are parked until the game content lands.

Contributors

mdbergmann

128 commits

mdbergmann/lambda-tale

Lambda's Tale, Bard's Tale like game engine in Common Lisp

9

stars

128

commits

Common Lisp

primary language

Sep 7, 2026

updated

README

Lambda's Tale (engine)

A Bard's Tale-style dungeon-crawler engine written in Common Lisp for cl-amiga. Separate repo: the engine is pure Lisp and runs on the host clamiga for development; the Amiga front-end renders in an Intuition window or on an own custom screen.

The engine ships no story: a game is a sibling repo holding its worlds (maps + campaign) and loading the engine — the town of Closure next door is the playable example. The engine's own test suite plays a minimal fixture world (tests/world/).

Using the engine

Build clamiga in the cl-amiga checkout first (make host there), then from this directory:

make test    # run the engine test suite (plays tests/world/)
make assets  # regenerate the default tile packs (data/gfx*, tools/gen-walls.lisp)

A game loads the engine from wherever it lives and names its starting map — the engine is self-locating (it finds its own sources and default tile packs through *load-truename*, never through the working directory; the working directory belongs to the game):

(load "lambda-tale/src/load.lisp")     ; engine vendored as the game repo's
                                       ; lambda-tale/ submodule; or self-locate
                                       ; via *LOAD-TRUENAME* like
                                       ; Closure's src/load.lisp
(tale:play "mygame/village.map")               ; host front-end
(tale:play-amiga "mygame/village.map")         ; AmigaOS front-end

The test suite runs on the Amiga the same way (it is not part of the cl-amiga test-amiga run — the engine is a separate repo, mounted as its own FS-UAE volume; see CLAUDE.md for the launch command):

cd LambdaTale:
stack 128000
CLAmiga:build/amiga/clamiga --heap 8M --non-interactive --load tests/run-tests.lisp

On AmigaOS the suite additionally runs GUI smoke tests (both display profiles) and three unattended *autoplay* sessions through the fixture world.

Races

Races follow the engine's usual split: the engine knows what a race is — ability-score modifiers plus the list of hero classes the race may take — and a campaign registers the concrete ones with define-race in its campaign.lisp (the Closure game next door ships its own canon, specs/canon-data.md there). make-hero rolls the abilities (3d6 each), then adds the racial modifiers in place — no extra dice — and rejects a race/class pairing the race does not allow:

(define-race :dwarf :str 2 :con 2 :iq -2
  :classes '(:warrior :paladin :rogue :bard :hunter :monk)
  :description "Amazingly strong and healthy, but not always bright.")

(make-hero "Grod" :warrior :race :dwarf)   ; a stout dwarf warrior
(make-hero "Grod" :conjurer :race :dwarf)  ; error: dwarves cast no spells
(make-hero "Nym"  :rogue)                  ; raceless is still fine
(make-hero "Mab"  :rogue :woman t)         ; wears the class's woman's
                                           ; portrait (:image-woman)

The race rules are exercised end to end in tests/run-tests.lisp (search "Races") — the executable reference.

Layout

src/package.lisp     package TALE
src/version.lisp     the engine's version + the slots a game fills in
                     with its own (see "Version" below)
src/profiles.lisp    display profiles (:lores / :hires — screen geometry,
                     viewport, tile pack, layout tuning per target) and
                     the self-located *ENGINE-DIR* / ENGINE-PATH
src/dice.lisp        dice notation ("2d6+1") and the scriptable *RNG*
src/ilbm.lisp        IFF ILBM image reader/writer (pure CL, ByteRun1)
src/map.lisp         dungeon map model + ASCII map parser + story layer
src/knowledge.lisp   the party's automap knowledge (explored cells, seen walls)
src/view.lisp        first-person view geometry (view cone, perspective
                     planes, backend-independent display list)
src/time.lisp        the game clock: day/night, darkness, timed effects
src/game.lisp        game state, movement, automap observation
src/events.lisp      engine event bus + story flags
src/races.lisp       races: ability-score modifiers + which classes each
                     race may take (mechanics; DEFINE-RACE)
src/party.lisp       heroes, classes, races, xp/levels, party queries
src/items.lisp       item types, packs and equipment
src/spells.lisp      spell types, spell points, casting + the cast menu
src/combat.lisp      monster types, round-based combat
src/specials.lisp    cell-special interpreter (the story op vocabulary)
src/locations.lisp   locations (shops): mechanics + shared menu model
src/save.lisp        save games (readable Lisp data, never evaluated)
src/save-menu.lisp   named saves: the saves/ dir + the slot-picker menu
src/render.lisp      ASCII automap renderer (player view + omniscient debug view)
src/render-fp.lisp   ASCII wireframe first-person renderer
src/host-ui.lisp     host front-end (interactive ASCII walkabout, PLAY)
src/amiga-ui.lisp    AmigaOS front-end (Intuition window, graphics.library)
data/gfx/*.iff       the default tile pack for :lores (wall pieces +
                     floor/ceiling ILBM assets; regenerate: make assets)
data/gfx-hires/*.iff the same pack drawn for the :hires viewport
tools/gen-walls.lisp procedural wall-art generator
tests/run-tests.lisp engine test suite (make test)
tests/world/         the minimal fixture world the suite plays (a keep,
                     a dark crypt, a 30-line campaign)
specs/               design constraints (UI layout, map scale, screens)

Both front-ends draw the Bard's Tale split screen (see specs/ui-and-engine.md): the first-person view with the location plaque under it on the left, the scrolling message log on the right (in the engine's condensed bold microfont on the Amiga) with a slim strip of active-effect icons below it, laid out in effect order — an effect granting a compass shows the live rose in its slot — and the numbered party roster (# CHARACTER AC HIT PTS SPL PTS CL, two-letter class codes, the number columns right-aligned under their headings) at the bottom, its rows set solid — one glyph box each, no leading — the way a printed roster reads. Locations (shops, taverns) and the character sheet take over the message area: their menu renders at the top of the log page with the trailing log lines still scrolling underneath, while the view column shows the location's picture or the hero's portrait when the campaign ships one ((location ... :image FILE), define-hero-class ... :image FILE — resolved relative to the map file like effect icons; without one the live first-person view stays). A location also has a face from the street: facing its door puts its facade in the view column before the party ever steps in, so a city's houses have faces (the Bard's Tale building-front look). An optional :facade FILE names that street face; without one the :image picture shows from the street too — so a location can pair an exterior with a distinct interior, or ship one picture for both. A fight reads the same way: the round-orders page — one hero at a time, then the review — takes over the message area and the view column carries the enemy's portrait (define-monster ... :image FILE), so the party sees what it is fighting while it picks the round; the picture belongs to the leading group and passes to the next one as groups fall. The pickers — save and load, cast a spell, play a song, use an item — open as a centered dialog over the page instead, all four the same size: each of them lists names, a name has no room in the view column, and nothing worth watching happens beside a picker while it is open. The key reference lives on the help page under h/? — set in the same condensed small face as the message page, and scrolling (u/d, or its scrollbar) when the reference outgrows the page. The full automap lives under m — black ink on the grey page, doors and the party amber, with a legend beside the map listing the special places the party has found (shops, taverns and the like, each marker also drawn on its map cell — plain houses are scenery and carry no marker, or a city's front doors would bury the places that matter) and a footer showing the zone, position and game clock; maps can be large (30x30 like Bard's Tale I, up to 128x128). The page shows the map whole while it can do so legibly, and windows it when it cannot: cells below the glyph size carry no stairs, doors or markers at all, so rather than shrink past that the page keeps the cell its width affords and scrollsu/d, or the scrollbar down the map's right edge — opening centered on the party each time. The legend's column is reserved before the cell size is chosen, so the map never grows over it. The overlay pages, the takeover and the whole map page set the same face as the log — the engine's 5x7-on-6px condensed bold cut, the metrics of the actual Bard's Tale II text — so the Amiga UI carries one type size throughout. The key bindings are listed in the Closure README. On the Amiga the window uses the same geometry as the custom screen, so both displays lay out identically; the custom screen's geometry comes from a display profile (play-amiga's :profile argument):

  • :lores (the default) — a 320x200 layout, 32 colors, the ECS target: half the chip-RAM/DMA cost of hires and near-square pixels for the art. 200 lines rather than PAL's 256 so one layout serves PAL and NTSC alike — an NTSC machine has no 256-line mode to fall back on. That budget sizes the 120x100 viewport: the chrome pads, view, plaque and the seven solid-set roster rows fill it exactly — roster row 7 ends on the last usable line, with the chrome ring's clearance below it. The effect strip at the message column's foot is 20 pixels bought from dead seams in the layout — page slack, flush strip bottom — so the message page above it still clears eleven rows of text: enough that the shop's and the character sheet's last option stays on the page.

    The screen is a separate question from the layout, and it grows to fit the display it landed on: play-amiga asks the display database how tall the chosen mode really is (amiga.intuition:display-mode-height) and opens the screen at that height, capped by the profile's screen-max-height (256 here). So PAL opens 320x256 and NTSC 320x200, with the backdrop window clamped to the 200-line layout either way — the game lays out identically on both, and only the background below it differs. There is no PAL/NTSC test in the engine; the database answers for whatever the machine actually is, RTG included. A screen shorter than its display is one an emulator's auto-zoom crops and rescales and a real monitor letterboxes, which is the point of asking.

    Which display it lands on is a third question, and the profile's screen-max-height answers that one too: BestModeIDA is asked for 256 rows here, not the layout's 200. It matches on the size it is handed, and on a machine carrying both a chipset display and an RTG board 320x200 is an exact hit on an RTG mode — so asking for the layout would put the game on the graphics card, where its pixels are resampled to the monitor instead of shown. Asking for the tallest display the profile would accept keeps the native mode in front there and changes nothing elsewhere: a machine with no 320x256 returns its own best fit, which the clamp above brings back to the layout.

  • :hires — 640x256 PAL hires, 16 colors, the classic presentation with the larger 240x130 viewport.

Draw distance (slower machines)

The first-person view draws up to +view-depth+ (4) distance levels ahead. play-amiga's :draw-depth argument (1-4) trades some of that distance for frames on a slower machine:

(tale:play-amiga "mygame/village.map" :draw-depth 2)

Each level dropped is up to three fewer wall blits per frame, and ten fewer piece images (plus their style variants) decoded into bitmaps at load time; the corridor then ends that much nearer, fading into the ceiling/floor backdrop. Note that the far levels are the small ones — the win is mostly in per-blit overhead, not blitter time, so measure on the target rather than assuming.

A tile pack is unaffected: it must always ship the full set for all four depths (print-tile-manifest still lists every piece, and the loader still errors on a missing one), because a pack is data that has to work on any machine — the draw distance of whoever built it must not shape it. Draw depth decides which of those images are loaded, never which must be provided.

It is a rendering cap only: the automap still records everything the party could see, and darkness (a (zone :dark N) or nightfall) still shortens the view further when it is the tighter of the two. Each display profile carries a default, since a profile describes a screen while draw distance tracks the CPU: :lores draws the full 4, :hires draws 3 — it blits roughly twice the pixel area per frame, and the deepest level spends a blit on an 8x8 far wall. :draw-depth overrides the default either way (:draw-depth 4 buys the last level back on a hires machine that can afford it).

Note that :draw-depth is the only way in: binding tale:*draw-depth* around play-amiga has no effect, because with-display-profile rebinds it from the profile on the way in.

Draw width (faster machines)

:draw-depth's sideways twin, :draw-flanks (0-8, default 1), sets how many cells to each open side the view draws of a facing row of houses. The classic view shows at most the corridor's immediate neighbors — three houses where a whole street front stands (the original Bard's Tale drew just the one). Raising the knob repeats the already-loaded flank pieces one cell width further out per step, each with its own building's style, until the row, the viewport edge or a nearer wall ends it:

(tale:play-amiga "mygame/village.map" :draw-flanks 8)  ; fill the horizon
(tale:play-amiga "mygame/village.map" :draw-flanks 0)  ; the lone BT house

Each step costs one more blit per visible row cell per frame and nothing else — no extra piece files, no load time, so a tile pack is again unaffected. Like draw depth it is a rendering knob only: the automap records the same walls whatever the machine draws. Each display profile carries the default (both ship 1, today's look), and play-amiga's argument overrides it per machine.

Both profiles give the first-person view about 2/5 of the screen and the message log the other 3/5 — the text carries the game. The split is a profile knob, not engine code: the view column is exactly the profile's fp-width and the log takes the remainder, so a custom target with a different balance is a new profile plus a matching tile pack (see print-tile-manifest).

Both are picked RTG-aware through graphics.library/BestModeIDA (so Picasso96/CyberGraphX/MorphOS promote them to a suitable RTG mode), with the tile pack's palette, a borderless backdrop window and an Intuition menu strip on the right mouse button (GadTools). Quitting — from the menu, from q or from Esc — always asks first: a small confirmation box takes over until y ends the session or n/Esc (or a click beside it) returns to the game. For development there is also a window view on the Workbench screen (no custom palette): (tale:play-amiga "mygame/village.map" :display :window).

The whole game also plays by mouse: clicking the first-person view walks (left/right quarters turn, the middle steps forward, its bottom band steps back), clicking a roster row opens that character sheet, clicking a menu's numbered rows or its Sell-style option rows (the first letter is the key) acts as those keys, and the map/help/sheet pages close on a click elsewhere. The pages that open out of nothing have no row to click, so on the Amiga the menu strip carries them all: Game holds Save, Load and Quit, Screens holds Map, Help, Cast, Play and Use. No item shows a shortcut — Intuition can only ever offer right-Amiga+key there, and the game's own keys are Shift-S, Shift-L, Q, M, H, C, P and U, so the help page stays the one place that says what they are. The Screens items only ever open a page (asked for twice, a page starts over rather than closing), and they decline while a picker, a shop or a combat round owns the keys. The strip is data — *menu-strip* in src/keys.lisp, where the host suite checks both its layout and the pick decode; the Amiga front-end turns it into the GadTools NewMenu array. Menu option rows carry their pick key (menu-option / menu-numbered in src/events.lisp), so front-ends map clicks to keys without parsing the text. A page emits one option per row, the Bard's Tale look; a page with fewer rows than that needs squeezes in order — spacer rows go first, then the options themselves pack onto shared rows (fit-menu-lines), each keeping its own click target on the packed row (menu-line-spans). So a short page loses its blank lines and its vertical listing before it loses an option, and the last row — the sheet carousel's NEXT, the shop's Pool gold — stays on the page and stays clickable. A menu list deeper than a page — a big shop stock, a full pack on the sell page or the character sheet, a fat spell book — scrolls: u/d (or the scrollbar at the page's right edge — a click above the thumb is a window up, below it down) move the window and digits pick within it, so every item stays reachable with single-digit keys (menu-window in src/events.lisp; the scroll walks live in the model tests in tests/run-tests.lisp). Because those digits are the keys, they count from 1 again in every window — so a scrolled list says which entries it is showing, Spells: 9-16 of 24, on the head standing over it where the column can hold both and on a row of its own where it cannot (menu-scroll-head). Without it the second window of a long book reads as the same eight entries over again. The pointer is an open hand that turns into a pointing finger whenever it rests on something clickable; over the first-person view's walk zones it becomes the arrow of the move a click would make — left/right turn arrows on the side quarters, an up arrow on the middle, a down arrow on its back-step band (all campaign-replaceable — see pointer.iff and friends under "Custom tile packs"), and a busy hourglass shows during the loads that take real seconds at 14MHz: tile packs, save games, first-sight location pictures and icons. See the hotspot tests in tests/run-tests.lisp (amiga-ui autoplay drives the game by mouse clicks) for the full contract.

The first-person view never looks around corners (Bard's Tale rules): compute-view walks the cells straight ahead, stopping at walls, doors and the view-depth cap, and view-display-list flattens the result into line/door primitives that both the ASCII renderer and the Amiga draw-line renderer consume. Walking and turning call observe, which records every wall the party can currently see into the automap.

Wall graphics

On the Amiga the first-person view is drawn with blitted wall graphics (M3): every wall the view can show falls into a fixed Bard's Tale-style screen slot (view-blit-list — front walls, receding side walls, walls seen through open sides, each with a door variant, at four depths), and each slot is filled by a pre-rendered bitmap piece. The pieces are IFF ILBM files — one pack per display profile, data/gfx/ for :lores and data/gfx-hires/ for :hires (each profile's viewport dictates the piece sizes) — loaded by the pure-Lisp reader in src/ilbm.lisp and drawn by the procedural generator in tools/gen-walls.lisp (make assets regenerates both; the test suite compares the checked-in files pixel-for-pixel against the generator, so art and code cannot drift apart).

The rendering path is RTG-safe — no chipset or planar assumptions, so it works unchanged under Picasso96/CyberGraphX/MorphOS: pieces are uploaded once into AllocBitMap bitmaps in the display's native format (chunky pens through WriteChunkyPixels). Each frame draws the ceiling/floor backdrop first, then composites the walls back-to-front over it — the receding side pieces cookie-cut through a 1-bit mask (BltMaskBitMapRastPort, transparent where the piece uses pen 0), so the backdrop shows through the corners they don't cover instead of black wedges. When the assets are missing the view falls back to the wireframe renderer.

Custom tile packs

The default art is replaceable: a tile pack is a directory of IFF ILBM files. A zone declares its own pack in its map file — (zone :kind :city :gfx "gfx/") — and travel swaps packs as the party crosses zones. The directory resolves relative to the map file when the pack lives in the world directory, else relative to the game directory (a pack the game ships beside its worlds). play-amiga's :gfx-dir argument overrides everything for the session; without either, the active profile's pack applies (the engine's own data/gfx/ for :lores). A pack is drawn for one profile's viewport; a mis-sized pack is rejected at load time and the view falls back to the wireframe.

The generator ships two wall styles: the dungeon brick the default packs are drawn with (draw-wall-piece), and a city house style (draw-city-wall-piece in tools/gen-walls.lisp) that renders every piece as a timber-framed house — thatch roof band, plaster with dark framing, lit amber windows, stone foundation — so a city street reads as rows of houses, Bard's Tale style. A city pack using it must carry *house-colors* in pens 7–9 of its palette; Closure's worlds/closure/gfx/make-pack.lisp is the worked example.

A pack holds the 40 wall pieces plus optional extras:

  • front-0-v1.iff, side-2-l-v2.iff, ... — per-building style variants of any wall piece, probed in order (-v1, -v2, ...) until one is missing. The view deals them out deterministically per building — one walled-in mass of cells is one house and wears one look all the way along its front — so a street reads as a row of different houses instead of one repeated front; a (location ... :style N) op anywhere in the mass pins that building's look explicitly (Closure matches each block's street pieces to its houses' facade pictures this way). A pack ships as many looks — for as many pieces — as it wants to pay load time for: variants are per-piece, so trimming the far depths' files is a valid budget cut. draw-city-wall-piece's :style argument (:timber/:stone/:townhouse) draws the engine's three house looks.

  • floor.iff / ceiling.iff and ground.iff / sky.iff — the Bard's Tale split backdrop: one image fills the view above the horizon, one below, and the walls blit on top, carving the perspective. A zone takes one pair — ceiling/floor when it is :dark, sky/ground when it has a sky — because the two are painted to different rules. A pack that ships ground.iff paints its street instead of settling for one flat colour; only pens 5 and 6 follow the day bands, so whatever in sky.iff/ground.iff should darken with the hour must be one of those two pens. A missing file leaves a flat fill — pens 5 and 6 in the open, black underground. The default packs paint the floor as one flat color and the ceiling as solid distance bands darkening toward the horizon, split at the perspective-plane rows so each band lines up with a corridor depth — a pack can use the same trick, since the bands sit at fixed screen rows.

    Put the ceiling on pen 5 and the floor on pen 6, whichever pair a pack ships. The names are the contract, and a zone's :sky/:ground now reaches those two registers underground as well as out of doors (see "Every zone can declare its own colours"), so a dungeon pack laid out this way can be recoloured per zone for free — one pack dressing many dungeons. The demo packs predate that and do not: draw-backdrop-piece spends pen 6 on its near ceiling band and paints the floor in a fixed mid grey, which means a zone colour on one of them repaints the ceiling and calls it a floor. They are a drawing sample, not the layout to copy; Closure's worlds/closure/make-dungeon-packs.lisp (three dungeon packs, one painted stone each, the same flagged floor in all of them) is the worked example that keeps the contract.

  • palette.iff — any ILBM whose CMAP provides the pack's colors. Only the pens a pack owns are read from it (see the pen contract); entries for the engine's pens are ignored, so a stale palette cannot recolor the UI or the monsters.

  • pointer.iff / pointer-click.iff / pointer-forward.iff / pointer-back.iff / pointer-turn-left.iff / pointer-turn-right.iff — optional mouse-pointer art: the neutral pointer, the one shown over a click target, and the four directional cursors shown over the first-person view's walk zones. At most 16 pixels wide (a hardware sprite), pens 0–3 only — pen 0 transparent, pens 1–3 show as screen colors 17–19, taken from pointer.iff's CMAP entries 1–3 (the sprite has one palette; all pointers share it). The hot spot is the topmost-leftmost inked pixel. Missing files get the engine's built-in art — an open hand, a pointing finger and four arrows (*hand-pointer-art* and friends in src/ilbm.lisp).

(tale:print-tile-manifest) prints the full contract — every filename with its exact pixel size for the active profile (wrap it in tale:with-display-profile for another one) — so custom art can be drawn to spec; mis-sized pieces are rejected at load time with a message naming the file and both sizes.

The pen contract

The game runs on one screen with one palette, and walking into another zone swaps the tile pack under it. A bitmap, though, is nothing but pen indices — so an image loaded in one zone and still cached in the next re-colors the moment the new pack's CMAP lands. The screen's pens are therefore split in two, and src/palette.lisp is where the line is drawn:

pensowner
0enginetransparent key in wall pieces
1–3engineUI white, grey, amber
4engineopaque black
5–6packsky / ceiling, ground / floor
7–16packart
17–19enginemouse-pointer sprite registers
20–23packart
24–31enginethe shared figure core

Engine pens hold the same color in every zone. Pack pens are the pack's own and change under the player's feet on zone travel, which is the point: the night street and the cellar are the same pens in different colors. Only pack pens are loaded from a pack's palette.iff — a stale or hand-edited palette therefore cannot recolor the UI text, the mouse pointer, or the monsters standing in front of its walls.

A fixed pen is shared, not lost. A wall may paint in the figure core freely — the quantizer is offered it — it just cannot re-color it. So a pack has 16 pens of its own plus 12 more to draw with.

:hires is 16 colors, has no pen 24, and so has no figure core and no pointer pens: 0–4 engine, 5–15 pack, exactly as before. It is a wall-pack target only. :lores is where the contract lives and where new art should be drawn.

Pack colors need :display :screen — a window on the Workbench screen keeps the Workbench palette.

Transparency: in a wall piece pen 0 is transparent — the ceiling/floor backdrop shows through it, so the receding side walls don't stamp black wedges over the sky/floor. Paint solid black inside a wall (mortar, joints, door frames) with pen 4, not pen 0. The walls are composited over the backdrop with BltMaskBitMapRastPort (cookie-cut, RTG-safe); the backdrops are opaque, so pen 0 there is plain black.

The Closure game ships worked examples, all declared map-relative and all generated: worlds/closure/gfx/ — the town's painted houses under a day-banded sky, from its own make-pack.lisp — and three dungeon packs, gfx-rubble/, gfx-ashlar/ and gfx-rock/, one painted stone each over the same flagged floor and ceiling, from one make-dungeon-packs.lisp; each dungeon zone names the stone it wears. See also the "Backdrop slots" and wall-art sections of tests/run-tests.lisp for executable examples of the contract.

Packs from one hand-drawn facade

Drawing 40 pieces to spec by hand is a lot of work, and the procedural generators only make the looks they were coded for. The third route is tools/gen-pack-from-art.lisp: give it one flat, front-on picture of a wall and it derives the whole pack.

make pack ART=art/house.iff OUT=data/gfx-town/
make preview PACK=data/gfx-town/ OUT=street.iff

The source can be an IFF ILBM of any size and any depth — indexed art is expanded through its CMAP, 24/32-bit "deep" ILBMs are read directly — or a binary PPM (P6), the bridge for art that was never an Amiga file:

ffmpeg -i house.png -pix_fmt rgb24 house.ppm

The format is sniffed, not guessed from the name. write-deep-ilbm turns any of them back into a 24-bit IFF, so art that arrived as a PNG becomes a source you can keep editing in DPaint.

The natural size is the viewport (120×100 at :lores, 240×130 at :hires), which is exactly one wall cell at the nearest plane, so every piece comes out of a downscale. The perspective is pure geometry: front and flank slots are rectangles cut at the front slot's scale, side slots are the trapezoid between two perspective planes with the wall compressed into each column's visible span and the ceiling/floor corners left transparent.

More than one house. :variants takes further wall pictures, each a whole extra look, written as the -v1, -v2, … files the view deals out per building — so the street becomes a row of different houses rather than one facade repeated. :style N on a location pins a building's look, counting the base source as 0:

(generate-pack-from-art "art/house-1.iff"
  :out "worlds/closure/gfx/"
  :variants '("art/house-2.ppm" "art/house-3.ppm" "art/house-4.ppm")
  :pictures '(("art/house-1.iff" . "house-1.iff")))

Because a pack is one shared CMAP and pictures blit with the live screen palette rather than their own, every source above — base, variants and pictures — is quantized together, which is what makes a shop's takeover art belong to the same street it stands in. It also means the pens are a budget: four looks sharing 22 colors get noticeably less each than one look with all of them.

The pen layout follows the contract above, with the pointer's pens and the figure core held back — 14 art colors at :lores, 9 at :hires. The core is not a loss on top of that: the quantizer matches against those eight guaranteed colors too, it just may not redefine them.

Quantization works on the 12-bit grid the screen can actually show (set-rgb4, four bits a channel, on RTG as much as on ECS), and no two art pens are allowed to land on the same screen color or duplicate a fixed one — otherwise pens are spent on colors the machine cannot tell apart.

Every pack gets a palette.gpl beside its palette.iff: the same colors as a GIMP palette, which GIMP, Aseprite, Krita and Inkscape all import, each entry named with its pen number and role. That closes the loop — draw the next house against the pack's palette, then pass the pack's palette.iff back as :palette-source and quantization becomes a lossless lookup instead of a re-derivation:

(generate-pack-from-art "art/house-5.ppm"
  :out "worlds/closure/gfx/"
  :palette-source "worlds/closure/gfx/palette.iff")

Every piece kind has a -door- twin. By default both come from the same source, which suits a dense street whose facade already has a door in it; pass :door-source to give the door pieces their own art as soon as the player needs to see which walls can be entered.

tools/preview-view.lisp composites a pack the way the Amiga front end does — same blit list, same order, same cookie-cut rule — and hands back an ILBM, so art can be judged on the host without booting an emulator. make preview renders a fixture street that shows every piece kind at more than one depth. It is a preview, not a second renderer: where it and the Amiga disagree, the Amiga is right.

The art-pack tests in tests/run-tests.lisp are the executable spec — deep-ILBM reading, the box filter, median cut, the pen contract, and a whole pack built, reloaded through the loader's own size checks and composited.

Figures: art that travels between zones

A monster sprite, a hero portrait or an effect icon is not part of any pack. It is cached by the path it was loaded from and keeps rendering after the player walks into a zone with a different palette, so it must be drawn in pens no pack can move: pen 0 for transparency, plus 1–4 and 24–31 — twelve solid colors and a cookie-cut key, which is a Bard's Tale bestiary's worth.

generate-figure is the build step, and it enforces that rather than trusting it:

(tale::generate-figure "art/skeleton.png" "worlds/closure/gfx/skeleton.iff"
                       :transparent '(255 0 255))

:transparent names the source color meaning "nothing here"; those pixels become pen 0. Every other pixel is quantized into the figure palette, and the written image is then audited pen by pen — a pixel on a pack pen is an error naming the file, the pen, its role and the coordinate. The check runs on the host, where a per-pixel scan is free; a 68020 doing it at load time would not be.

The figure core is eight hand-picked constants, deliberately not derived from the art that uses it: a core computed over the bestiary would mean monster #17 changes the core and every pack in every world goes stale. Fixed means a new monster is a new file and nothing else moves. Given white, grey, amber and black come free from the UI pens, the core covers what hangs off a figure — a three-step flesh ramp (also wood and leather), two steels (armour and cold shadow), a red and a green (cloth, blood, scales, slime), and a bone highlight.

Every pack's palette.gpl marks each entry [FIXED] or as the pack's own, so this contract is visible in the file an artist opens.

Animated images

Any image the view column or the effects band shows — a monster portrait, a location picture, an effect icon — may ship animation frames beside it: mon-kobold.iff is frame 0, mon-kobold-f1.iff, mon-kobold-f2.iff, … the frames after it, probed in order until one is missing (the wall pieces' -v1/-v2 variant convention applied to time instead of style). Frames must match the base image's size and follow the same pen contract; the placeholder generators (draw-monster-portrait, draw-effect-icon :flame) take a frame argument and draw a two-frame pulse.

On the Amiga the frames cycle in place at ~3 steps a second on the window's INTUITICKS heartbeat — never through a full redraw. At load time the frames are diffed against the base and only the rectangle where they actually differ is re-blitted per step, so a portrait that only moves its eyes costs an eyes-sized blit, not a viewport-sized one. Identical frames are dropped at load; a mis-sized frame is a loud error like any mis-sized pack piece. The host renderer ignores frames entirely.

How a pack loads

Pack art is plain IFF ILBM — planar, ByteRun1-compressed, exactly what Deluxe Paint and friends read and write, so pieces can be edited in any Amiga paint program.

On the Amiga the pieces load without ever becoming chunky: an ILBM plane row and an Amiga BitMap plane row have the same layout, so the rows are poked straight into a scratch planar BitMap and the blitter moves them into the piece's own display-format bitmap (tale:*wall-load-planar*, on by default; bind it to NIL for the portable chunky path through WriteChunkyPixels). The cookie-cut mask comes from the same rows — for the usual pen-0 transparent key it is just the OR of the planes, folded once and reused both to decide whether a piece needs a mask at all and as the chip-RAM mask plane. Location pictures, portraits and effect icons load through the same planar recipe. All the per-byte and per-row work runs at C speed on clamiga: the file arrives through the bulk read-sequence path, the whole BODY is decoded in a single ext:unpack-byterun1 call, each plane's rows are gathered out of that interleaved buffer with one ext:copy-rows call per plane (the pure-CL loops are kept as the portable fallbacks), and the mask fold is a map-into #'logior over the packed plane bytes.

read-ilbm (chunky pens) remains the general reader and is what the host renderer, the pointer sprites and the asset generator use; read-ilbm-planar is the Amiga load path. The two are cross-checked pen for pen in tests/run-tests.lisp, and the Amiga suite blits a loaded piece back off the screen to confirm it carries the pens its ILBM declares.

Keeping packs loaded

Loading a pack decodes a directory of ILBMs into offscreen bitmaps — real seconds on a 14MHz 68020 — so a zone boundary between two packs (a town and its cellar) pays for the swap each way. tale:*gfx-cache-packs* trades memory for that time by keeping the pack the party just left, so walking back is instant:

valuebehavior
0never cache; reload on every swap (smallest footprint)
Nkeep up to N inactive packs, least-recently-used evicted first
:auto (default)keep one, but drop the cache when free memory falls below tale:*gfx-cache-min-free* (1 MB)

A :lores pack is roughly 40K of bitmaps plus 8K of chip-RAM masks, so :auto caches freely on a big machine and reloads on a small one. The cache lives inside a play-amiga session and is freed with it. Set it in a campaign, or bind it around the call:

(let ((tale:*gfx-cache-packs* 0))          ; a tight machine: never cache
  (tale:play-amiga "worlds/closure/town.map"))

Engine vs. story

Lambda's Tale is an engine; a game is one instance of it. The engine never hard-codes story facts. It emits events (:message, :enter-cell, :enter-zone, :enter-location, :blocked, :combat-start, :combat-end, :hero-died, :party-defeated, ...) that the front-end and the campaign subscribe to with on-event; story state lives in flags (set-flag/flag). A campaign is pure data on top of the engine: hero classes (define-hero-class), monsters (define-monster), items (define-item), spells (define-spell) and maps with cell specials. The Closure game is the shipped example; the engine suite's tests/world/ is the minimal one.

The world: cities, dungeons, shops

A world is a set of zones — ordinary map files linked by travel. Cities and dungeons are both first-class and both just maps: a (zone :kind :city :title "Frogmorton" :gfx "gfx/") form in the map file says what a zone is (and, optionally, which tile pack it wears), and the (travel FILE [X Y] [FACING]) special op links zones together — city gates, stairs and portals are all map data (a stair that asks before it is taken wraps its travel in the ask op, see "Asking first" below). A world can hold any number of cities and dungeons — every zone is its own file, and each keeps its own map and automap knowledge alive for the whole session; save games carry the whole world.

A location — a shop, or any enterable building — is the (location TITLE KIND ARG...) special op on a cell. The engine ships shop mechanics: items are campaign data (define-item — with prices, damage dice, AC bonuses, class restrictions, a player-facing :description and designer :notes carried as data for generated catalogues, in the Bard's Tale equipment kinds: weapon, armor, shield, helmet, gloves, bow, arrow, instrument, ring, wand, figurine, plus plain :misc), heroes carry up to 8 items and equip one item of each equipment kind — every worn piece's AC counts — combat uses the equipped gear, and shops sell their :stock and buy anything back at half price; p pools the party's gold onto the shopper, Bard's Tale style (the character sheet offers the same key, pooling onto the viewed hero), and t on the sheet trades gold back out: a digit picks who receives, then the sum is typed — digits, Backspace, Return — so a pooled purse splits again without a shop in sight. o on the sheet changes the marching order (move-hero): a digit names the viewed hero's new slot and the others close ranks — order matters, because the first three living members are the front ranks, and reach cuts both ways: they are the heroes monsters can hit and the only ones who can trade melee blows back. A hero behind them attacks only with a missile — an equipped bow and arrows (the arrows carry the damage dice, the shot aims by DEX instead of STR), or a weapon given a :reach of its own, the thrown kind, which needs no bow beside it. Bare of either, the attack action is out of reach and the orders page says so; carrying one, the hero shoots as far as its :reach allows (see the distance rules under Combat). A weapon defined :two-handed fills both hands, D&D-style: it will not go on beside a shield, nor a shield beside it, and the pack and shop pages mark it (2H); an instrument defined :tireless spends no tune (see Bard songs). Equipment is managed from the character sheet: the NEXT row (n, or a click — the sheet pages as a carousel: stat block, pack, a caster's or singer's spells/songs page, back around) turns to the hero's pack page, where a digit puts a pack item on or takes it off again; items a hero's class cannot use are marked (u) there, on the sheet and in the shop (the item card spells the full (unfit) out) — the shop still sells them (another hero may carry them), the marker just warns before the gold is gone. p on that page hands an item to another party member: a digit picks what to give, then a digit picks who receives it (each row showing the room left in their pack). Carrying is not using — an unfit item passes freely, so one hero can haul another's gear — and the fallen both give and receive, as with pooled gold; a full receiving pack refuses the item and leaves it whole with the giver — and the numbered rows are the gear, the quest pieces having a page of their own (see Quest pieces below). t on the pack page throws an item away for good: a digit picks it and a clickable yes/no stands guard — the one pack action that destroys, so it alone asks twice. i on the pack page — and on the shop's buy page, before any gold changes hands — inspects an item: a digit opens its card — kind, damage, AC bonus, price, class restriction, and the campaign's :description text when the item carries one. An item can also be usable (:use — a torch, a potion, a wand): using it (u, the use menu, in the open or as a combat-round order) heals a chosen hero, fires another non-battle instant (a figurine's :summon), installs a timed effect from the same vocabulary spells speak — or casts a registered spell outright: :use '(:cast SPELL) is the spell-trigger item (Bard's Tale's Wizhelm), casting for free with no spell points and no spellbook; a battle spell politely waits for a fight. A :consumed item is spent on use, and :image gives the effect its band icon. See the "Usable items" and "Spell-trigger items" test sections of tests/run-tests.lisp for the exact rules.

Quest pieces. A pack holds eight things, and a long campaign hands out more keys, tokens and proofs than a party has hands. :quest t on an item marks it a plot piece: it rides outside the eight-slot limit, so it never fits or fails to fit and never crowds out a sword. A quest piece has no price (define-item refuses one), no shop buys it and no hand throws it away — the way forward is not the player's to sell or destroy. It reads on the pack page's own quest page (r, offered only to a hero carrying one) rather than among the numbered gear rows: a title and the item's :description under it, scrolled like any long page. The gate ops (when-item, take-item) see it like any other item, so a piece is still something the party is carrying — visible, on a character sheet, spendable at the door it opens — and not a story flag wearing an item's name.

Two more location kinds spend gold on recovery, Bard's Tale style. A temple(location TITLE :temple :price N :raise M :raise-per-hp R :cures ((AILMENT PRICE) ...)) — heals for N gold per missing hit point (default 2), plus a fee to raise a fallen hero: the flat M (default 50) and R (default 0) for every one of the patient's full hit points, so a map may make a raising scale with the life restored. That fee buys the life, not the health: the hero comes back at a single hit point, and every hit point above it is a wound like any other at N — so making the fallen whole costs the fee plus the rest of their health. Its priests also lift conditions, at the flat price :cures names per ailment — and only the ones that table names: a condition this house does not treat walks out of the temple however much gold is offered. Its menu asks twice: Who wishes healing? — a digit per hero they have work for, wounded or merely ailing — then Who will pay? — any purse in the party, the patient's own included. The purse buys in one order: the raising first (there is no cleansing a corpse, so a purse short of the fee buys nothing at all), then each cure it can cover whole, then wounds one at a time with what is left. A purse that buys nothing leaves Not enough Gold as the menu's last line. An energy fount(location TITLE :energy :price N) — refills a living caster's spell points at N gold apiece (default 3), the Roscoe's of the piece; singers refill at the tavern instead. It asks the same two questions as the temple: Who wants to refresh spell points? — a bare prompt, since the roster pane below it already shows every hero's spell points and purse (a digit or a click on a roster row picks, and a hero the waters have no work for is told so) — then Who will pay? over the party's purses, with the same Not enough Gold notice for one that falls short. The "Temples" and "The energy fount" test sections of tests/run-tests.lisp are the executable specification.

The guild(location TITLE :guild :gold DICE) — is the Adventurers' Guild of Bard's Tale tradition: characters are made and parties formed there. Heroes not marching wait in the game's roster (saved with the game); the guild's pages move them — Create a character walks race, class, portrait where the class carries two (:image-woman), and a typed name (letters, digits, spaces, - and _, up to 12), then shows the 3d6 roll for keeping or rolling again, the fresh hero signing the roster with DICE gold (campaign data decides the purse; default 0); Add a member moves a waiting hero into the party, Remove a member sends one back to the hall, and Delete a character strikes a name for good behind a yes/no. Save game and Load game open the save/load picker right there, so the guild is where a tale is put down and taken up again — a save made inside any location now restores inside it. A campaign that starts at its guild puts the location on the map's start cell and defines default-roster (heroes waiting at boot) instead of default-party (heroes marching at boot; both hooks are optional) — the boot walks straight into the guild menu, and the guild will not send an empty party out onto the street. Leaving a location entered without a step (the boot's start cell, a TRAVEL arrival) still steps out the front door when the cell has exactly one passable side. The "The guild" test section of tests/run-tests.lisp is the executable specification.

Any location may keep hours(location ... :closed :night), a day-band or a list of them — and its door will not open while the clock stands in one: the party is told, an entering step is bounced back onto the street facing the shut door, and :location-closed fires for campaign scripts. The op stays top-level map data, so the closed shop keeps its street facade. See the "Opening hours" test section.

A location may also name a picture(location ... :image "gfx/shop.iff") — shown in the view column while its menu is up, plus an optional street-facing facade:facade "gfx/house-0.iff" — shown instead of the :image when the party faces the location's door from outside (see the "Facades from the street" test section), and a plaque name:plaque "The Guild" — a short form the view plaque under the first-person view (and the host header) shows while the party stands inside, where the full title would not fit; without one the plaque keeps the zone's name. A location page's title banner drops its closing *** when the full *** TITLE *** would overflow the narrowest takeover column — the ornament may not cost the page a row. For the same reason a location model may answer a key with (:notice TEXT) instead of a note row: a sentence wider than that column would wrap, and a wrapped row on a full page costs the menu its spacer and packs its options onto shared rows, so the page visibly reflows around the answer. The instruction puts TEXT alone under the banner (notice-lines), holds it *notice-linger* seconds — the Amiga front-end; the host speaks it through the log — and then the menu returns exactly as it was. A hero class names a portrait(define-hero-class ... :image "gfx/hero-warrior.iff") — shown beside the character sheet. A class open to both men and women may carry a second one (:image-woman); make-hero stamps the chosen portrait onto the hero at creation (:woman t picks the woman's), and the face stays theirs through every later class change. A monster type carries a portrait too — (define-monster ... :image "gfx/mon-rat.iff") — shown for as long as the fight lasts. All of them resolve relative to the map file, like effect icons and zone tile packs, so a world directory carries its own art. tools/gen-walls.lisp draws placeholder scenes and portraits (draw-location-scene, draw-portrait, draw-monster-portrait); Closure's worlds/closure/gfx/make-pack.lisp shows how a world generates and ships them.

Building your own world

A world is a directory: map files plus a campaign.lisp beside them — play and play-amiga load the campaign next to whatever map you start, so your world brings its own classes, monsters and items. Put a shop wherever you want it, stocked however you see fit:

mygame/campaign.lisp     (define-item 'rusty-dagger :kind :weapon
                           :price 5 :damage "1d4") ...
mygame/village.map       the art, then:
                         (zone :kind :city :title "Frogmorton"
                               :gfx "gfx/")   ; the world's own pack
                         (special (3 7)
                           (location "Bree's Bargains" :shop
                                     :stock (rusty-dagger torch)))
                         (special (9 2) (travel "warrens.map"))
mygame/warrens.map       (zone :kind :dungeon :title "the warrens") ...
mygame/gfx/*.iff         optional zone tile pack (see the manifest)
(tale:play "mygame/village.map")

Everything is data read with *read-eval* bound to NIL except campaign.lisp, which is a Lisp file of define-* calls (Closure's worlds/closure/campaign.lisp is the template; the engine suite's tests/world/campaign.lisp is the smallest possible one).

Map format

Maps are ASCII art on a (2W+1) x (2H+1) character grid — see the header of src/map.lisp for the exact rules and tests/world/crypt.map for a small example:

+-+-+-+
|@  | |
+ +D+ +
| |  <|
+-+-+-+

- | walls, D doors, @ party start, other cell characters are features (> stairs down, < stairs up by convention). Walls are stored per cell, so one-way (phantom) walls are expressible; maps can wrap Bard's Tale-style (:wrap t).

After the art a map file may carry its story layer as Lisp data forms (read with *read-eval* bound to NIL, never evaluated):

(zone :kind :dungeon :title "the cellar" :dark 3)

(special (1 2)
  (once (message "Something stirs in the darkness...")
        (encounter ("giant rat" "1d3+1"))))

The zone form's keywords are :kind, :title, :wrap, :start-facing, :gfx (the tile pack), :dark (see above), :sky / :ground — the outdoor noon colours the day-band tint works from (see "The day-bands and the sky") — and the wandering-monster keys :encounters / :encounter-chance and their :night-* counterparts (see "Wandering monsters").

The op vocabulary — message, set-flag/clear-flag, when-flag/unless-flag, at-night/at-day, once, teleport, travel, location, spin, damage, trap, heal, gold, give-item/take-item, when-item/unless-item, encounter, event, ask — is documented in src/specials.lisp.

Asking first. Every op acts the moment the party's foot lands, and for stairs that is too soon: a stair cell is a cell to walk across, and a descent is a decision. (ask TEXT... OP...) is the pause — Bard's Tale's "Stairs down. Take them?" — as map data: the leading strings are the question (free prose; the page wraps it), and the ops after them run only on a yes:

(special (3 2)
  (ask "Stone stairs spiral down into darkness.  Take them?"
       (travel "undervault.map" 1 1 :south)))

The question is a box both front-ends draw over the play page, the quit confirmation's shape (question-lines / question-act): Y takes the offer, N or Esc declines and the party simply stands on the cell, Q still asks to quit. It eats every other key until it is answered, pauses the Amiga's idle clock, and a step that ends on a question draws no wandering roll — the party has stopped to decide. Ops after the ask on the same cell run at once; a once belongs inside the ask, not around it, or a no would spend it.

The item ops. gold hands the party coin; (give-item NAME) hands it something with a name — a chest, a niche, a reward, anything no monster carries in for the party to kill it for. The first living hero with pack room takes it, the same rule a combat find follows, and with every pack full it is left where it lay. (take-item NAME) spends one copy — a key on the gate it opened — and carrying none is silent, not an error. (when-item NAME OP...) and its unless-item twin are when-flag's counterpart for things the party can hold, drop and hand around: a flag only remembers that the party once could, while these ask what it is carrying now. All four count a fallen hero's pack — a key its bearer died holding is still the party's — and all four signal an error on an unregistered item name, because that is a typo in map data.

Naming a hero in map text. Map text is literal — the ops carry strings, never forms — which leaves a line unable to say a hero's name, and that is exactly the line a parting scene wants. A text may therefore name the party in braces: {leader} is the hero walking in front (the first living one in marching order, the rank the gold op pays), so (message "{leader} stays behind.") reads Percival stays behind. damage and trap read their TEXT the same way. The vocabulary is small and closed on purpose: an unknown {token} is an error, as loud as an unknown op, and a brace with no closing twin is just a brace.

Traps. A (trap DICE [TEXT] [DIFFICULTY]) op is a floor trap with three layers of defence: a levitating party (a :levitate effect) floats over it; else a trap-skilled hero (the rogue's art, define-hero-class ... :trap-skill N) may spot and disarm it for this crossing; else it springs and every living hero rolls a saving throw — d20 + level + the LCK bonus + any :save-bonus effects against the trap's difficulty (default 14) — to halve the damage dice. The Trap Zap spell effect (:disarm-traps N) destroys traps up to N squares ahead for good; wrap a trap in once for a one-shot.

The character-by-character art parse scales with map area (a 30x30 city costs real seconds at 14MHz), so a successful parse writes a binary sidecar next to the map (town.maptown.mapc) holding the art-derived grid; while the sidecar is newer than the map it loads in one bulk read, and the story forms are read from the map file itself. Editing the map transparently reparses and rewrites the sidecar — delete .mapc files freely, they are pure cache (and the format is big-endian, so a sidecar written on the host works on the Amiga). See the map-cache tests in tests/run-tests.lisp for the contract.

Time, day and night

The game has a clock (shown in the map view's footer): every step, turn and combat round costs a minute, a fresh game starts at day 1, 08:00, and daylight runs 06:00–22:00 — :sunrise/:sunset events fire at the boundaries and the at-night/at-day special ops make map encounters time-dependent.

Darkness and light. A zone declared (zone ... :dark t) — a dungeon or cellar — is pitch black, the Bard's Tale dungeon: until a light effect burns the party sees nothing at all — the view is black, the automap learns only the cell the party stands on, and the door it faces (and any location behind it) goes unseen. :dark N (a positive integer) keeps it dark but grants N cells of sight there, a dimly glowing place. Outdoors at night there is no sun but there is a moon: sight falls to *moonlight-depth* cells (a few — dimmer than the daytime +view-depth+, but not the blind nothing of the underground). *moonlight-depth* is a plain special (default 3, capped at +view-depth+); set it to 1 for near-black nights.

A light effect — a torch, a lamp, a light spell or song — restores the full depth, then gutters: over its last minutes the circle of sight draws in a cell at a time (one cell per *light-fade-minutes*, default 4, remaining — so the last twelve minutes go three cells, two, one, out), and in a pitch-black zone the walls, ceiling and floor dim with it (light-brightness, a palette-only effect on the pack's own pens — portraits, monsters and text keep their colour), so a failing torch is read off the view itself before the "wears off" line. The view always shows whichever reaches further, the light or the zone's own glow: a guttering torch under the moon never shows less than the moon does. The schedule is in absolute minutes, not a share of the light's life — a four-hour lamp gutters over the same last minutes as a half-hour spell. Active effects can carry durations on the clock and wear off with a message. See the "Game time" sections of tests/run-tests.lisp for the exact rules.

The day-bands and the sky

The clock also names five bands of the day — morning, noon, afternoon, evening, night — and the map view prints the current one ("It's Morning."). Each band turn is announced in the message log ("The sun rises.", "The sun climbs high.", "The afternoon wears on.", "Dusk gathers.", "Night falls." — see *time-band-messages*), so the day reads as passing whether the party walks or stands. Outdoors, the first-person sky and ground take a different colour in each band: a bright blue that lifts toward dawn, softens through the afternoon, warms at dusk and sinks to near-black at night. It is a palette-only effect — two colour registers reloaded when the band turns (:time-band event), no new art and no redraw — so it is free even on a 14 MHz 020.

Every zone can declare its own colours:

(zone :kind :city :title "Closure" :gfx "gfx/"
      :sky (102 170 204) :ground (110 96 74))

:sky and :ground are (R G B) triples (a #(...) vector works too) giving the zone's noon colour; the engine derives the other bands by tinting that base, so a zone that paints a red alien sky still goes dark at nightfall. A zone that declares neither uses the engine defaults (*default-sky* / *default-ground*).

Indoor zones take their colours too, but not the clock. In a (zone ... :dark ...) the same two pens are the ceiling and the floor, and a ceiling does not brighten at dawn — so a dark zone's :sky and :ground are used exactly as declared, with no band tint. Declaring them is how several dungeons share one tile pack and still look like different places: the pack paints the stone, the zone line colours it. A dark zone that declares neither keeps whatever its pack loaded — there is no engine default underground, because substituting one would repaint art the pack meant to show. The two are independent, so a zone may colour its floor and leave its roof to the pack.

zone-pen-colors is the single function that decides all of this (src/map.lisp); the front end loads exactly what it returns and nothing else. The tint tables (*sky-band-tints* / *ground-band-tints*) and the pure sky-color-for / ground-color-for live in src/palette.lisp; the "Day-time sky and ground colour" section of tests/run-tests.lisp is the spec.

Wandering monsters

Classic Bard's Tale springs random fights on a walking party, and the streets get meaner after dark. A zone opts in by declaring a wandering-monster table and a per-step chance:

(zone :kind :city :title "Closure" :gfx "gfx/"
      :encounters (("giant rat" "1d3" 2) ("footpad" 1))
      :encounter-chance 2
      :night-encounters (("footpad" "1d2" 2) ("skeleton" "1d2"))
      :night-encounter-chance 8)

Every successful step rolls :encounter-chance percent; when it comes up, one table entry — (MONSTER COUNT-DICE [WEIGHT]), drawn by weight (default 1) — spawns as a group and combat starts exactly as from an (encounter ...) special. After dark (the daylight window, not the day-bands) an outdoor zone switches to its :night-encounters / :night-encounter-chance, each falling back to the base key it goes without — so "same monsters, more often" and "meaner monsters, same rate" are both one extra key. A :dark zone keeps its base pair at all hours: there is no night underground. The roll happens after the target cell's special has run — a scripted fight, an entered location or a travel to another zone all preempt it — and the step's own minute decides the table, so the sunset-crossing step already rolls against the night pair.

*encounter-rate* scales every zone's chance globally: 1 plays the authored numbers, 2 doubles them, 1/2 halves them, and NIL (or 0) disables wandering monsters entirely — combat then comes only from scripted specials. It is a plain special variable read on every step: a campaign sets its taste, a difficulty setting can rebind it later, the REPL can turn it live.

The living-world clock (time passes while you stand)

Classic Bard's Tale moves the clock only on an action, so standing still freezes the sky. The Amiga front-end instead drips time forward while the party stands idle in free exploration (not in combat, a location, a menu, or the map/help pages), on the window's idle heartbeat: the sky cycles, casters slowly regain magic outdoors, and timed effects burn down whether or not you walk. It is the same advance-time, so every consequence — spell-point regen, effect expiry, :sunrise/:sunset, :time-band — fires exactly as a step's would.

The pace is the special variable *idle-clock-rate* — game-minutes per real second — read on every tick, so a campaign, a display profile or the REPL can rebind or disable it live:

(setf tale:*idle-clock-rate* 4)    ; brisk (default): a day in ~6 real min
(setf tale:*idle-clock-rate* 1)    ; ambient: a day in ~24 real min
(setf tale:*idle-clock-rate* 20)   ; demo: a day in ~72 real sec
(setf tale:*idle-clock-rate* nil)  ; off: classic, time only moves on an action

The whole-minute/remainder arithmetic (idle-minutes-elapsed / idle-minutes-cost, driven off get-internal-real-time so the pace is independent of tick jitter and no sub-minute time is lost) is host tested in the "living-world idle clock" section of tests/run-tests.lisp.

The living world hunts, too. Idle time feeds the wandering-monster vigil: every full *idle-encounter-minutes* (default 30) of idle game time draws one roll of the zone's wandering check — the same chance and table a step pays per minute, spread far more thinly, since a loitering party draws less attention than a marching one. The remainder carries across heartbeats (maybe-idle-encounter), the usual guards apply (no fight starts inside a fight or a location, and *encounter-rate* scales or disables globally), and NIL turns idle encounters off while steps keep rolling as ever. A zone may keep its own vigil: (zone ... :idle-encounter-minutes M) overrides the global dial, and an explicit NIL there makes that zone's loitering safe — a friendly world can let its streets ambush walkers yet leave campers alone, or drop :encounters entirely and know no wandering monsters at all.

Party and combat

Heroes have Bard's Tale-ish stats (str/dex/iq/con/lck, descending AC, hit dice per class) and bank experience toward xp thresholds. The ladder those thresholds climb is the campaign's, not the engine's: how long a game runs, and how much of it a monster tier is worth, is content. (define-xp-table '(TOTAL ...) :growth N) registers the running totals to reach levels 2, 3, 4, ... in order, with :growth compounding the last of them once per level beyond the list so the ladder never simply stops; the form checks that the totals are positive, strictly increasing and that the growth exceeds 1, since a ladder that stalls would leave a hero for ever ready to rise. A game that registers none plays the engine's own gentle curve (50 x L x (L-1)), which is what the fixture world does. A crossed threshold flags the hero in the roster (a white up-arrow beside the name) and the rise itself is taken by hand on the character sheet — l, one level per press, each rise reporting its story (the new level, the stat gain, the spells learned) on a page of its own before the sheet returns. A level-up rolls the class hit dice again (plus the CON bonus) and gives every ability a Bard's Tale chance to rise by one — a d18 per stat, the score rises when the draw lands at or above it, so gains thin out toward the cap of 18 — though at most one ability rises per level: the first successful draw takes it. High CON pays into hit points and high DEX into the effective armor class (never the reverse: low scores cost nothing, the Bard's Tale kindness — see stat-gift). A class may carry a fighting art: define-hero-class ... :extra-attack-levels 4 grants an extra strike per four levels beyond the first (the warrior's way), :crit-chance N a percent chance — growing one point per level — that a landed blow fells the foe outright (the hunter's), :ac-per-level N one point of natural armor per N levels beyond the first, floored at -10 (the monk's), :trap-skill N a percent chance — growing one point per level — to spot and disarm a springing floor trap (the rogue's), and :description a lore line for the campaign to show.

A class may also be closed and conditional. :startable nil keeps it off the creation menu — make-hero refuses it, startable-hero-classes lists what is left — so the only way in is the second ladder: changing class. :change-at N is the level a hero must reach before they may leave that class, :change-group the family it changes within (a hero only ever moves between classes naming the same group, which is how "a mage moves up to another mage class" is said — a class missing either key is for life), and :requires ((:class . level) …) what they must have attained before they may enter this one. change-class (the sheet's c, offered only when hero-class-change-targets is non-empty) freezes the art being left at its current level and starts the new one over: level 1, no experience, but hit points, spell points, gold, abilities, armor and pack all carry across untouched, and the maximum spell points never fall. The frozen rating is the point — hero-class-level reads it, so a class left behind goes on granting every spell it had opened and never opens another, and a hero's book is the sum of every art they have worn. An art already worn cannot be taken up a second time. The roster holds up to 7 members (join-party): six regular heroes plus one guest slot for a summoned monster or story NPC. Combat is round-based, Bard's Tale style: every round opens on the engage pageFight or Run, the one choice that is the whole party's (everyone runs or nobody does, and only at the top of the round; a failed run costs a free monster round and asks again) — then the round-orders page asks one hero at a time (attack, defend, cast a spell, play a song, use an item; Esc undoes the previous pick), and when the last of them has picked it turns into the review — every hero with the order it gave, under Is this OK? — where y fights the round and n throws the orders away and asks again from the first hero. On the Amiga all these pages take over the message area, with the enemy's portrait in the view column. Then the round runs — heroes strike first, then every surviving monster swings at a random front-rank hero, or shoots from where it stands off if its type can (see the distance rules below).

The enemy also stands at a distance, in feet, on the scale the spellbooks already speak: ten feet is one dungeon square, and a group at +melee-distance+ (10') is toe to toe with the front rank — the only distance at which a melee blow lands, in either direction. start-combat's spec takes a distance per group, (MONSTER COUNT DISTANCE), and a zone's encounter table a fourth element for the same; name none and the groups line up from melee backwards, one *combat-group-spacing* apart (10', 30', 50' …), so a fight of one group opens at melee and plays as it always did. At the end of every round the lines still walking cover their ground and say so — one *combat-close-step*, or the type's own :speed where the campaign gave it a gait, so a runner crosses in one round what a shambler takes four over; set the dial to NIL for a campaign of fixed skirmish lines, and a type's :speed 0 nails that one line wherever the fight found it. Blows and bolts land on the nearest group (nearest-monster); a group spell breaks the group it lands among, and an all-foes word covers only what stands inside its reach (monsters-in-reach). A missile carries as far as its :reach says and a spell as far as its :reach, each measured against combat-distance — a spell that falls short is refused before it is paid for, and the orders page drops the Attack row for a hero with nothing in range. Both are campaign data: an item or a spell given no :reach goes unmeasured and carries however far the fight asks, which is what keeps a campaign written before distance existed playing unchanged.

The enemy answers in kind. A monster type given a :missile — the dice its arrow, its venom or its breath throws — and a :missile-reach shoots from where it stands while its group is still walking in, under the same to-hit roll, the same +4 for a defence and the same :inflicts as its blow; an unmeasured missile carries however far the fight asks, exactly as the hero's does. Melee still comes first on both sides: a group that has closed swings rather than shoots. A shot picks any living hero, whatever rank they stand in — rank is a melee line and a missile flies over it, the mirror of the back-rank hero shooting over the enemy's heads, and the only thing in the game that reaches the back ranks. :missile-verb is the transcript's word for a landed one (SHOOTS by default, SPITS AT, WAILS AT). A type without a missile is the plain fighter it always was: until its group reaches melee, its round is the ground it covers.

Each round opens with a -- Round N -- line and its transcript plays out one message at a time on a fresh page of its own; +/- set the pace (5 speeds, from a second per line — the starting pace — to instant). A won fight pays out each monster type's XP and gold: the sum over the fallen goes to each hero still standing, Bard's Tale style, rather than being divided among them — a hero who went down takes neither. The fight may also turn up an item a fallen monster carried (define-monster ... :item NAME :item-chance P, one find per fight at most). The :combat-end :victory event fires the moment the last foe falls, before the spoils are told, so the Amiga front end puts the campaign's *victory-image* treasure picture up first and the victory lines pace in under it; the page then lingers *victory-linger* seconds before play resumes. All randomness goes through *rng*, so the test suite scripts entire fights deterministically.

Ailments

Four conditions a hero carries about until something lifts them — none wears off with time, which is what makes a cure worth paying for:

AilmentIn play
:poisonbites for *poison-bite* hp on every party step and every combat round; it can kill
:insanitythe hero fights on its own — the round's order is void and the madness strikes a companion
:paralysisthe hero cannot act at all
:stonea statue: cannot act, and stands in the rank taking blows

afflict-hero lays one on (once — an ailment does not deepen), cure-ailment lifts one quietly, cure-hero lifts a list and says what it lifted. A hero's hero-ailments list is always in *ailments* order, so conditions read and cure in the same order however they arrived, and they stack: a poisoned hero can be paralysed too, and each is cured on its own. Saves carry them.

The paralysed and the stone are helpless: acting-heroes leaves them out, so the orders page never asks them and the round never spends an action on them (they are named once a round instead), while front-ranks still offers them to the monsters. A party where nobody can act — every hero frozen or petrified — loses the fight (party-can-act-p), which is the honest end for a fight that could otherwise run rounds forever with no one able to swing.

Who hands them out and what lifting one costs is campaign data:

  • define-monster ... :inflicts ((:poison 25) (:stone 5)) — a landed blow rolls each entry's percent chance on the hero it struck. This is how the Bard's Tale monster powers are expressed; a blow that misses, or that fells its target, carries nothing. A landed shot carries the same: a venom spat across thirty feet is the same venom.
  • a spell's :cure (:poison :insanity) lifts exactly the conditions it names — a word against poison leaves a madness where it found it. With :heal-party or :resurrect it reaches the whole roster.
  • a temple's :cures ((:poison 60) ...) prices each condition its priests know how to treat (see below).

Both front-ends read a hero's state from the engine, so they name the same things: hero-condition-titles gives the roster's parenthetical (poisoned, down, insane), hero-condition-code the Amiga table's four-letter code for the worst of it (DEAD, STON, PARA, INSA, POIS), and the character sheet lists the conditions one per line.

Spells

Spells are campaign data (define-spell); the engine knows the mechanics: casters (define-hero-class ... :caster t) carry spell points (2 per level plus the IQ bonus) and pay them per cast. A caster knows every registered spell of their class at or below the level they have reached in that class — no separate learning step; a fresh level's spells arrive with the rise, which names each one ("Zzal learns test flame!"). "In that class" matters only for a hero who has changed class (below): an art left behind keeps granting what it had opened. The character sheet's spells/songs page carries the spellbook as it stands — numbered, so a digit opens that spell's card: the tier, what the cast costs against the caster's own points, the four-letter incantation, the range and duration the campaign gave it, and what the spell does. That last line is derived from the effect spec ((:heal "4d4") reads "Heals 4-16"), so it cannot drift from the mechanics; a spell may carry its own :description where the derived words are too plain. The card casts too — c there spends the points without a trip through the c menu, asking for a target only when the spell needs one. A spell's effect is a plist over a shared vocabulary, and the keys combine freely — a restoration heals and cures, a batchspell installs five enchantments in one casting:

  • Instant keys resolve at cast time: :damage, :damage-per-level (the roll times the caster's level), :damage-group (the front group), :damage-all, :slay N (percent chance to fell the front monster) — all combat-only — plus :heal and :heal-party (dice or :full), :resurrect (the fallen rise at 1 hp), :scry (speaks the party's position), :disarm-traps N (destroys the traps up to N squares ahead, for good) and :teleport N — a real fold in space: the cast menu asks for a heading (N/E/S/W) and a count up to N, wrapping zones fold around the seam, a plain map's edge refuses (the spell is spent), and the destination cell's special fires on arrival. :teleport t is the other kind of flight: not a distance but a named destination — the cast menu lists the places the campaign registered (see Named destinations below) and a digit picks one. Neither teleport casts in combat: there is no walking away from a fight, and a cast that reaches the mechanism anyway (an item's free cast, a scripted cast-spell) finds the way shut. :cure (:poison ...) lifts the ailments it names (see Ailments above) and refuses anything that is not one. :summon and the foe-handling keys carry canonical data and speak their line today; their subsystems (allies) are still to come.
  • Timed keys merge into one effect record with a :duration in game minutes (or :indefinite): :buff-ac, :light, :night-vision and :reveal (all three defeat darkness), :compass (the party sees its facing only while one burns), :buff-damage, :regen-sp (multiplies the daylight trickle), :extra-attacks, :combat-heal (mends the party every round), :foes-ac and :foes-attack (the enemy fights worse), plus :save-bonus (weighs into every saving throw) and :levitate (the party floats over floor traps). A timed spell may name an :image, the icon the effect strip shows.

Beside the mechanics a spell keeps its lore: :code (the four-letter incantation), :range and :duration-text — display metadata the engine stores (spell-code, spell-range, spell-duration-text) and never interprets — plus :description, the player-facing line the card shows, and designer :notes carried as data for generated spellbooks (define-song takes both too). The two are deliberately separate: notes may record where an engine stand-in departs from canon, and a player should never read that, so only :description reaches a card. Spell points trickle back Bard's Tale style while walking outdoors in daylight. The "Spells" and "extended effect vocabulary" test sections of tests/run-tests.lisp are the executable specification.

Named destinations. A homing spell needs somewhere to home to, and where that is, is the campaign's business:

(define-destination 'testville-guild :title "The Guild at Testville"
                    :map "town.map" :x 14 :y 28 :facing :north)

The engine keeps the list and flies the party there; the game names the places worth flying to. :x/:y and :facing are optional and omitted means the map's own start, exactly as for the travel op — which is also how the map file is resolved, relative to the zone the party stands in when the flight begins. Registration order is menu order, and registering a name twice replaces the destination without moving it. The flight itself is a travel: the zone loads (or is remembered, automap and all) and the arrival cell's special fires, so a guild that greets its visitors greets these too. With nothing registered the spell is one with nowhere to go and says so on its card. An item that casts such a spell (:use '(:cast SPELL)) asks the same question from the use menu.

Bard songs and taverns

Songs are campaign data too (define-song): a song is always a timed effect over the same vocabulary — keys combine here as well, so one tune can quicken spell points on the road and grant extra attacks in a fight (:regen-sp 2 :extra-attacks 1 :duration 60). Singers (define-hero-class ... :singer t) pay tunes — one charge per song, one charge per level when rested — and only one song plays at a time: striking up a new one displaces the old, the Bard's Tale rule. A class may also need something in hand to play at all — :sings-with :instrument names an item kind (any of *item-kinds*) the singer must have equipped, the Bard's Tale rule that the music needs an instrument; carrying one in the pack is not enough, and a class without the option sings bare-handed. p opens the sing menu, and in combat (:sing SONG) is a party action beside attacking and casting. The songbook also lists on the character sheet's spells/songs page, where a digit opens the song's card — its level, the tunes in hand and what the tune does — and p there strikes it up on the spot, or says why it will not go (song-refusal: not in the book, no instrument in hand, no tunes left). Tunes come back with a drink at a tavern — a (location TITLE :tavern :price N) map special; a tavern may also hold the way below (:down FILE, the trapdoor to the cellar). An instrument registered :tireless t (define-item, instruments only) spends no tune at all: a singer holding one never runs dry, the menus say "Tireless" where they would count, and the count waits on the sheet for the day the instrument comes off. The "Bard songs" test section of tests/run-tests.lisp is the executable specification.

Magic at work

The effects band shows an icon per active working; the magic-at-work page spells them out — every active effect by name (a song marked as one), what it does in the same phrases the spell and song cards use, and how long it has ("43 minutes left", "until dispelled"). E opens it on the road and in a fight alike (over the round's orders, while no cast/play/use picker of theirs is up), E or Esc closes it, u/d turn a long page; on the Amiga the band itself clicks to it. workings-lines / workings-act in src/help.lisp are the model both front-ends draw.

Sound

Sound works like tiles: the engine names the cues, a sound pack ships them. The vocabulary (*sound-names*) covers the moments the game already announces — hit, slay, miss, hurt, death, combat, victory, defeat, door, blocked, cast, song, level, coin, drink — and a pack is a directory of IFF 8SVX samples named after them (hit.8svx, door.8svx, ...), declared per zone in the map right beside the tile pack:

(zone :kind :city :gfx "gfx/" :sfx "sfx/")

A pack may ship any subset; missing cues stay silent. hit.8svx is the pack's probe file (the front-0.iff of sound): zone-sfx-dir resolves the directory beside the map file first, then relative to the game directory.

Mechanics never play sounds — they emit events, and attach-sounds (wired by both front-ends, next to the message log) maps them to cues through play-sound. On the Amiga the cues are audible: the pack's samples are uploaded to chip RAM once and one audio.device channel plays them (cl-amiga's AMIGA.AUDIO module), a new cue cutting the one still sounding; travel swaps sound packs exactly like tile packs. The host front-end stays silent — *sound-backend* is the single hook, so a host player (or a test) can install its own. read-8svx / write-8svx round-trip the format (uncompressed and Fibonacci-delta), so asset generators need no second toolchain. The "Sound" test section of tests/run-tests.lisp is the executable specification.

A location may also name a tune(location ... :music "sfx/guild-theme.8svx"), resolved relative to the map file like :image. On the Amiga the 8SVX loops on a second, quieter channel for as long as the party stands inside (a save loaded inside the location picks the tune straight back up) and falls silent at the door; the cue channel keeps speaking over it. The host front-end ignores it like every cue.

Save games

Save games (save-game/load-game) are a single readable Lisp form: the current zone's map file, position, the game clock, active effects, every visited zone's automap knowledge, story flags and the party with packs, equipment and each hero's ailments. Up to 9 named saves live side by side as saves/NAME.sav: both front-ends share the same slot picker (Shift-S/Shift-L, and the Save/Load menu items on the Amiga) — pick a slot by number or type a new name (refused once 9 slots exist, so every slot stays reachable by its digit) — and saving is refused during combat.

The test suite (tests/run-tests.lisp) doubles as the executable specification for the map model, movement, knowledge tracking, renderers, events, specials, zones and travel, party, items, shops, combat and save games.

Version

src/version.lisp holds the engine's version — MAJOR.MINOR.PATCH plus a DD.MM.YYYY date, the same shape the clamiga runtime uses:

(tale:engine-version-string)   ; => "0.1.0"
(tale:engine-version)          ; => 0, 1, 0   (three values)
tale:*engine-name*             ; => "Lambda's Tale"
tale:*engine-version-date*     ; => "24.07.2026"

A game built on the engine has its own version, which moves independently of the engine's. The engine only declares the slots — tale:*game-name*, tale:*game-version*, tale:*game-version-date*, all NIL until a game sets them — and the game fills them in from a src/version.lisp of its own, loaded after the engine:

(in-package :tale)
(setf *game-name* "Closure" *game-version* "0.1"
      *game-version-date* "24.07.2026")

See ../closure-tale/src/version.lisp for the worked example, and the version sections of both test suites for the contract.

Debug log

(tale:debug-log-enable) opens a timestamped trace file (tale-debug.log by default, or pass a path) and (tale:debug-log-disable) closes it again; setting the environment variable TALE_DEBUG_LOG (a path, or 1 for the default) enables it as the engine loads. While enabled, the engine logs every image, map and campaign load with its duration, every emitted event with its handler count, and every key press — each line wall-clock timestamped with a millisecond fraction and flushed immediately, so a session that crashes still leaves the trace up to the moment it died. Off by default and free when off. Game code can write its own lines with (tale:dlog "..." args...) and time a block with tale:dlog-timed; see the debug-log section of tests/run-tests.lisp for usage.

The log doubles as a launch profiler: the engine loader logs each source file's load time and a launch -> loaded summary, and play-amiga marks new-game, display open and first frame up, each with milliseconds since clamiga started (get-internal-real-time counts from process launch). Together with clamiga's --boot-log (the pre-engine runtime/CLOS boot phases) one debug-log trace shows where every second between launch and the first rendered frame goes — see "Load times and memory" in the Closure README for measured results.

Seams for tooling

Three specials let a game hang its own tools off a running session — a debug console, a cheat menu, a recorder — without forking a front-end. The engine never reads any of them, and a release build simply never sets them.

tale:*game* is the game the running front-end is playing, or nil outside a session. Both play and play-amiga assign it as they wire a game up, for a fresh game and for one restored from a save alike, and leave it standing when the session ends, so a tool can still inspect what the party walked away from. It is assigned, never bound: dynamic bindings are per-thread in this runtime, so a let would be invisible to any other thread and would vanish the moment the front-end returned.

tale:*key-hook* is a function of (game char) that both front-ends offer every key before their own dispatch — ahead even of the quit confirmation, so a console stays reachable when a page has wedged. A true return means the hook consumed the key: the front-end redraws and no page sees it.

tale:*tick-hook* is a function of (game) that the Amiga front-end calls once per heartbeat, beside the *autoplay* step. It is where work arriving from outside the event loop gets run on the loop's own task: the front-end draws from that task and the game state carries no locks, so a channel fed by another thread posts its forms here rather than evaluating them wherever they were typed. A true return means the hook changed something the frame does not show yet, and the front-end redraws; an idle hook must return nil, because the heartbeat runs about ten times a second and a redraw at that rate is more than a 68020 has to give. The host front-end blocks on the keyboard and has no heartbeat, so it never calls this.

The Closure game's src/debug.lisp is the worked example: an in-game Lisp console, loaded only when :debug is on *features*.

Roadmap

  • M0 (done): map model, movement, automap knowledge, ASCII map view, interactive walkabout on the host
  • M1 (done): wireframe first-person view (shared geometry + display list, ASCII and Amiga renderers, automap fed by what the party sees)
  • M2 (done): events + cell-specials story layer, party and character system, round-based combat, save games
  • M2.5 (done): Bard's Tale screen layout (scrolling message log, active-spells strip, 7-slot party roster, full map under m), large maps (30x30 up to 128x128), custom screen via BestModeID (RTG-aware) — see specs/ui-and-engine.md
  • M3 (done): ILBM asset loading (src/ilbm.lisp), blitted wall graphics — RTG-aware (MorphOS / Picasso96 / CyberGraphX): no chipset or planar assumptions, bitmaps via AllocBitMap, blits through OS calls only; procedurally generated wall-piece assets (make assets)
  • M3.5 (done): swappable tile packs (:gfx-dir, print-tile-manifest), custom screen with per-pack palettes, ceiling/floor backdrop; display profiles (:profile) — 32-color lo-res ECS default, 16-color hi-res alternative, each with its own generated pack
  • M4 (in progress): the game proper — now the separate Closure subproject — plus engine support as it needs it: zones/travel/shops (done), day/night and darkness (done), spells (done), named saves (done), sound — 8SVX cue packs per zone, audio.device playback (done); next polish. The Bard's Tale II chrome and the day/night sky art are parked until the game content lands.

See what people are saying

Contributors

mdbergmann

128 commits

Languages

Common Lisp

99.0%