Skip to content

Wizard Spellbook Management

Wizards maintain a dedicated spellbook alongside the regular spell-preparation workflow. A spell must first be learned into the spellbook before it can be prepared or cast. Wizard handling applies both to the class whose name matches the localized wizard name and to any class with Force Wizard Mode enabled in Class Rules.

Each wizard-enabled class is treated independently: its own Learn tab, its own journal-backed spellbook, its own copied-spell metadata flag, and its own counters.


Wizard Learn tab with spellbook entries and scroll sources

A Learn tab is rendered in the Spell Book for every wizard-enabled class on the actor, in addition to that class’s regular Prepare tab. The tab’s label is the class name, and it is displayed with the module icon so it can be visually distinguished from Prepare tabs.

The tab lists:

  • Spells from the class’s assigned spell list that are not yet in the spellbook.
  • Eligible spell scrolls detected in the actor’s inventory (see Scroll Scanner).

Spells above the actor’s maximum castable level for that class are filtered out.

Each spell row on the Learn tab resolves to one of three states:

StateControlBehavior
Available from the spell listLearn Spell buttonFree copies are added immediately. Paid copies open a confirmation dialog quoting the gold cost and time from WizardBook.getCopyingCost() and WizardBook.getCopyingTime().
Available from a scrollLearn Spell button with scroll iconCalls learnSpellFromScroll. If Consume Scrolls When Learning (world setting) is enabled, the scroll’s quantity is decremented; the scroll is deleted when quantity hits zero.
Already in the spellbookIn Spellbook badgeShows a source icon (book / scroll / shopping cart / level-up arrow; see Learning Sources). Hovering reveals an eraser-icon unlearn affordance; clicking removes the spell from the spellbook.

The Learn tab footer displays:

  • Known spells: current / max, where max is getMaxSpellsAllowed().
  • Free copies remaining: from getRemainingFreeSpells(). When this reaches zero, any further non-scroll copies require gold and time.

Clicking Learn Spell queries WizardBook.getCopyingCost(actor, classId, spell):

  • If the result is free (cantrip or free pool not yet depleted), the spell is added directly without a dialog.
  • Otherwise a confirmation dialog shows the gold cost and time. On confirm, the spell is added with source copied. If the Deduct Spell Learning Costs world setting is enabled, the gold is deducted from the actor’s currency (base currency first, then other denominations in increasing conversion order). If total wealth is insufficient, the learn is aborted with a warning.

Scroll rows route to learnSpellFromScroll. The spell is added with source scroll; no gold is charged and no free-pool slot is consumed. If Consume Scrolls When Learning is on, the scroll item’s quantity is decremented (or the item deleted when quantity would drop below 1).

The in-spellbook eraser-icon action calls WizardBook.removeSpellFromSpellbook(actor, classId, uuid):

  • Removes the UUID from the spellbook journal page’s system.spells Set.
  • Removes the matching metadata entry from the wizardCopiedSpells_<classId> actor flag.

cost = spellLevel * spellLearningCostMultiplier
  • Default multiplier: 50 GP per level (WIZARD_DEFAULTS.SPELL_LEARNING_COST_MULTIPLIER).
  • Cantrips (level === 0): 0 GP.
  • Free copies: 0 GP.
minutes = spellLevel * spellLearningTimeMultiplier
  • Default multiplier: 120 minutes per level (WIZARD_DEFAULTS.SPELL_LEARNING_TIME_MULTIPLIER).
  • Cantrips: 1 minute.

Formatted via dnd5e.utils.formatTime.

Both multipliers are configurable per class in Class Rules.


max = startingSpells + max(0, classLevel - 1) * spellsPerLevel
  • startingSpells default: 6 (WIZARD_DEFAULTS.STARTING_SPELLS).
  • spellsPerLevel default: 2 (WIZARD_DEFAULTS.SPELLS_PER_LEVEL).

Both are configurable per class in Class Rules. Class level reads from actor.spellcastingClasses[classId].system.levels.

