Skip to content

Getting Started

MorquinDevlar edited this page Sep 24, 2026 · 8 revisions

Getting Started

MDW is a Mudlet package. Once installed, it builds its sidebar UI automatically when the profile loads and ships a set of example widgets so you can see it working immediately. This page covers how to configure MDW and register your own widgets so they survive reloads and package updates.

Installing

Install the .mpackage through Mudlet's package manager (Toolbox -> Package Manager -> Install). MDW sets up its UI on install and again every time the profile loads. Example widgets appear in the left and right sidebars by default. A game package built on MDW can install it for the player instead - see Bootstrapping MDW from your game package.

The integration contract

Everything on this page follows one rule: your scripts never call MDW functions at script-load time - they seed tables that MDW consumes when it builds its UI. Because of that, none of the following matters:

  • where your script sits in Mudlet's script order (above or below MDW)
  • whether your package is installed before or after MDW
  • MDW package updates (the seeds survive; MDW re-reads them on every setup)

Every snippet below starts with mdw = mdw or {} so it is safe even when your script is the first thing that ever mentions MDW.

Registering your own widgets

Widgets are destroyed and recreated every time the package reloads (profile load or package update). Register a named init function in the mdw.onReady registry and MDW runs it during every setup:

-- Safe at any load position: this only touches tables, calls nothing.
mdw = mdw or {}
mdw.onReady = mdw.onReady or {}

mdw.onReady["MyGame"] = function()
  local vitals = mdw.Widget:new({
    name = "Vitals",
    title = "Character Stats",
    dock = "left",
    height = 150,
  })
  vitals:clear()
end

-- If MDW is already running (e.g. your package was just installed),
-- initialize now instead of waiting for the next profile load.
if mdw.isSetUp and mdw.runReadyCallbacks then mdw.runReadyCallbacks("MyGame") end

Pick a unique key ("MyGame"): if your script re-runs, the assignment replaces your old registration instead of duplicating it. Write the function so it is safe to run repeatedly - Widget:new() already returns the existing widget on a name collision, so the usual clear-then-echo pattern is enough.

Registrations run during mdw.setup(), sorted by key, before the header menus are finalized - so your widgets are part of the layout from the start. Each registration is isolated: an error in one is reported to the main window and does not stop the others.

MDW's own example widgets register exactly this way (MDW_Examples.lua uses mdw.onReady["MDW_Examples"]), so they double as a template. If your code depends on newer MDW APIs, check mdw.version inside your callback.

mdw.registerWidgets(func) from earlier versions still works, but it is a legacy API: its registrations are anonymous (no replace-on-rerun) and they do not survive package updates. Prefer the registry.

Configuring MDW

For settings that should be in place before the UI is ever built, seed mdw.gameConfig the same way. These act as defaults: the player's own saved choices (theme, font sizes, dock widths) still override them.

mdw = mdw or {}
mdw.gameConfig = mdw.gameConfig or {}
mdw.gameConfig.theme = "sapphire"        -- default theme for new installs
mdw.gameConfig.leftDockWidth = 300
mdw.gameConfig.usePromptTrigger = false  -- your package drives the prompt bar

At runtime - from an onReady callback, an alias, or any script that runs while MDW is up - use mdw.configure() instead. It merges values into mdw.config and immediately re-applies settings that can change live (such as usePromptTrigger):

mdw.configure({ usePromptTrigger = false })

mdw.configure() accepts any configuration key and returns mdw so calls can be chained. Note it is an MDW function, so unlike the seeds above it only exists once MDW has loaded - that is why load-time defaults belong in mdw.gameConfig.

Persisted game settings

mdw.gameSettings is a free-form table saved into MDW's layout file and restored before your onReady runs. Namespace it by package name, mutate at will, and call mdw.saveLayout() to persist - the natural home for the player's own toggles (see Context Menus for the settings-menu pattern):

mdw.onReady["MyGame"] = function()
  mdw.gameSettings["MyGame"] = mdw.gameSettings["MyGame"] or { showBalance = true }
  -- ...
end

Like onReady, the table survives teardown and script re-runs, so live toggles are never lost to a rebuild, and mdw.resetLayout() keeps it unless told otherwise (Scripted Control).

Disabling the example widgets

The bundled example widgets are only there to demonstrate features. To turn them off without editing the package (so the change survives a re-download), seed the flag from your own script:

mdw = mdw or {}
mdw.loadExamples = false

The flag is checked at setup time, so load order does not matter.

The mdwReady event

MDW raises the mdwReady event once the UI is fully built. Use it when you only need to react to MDW coming up (start GMCP feeds, wire triggers) rather than own widgets:

