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/).
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 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.
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 scrolls — u/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.
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-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.
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.
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 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:
| pens | owner | |
|---|---|---|
| 0 | engine | transparent key in wall pieces |
| 1–3 | engine | UI white, grey, amber |
| 4 | engine | opaque black |
| 5–6 | pack | sky / ceiling, ground / floor |
| 7–16 | pack | art |
| 17–19 | engine | mouse-pointer sprite registers |
| 20–23 | pack | art |
| 24–31 | engine | the 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.
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.
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.
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.
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.
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:
| value | behavior |
|---|---|
0 | never cache; reload on every swap (smallest footprint) |
N | keep 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"))
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.
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.
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).
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.map → town.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.
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 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.
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.
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.
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
page — Fight 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.
Four conditions a hero carries about until something lifts them — none wears off with time, which is what makes a cure worth paying for:
| Ailment | In play |
|---|---|
:poison | bites for *poison-bite* hp on every party step and every combat round; it can kill |
:insanity | the hero fights on its own — the round's order is void and the madness strikes a companion |
:paralysis | the hero cannot act at all |
:stone | a 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.: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.: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 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:
: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.: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.
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.
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 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-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.
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.
(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.
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*.
m),
large maps (30x30 up to 128x128), custom screen via
BestModeID (RTG-aware) — see specs/ui-and-engine.mdsrc/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):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 pack128 commits
Hacker News (1)
Common Lisp
99.0%
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/).
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 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.
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 scrolls — u/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.
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-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.
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.
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 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:
| pens | owner | |
|---|---|---|
| 0 | engine | transparent key in wall pieces |
| 1–3 | engine | UI white, grey, amber |
| 4 | engine | opaque black |
| 5–6 | pack | sky / ceiling, ground / floor |
| 7–16 | pack | art |
| 17–19 | engine | mouse-pointer sprite registers |
| 20–23 | pack | art |
| 24–31 | engine | the 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.
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.
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.
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.
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.
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:
| value | behavior |
|---|---|
0 | never cache; reload on every swap (smallest footprint) |
N | keep 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"))
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.
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.
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).
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.map → town.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.
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 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.
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.
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.
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
page — Fight 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.
Four conditions a hero carries about until something lifts them — none wears off with time, which is what makes a cure worth paying for:
| Ailment | In play |
|---|---|
:poison | bites for *poison-bite* hp on every party step and every combat round; it can kill |
:insanity | the hero fights on its own — the round's order is void and the madness strikes a companion |
:paralysis | the hero cannot act at all |
:stone | a 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.: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.: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 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:
: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.: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.
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.
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 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-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.
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.
(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.
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*.
m),
large maps (30x30 up to 128x128), custom screen via
BestModeID (RTG-aware) — see specs/ui-and-engine.mdsrc/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):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 packHacker News (1)
128 commits
Common Lisp
99.0%