Splits creature speed into two clocks so movement pace can be tuned without touching reaction time: - **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence. - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value. ### How - Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22). - `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs. - `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes. - `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes. - The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly. ### Behavior changes - **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted. - **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock. - **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep. - **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles. - **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too. - Removes the vestigial `MoveSpeedMod` (never read, written, or serialized). - With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before. ### Testing - Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding). - In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes.
18 KiB
ModernUO Content Creation Patterns
This document covers the patterns and templates for creating game content in ModernUO: items, creatures, spells, skills, loot, context menus, and file organization.
Table of Contents
- New Item
- New Creature
- New Spell
- Skill Implementation
- Loot System
- Context Menus
- Entity Lifecycle
- File Organization
New Item
Minimal Item
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class SimpleItem : Item
{
[Constructible]
public SimpleItem() : base(0x1234) // itemID from UO art
{
Weight = 1.0;
}
public override string DefaultName => "a simple item";
// OR: public override int LabelNumber => 1234567; // cliloc number
}
Item with Properties and Behavior
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class MagicLantern : Item
{
[SerializableField(0)]
[InvalidateProperties]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _charges;
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
private TimerExecutionToken _glowTimer;
[Constructible]
public MagicLantern() : base(0xA25)
{
_charges = Utility.RandomMinMax(5, 15);
Weight = 2.0;
Light = LightType.Circle300;
StartGlow();
}
public override string DefaultName => "a magic lantern";
private void StartGlow()
{
Timer.StartTimer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3), Glow, out _glowTimer);
}
[AfterDeserialization]
private void AfterDeserialization() => StartGlow();
public override void OnAfterDelete()
{
_glowTimer.Cancel();
base.OnAfterDelete();
}
private void Glow()
{
if (_charges > 0)
Effects.SendLocationParticles(this, 0x376A, 9, 10, 5042);
}
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in your backpack
return;
}
if (_charges <= 0)
{
from.SendMessage("The lantern is depleted.");
return;
}
Charges--;
from.SendMessage("The lantern flares brightly!");
from.FixedParticles(0x376A, 9, 32, 5042, EffectLayer.Waist);
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);
list.Add(1060741, $"{_charges}"); // "charges: ~1_val~"
}
}
Common Item Base Classes
| Base Class | Use For |
|---|---|
Item |
Generic items |
BaseWeapon |
Melee weapons |
BaseRanged |
Ranged weapons (bows, crossbows) |
BaseArmor |
Armor pieces |
BaseShield |
Shields |
BaseClothing |
Wearable clothing |
BaseJewel |
Rings, bracelets, necklaces |
BaseContainer |
Containers (bags, boxes) |
BasePotion |
Potions |
BaseReagent |
Spell reagents |
Food |
Edible items |
SpellScroll |
Spell scrolls |
Key Item Properties
Weight = 1.0; // Item weight in stones
Stackable = true; // Can stack with same type
Amount = 1; // Stack amount
Movable = true; // Can be picked up
Visible = true; // Visible to players
Hue = 0; // Color hue (0 = default)
Light = LightType.Circle300; // Light emission
LootType = LootType.Regular; // Regular, Newbied, Blessed, Cursed
Layer = Layer.OneHanded; // Equipment layer
New Creature
Basic Creature
using ModernUO.Serialization;
using Server.Items;
namespace Server.Mobiles;
[SerializationGenerator(0)]
public partial class ForestWolf : BaseCreature
{
[Constructible]
public ForestWolf() : base(AIType.AI_Melee, FightMode.Closest)
{
Body = 225; // Wolf body graphic
BaseSoundID = 0xE5; // Base sound ID
SetStr(80, 120);
SetDex(90, 110);
SetInt(20, 40);
SetHits(60, 80);
SetMana(0);
SetDamage(8, 14);
SetDamageType(ResistanceType.Physical, 100);
SetResistance(ResistanceType.Physical, 25, 35);
SetResistance(ResistanceType.Fire, 5, 10);
SetResistance(ResistanceType.Cold, 15, 25);
SetResistance(ResistanceType.Poison, 10, 15);
SetResistance(ResistanceType.Energy, 5, 10);
SetSkill(SkillName.MagicResist, 30.0, 50.0);
SetSkill(SkillName.Tactics, 50.0, 70.0);
SetSkill(SkillName.Wrestling, 50.0, 70.0);
Fame = 600;
Karma = 0;
VirtualArmor = 28;
Tamable = true;
ControlSlots = 1;
MinTameSkill = 50.1;
}
public override string CorpseName => "a wolf corpse";
public override string DefaultName => "a forest wolf";
public override int Meat => 1;
public override int Hides => 6;
public override HideType HideType => HideType.Regular;
public override FoodType FavoriteFood => FoodType.Meat;
public override PackInstinct PackInstinct => PackInstinct.Canine;
public override void GenerateLoot()
{
AddLoot(LootPack.Meager);
}
}
AI Types
| AIType | Use For |
|---|---|
AI_Melee |
Warriors, melee fighters |
AI_Mage |
Spellcasters |
AI_Archer |
Ranged attackers |
AI_Animal |
Passive animals (flee/fight back) |
AI_Predator |
Hunting animals |
AI_Healer |
Healing NPCs |
AI_Vendor |
Shop NPCs |
AI_Berserk |
Mindless aggressors |
AI_Thief |
Pickpockets |
Fight Modes
| FightMode | Behavior |
|---|---|
None |
Never attacks |
Aggressor |
Only retaliates |
Strongest |
Targets highest-stat enemy |
Weakest |
Targets lowest-stat enemy |
Closest |
Targets nearest enemy |
Evil |
Attacks aggressors or evil-karma targets |
Creature Stats Guide
| Creature Level | Str | Dex | Int | Hits | Damage | Fame |
|---|---|---|---|---|---|---|
| Weak | 30-60 | 30-50 | 10-20 | 20-40 | 2-6 | 100-300 |
| Average | 80-120 | 60-90 | 20-40 | 60-100 | 6-14 | 500-1500 |
| Strong | 150-250 | 80-120 | 50-100 | 120-200 | 12-22 | 2000-5000 |
| Elite | 300-500 | 100-150 | 100-200 | 250-500 | 18-30 | 5000-15000 |
| Boss | 500-1000 | 150-250 | 200-400 | 500-2000 | 25-40 | 15000+ |
Optional Creature Overrides
public override Poison PoisonImmune => Poison.Regular; // Poison immunity
public override Poison HitPoison => Poison.Lesser; // Melee poison
public override double HitPoisonChance => 0.2; // 20% poison chance
public override bool CanRummageCorpses => true; // Loots corpses
public override bool BardImmune => true; // Cannot be provoked/peaced
public override bool Unprovokable => true; // Cannot be provoked
public override bool CanFly => true; // Can fly
public override int TreasureMapLevel => 3; // Drops treasure map
public override double WeaponAbilityChance => 0.4; // Weapon ability chance
Creature Speeds (think vs move clocks)
All "speed" values are delays in seconds (smaller = faster). A creature runs two clocks:
- Think clock —
ActiveSpeed/PassiveSpeed/CurrentSpeed: seconds per AI decision (combat decisions, target acquisition, spell timing). - Move clock —
ActiveMoveSpeed/PassiveMoveSpeed/CurrentMoveSpeed: seconds per step. Inherits the matching think value until overridden, so a creature configured with only think speeds behaves as one clock. Any value is legal — steps are scheduled independently of think ticks, so the two need not divide evenly.
Speeds normally come from Distribution/Data/npc-speeds.json (via SpeedClass or type
lists); activeMove/passiveMove are optional per bucket. Prefer data over code:
public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json
Code-level overrides for special cases:
SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics)
SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted
ClearMoveSpeed(); // back to inheriting the think clock
All four are [props-tunable per instance (move values: set 0 to re-inherit); per-instance
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
New Spell
Targeted Damage Spell (Magery)
using System;
using Server.Targeting;
namespace Server.Spells.Third;
public class FireballSpellCustom : MagerySpell, ITargetingSpell<Mobile>
{
private static readonly SpellInfo _info = new(
"Fireball", // Name
"Vas Flam", // Mantra
212, // Cast animation
9041, // Cast sound
Reagent.BlackPearl // Reagents (comma-separated)
);
public FireballSpellCustom(Mobile caster, Item scroll = null) : base(caster, scroll, _info) { }
public override SpellCircle Circle => SpellCircle.Third;
public override bool DelayedDamage => true;
public void Target(Mobile m)
{
if (CheckHSequence(m)) // Harmful spell sequence check
{
var source = Caster;
SpellHelper.Turn(source, m);
SpellHelper.CheckReflect((int)Circle, ref source, ref m);
double damage;
if (Core.AOS)
{
damage = GetNewAosDamage(19, 1, 5, m);
}
else
{
damage = Utility.Random(10, 7);
if (CheckResisted(m))
{
damage *= 0.75;
m.SendLocalizedMessage(501783); // You resist
}
damage *= GetDamageScalar(m);
}
source.MovingParticles(m, 0x36D4, 7, 0, false, true, 9502, 4019, 0x160);
source.PlaySound(0x15E);
// Damage types must sum to 100
SpellHelper.Damage(this, m, damage, 0, 100, 0, 0, 0);
// phys fire cold pois energy
}
}
public override void OnCast()
{
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Harmful);
}
}
Spell Helper Methods
SpellHelper.Turn(caster, target); // Face target
SpellHelper.CheckReflect(circle, ref source, ref target); // Magic reflect
SpellHelper.Damage(spell, target, damage, phys, fire, cold, poison, energy);
SpellHelper.AddStatCurse(caster, target, stat);
SpellHelper.AddStatBonus(caster, target, stat);
SpellHelper.CanRevealCaster(spell);
CheckHSequence(target); // Harmful spell checks (LOS, range, criminal)
CheckBSequence(target); // Beneficial spell checks
CheckResisted(target); // Resistance check
GetNewAosDamage(bonus, dice, sides, target); // AOS damage formula
GetDamageScalar(target); // Pre-AOS damage multiplier
Spell Circles (Magery)
| Circle | Mana | Base Delay |
|---|---|---|
| First | 4 | 0.25s + circle |
| Second | 6 | 0.50s + circle |
| Third | 9 | 0.75s + circle |
| Fourth | 11 | 1.00s + circle |
| Fifth | 14 | 1.25s + circle |
| Sixth | 20 | 1.50s + circle |
| Seventh | 40 | 1.75s + circle |
| Eighth | 50 | 2.00s + circle |
Skill Implementation
Registering a Skill Handler
namespace Server.SkillHandlers;
public static class MySkillHandler
{
public static void Initialize()
{
SkillInfo.Table[(int)SkillName.Tracking].Callback = OnUse;
}
public static TimeSpan OnUse(Mobile from)
{
from.SendMessage("You begin tracking...");
from.Target = new TrackingTarget();
return TimeSpan.FromSeconds(10.0); // Cooldown
}
}
Skill Check
// Difficulty-based check (with skill gain chance)
if (from.CheckSkill(SkillName.Mining, 0.0, 100.0))
{
// Success
}
// Direct chance check
if (from.CheckSkill(SkillName.Hiding, minSkill: 25.0, maxSkill: 75.0))
{
// Success
}
SkillName Enum (58 skills)
Key skills: Alchemy, Anatomy, AnimalLore, AnimalTaming, Archery, ArmsLore, Begging, Blacksmith, Bushido, Camping, Carpentry, Cartography, Chivalry, Cooking, DetectHidden, Discordance, EvalInt, Fencing, Fishing, Fletching, Focus, Forensics, Healing, Herding, Hiding, Inscribe, ItemID, Lockpicking, Lumberjacking, Macing, Magery, MagicResist, Meditation, Mining, Musicianship, Necromancy, Ninjitsu, Parry, Peacemaking, Poisoning, Provocation, RemoveTrap, Snooping, Spellweaving, SpiritSpeak, Stealing, Stealth, Swords, Tactics, Tailoring, TasteID, Tinkering, Tracking, Veterinary, Wrestling
Loot System
Using Predefined Packs
public override void GenerateLoot()
{
AddLoot(LootPack.Poor); // ~50g equivalent
AddLoot(LootPack.Meager); // ~100g equivalent
AddLoot(LootPack.Average); // ~250g equivalent
AddLoot(LootPack.Rich); // ~500g equivalent
AddLoot(LootPack.FilthyRich); // ~1000g equivalent
AddLoot(LootPack.UltraRich); // ~2000g equivalent
AddLoot(LootPack.SuperBoss); // Boss-level loot
// Auxiliary packs
AddLoot(LootPack.Gems, 2); // 2 random gems
AddLoot(LootPack.Potions); // Random potion
AddLoot(LootPack.LowScrolls); // Low circle scroll
AddLoot(LootPack.MedScrolls); // Med circle scroll
AddLoot(LootPack.HighScrolls); // High circle scroll
}
Packs auto-select era-appropriate loot (Pre-AOS, AOS, SE variants).
Specific Items
PackItem(new Arrow(Utility.RandomMinMax(20, 40)));
PackGold(100, 200);
PackItem(new Bandage(Utility.RandomMinMax(5, 10)));
Context Menus
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (from.Alive && from.InRange(this, 2))
{
list.Add(new RepairEntry(this));
}
}
private class RepairEntry : ContextMenuEntry
{
private readonly Item _item;
public RepairEntry(Item item) : base(6100) // Cliloc number
{
_item = item;
Enabled = item is { Deleted: false };
}
public override void OnClick(Mobile from, IEntity target)
{
if (_item.Deleted || !from.InRange(_item, 2))
return;
from.SendMessage("You repair the item.");
}
}
Entity Lifecycle
Two-Phase Deletion
// Phase 1: Pre-removal cleanup
public override void OnDelete()
{
_timerToken.Cancel(); // Cancel managed timers
// Remove from tracking systems
base.OnDelete();
}
// Phase 2: Post-removal cleanup
public override void OnAfterDelete()
{
_timer?.Stop(); // Stop Timer references
_timer = null;
_owner = null; // Null Item/Mobile refs
base.OnAfterDelete();
}
OnDoubleClick Validation
public override void OnDoubleClick(Mobile from)
{
if (!IsChildOf(from.Backpack))
{
from.SendLocalizedMessage(1042001); // Must be in backpack
return;
}
if (!from.InRange(GetWorldLocation(), 2))
{
from.SendLocalizedMessage(500446); // Too far away
return;
}
// Item logic here
}
File Organization
Projects/UOContent/
├── Items/
│ ├── Weapons/Swords/ # Swords
│ ├── Weapons/Maces/ # Maces
│ ├── Weapons/Ranged/ # Bows, crossbows
│ ├── Armor/Plate/ # Plate armor
│ ├── Armor/Chain/ # Chain armor
│ ├── Armor/Leather/ # Leather armor
│ ├── Clothing/ # Wearable clothing
│ ├── Containers/ # Bags, boxes
│ ├── Misc/ # General items
│ ├── Special/ # Unique/quest items
│ └── Resources/ # Crafting materials
├── Mobiles/
│ ├── Animals/Bears/ # Bears (BlackBear, GrizzlyBear)
│ ├── Animals/Birds/ # Birds
│ ├── Monsters/AOS/ # AOS-era monsters
│ ├── Monsters/SE/ # SE-era monsters
│ ├── Monsters/ML/ # ML-era monsters
│ ├── Special/ # Champions, bosses
│ ├── Vendors/ # NPC vendors
│ └── Townfolk/ # NPCs
├── Spells/
│ ├── Base/ # Spell base classes
│ ├── First/ - Eighth/ # Magery circles
│ ├── Necromancy/ # Necromancer spells
│ ├── Chivalry/ # Paladin spells
│ ├── Bushido/ # Samurai abilities
│ ├── Ninjitsu/ # Ninja abilities
│ └── Spellweaving/ # Spellweaving
├── Skills/ # Skill handlers
├── Gumps/ # UI dialogs
│ └── Base/ # Gump base classes
├── Engines/ # Complex systems
│ ├── Craft/ # Crafting system
│ ├── CannedEvil/ # Champion spawns
│ ├── Factions/ # Faction system
│ └── Quests/ # Quest system
└── Misc/ # Miscellaneous
└── LootPack.cs # Loot tables
Naming Rules
- File name = class name
- One primary class per file
- Group related items in subdirectories
- Era-specific content goes in era-named subdirectories