registerAnonymousEventHandler("mdwReady", function()
  -- MDW is up; safe to talk to it
end)

The event only fires on setups that happen after your handler is registered - a script loaded while MDW is already running has missed it, so check mdw.isSetUp after registering. The onReady registry handles this case for you, which is why it is the recommended path for anything that creates widgets.

In triggers and GMCP handlers, guard your lookups - events can fire in the brief gap while MDW rebuilds during a package update:

local w = mdw.Widget.get("Vitals")
if w then w:cecho("<green>HP restored\n") end

Custom elements beyond widgets

Everything you create through MDW's APIs - widgets, gauges, rows, menus - is cleaned up by MDW automatically at every teardown. If your package needs something MDW has no class for (an extra label, its own console), create it with raw Geyser inside your onReady function and hand it to MDW's element registry:

mdw.onReady["MyGame"] = function()
  local badge = mdw.trackElement(Geyser.Label:new({
    name = "MyGame_Badge", x = 10, y = 10, width = 60, height = 20,
  }))
end

Tracked elements are destroyed at every teardown, and because onReady re-runs at every setup, your element rebuilds correctly through MDW updates - the widget lifecycle, for anything you make. Event handlers get the same treatment through mdw.registerHandler(event, name, fn). Two caveats: on Mudlet 4.19 and older consoles cannot be deleted (only hidden), so a rebuilt same-named console still holds its old content - clear yours at creation if that would be stale (harmless on 4.20+, where MDW deletes them for real); and creation must happen in onReady, never at script-load time.

For runtime state MDW cannot see at all - tempTimers, tempTriggers, references into UI that is about to go away - register a named teardown hook, the mirror image of onReady:

mdw = mdw or {}
mdw.onTeardown = mdw.onTeardown or {}
mdw.onTeardown["MyGame"] = function()
  if myTicker then killTimer(myTicker) myTicker = nil end
end

It runs at the start of every teardown (package updates and rebuilds included), while the UI still exists. Without it, anything your onReady starts on every setup and never stops would duplicate across each rebuild. Like onReady, the registry uses named keys (re-runs replace, never duplicate), survives teardown, and isolates errors per callback.

Branding and one-button uninstall

Two more seeds turn MDW's chrome into your game's UI:

mdw = mdw or {}
mdw.gameConfig = mdw.gameConfig or {}
mdw.gameConfig.uiName = "WillowdaleUI"      -- admin menu: "Uninstall WillowdaleUI"

mdw.gamePackages = mdw.gamePackages or {}
mdw.gamePackages["WillowdaleMUDUI"] = true  -- your mfile "package" name

