Proposal: more intuitive IMPORT semantics for Playdate with Lua

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:

  1. Keep a reference to Playdate's original import.
  2. Replace global import with a wrapper.
  3. On first import, call the native import and cache the returned value.
  4. On later imports, return the cached value instead of nil.
  5. 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 import cache and return module values consistently.
  • Add an official require-like function that works with pdc dependency 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