A spell occupies a free slot unless it was learned via copied or scroll (recorded in the wizardCopiedSpells_<classId> flag). Specifically:

  • Used free = spellbook entries with no matching entry in the copied-spells flag.
  • Remaining free = max(0, capacity − used).

Cantrips are always treated as free regardless of remaining pool.

getMaxSpellsAllowed() returns the same formula and defines both the free-pool ceiling and the total spellbook capacity shown in the footer.


Each wizard-enabled class gets a dedicated JournalEntry in the spell-book.custom-spell-lists compendium pack, placed in the Actor Spellbooks folder. Access goes through WizardBook._getOrCreateSpellbookJournal(actor, classId).

The journal has one page of type spells; its system.spells Set holds the learned UUIDs.

LocationValue
Journal flags (spell-book)actorId, classIdentifier, isActorSpellbook: true, creationDate
Page system.identifier<slug(actor.name)>-<classId>-spellbook
Page system.type'actor-spellbook'
OwnershipMirrors the actor’s owners (everyone with ownership level 3 retains ownership on the journal and page).

Journal name is the actor’s name for the default wizard class, or <actor> (<className>) for other wizard-enabled classes.

Spells learned as copied or scroll append an entry to the actor.flags.spell-book.wizardCopiedSpells_<classId> flag:

FieldDescription
spellUuidSpell UUID
dateCopiedDate.now() timestamp
costGold paid (0 for scroll)
timeSpentMinutes taken
fromScrolltrue for scroll learns, false for paid copies

Unlearning removes the entry. Spells learned via free have no flag entry; absence of an entry is what marks a spell as a free-pool occupant. The initial and levelUp source constants are defined but produce no flag entry and are currently unused in any write path.


Every spell in the spellbook resolves to one of the following sources, defined in WIZARD_SPELL_SOURCE:

SourceMeaningIcon (in-spellbook badge)
initialDefined constant; no grant logic is currently implementedbook
freeCopied from the class spell list while the free pool still had capacitybook
copiedPaid gold-copy from the class spell listshopping cart
scrollLearned by consuming/reading a scrollscroll
levelUpDefined constant; no grant logic is currently implementedlevel-up arrow

WizardBook.getSpellLearningSource() infers from the wizardCopiedSpells_<classId> flag: entries with fromScroll: true resolve to scroll, other entries to copied, and missing entries fall back to free. initial and levelUp produce no flag entry and are therefore indistinguishable from free at runtime; the render code in player-spell-book.mjs makes no attempt to differentiate them.


scanForScrollSpells(actor, maxSpellLevel) in scripts/data/scroll-processor.mjs builds the scroll section of the Learn tab.

  1. Filters actor.items to entries with type === 'consumable' and system.type.value === 'scroll'.
  2. For each scroll, extracts a spell UUID from (in order):
    • Any activity with activity.spell.uuid.
    • Any activity effect reference whose corresponding effect has an origin UUID.
    • Fallback: parse the scroll name ("Spell Scroll: <Name>") and search item packs for a spell matching that name, preferring system.level === scroll.flags.dnd5e.spellLevel.base.
  3. Rejects spells above maxSpellLevel (cantrips always pass).

Scrolls whose contained spell UUID is already in the wizard’s spellbook are excluded from the Learn tab; the spell is already known.


Per-class ritual behavior is set in Class Rules under Ritual Casting. Three modes:

ModeBehavior
noneDefault. Rituals are prepared like any other spell.
preparedRituals can be cast without consuming a slot, but only while prepared.
alwaysOn save, spells in the wizard’s spellbook that are flagged ritual are auto-injected into the actor as ritual-mode items (system.method: 'ritual', system.prepared: 0), even when not prepared. Lets the wizard cast any spellbook ritual at will.

In always mode, SpellManager tracks module-managed ritual items on the wizardRitualCasting actor flag. On each save:

  • Items flagged from a previous mode that no longer apply are cleaned up.
  • Current ritual spells from the spellbook are inserted or refreshed.