uiName is used verbatim wherever MDW names the whole UI (the admin menu's uninstall entry, the ready message). mdw.gamePackages registers your package for co-removal: the admin menu's full uninstall removes every registered package first - your own sysUninstallPackage handler runs while MDW's APIs are still alive - and then MDW itself, so the player's one button takes down the entire UI. It is a set keyed by package name, so re-running scripts cannot duplicate an entry.

Registration also works in the other direction. Everything created while your onReady callback runs - widgets, groups, chrome bars, adopted elements, handlers, the prompt-bar declarations - is stamped with your registration key. If your package alone is uninstalled while MDW stays, MDW reaps exactly your creations automatically (mdw.cleanupGame(owner) is also directly callable). When your onReady key differs from your package name, register the mapping as the set value: mdw.gamePackages["MyPkg"] = "MyOnReadyKey". Only creations made inside onReady carry the stamp - anything you build lazily (from a GMCP handler, say) remains yours to remove. A package update survives the reap: your reinstalled scripts re-seed the registrations and the late-join line rebuilds your UI.

Well-behaved packages still clean up in their own sysUninstallPackage handler too (destroy widgets, withdraw mdw.setPromptGauges(nil) / mdw.setPromptBarMenu(nil), remove the mdw.onReady / mdw.onTeardown / mdw.gamePackages entries) - the automatic reap is guarded and idempotent, so doing both is safe, and your own handler also covers older MDW versions.

If that handler destroys your widgets, hold the layout save across it. Every widget:destroy() asks MDW to save, and a save writes from the live registry - so a handler that dismantles as it saves records a layout file with your widgets missing, and the reinstall then restores that. MDW's own reap holds the lock for the same reason, but which of the two sysUninstallPackage handlers Mudlet runs first is not yours to choose:

function myGame.onUninstall(_, package)
  if package ~= "MyPkg" then return end
  local held = mdw and mdw.deferLayoutSaves and mdw.resumeLayoutSaves
  if held then mdw.deferLayoutSaves() end
  -- ... destroy your widgets, remove your bars, withdraw your registrations
  if held then mdw.resumeLayoutSaves(false) end   -- release WITHOUT writing
end

resumeLayoutSaves(false) releases without writing: the layout worth keeping is the one from before the uninstall started, and it is already on disk. See Layout Persistence.

Chrome bars

For fixed strips that are chrome rather than widgets - a status line, a second prompt-style bar - mdw.createBar places a full-span bar between the docks: edge = "top" bars stack downward below the header, edge = "bottom" bars stack upward above the prompt bar. MDW owns geometry (border reservation, window resize, sidebar interplay, theme restyle, teardown); you own the content. Create from onReady, like widgets:

mdw.onReady["MyGame"] = function()
  local bar = mdw.createBar({
    name = "MyStatus",
    edge = "top",
    console = true,          -- a MiniConsole to render into
    reflow = renderMyStatus,  -- repaint; called on every layout pass
    -- height = 24,          -- default mdw.config.barHeight
    -- css = "...",          -- custom background; omit for the theme's
  })
end

Give a bar a reflow and MDW calls it whenever the bar's width changes - window resize, sidebar toggle, splitter drag, font-family change - and once as the bar is created, so the callback is the only place that has to paint it. This is the bar's half of Widget:reflow: MDW replays a widget's echo buffer for it, but a bar has no buffer, so the owner supplies the repaint. It runs on every mouse move of a live drag, unlike a widget's deferred reflow, so it must be a cheap repaint from state - clear the console and redraw, no buffering and no send. A bar without a reflow behaves as before: MDW resizes it and leaves its content alone.

mdw.removeBar(name) destroys a bar; mdw.setBarVisible(name, false) hides it and returns its strip to the main console. Going through MDW here is not optional politeness: Mudlet's setBorder* is global state that MDW re-applies on every reconnect, so border space reserved behind its back would be clobbered.

Version exposure

Expose your package's version as a plain field assigned at script-load time, matching your mfile: mygame.version = "1.2.0". MDW does this (mdw.version), and any script can then read another package's version at runtime with a guarded lookup (mdw and mdw.version) - never at load time, where the other package may not have loaded yet.

Bootstrapping MDW from your game package

A game package can install MDW for the player instead of asking them to install two packages - most UI authors cannot have the game server push a second package for them. The rule that makes this safe: MDW never updates itself. A framework swap is only safe when the thing built on it says so, so your package pins the exact MDW release it was tested against and is the only thing that ever moves that pin. Releases are tagged vX.Y.Z and always carry the asset MDW.mpackage, so https://github.com/MorquinDevlar/mdw/releases/download/vX.Y.Z/MDW.mpackage is a stable URL - that is the contract. Raise the pin and your minimum together when you adopt a newer MDW API; never pin "latest".

-- Bootstrap MDW: install the release this package was tested against when
-- MDW is missing or older than the minimum. Never downgrades a newer MDW.
mygame = mygame or {}
mygame.packageName = "MyGameUI"   -- must match your mfile "package"
mygame.mdwMinVersion = "0.4.0"
mygame.mdwUrl = "https://github.com/MorquinDevlar/mdw/releases/download/v0.4.0/MDW.mpackage"

local function versionAtLeast(have, want)
  if type(have) ~= "string" then return false end
  local h, w = {}, {}
  for n in have:gmatch("%d+") do h[#h + 1] = tonumber(n) end
  for n in want:gmatch("%d+") do w[#w + 1] = tonumber(n) end
  for i = 1, math.max(#h, #w) do
    local a, b = h[i] or 0, w[i] or 0
    if a ~= b then return a > b end
  end
  return true
end

function mygame.ensureMdw()
  if versionAtLeast(mdw and mdw.version, mygame.mdwMinVersion) then return true end
  if mygame._mdwDownload then return false end   -- one attempt per session
  local file = getMudletHomeDir() .. "/MDW.mpackage"
  mygame._mdwDownload = file
  cecho(string.format("\n<yellow>[%s]<reset> Fetching MDW %s...\n",
    mygame.packageName, mygame.mdwMinVersion))
  downloadFile(file, mygame.mdwUrl)
  return false
end

-- Swap only once the file is on disk, so a failed download leaves the old
-- MDW (and your UI) running. MDW saves its layout on uninstall and rebuilds
-- your UI from your onReady registration when the new version installs.
registerNamedEventHandler(mygame.packageName, "mdwDownloadDone", "sysDownloadDone",
  function(_, path)
    if path ~= mygame._mdwDownload then return end
    if table.contains(getPackages(), "MDW") then uninstallPackage("MDW") end
    installPackage(path)
    os.remove(path)
  end)
registerNamedEventHandler(mygame.packageName, "mdwDownloadError", "sysDownloadError",
  function(_, err, path)
    if path ~= mygame._mdwDownload then return end
    mygame._mdwDownload = nil
    cecho(string.format("\n<red>[%s]<reset> Could not download MDW (%s) - install it by hand: %s\n",
      mygame.packageName, tostring(err), mygame.mdwUrl))
  end)

-- Run when this package installs (one tick later: never re-enter Mudlet's
-- installer from inside its own event) and on every profile load (MDW may
-- have been removed by hand, or this package's update raised the minimum).
-- Never call ensureMdw at script-load time: MDW may not have loaded yet.
registerNamedEventHandler(mygame.packageName, "mdwBootstrapInstall", "sysInstallPackage",
  function(_, name)
    if name == mygame.packageName then tempTimer(0, mygame.ensureMdw) end
  end)
registerNamedEventHandler(mygame.packageName, "mdwBootstrapLoad", "sysLoadEvent",
  function() mygame.ensureMdw() end)

This composes with the rest of the contract rather than replacing it. Your seeds (mdw.onReady, mdw.gameConfig, mdw.gamePackages) are already in place when MDW's install runs setup, so MDW builds your UI by itself - there is nothing to "activate" afterwards. A player who already has a newer MDW than your minimum is left alone. A player with an older one keeps a working UI until the new file is on disk, and MDW's own uninstall/install path preserves the layout across the swap. Keep the mdw.version gate in your onReady callback as the fallback for a bootstrap that could not complete (no network, a Mudlet older than 4.14), and have it say that MDW is being fetched rather than asking the player to update it by hand. Updating your own package stays your package's business, but MDW does the swap for you - see Updating your own package below; the bootstrap only acts again when a new release of yours raises the minimum.

Updating your own package

local ok, why = mdw.swapPackage("MyGameUI", downloadedFile)
if not ok then
  cecho("<red>Update failed: " .. why .. "\n")   -- known immediately
elseif why then
  -- "retrying" or "queued": Mudlet is saving the profile, and the new copy
  -- announces itself on sysInstallPackage once it lands
end

A package cannot reliably swap itself. The code running the swap lives inside the package being uninstalled, so it cannot check the result and cannot report a failure - and Mudlet will ACCEPT an install offered before the uninstall has finished and then silently ignore it, leaving the player with no package and nothing said. The workaround package authors reach for is uninstallPackage(name), tempTimer(1, ...), installPackage(path) and a watchdog to notice when that guess was wrong.

MDW is not the package being removed, so it does both halves back to back and hands back what Mudlet actually reported. Verifying the file stays yours - only you know what a valid build of your package looks like - and MDW refuses to swap itself, which is that same problem in reverse: a package built on MDW is what moves MDW.

One thing stops the two halves happening at once. Mudlet refuses every uninstall while it saves the profile, and Mudlet 5 queues every install behind the save - and installing or removing a package is what starts one, so a swap made a second after installing MDW lands in it. swapPackage checks Mudlet's package list instead of trusting either call: a refused uninstall is retried after 1, 2, 4 and 8 seconds, and a queued install lands when the save ends. It returns true plus "retrying" or "queued" for those, and the new copy announces itself on sysInstallPackage as usual. A retry that still fails is reported by MDW, with the path of the file to install by hand.

Two things to know about what happens next:

  • MDW re-runs your onReady when Mudlet reports the install finished (on sysInstallPackage), whether the install came from swapPackage or from a player installing your package by hand. Your creations are reaped by ownership stamp on the way out, and this puts them back.
  • That re-run restores placement. MDW treats a package coming back mid-session as a restore, not a fresh build: it re-seeds your widgets' saved records from the layout file before your callback runs, and re-forms the groups they were in once it has finished. The player gets the layout they arranged, not your first-run defaults - including a widget they had dragged into another package's group, which goes back into it. mdw.rebuild() remains the recovery hatch if a UI still comes back wrong, but an update does not depend on it.

If a package comes back half-built, mdw.debugMode = true traces the whole sequence - see Debugging.

Removing your package

If your package installed MDW, consider taking it with you when the game or the player asks for the UI to be removed:

if mdw and mdw.uninstall then mdw.uninstall() end

That removes every registered game package, restores the main console font and background, deletes the saved layout, and removes MDW. Removing only your own package leaves behind the framework you installed, its layout file and the font it applied. An ordinary uninstall from Mudlet's package manager should still leave MDW running - that is not a request to remove the interface.

Where to go next

Clone this wiki locally