I ran into a module loading issue while building a Playdate game, and I wanted to share the problem, the workaround I ended up using, and why I think this might be worth considering for a future SDK improvement.
The short version: Playdate's current import behavior works well for side-effect modules, but it is surprising when a module returns a value. A value-returning module only gives that value to the first import call. Later imports of the same module return nil, which makes it hard to build reusable Lua modules in the style many developers expect from JavaScript, TypeScript, Python, or Lua module systems built around require.
The Problem
Suppose I have a small module:
-- ui/node.lua
local Node = {}
Node.__index = Node
function Node:new()
return setmetatable({ children = {} }, self)
end
return Node
The first import works:
-- ui/label.lua
local Node = import "ui/node"
local Label = setmetatable({}, { __index = Node })
Label.__index = Label
return Label
But if another file imports the same module later:
-- ui/button.lua
local Node = import "ui/node" -- This can be nil if ui/node was already imported elsewhere.
That is surprising when using import as a module system. It means module values cannot be safely imported from multiple files unless the developer adds a workaround.
In my UI code, this became visible with a normal component hierarchy:
-- ui.lua
local Node = import "ui/node"
local Label = import "ui/label"
local Button = import "ui/button"
return {
Node = Node,
Label = Label,
Button = Button,
}
Each component also wants to import its base class:
-- ui/label.lua
local Node = import "ui/node"
local Label = setmetatable({}, { __index = Node })
Label.__index = Label
return Label
This is a very natural shape for a component library, but it depends on repeated imports returning the same exported value.
Why a Wrapper Function Was Not Enough
My first thought was to create a wrapper:
local myImport = import "myImport"
local Node = myImport "ui/node"
The problem is that pdc appears to rely on real import calls in source files to discover files that should be included in the compiled .pdx.
If I replace real imports with a custom runtime loader, pdc may not see those dependencies. If I keep both:
import "ui/node" -- For pdc discovery.
local Node = myImport "ui/node" -- For runtime value loading.
then the first native import has already consumed the returned value, so the wrapper cannot reliably retrieve it later.
That is why the most practical workaround I found was to patch import itself, while still leaving real import calls in the source code for pdc.
The Workaround: Cache Returned Module Values
The idea is small:
- Keep a reference to Playdate's original
import. - Replace global
importwith a wrapper. - On first import, call the native import and cache the returned value.
- On later imports, return the cached value instead of
nil. - Track currently loading modules to report circular dependencies.
Here is a simplified version of the idea:
-- import_patch.lua
local nativeImport = import
local cache = {}
local loading = {}
local loadingStack = {}
local NIL = {}
local function cachedImport(path)
local cached = cache[path]
if cached ~= nil then
if cached == NIL then
return nil
end
return cached
end
if loading[path] then
error("Circular import detected: " .. table.concat(loadingStack, " -> ") .. " -> " .. path)
end
loading[path] = true
table.insert(loadingStack, path)
local ok, value = pcall(nativeImport, path)
loading[path] = nil
table.remove(loadingStack)
if not ok then
error(value)
end
cache[path] = value == nil and NIL or value
return value
end
import = cachedImport
Then the entry point installs the patch before other project imports:
-- main.lua
import "import_patch"
import "CoreLibs/graphics"
import "CoreLibs/sprites"
local UI = import "ui"
This keeps the source compatible with pdc because the project still uses normal import statements, but the runtime behavior becomes more intuitive for value-returning modules.
I also put the longer design notes and the full implementation in this Gist: Intuitive Module System for Playdate.
Desired Semantics
The behavior I wanted was:
local A = import "some/module"
local B = import "some/module"
assert(A == B)
This is close to how many module systems behave:
- The module executes once.
- The returned module value is cached.
- Later imports return the same value.
- Side-effect-only modules can still return
nil.
I do not think every Playdate project needs this, but when a project grows into many Lua files, this behavior makes it much easier to write small reusable modules.
Circular Dependencies
Caching module values also made circular dependencies easier to see.
For example, scene modules often need to navigate to each other:
-- scenes/menu.lua
local GameScene = import "scenes/game"
-- scenes/game.lua
local MenuScene = import "scenes/menu"
With loading-stack tracking, the import wrapper can produce a clearer error:
Circular import detected: scenes/menu -> scenes/game -> scenes/menu
In my project, I solved this kind of scene navigation cycle with a small registry module instead of direct scene-to-scene imports.
-- scenes_registry.lua
local Scenes = {}
local registry = {}
function Scenes.register(name, scene)
assert(name, "Scene name is required")
assert(scene, "Scene value is required")
registry[name] = scene
end
function Scenes.get(name)
local scene = registry[name]
if not scene then
error("Scene is not registered: " .. tostring(name))
end
return scene
end
setmetatable(Scenes, {
__index = function(_, name)
return registry[name]
end,
})
return Scenes
The entry point imports and registers all scenes:
-- main.lua
import "import_patch"
local Scenes = import "scenes_registry"
local SceneController = import "scene_controller"
Scenes.register("Menu", import "scenes/menu")
Scenes.register("Game", import "scenes/game")
SceneController.changeScene(Scenes.Menu)
Then scenes depend only on the registry, not on each other:
-- scenes/game.lua
local Scenes = import "scenes_registry"
local SceneController = import "scene_controller"
local GameScene = {}
function GameScene:finish()
SceneController.changeScene(Scenes.Menu)
end
return GameScene
This pattern is not specific to scenes. It can also help with any group of modules that need to reference each other indirectly.
Why I Think This Could Help the SDK
The current behavior is understandable historically, especially if import is mainly viewed as "execute this file once" rather than "load a module value." But many Lua projects naturally evolve toward value-returning modules:
return {
new = new,
update = update,
draw = draw,
}
For that style, repeated imports returning nil feels like a footgun.
It also pushes developers toward global temporary variables:
_SharedNode = import "ui/node"
local Label = import "ui/label"
_SharedNode = nil
That workaround solves the immediate issue, but it makes code harder to reason about and less modular.
Possible SDK-Level Improvements
Any of these would make module-style Lua development smoother:
- Make
importcache and return module values consistently. - Add an official
require-like function that works withpdcdependency discovery. - Document a recommended pattern for value-returning modules and repeated imports.
- Provide clearer circular dependency diagnostics when a module is imported while it is already loading.
The most ergonomic version would be:
local Module = import "path/to/module"
and that same call would be safe from any file, no matter how many times the module had already been imported.
Closing
This workaround has made my project structure much cleaner. UI components can import their base classes directly, scenes can use a registry to avoid cycles, and I no longer need global variables just to pass module values around.
I wanted to share this in case it is useful feedback for future SDK design. Even if the exact workaround is not something the SDK should adopt internally, I think the underlying need is common: Playdate Lua projects benefit from an import mechanism that works well for both side-effect files and value-returning modules.
By the way, the project that led me to this workaround is Sixdoku, my first Playdate game. It is a small 6x6 Sudoku-style puzzle game, and if you are curious about the kind of project structure where this import behavior starts to matter, feel free to check it out.
Here is the gist of full implementation and spec