This is the capstone. The mod records how many different cells the player has visited and shows the current cell and total when the player presses X.
It is intentionally small. The point is to connect the boundaries, not to build a framework.
The project tree
CellGreeter/
├── CellGreeter registration file
└── scripts/
└── CellGreeter/
└── player.lua
Register the entry point:
PLAYER: scripts/CellGreeter/player.lua
Put the directory in an active OpenMW data directory.
The repository contains the checked-in CellGreeter example alongside the walkthrough.
The first working version
---@omw-context player
local self = require 'openmw.self'
local ui = require 'openmw.ui'
local util = require 'openmw.util'
local visited = {}
local currentCellId
local currentCellName = 'Nowhere'
local root
local function checkCell()
local cell = self.cell
if not cell or cell.id == currentCellId then
return
end
currentCellId = cell.id
currentCellName = cell.name
visited[currentCellId] = true
end
local function makeLayout(visitedCount)
return {
type = ui.TYPE.Window,
layer = 'Windows',
props = {
title = 'Cell Greeter',
position = util.vector2(80, 80),
size = util.vector2(360, 90),
},
content = ui.content {
{
type = ui.TYPE.Text,
props = {
text = ('%s — %d cell%s visited'):format(
currentCellName,
visitedCount,
visitedCount == 1 and '' or 's'
),
},
},
},
}
end
local function showJournal()
checkCell()
local visitedCount = 0
for _ in pairs(visited) do
visitedCount = visitedCount + 1
end
if root then root:destroy() end
root = ui.create(makeLayout(visitedCount))
end
return {
engineHandlers = {
onUpdate = checkCell,
onKeyPress = function(key)
if key.symbol == 'x' then
showJournal()
end
end,
onSave = function()
return {
visited = visited,
}
end,
onLoad = function(data)
visited = data and data.visited or {}
currentCellId = nil
currentCellName = 'Nowhere'
if root then root:destroy() end
root = nil
end,
},
}
This version already demonstrates the architecture:
The Cell Greeter architecture
Registration gives the script a context; handlers observe the world; ordinary state and save hooks turn that observation into player feedback.
-
source
.omwscripts registration
Places the entry point in the player context.
-
boundary
Player context
Provides player-owned APIs and lifecycle.
-
service
Engine handlers
OpenMW calls the script when relevant events occur.
-
engine
Cell and identity
The current cell supplies the fact being observed.
-
state
Lua table state
Tracks visited cells and current display state.
-
boundary
onSave / onLoad
Preserves the small authoritative state across saves.
-
result
Visible player feedback
The UI presents the result without becoming its source of truth.
How the pieces relate
- .omwscripts registration Player context
- Player context Engine handlers
- Engine handlers Cell and identity
- Cell and identity Lua table state
- Lua table state onSave / onLoad
- onSave / onLoad Visible player feedback
Keep the UI owned
The window is deliberately plain. root owns the live UI element, while visited and cell detection remain ordinary Lua state. The next time you improve the window, keep that separation: change the layout without turning the UI element into your source of truth.
The UI: From Nothing to Something page shows the layout lifecycle; the UI Recipes show the next layer.
What to inspect when it fails
- No script activity: check the
.omwscriptsregistration and data directory. - No cell count: check that
self.cellis present and that the script is running asPLAYER. - Count resets after loading: inspect the
onSavereturn table andonLoadinput. - UI breaks after changing screens: treat the element as an owned runtime object and rebuild it when needed.
Now go to Where Do I Go From Here?.