Playdate's native import() has severely counter-intuitive behavior:
Current Problem:
-- In main.lua
local result1 = import("core/ui") -- First call returns module content
local result2 = import("core/ui") -- Second call returns nil!This forces developers to use global variables as workarounds, as seen in src/core/ui.lua:
-- Use global variable to work around Playdate's import caching
-- (import only returns value on first call, nil on subsequent calls)
_UI_Node = import "core/ui_components/node"
-- Now import other components (they will see _UI_Node as global)
local Label = import "core/ui_components/label"
-- ...
-- Clean up global after imports
local Node = _UI_Node
_UI_Node = nilCreate a solution that provides TypeScript-like module semantics:
- β Execute module on first load and cache the return value
- β Return cached value on subsequent calls (not nil)
- β Detect and throw errors on circular dependencies
- β Work seamlessly with Playdate's compilation system (pdc)
Initially, we considered creating a use() wrapper function:
use = import("core/use")
local MyModule = use("core/ui") -- Custom functionProblems Encountered:
- pdc Compilation Issue: Playdate's compiler (
pdc) scans source code forimportstatements to determine which files to include in the.pdxpackage. If a file is never referenced by animportstatement, it won't be compiled into the game. - Can't Mix Approaches: We couldn't use
importfor compilation anduse()for runtime because the firstimportcall would consume the return value, leavingnilfor subsequentuse()calls.
The elegant solution: Patch the native import() function itself.
This approach:
- β
Works with pdc: Source code uses real
importstatements - β Zero code changes: No need to modify existing modules
- β Transparent: One-time patch, entire project benefits
- β Backward compatible: Works with both side-effect imports (CoreLibs) and value-returning imports
Implementation: src/core/import.lua (patches the native import() function)
-- Save the original import function
local originalImport = import
-- Replace the global import function
function import(modulePath)
-- 1. Check cache first
if _moduleCache[modulePath] then
return _moduleCache[modulePath]
end
-- 2. Check for circular dependency
for i, loading in ipairs(_loadingStack) do
if loading == modulePath then
local chain = table.concat(_loadingStack, " -> ", i)
error("Circular dependency detected: " .. chain .. " -> " .. modulePath)
end
end
-- 3. Load module using original import
table.insert(_loadingStack, modulePath)
local exports = originalImport(modulePath)
-- 4. Cache result (even if nil)
_moduleCache[modulePath] = exports
-- 5. Clean up and return
table.remove(_loadingStack)
return exports
end- pdc sees real import statements: The compiler scans source code statically and finds all
importcalls - Runtime behavior is enhanced: At runtime, our patched
import()adds caching - No module changes needed: Existing modules work as-is
- Single point of control: One file patches the entire import system
-- In main.lua, load the patch FIRST
import "core/import"
-- Now all subsequent import() calls are automatically cached!
import "CoreLibs/graphics"
local UI = import "core/ui" -- First call: loads and caches
local UI2 = import "core/ui" -- Second call: returns cached value
assert(UI == UI2) -- true!Before (using global variables):
-- core/ui.lua
_UI_Node = import "core/ui_components/node"
local Label = import "core/ui_components/label"
-- ...
local Node = _UI_Node
_UI_Node = nilAfter (with patched import):
-- core/ui.lua
local Node = import "core/ui_components/node"
local Label = import "core/ui_components/label"
-- Clean and simple! Both calls return the correct values β¨-- moduleA.lua
local B = import("moduleB")
return { name = "A" }
-- moduleB.lua
local A = import("moduleA") -- π₯ ERROR!
return { name = "B" }
-- Output:
-- Error: Circular dependency detected: moduleA -> moduleB -> moduleAAdd this as the first import in your main file:
-- main.lua
import "core/import" -- Install patch FIRST
-- All other imports now benefit from caching
import "CoreLibs/graphics"
import "core/scene"
-- ...Clean up any global variable hacks in your modules:
-- Before
_GlobalTmp = import "module"
local Module = _GlobalTmp
_GlobalTmp = nil
-- After
local Module = import "module" -- Just works!That's it! All your existing import statements now work correctly.
- Cache lookup: O(1) table lookup, excellent performance
- Circular detection: O(n) stack traversal, but stack depth typically < 10, negligible
- Memory: All loaded modules remain cached (consistent with Playdate's native behavior)
- Runtime overhead: Minimal - only one additional table lookup per import after first load
We could have created a use() function:
use = import("core/use")
local Module = use("path/to/module")Problems:
- pdc wouldn't see the imports, files wouldn't be compiled
- Would need both
import(for pdc) anduse()(for runtime), causing conflicts
We could have made modules register themselves:
-- In each module
return use.register("my/module", { ... })Problems:
- Requires modifying every single module
- Easy to forget registration
- Breaks existing code
- Module path must be hardcoded in each file
β
Zero code changes in existing modules
β
Works with pdc compilation
β
Single point of control
β
Transparent to developers
β
Backward compatible
The patched import() function provides optional utility methods:
-- Clear cache (useful for testing/hot reload)
import.clearCache()
-- Get cache statistics
local stats = import.getCacheStats()
-- Returns: { cachedModules = 5, loadingStackDepth = 0 }
-- Enable/disable debug logging
import.setDebug(true) -- Print loading logs
import.setDebug(false) -- Disable loggingProblem: import("my/module") returns nil
Possible causes:
- Module doesn't return anything - add
return { ... }at the end - Module has a runtime error - check console for errors
- Module path is wrong - verify the path matches file structure
Problem: Error: Circular dependency detected: A -> B -> A
Solution: Restructure your code to break the cycle:
- Extract shared code to a third module
- Use dependency injection
- Use a Registry pattern (recommended)
The most common circular dependency scenario is when multiple modules need to reference each other (e.g., scenes in a game navigating to each other). The Registry Pattern provides a clean solution.
-- scenes/menu.lua
local LevelScene = import "scenes/level" -- Need to navigate to level
-- ...
SceneController.changeScene(LevelScene)
-- scenes/level.lua
local MenuScene = import "scenes/menu" -- Need to return to menu
local GameScene = import "scenes/game" -- Need to start game
-- ...
-- scenes/game.lua
local MenuScene = import "scenes/menu" -- Need to return to menu
-- ...
-- π₯ Circular dependency: menu β level β game β menuStep 1: Create a Registry (src/scenes.lua)
-- Scenes Registry
-- Acts as a registry for scene management without directly importing scenes
-- Scenes should be registered by the main entry point
local ScenesRegistry = {}
-- Private registry storage
local _registry = {}
-- Register a scene with a key
function ScenesRegistry.register(key, scene)
assert(key, "Scene key is required")
assert(scene, "Scene object is required")
assert(not _registry[key], "Scene '" .. key .. "' is already registered")
_registry[key] = scene
end
-- Get a registered scene by key
function ScenesRegistry.get(key)
local scene = _registry[key]
if not scene then
error("Scene '" .. tostring(key) .. "' is not registered")
end
return scene
end
-- Convenience accessors (lazy loaded via metatable)
setmetatable(ScenesRegistry, {
__index = function(table, key)
return _registry[key]
end
})
return ScenesRegistryStep 2: Register Scenes in Main Entry Point (src/main.lua)
-- Patch import first
import "core/import"
-- Initialize scene registry
local Scenes = import "scenes"
local SceneController = import "core/scene"
-- Load and register all scenes
local MenuScene = import "scenes/menu"
local LevelScene = import "scenes/level"
local GameScene = import "scenes/game"
Scenes.register("Menu", MenuScene)
Scenes.register("Level", LevelScene)
Scenes.register("Game", GameScene)
-- Start application
SceneController.changeScene(Scenes.Menu)Step 3: Scenes Import Registry (Not Each Other)
-- scenes/menu.lua
local Scenes = import "scenes" -- Import registry only
local MenuScene = {}
function MenuScene:someFunction()
-- Navigate to level scene via registry
SceneController.changeScene(Scenes.Level, {
difficulty = "easy"
})
end
return MenuScene
-- scenes/level.lua
local Scenes = import "scenes" -- Import registry only
local LevelScene = {}
function LevelScene:returnToMenu()
SceneController.changeScene(Scenes.Menu)
end
function LevelScene:startGame()
SceneController.changeScene(Scenes.Game)
end
return LevelScene
-- scenes/game.lua
local Scenes = import "scenes" -- Import registry only
local GameScene = {}
function GameScene:onComplete()
SceneController.changeScene(Scenes.Menu)
end
return GameSceneβββββββββββββββββββββββββββββββββββββββββββ
β main.lua β
β (Application Entry & Orchestrator) β
ββββββ¬βββββββββββββ¬βββββββββββ¬βββββββββββββ
β β β
β β β
βββββββββββ βββββββββββ βββββββββββ
β menu β β level β β game β
ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ
β β β
ββββββββββββββΌβββββββββββββ
β
ββββββββββββββββ
β scenes β
β (Registry) β
ββββββββββββββββ
Dependency Flow:
mainβscenes(registry)mainβmenu,level,game(scene modules)menu,level,gameβscenes(registry)- β No circular dependencies - scenes reference each other indirectly through registry
β
No Circular Dependencies: Scenes don't import each other directly
β
Clear Separation of Concerns: Registry is pure data structure, doesn't manage loading
β
Centralized Control: main.lua controls what gets loaded and when
β
Easy to Extend: Add new scenes by registering in main.lua
β
Type Safety: Registry provides error checking on access
β
Works with Playdate Constraints: All imports at file top level
- Registry must not import scenes - It's a pure registration system
- Main.lua controls loading - It imports and registers all scenes
- Scenes import registry - Not each other
- All imports at top level - Required by Playdate (no runtime imports)
This pattern can be applied to any situation where you have mutual references (e.g., UI components, game entities, state machines).
The import() module provides optional utility functions:
-- Clear cache (useful for testing/hot reload)
import.clearCache()
-- Get cache statistics
local stats = import.getCacheStats()
-- Returns: { cachedModules = 5, loadingStackDepth = 0 }
-- Enable/disable debug logging
import.setDebug(true) -- Print loading logs
import.setDebug(false) -- Disable loggingFuture optional features:
- Hot reload support:
import.reload(modulePath) - Dependency graph visualization:
import.printDependencyTree() - Conditional compilation: Track which modules are actually used
With the monkey-patched import() system, we achieve:
- β Intuitive module system - Multiple imports return the same value
- β Zero code changes - No need to modify existing modules
- β Eliminates global variable workarounds - Clean, modern code
- β Circular dependency protection - Catches issues early
- β Works with pdc - Seamless integration with Playdate's build system
- β Better developer experience - Just works as expected
This brings Playdate Lua development closer to the modern TypeScript/JavaScript development experience, while working within the constraints of Playdate's compilation system.
Key Insight: The problem wasn't just that import() doesn't cache - it's that Playdate's compiler (pdc) only includes files referenced by import statements in the source code. This meant we couldn't replace import with a custom function without breaking compilation.
The Solution: By monkey-patching the native import() function itself, we get both:
- pdc sees real
importstatements β files get compiled - Runtime gets enhanced caching β values are preserved
This elegant solution gives us TypeScript-like module semantics while respecting Playdate's build system constraints.