August 21, 2026 - prerelease - stable
Neo Angband v0.22.0
Neo Angband 0.22.0
What you can download
| Platform | File |
|---|---|
| Windows (installer) | Neo Angband Setup 0.22.0.exe |
| Windows (portable, one file) | Neo Angband-0.22.0-portable.exe |
| macOS | .dmg, or the .zip if you prefer to unpack it yourself |
| Linux | .AppImage (no install), .deb, or .tar.gz |
| Self-hosting | neo-angband-web-0.22.0.zip - static files, any web server |
The portable Windows build and the AppImage need no installer: download, run, and the game keeps its saves in a folder beside itself.
These builds are not code-signed
There is no Apple Developer identity or Windows certificate behind this project yet, so your OS blocks the first launch.
Windows is one click: SmartScreen says “Windows protected your PC”, choose More info then Run anyway.
macOS is not one click, and the dialog it shows you does not contain the way through - it offers only Done and Move to Trash. Do this:
- Drag the app out of the
.dmg, then double-click it and press Done on the refusal. This step is required: it is what makes the permission below appear, and it expires about an hour later. - Open System Settings -> Privacy & Security and scroll to Security, near the bottom.
- Press Open Anyway on the line naming Neo Angband, authenticate, and launch it again.
Or, the same decision in one command:
xattr -d com.apple.quarantine "/Applications/Neo Angband.app"
The old right-click -> Open trick does NOT work: Apple removed that bypass in macOS 15 Sequoia.
On Apple Silicon take the arm64 build. The x64 one is for Intel Macs, not a fallback - Apple is withdrawing Rosetta 2, so on a current Mac it is likelier to refuse to launch than to run slowly.
If that trade is not one you want to make, build it yourself -
docs/INSTALL.md - or play in the browser, which needs no trust decision.
Your save
Saves survive an update. Every save-format change ships the conversion that reads the version before it, and a save this build cannot open is left untouched rather than replaced.
Current state of the project at version 0.22.0 - the mod-resilience release.
Nothing about the game’s rules changed. What changed is that a mod can now add
one line to a list instead of restating it, that a mod’s own content is drawn
rather than lettered, and that thirteen defects are fixed - four of them ways one
line of one mod could take the whole game down at boot, which now cost that mod
its line and nothing else.
Added
- A mod can add one entry to a list. Two new field-patch ops:
append, which adds entries to the array at a dot-path without restating it, andremoveValue, which drops entries deep-equal to a value. This closes the limitation recorded under 0.21.0: putting a modded item in a shop’s stock is now a three-line patch, core’s own stock list survives untouched, and two mods can both add to the same store because neither has to replace the other’s list.appendis treated as composing (like the flag ops) so two mods appending to one list is not a conflict;removeValueis order-dependent, because it can erase another mod’s entry, so it is reported and the mod that loads last wins. - An added monster or item is drawn from its family in tile mode. A tile set
maps named monsters to pictures and has never heard of a mod’s, so modded
content used to stand out as a coloured letter among pictures, and the only
fix available to an author was a pref file naming atlas coordinates, which are
correct for one tile set and wrong for every other. A monster with no tile of
its own now takes one from a race sharing its
base, and an object kind from a kind sharing itstval, so a modded ant is an ant in every tile set at once. Restricted to records a mod ADDED, by provenance, so core’s own drawing is untouched, including the object kinds that are deliberately blank because they are drawn by flavour. Runs in both tile engines. A pref file naming a specific tile still wins. - Tutorial 2 now stocks its item in the Armoury, and its finished mod is checked against the real store binder, which is where an item name that does not resolve is caught, rather than becoming a shop that quietly lacks it.
- A seventh tutorial: add an artifact. The one shape the first six did not
cover, and the last of the three examples a player asked for by name. An
artifact is a layer over an item the game already ships rather than an item of
its own, so it gets its own page: what
base-objectis, why an artifact’snameis only half a name and carries none of the&and~decoration an ordinary item’s does, and the trap that a wrongtvalis reported while a wrongsvalsilently invents a placeholder base object, because that is the behaviour the Phial, the Star and the Arkenstone depend on. Its finished mod is a real folder like the other six, bound by the real object registry rather than read back as JSON.
Fixed
-
A mod’s artifact naming a base object that is not there costs the artifact, not the game. Third instance of the store binder’s defect, and the first where the right size of the drop is the whole record: a shop with one fewer stock line is a shop and an ego with one fewer candidate base is an ego, but an artifact with no base kind is not an artifact, because every number on it is an adjustment to a kind that has to exist. So a mod-contributed artifact whose
base-objectresolves to nothing is dropped whole and reported against that mod through the same mod-manager path the store and ego drops already use, and core’s own still throws the message it always threw. Index-safe in the direction that matters: core’s pack composes first and mods append, so no core artifact can sit behind a mod’s and a savefile’s core artifacts keep their numbers. An invalidflags,valuesoracttoken on a mod’s artifact still throws and is recorded in docs/PLANNED.md. -
The look/target UI ran terrain prefixes into the name: “the entrance to theArmoury”, “You are inan open door”, “somelava”.
terrain.txtwrites everylook-prefixandlook-in-prepositionwithout a trailing space, and upstream separates them from the name infinish_parse_feat(init.cL2256-2272), which appends a space to each non-empty one after the parse. The port copied the data faithfully and never ported that hook, so the targeting code’s<preposition><prefix><name>concatenation had nothing between its parts. Ten features, three visible strings: the eight store entrances (“the entrance to the”), the open and broken doors (“in”), and lava (“some”), plus the stores’ “at” preposition, which the same normalisation covers. Two comments inknown.tsasserted this was upstream’s own data rather than a missing hook and are corrected; the assertions that had encoded the unseparated values are now the test that would have caught it. A port defect rather than an upstream wart: upstream renders these lines correctly, so it belongs in core, not inbug-fixes. -
The town laid out eight store lots no matter what the terrain data said, and three other post-parse hooks were absent.
finish_parse_feat’s trailing-space half landed above; the same hook also derives each shop entrance’sshopnumfrom the order of theSHOPflags and counts them intoz_info->store_max(init.cL2249-2257, L2275), and the port hard-coded that eight-feature list intown_gen_layoutinstead. So a mod that flags another terrainSHOPgot a store with no door anywhere in town, unreachable for the whole game, and one that cleared aSHOPflag left a lot leading to a shop that no longer existed.FeatureRegistrynow assignsshopnuminFEATorder and exposesstoreMax/shopFeats(), and the town reads those.TOWN_STORE_FEATSstays as the shipped-data expectation with a test that fails if the two ever part.Audited the other twelve
finish_parse_*hooks against the port at the same time. Two more were missing:- A bad critical-level table was accepted in silence.
finish_parse_constantsrunscheck_critical_levels(init.cL986-1020) over the melee and ranged cutoff tables and refuses the data when the cutoffs do not strictly increase, because thepower >= cutoffwalk can never reach a row whose cutoff did not rise. That critical grade simply stops happening and the damage multiplier is quietly wrong.bindConstantsdid no such check, andmelee-critical-levelis a top-level key of the constants record that a mod can replace wholesale. It now rejects, with the last row’s cutoff exempt exactly as upstream leaves it (which is why the shipped tables can end in-1), and theo-tables unchecked exactly as upstream leaves them. - Shopkeeper tips came out in the wrong order.
parse_hintprepends onto a list andfinish_parse_hintspublishes its head, so upstream’shintsis in reversehints.txtorder;bindCorepublished file order.random_hintreservoir-samples over that list, so the draw count matched and the tip did not. Reversed at boot, the waynames.txtalready is for the same reason.
The remaining ten are reproduced or have nothing to reproduce, recorded here so the next audit does not repeat the reading:
player_prop‘s per-element expansion and itsbinduibinding are ported (player/abilities.ts,game/ui-entry.ts),names’ list-to-array reversal (session/boot.ts),trap’s list-to-array withtidx(world/trap.ts),history’s entry reversal and successor resolution (player/bind.ts),p_race’sridxandclass’scidx(array index,player/bind.ts);body’sequip_slots_maxpadding is a no-op for a single 12-slot body and the port carries the real slot count;world’s level-reference validation has no port subject, since nothing bindsworld.json;realm,shapeandflavorpublish a list and free the parser, andflavor’s reverse order is reproduced at its reader (obj/flavor.ts). - A bad critical-level table was accepted in silence.
-
A shop line naming a missing item took the whole game down.
bindStorethrew on a stock entry it could not resolve, from insidebindCore→startGame, which the host runs at module top level, so the player got the crash screen and no game at all. Theappendfield op made that reachable from an ordinary pair of mods and an ordinary click: mod A appends an item mod B defines to a store’snormaltable (tutorial 2 is exactly this patch), the player disables mod B, and the appended line now names nothing. A mod-contributed entry that resolves to nothing is now dropped and reported against the mod on its own row in the mod manager; the rest of the store and every other shop in town are untouched. Core’s own data still fails loudly: the tolerance is decided per entry from the record’s provenance, so an unresolvable line in a store no pack has touched throws exactly the message it always threw, which is every store in a modless game. Covers every field a patch can reach:normal,always(including its svalless book lines) andbuyeach lose one entry, and astore:entrance repointed at a feature that does not exist leaves the shop unenterable rather than taking the game down. The record keeps its place in the store list, because that list is read positionally and renumbering it would move a saved game’s stock between shops. The owner list resolves no names and so has nothing to refuse. -
The character dump called every installed content mod “(not installed)”. The
[Mods enabled]block resolved each enabled id’s version out of the two bundled PLUGIN registries only, so a mod carrying noplugin.js(most of them, and all of the tutorial mods) matched neither and printed the “(not installed)” fallback. A pack in the player’s own mods folder was equally invisible whether it shipped code or not, since those registries glob the bundle rather than the folder. Measured in the running desktop build: two tutorial content packs enabled and demonstrably composed, both reported as not installed. The version now also resolves through the content-pack registry: every bundled pack plus everything from the mods directory, a picked folder, or a repository install, and “(not installed)” is kept for an id that genuinely resolves to nothing, which is a real state worth a line. This mattered because naming the mods is the block’s entire purpose: a dump claiming a loaded mod is absent points the reader at core for behaviour a mod caused. The list also moved out ofmain.tsintomod-summary.tsso it is testable at all: the entry module cannot be imported, which is why a list that was wrong for every content-only mod stayed green. -
Monster recall did not know what a monster’s KIND implies. Upstream unions each race’s base flags into its lore at startup (
finish_parse_lore), so a player who has never met a giant black ant still knows ants are animals with weird minds, and that ainu resist fire and cannot be confused. The port had the race half of that inheritance and not the lore half, which is exactly why nothing noticed, since the flags were on the race all along and simply never known. Measured against the shipped pack: 54 of the 56 monster bases carry flags, so recall was quieter than upstream’s for every monster the player has not met. The wizard “wipe monster lore” command still loses them for good, because upstream’s union runs once at startup and never again; the existing wipe test is what caught the first attempt putting it in the wrong place. -
A curse could multiply an object’s weight by a negative number.
finish_parse_curserefuses a curse that carriesMULTIPLY_WEIGHTtogether with a negative weight adjustment, and the port had the parser-side weight check but not this one: it is a FINISH hook, and the port’s own comment said as much while never implementing it, which is exactly the blindness a parity test written against upstream’s parser tests cannot see. Core’s own data now fails the same way upstream’s does. A mod’s curse instead loses the FLAG and is told: of the two halves, the flag is the one whose removal leaves a coherent curse (a plain additive weight reduction), and failing the parse for a mod would mean the crash screen and no game. Core ships no curse using the flag at all, so nothing shipped changes. -
An ego’s
item:line naming a missing base item took the game down. The same defect the store’s stock table had, in a second file:item:names a specific base kind,appendlets one mod add an entry to another pack’s list, and “mod A gives an ego a base item mod B defines, player disables mod B” then reachedego: unknown svalout ofbindCoreinsidestartGame, the crash screen over one line of one ego. A mod-contributed line that resolves to nothing is now dropped from that ego’s candidate list and reported against the mod; core’s own still throws the message it always threw. Dropping one entry is the whole cost here, becauseposs_itemsis a set of candidates and an ego with one fewer candidate still works: it simply cannot land on the kind that went away, which is what the player asked for by disabling the pack that defined it. The core-versus-mod decision itself now lives in one place (mod/refusal.ts) rather than in each binder, so two binders cannot come to different answers about the same provenance. -
A patch could make a field unreadable and take the game down at boot. A field patch that wrote a scalar, or
null, over a field core writes as a list or an object produced a record that composed perfectly and that no binder could read: the store binder’srec.owner.map(...)threw aTypeErrorfrom insidebindCoreinsidestartGame, which the host runs at module top level, so the player got the crash screen and no game. The composer already checked this: the record check’sfield/typerule fired on it and named the mod, but that check reports and never refuses by design, because the blueprint it reads is a measurement of core’s own records and an unlisted value is legal. Container-ness is the exception, since nothing can iterate a string, so the composer now refuses that one class: the field is put back to what the record had before, the pack is told on its own row, and the rest of the patch still lands. Two things it deliberately still allows: a scalar written as the wrong scalar (readable, and the measurement cannot prove otherwise), and a patch that REMOVES a field, because dropping fields is how a total conversion works and putting them back would undo it. -
Randart games handed out the wrong gems. flavor.txt writes a ring or amulet record’s
fixed:lines above itsflavor:lines, and the binder bound them the other way round, so the flavour list was not in the file’s order. That list is walked backwards byflavor_assign_random(it reproduces C’s prepend-into-a-linked-list), which makes a flavour’s position in it the thing that decides which ring it lands on. In an ordinary game nothing showed: a fixed flavour keeps its own sval and the random assignment skips it, so the random ones kept their relative order and every ring looked right. Underbirth_randartsit did show:flavor_reset_fixedscrubs every fixed sval but the One Ring’s, which drops seven more entries into the random pool at the wrong end of the list. The draw COUNT is identical either way, so the RNG stream never moved and no seed probe could have caught it; only the assignment itself differs, and a test now runs both orders against one seed to show that it does under randarts and does not without. -
Three field-patch ops silently destroyed data instead of failing. An
addormulaimed at a path holding a list or a string treated it as0and wrote a number over it; amergeaimed at a list replaced the list with an object. All three now raise a patch error naming the path and what was actually there. This was not theoretical: a documentation example shipped anaddagainst a store’s stock list, which turned the list into a number while composition reported no problems at all.
Changed
- Tutorial 3 no longer says an added monster is stuck as a coloured letter in tile mode, and no longer says a mod can carry its own art for its own monsters. A mod contributes a whole graphics mode, not one picture added to somebody else’s set. Both claims had outgrown the code.
- Cleaned up some artifacts in the documentation and in source comments: the prose now uses plain ASCII punctuation throughout.
Found something that does not match Angband 4.2.6? Open an issue or come and say so in the Discord.