Start at a boundary you can explain
A large game script often mixes player input, reward arithmetic, interface updates, and saving. Splitting it into files only helps when each part receives a clear responsibility. Begin with a boundary you can describe in one sentence, such as calculating a reward from a completed level. That module should not also discover the current player, play a sound, update a button, and write a save. Those operations belong to the surrounding integration that knows when the calculation is needed.
Explicit inputs make dependencies visible. A function receiving a base reward and multiplier can be reviewed without knowing which scene is loaded. A function that reads several global variables requires you to reconstruct hidden conditions before understanding its answer. Prefer the smaller contract until you have a concrete reason to introduce more state.
Write the calculation as a plain function
For an educational reward model, validate two finite, bounded, nonnegative numbers and return their rounded product. The bound in this example is a deliberate project rule, not a language limit. Keeping the arithmetic in one function lets you reuse it from a mission controller and from a test script. Neither caller needs to reproduce the calculation. This also gives you one place to change rounding when your design changes.
-- rewards.lua
local Rewards = {}
function Rewards.calculate(base, multiplier)
local function validate(value)
assert(type(value) == "number" and value == value
and value >= 0 and value <= 1000000,
"Expected bounded nonnegative numbers")
end
validate(base)
validate(multiplier)
return math.floor(base * multiplier)
end
return RewardsLoad modules through the host-supported mechanism
In a standalone Lua installation, a module can be loaded with require when the file is discoverable through the configured module path. An embedded game host may use another loader or a different representation of modules. Keep that integration distinction visible instead of claiming one file arrangement works everywhere. The following caller assumes rewards.lua is available to standard Lua's module search path. A host-specific project should adapt the loading line, while the calculation itself can remain ordinary Lua.
-- main.lua: run separately from rewards.lua.
local Rewards = require("rewards")
assert(Rewards.calculate(100, 1.5) == 150)
assert(Rewards.calculate(7, 0.5) == 3)
assert(Rewards.calculate(0, 2) == 0)
local ok = pcall(Rewards.calculate, -1, 2)
assert(not ok)
print("Reward checks passed")Avoid shared state by accident
A module exporting mutable settings or an inventory can become a shared meeting point for unrelated callers. That may be intentional for application configuration, but it is usually a mistake for individual player state. Export a constructor when each user or match needs a fresh object. State ownership should be a design decision that remains obvious at the call site. Do not rely on repeatedly loading the same module to manufacture isolated copies of its returned data.
Inject outside services instead of hiding them
Saving, logging, and scene access are useful dependencies to pass into a constructor or setup function. For example, a progress service can receive a save callback instead of discovering a global storage object. A test can then provide a recorder that captures calls without touching real data. Keep the injected contract small: what arguments does the callback accept, what does it return, and can it fail? Document those expectations before multiple systems depend on the behavior.
Exercise: separate policy from presentation
Take one existing reward or shop script and identify the portion that can run without the engine. Move that portion into a module and give it a few representative assertions. Keep button labels and scene lookup in the caller. Test a normal result, a boundary, and a rejected input. Finally, read the module's public functions aloud. If understanding one method requires knowing about unrelated interface state, reconsider the boundary. Good modularity reduces the amount of context needed to make a safe change.
Language reference
These examples use core Lua rather than an engine API. Consult the official Lua 5.4 reference manual for the language contract. Embedded runtimes can differ in available libraries, loaders, and scheduling behavior.
Design the public surface before expanding the module
List the functions another system genuinely needs to call and keep the rest local. A smaller public surface makes changes easier because fewer callers depend on implementation details. For a reward module, a calculation function may be sufficient; exposing internal rounding helpers and mutable settings can create unnecessary obligations. Give each public function an input contract, a result contract, and a clear failure policy. Decide whether invalid programmer input raises an assertion or becomes an ordinary returned rejection. Consistency matters more than having an elaborate module pattern. A caller should not need to inspect several unrelated files to learn whether a failed operation changed state before returning.
Prevent dependency cycles from becoming architecture
When two modules each need the other to initialize, the immediate loading problem often points to an unclear responsibility. Draw the dependency relationship and identify what information is actually being shared. A small data contract, a callback supplied by the owner, or a separate common module may remove the cycle. Do not solve every dependency problem with another global lookup. That can hide the relationship without making it simpler. Keep startup order understandable and avoid performing irreversible work merely because a file was loaded. Explicit setup makes it easier to test one module in isolation and to report a missing dependency at the place where the application connects its parts.
Practice changing an implementation without changing callers
Choose a private detail of the reward calculation, such as how a validated multiplier is rounded, and imagine replacing it while preserving the documented public behavior. Identify which tests should continue to pass and which internal details no caller should know. Then inspect a calling script for duplicated reward arithmetic or assumptions about module-local variables. Move those decisions behind the public function where appropriate. The exercise reveals whether the boundary is doing useful work. A module is successful when it reduces the context required for a safe change, not merely when the project contains more files or uses a familiar naming convention for its folders.