Switching a class away from always clears the ritual items it generated.

See Ruleset Types and Meanings for the full ruleset table.


WizardBook._ensureFlagsInitialized runs the first time a wizard-enabled class is processed for an actor. It creates an empty wizardCopiedSpells_<classId> flag array on the actor if one is not already present. It does not add any spell UUIDs to the spellbook and does not reference WIZARD_DEFAULTS.STARTING_SPELLS.

WIZARD_SPELL_SOURCE.INITIAL and WIZARD_SPELL_SOURCE.LEVEL_UP are defined constants. Neither has a corresponding write path in the codebase; no automatic grant of initial or level-up spells is implemented. The free-pool capacity formula in getMaxSpellsAllowed() uses startingSpells and spellsPerLevel as ceiling inputs only — the GM or player must manually learn the spells that the calculated capacity makes available.


Cauldron of Plentiful Resources Compatibility

Section titled “Cauldron of Plentiful Resources Compatibility”

When the Cauldron of Plentiful Resources module is active and the Cauldron of Plentiful Resources Compatibility world setting is enabled, every preparation save triggers an actor refresh. This ensures the module’s item replacements are re-evaluated after any spell preparation changes.


Class Rules expose a per-class Force Wizard Mode toggle. When on, the class is treated as a wizard for all purposes:

  • A Learn tab is rendered for it.
  • Its spells are managed through a dedicated wizard journal and copied-spells flag.
  • Cost, time, free pool, and ritual integration apply using that class’s own rules.

Useful for homebrew classes, variant classes, or subclasses that copy spells into a book.

Wizard detection comes from ClassManager.getWizardEnabledClasses(), which returns every spellcasting class whose name matches the localized wizard name or has forceWizardMode === true. Each returned class gets independent state; there is no priority between them.


A character with multiple wizard-enabled classes (e.g. Wizard multiclassed with a force-wizard Artificer) has:

  • One Learn tab per wizard-enabled class.
  • One spellbook journal per class in custom-spell-lists.
  • Independent wizardCopiedSpells_<classId> flags.
  • Independent free-pool, max-spell, and ritual-mode state driven by each class’s own level and rules.

No spells are shared between spellbooks.


Key static methods on WizardBook (scripts/managers/wizard-book.mjs):

MethodPurpose
getWizardSpellbook(actor, classId)Returns the UUID array from the journal page
isSpellInSpellbook(actor, classId, uuid)Membership check
addSpellToSpellbook(actor, classId, uuid, source, metadata)Add a spell and optionally record copied metadata
removeSpellFromSpellbook(actor, classId, uuid)Remove a spell and clean up metadata
copySpell(actor, classId, uuid, cost, time, isFree)Add with optional currency deduction
getCopyingCost(actor, classId, spell){ cost, isFree }
getCopyingTime(actor, classId, spell)Formatted time string
getMaxSpellsAllowed(actor, classId)Capacity formula result
getUsedFreeSpells(actor, classId)Count of non-paid spells in the spellbook
getRemainingFreeSpells(actor, classId)max - used, floored at zero
isSpellFree(actor, classId, spell)Whether the next copy would be free
getSpellLearningSource(actor, classId, uuid)Resolves to free / copied / scroll from the flag
invalidateCache(actor)Drop journal and spellbook caches

Related modules:

  • scripts/apps/player-spell-book.mjs: Learn tab render (_buildRenderContext with isLearn), #onLearnSpell, #onLearnSpellFromScroll, #onUnlearnSpell.
  • scripts/managers/spell-manager.mjs: save flow and ritual auto-injection.
  • scripts/data/scroll-processor.mjs: scanForScrollSpells.
  • scripts/constants.mjs: WIZARD_DEFAULTS, WIZARD_SPELL_SOURCE, FLAGS.WIZARD_COPIED_SPELLS.
  • templates/apps/player/tab-learn.hbs, templates/components/spell-item.hbs: Learn tab and per-row wizard action block.