Skip to content

Instantly share code, notes, and snippets.

@mutoo
Created May 1, 2026 10:02
Show Gist options
  • Select an option

  • Save mutoo/642f97d6eb149dab0af7bca3e09fafae to your computer and use it in GitHub Desktop.

Select an option

Save mutoo/642f97d6eb149dab0af7bca3e09fafae to your computer and use it in GitHub Desktop.
-- Intuitive Module System for Playdate
-- Monkey-patches the native import() to provide caching
--
-- Usage:
-- import "core/import" -- Install the patch
--
-- -- Then use import() normally throughout your code:
-- local MyModule = import("path/to/module") -- First call returns and caches value
-- local MyModule2 = import("path/to/module") -- Second call returns cached value
--
-- Features:
-- - Transparent caching of import() calls
-- - Works with pdc compilation (uses real import statements)
-- - Detects and throws errors on circular dependencies
-- - No code changes needed in existing modules
local _moduleCache = {} -- path -> module exports
local _loadingStack = {} -- Stack of modules currently being loaded
-- Optional debug mode
local DEBUG_IMPORT = false
-- Save the original import function
local originalImport = import
-- Create new import as a table with metatable
local newImport = {}
-- Make it callable like a function
setmetatable(newImport, {
__call = function(_, modulePath)
-- 1. Check cache first
if _moduleCache[modulePath] then
if DEBUG_IMPORT then
local indent = string.rep(" ", #_loadingStack)
print(indent .. "[import] Cache hit: " .. modulePath)
end
return _moduleCache[modulePath]
end
-- 2. Check for circular dependency
for i, loading in ipairs(_loadingStack) do
if loading == modulePath then
-- Build dependency chain from where the cycle starts
local chain = table.concat(_loadingStack, " -> ", i)
error("Circular dependency detected: " .. chain .. " -> " .. modulePath)
end
end
-- 3. Load module
table.insert(_loadingStack, modulePath)
if DEBUG_IMPORT then
local indent = string.rep(" ", #_loadingStack - 1)
print(indent .. "[import] Loading: " .. modulePath)
end
-- Use original import() to load and execute the module
local exports = originalImport(modulePath)
if DEBUG_IMPORT then
local indent = string.rep(" ", #_loadingStack)
print(indent .. "[import] Loaded: " .. modulePath .. " -> " .. type(exports))
end
-- 4. Cache result (even if nil, to maintain consistency)
_moduleCache[modulePath] = exports
-- 5. Clean up loading stack
table.remove(_loadingStack)
return exports
end
})
-- Add utility methods to the table
function newImport.clearCache()
_moduleCache = {}
if DEBUG then
print("[import] Cache cleared")
end
end
function newImport.getCacheStats()
local count = 0
for _ in pairs(_moduleCache) do
count = count + 1
end
return {
cachedModules = count,
loadingStackDepth = #_loadingStack
}
end
function newImport.setDebug(enabled)
DEBUG = enabled
end
-- Replace the global import function
import = newImport
-- Note: This module doesn't return anything
-- It just patches the global import function
print("import() was patched, check out the spec/intuitive-import.md for more details")

Intuitive Module System for Playdate

Problem Statement

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 = nil

Goals

Create 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)

Design Evolution

Initial Approach: Wrapper Function

Initially, we considered creating a use() wrapper function:

use = import("core/use")
local MyModule = use("core/ui")  -- Custom function

Problems Encountered:

  1. pdc Compilation Issue: Playdate's compiler (pdc) scans source code for import statements to determine which files to include in the .pdx package. If a file is never referenced by an import statement, it won't be compiled into the game.
  2. Can't Mix Approaches: We couldn't use import for compilation and use() for runtime because the first import call would consume the return value, leaving nil for subsequent use() calls.

Final Solution: Monkey-Patching

The elegant solution: Patch the native import() function itself.

This approach:

  • βœ… Works with pdc: Source code uses real import statements
  • βœ… 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

File Location

Implementation: src/core/import.lua (patches the native import() function)

Core Implementation

-- 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

Why Monkey-Patching Works

  1. pdc sees real import statements: The compiler scans source code statically and finds all import calls
  2. Runtime behavior is enhanced: At runtime, our patched import() adds caching
  3. No module changes needed: Existing modules work as-is
  4. Single point of control: One file patches the entire import system

Usage

Installation

-- 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!

Example: Before and After

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 = nil

After (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 ✨

Circular Dependency Detection

-- 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 -> moduleA

Migration Strategy

Step 1: Install the Patch

Add 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"
-- ...

Step 2: Remove Global Variable Workarounds

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!

Step 3: No Further Changes Needed

That's it! All your existing import statements now work correctly.

Performance Considerations

  • 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

Comparison with Alternative Approaches

Why Not a Wrapper Function?

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) and use() (for runtime), causing conflicts

Why Not Module Registration?

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

Why Monkey-Patching is Best

βœ… Zero code changes in existing modules
βœ… Works with pdc compilation
βœ… Single point of control
βœ… Transparent to developers
βœ… Backward compatible

Utility Functions

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 logging

Troubleshooting

Module Returns nil

Problem: import("my/module") returns nil

Possible causes:

  1. Module doesn't return anything - add return { ... } at the end
  2. Module has a runtime error - check console for errors
  3. Module path is wrong - verify the path matches file structure

Circular Dependency Error

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)

Avoiding Circular Dependencies with Registry Pattern

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.

Problem: Scene Navigation Circular Dependency

-- 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 ↔ menu

Solution: Registry Pattern

Step 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 ScenesRegistry

Step 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

Architecture Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚             main.lua                    β”‚
β”‚  (Application Entry & Orchestrator)     β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚            β”‚          β”‚
     ↓            ↓          ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  menu   β”‚  β”‚  level  β”‚  β”‚  game   β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
     β”‚            β”‚            β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  ↓
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚   scenes     β”‚
          β”‚  (Registry)  β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Dependency Flow:

  1. main β†’ scenes (registry)
  2. main β†’ menu, level, game (scene modules)
  3. menu, level, game β†’ scenes (registry)
  4. βœ… No circular dependencies - scenes reference each other indirectly through registry

Key Benefits

βœ… 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

Important Rules

  1. Registry must not import scenes - It's a pure registration system
  2. Main.lua controls loading - It imports and registers all scenes
  3. Scenes import registry - Not each other
  4. 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).

Utility Functions

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 logging

Possible Extensions

Future optional features:

  • Hot reload support: import.reload(modulePath)
  • Dependency graph visualization: import.printDependencyTree()
  • Conditional compilation: Track which modules are actually used

Summary

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.


Implementation Notes

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:

  1. pdc sees real import statements β†’ files get compiled
  2. Runtime gets enhanced caching β†’ values are preserved

This elegant solution gives us TypeScript-like module semantics while respecting Playdate's build system constraints.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment