This guide helps LLMs (particularly Claude) work effectively on the FactorioAccess mod, which makes Factorio playable by blind and visually impaired players through audio cues and keyboard controls.
-
API Access: Use dot notation for modules
-- WRONG: Viewpoint:get_viewpoint(pindex) -- RIGHT: Viewpoint.get_viewpoint(pindex)
-
Always format, test, and lint localisation before committing
python launch_factorio.py --format python launch_factorio.py --run-tests --timeout 60 python lint_localisation.py lint
-
Debug output: Use print(), not game.print()
-- WRONG: game.print("debug") -- Goes to GUI, blind users can't see -- RIGHT: print("debug") -- Goes to logs -- RIGHT: Speech.speak(pindex, "user message") -- Goes to screen reader
-
LuaLS annotations: Use three dashes
---@param pindex integer -- CORRECT -- @param pindex integer -- WRONG (linter will fail)
You are running in the mods/FactorioAccess directory of a Factorio install. launch_factorio.py is in your current working directory.
The full factorio install is available at ../... You almost never need to consult it.
Commands should always be relative to the current working directory.
# Format code (REQUIRED before committing)
python launch_factorio.py --format
# Run tests
python launch_factorio.py --run-tests --timeout 60
# Run linter
python launch_factorio.py --lint
# Run localisation linter (checks for unused/missing locale keys)
python lint_localisation.py lintLoading Stages (Critical!)
- Settings Stage (
settings.lua) - Defines settings, NO game access - Data Stage (
data.lua) - Defines prototypes, NO runtime access - Runtime Stage (
control.lua) - Event-driven gameplay logic
Never access runtime objects (game, script, storage) from data stage!
local pindex = player.index
local player = game.get_player(pindex)
local player_data = storage.players[pindex]local my_storage = storage_manager.declare_storage_module('module_name', {
-- default values
})
-- Access: automatically refers to storage.players[pindex].module_name
-- Also handles lazy initialization
local data = my_storage[pindex]local Router = require("scripts.ui.router")
local TabList = require("scripts.ui.tab-list")
local Menu = require("scripts.ui.menu-builder")
local my_menu = TabList.declare_tablist({
ui_name = Router.UI_NAMES.MY_MENU,
tabs = {
-- Tab definitions using KeyGraph
}
})scripts/ui/controls.lua, scripts/ui/form-builder.lua, and scripts/ui/menu-builder.lua are the major UI entrypoints for implementing a new GUI. scripts/ui/router.lua is the entrypoint for non-UI interaction with the UI system, e.g. opening a UI.
local message = MessageBuilder.new()
message:fragment({"entity-name.transport-belt"})
message:fragment({"fa.direction", "north"})
Speech.speak(pindex, message:build())WARNING: MessageBuilder handles spaces. MessageBuilder:fragment(" ") crashes in order to loudly catch this bug!
Correct:
mb:fragment("foo"):fragment("bar")
-- produces foo bar
Crashes!:
mb:fragment("foo"):fragment(" "):fragment("bar")
-- crashes: spaces were already added!
Style rules:
- Don't use
:(). Screen readers read these symbols verbatim in many setups. Better to use comma (aka list_item) or just leave out the punctuation. - Don't be verbose
- Avoid emdash
- Avoid unicode
- Prefer MessageBuilder list_item() for managing placement of commas
- Always familiarize with the contents of scripts/localising.lua and use those functions
- Localisation keys should always be in section
faand must never contain.. Example:fa.foo-baris good,fa.foo.baris bad. - When possible, fold things into a parameterized localisation key rather than using
fragment({"fa.key-intro}"):fragment(p1).... This allows the word order to be changed in translations.
We are writing for a screen reader. This means two core principles:
- The sooner a message conveying information varies, the faster the user can keep going.
- Ex: "cursor anchored" "cursor unanchored" makes the user listen to "cursor".
- ex: "anchored cursor" "unanchored cursor" lets the user move on as soon as the first syllable.
- Less punctuation is better, unless it's comma or period. Many setups read colon left paren etc.
Used to find and categorize entities. Effectively a streaming database which picks up new entities and tiles, grouping them and running fixed queries.
- Entry:
scripts/scanner/entrypoint.lua - Engine:
scripts/scanner/surface-scanner.lua - Backends:
scripts/scanner/backends/ - Uses spatial clustering and incremental processing
Modern graph-based architecture:
- Router:
scripts/ui/router.lua- Central UI manager - TabList: Multi-tab support with shared state
- Builders: Menu and Grid builders for common patterns
- Dynamic rendering with React-like rebuilding
- Explicit re-render is not possible because the UI does not have a painting step and is really a description of how to say things. Controls which change values often say their new value.
-- In scripts/tests/my-test.lua
local TestRegistry = require("scripts.test-registry")
local describe, it = TestRegistry.describe, TestRegistry.it
describe("Feature", function()
it("should work", function(ctx)
local test_value = 42 -- Use locals, not ctx.state
ctx:at_tick(5, function()
ctx:assert_equals(42, test_value)
end)
end)
end)Remember:
- Add test to
test_filestable inscripts/test-framework.lua(around line 29) - Test mod behavior, not Factorio API
- Use local variables for state
- Main entry:
control.lua(use grep/partial reads - it's huge!) - Utilities:
scripts/fa-utils.lua - Entity info:
scripts/fa-info.lua - Storage:
scripts/storage-manager.lua - Events:
scripts/event-manager.lua(migration in progress) - Tests:
scripts/tests/
- Define in
data.lua:type = "custom-input" - Handle in
control.lua:script.on_event("fa-keyname") - Add locale in
locale/en/locale.cfgunder[controls]
Our keys are named fa-s (s, with no modifiers) or fa-flags-s where flags is c, a, or s (shift), e.g. fa-cas-s is s with control+alt+shift.
local my_storage = storage_manager.declare_storage_module('my_module', {
-- defaults
})Settings allow users to configure mod behavior. They're defined in the settings stage and accessed at runtime.
-
Add declaration in
scripts/settings-decls.lua:{ name = "fa-my-setting", type = "bool-setting", -- or "int-setting", "double-setting", "string-setting" setting_type = "runtime-per-user", default_value = false, order = "b", -- Controls display order in settings menu }, -
Add locale in
locale/en/settings.cfg:[mod-setting-name] fa-my-setting=My setting label [mod-setting-description] fa-my-setting=Description shown in settings menu
-
Read at runtime:
local enabled = settings.get_player_settings(pindex)["fa-my-setting"].value
Settings automatically appear in the FA settings menu (opened via keybind). The menu is built dynamically from settings-decls.lua.
- Cache globals locally
- Avoid table creation in hot loops
- Use appropriate tick intervals (15, 60, etc.)
- Validate entities:
if entity and entity.valid then
- Syntrax: Rail description language integrated but not yet active
- Everything goes through
launch_factorio.py - Requires only work at file top-level
- No global state beyond current file
- Check
player and player.validalways - Never use
game.create_player()(doesn't exist) - Use
prototypes.recipe["name"]notgame.recipe_prototypes
The mod communicates with a launcher via stdout:
out <player_index> <message>
This is why Speech.speak() is used for user messages.
Remember: This mod makes a visual game accessible through audio. Every feature must be designed with audio-first interaction in mind!
The mod includes a message list system for providing help and documentation that integrates with Factorio's localization system.
- Create a
.txtfile anywhere underlocale/<language>/(e.g.,locale/en/ui-help/my-feature.txtorlocale/en/docs/controls.txt) - Write messages separated by blank lines:
; Comments start with semicolon This is the first message. This is the second message. - Run
python build_message_lists.pyto generate locale files - The message list name is the basename of the file (without
.txt) - Message list names must be globally unique across all directories
local Help = require("scripts.ui.help")
-- In your KeyGraph declaration or TabList callbacks:
get_help_metadata = function(ctx)
return {
Help.message_list("my-feature"), -- From my-feature.txt
Help.message({"fa.some-other-message"}), -- Direct localised string
}
end- Press
Shift+/(question mark) while in a UI to open help - Use W/S to navigate between help messages
- Press
Shift+/again orEto close help
WRONG:
- Removed the old UI system. Now x does y.
CORRECT: consider whether a comment is required.
WRONG:
-- Changed to use controllers. Now handles force_close
CORRECT:
-- Can be closed with the controller
x.valid is how the mod checks whether or not an entity from Factorio can be used without error. But, it is WRONG TO
DO THIS AT EVERY LEVEL. Before implementing a block of code, ALWAYS consider whether or not entity/object
validity has already been checked elsewhere.
CRITICAL: Excessive validation hides bugs. Let code crash to find edge cases.
WRONG:
function process_signals(entity)
if not entity or not entity.valid then return {} end
local cb = entity.get_control_behavior()
if not cb then return {} end
for _, section in ipairs(cb.sections or {}) do
local count = section.filters_count or 0
for i = 1, count do
local slot = section.get_slot(i)
if slot and slot.value then
-- process slot
end
end
end
endThis hides bugs:
- Returns empty table when entity is invalid (should crash)
cb.sections or {}returns empty on nil (should crash to find why cb.sections is nil)section.filters_count or 0hides missing filters_count (should crash)if slot and slot.valuesilently skips invalid slots (should crash if unexpected)
CORRECT:
function process_signals(entity)
local cb = entity.get_control_behavior()
for _, section in ipairs(cb.sections) do
for i = 1, section.filters_count do
local slot = section.get_slot(i)
if slot.value then -- Only check what's expected to be nil
-- process slot
end
end
end
endWhen to validate:
- At UI entry points (user can trigger with bad state)
- When nil is a valid expected value (e.g.,
slot.valuecan legitimately be nil for empty slots)
When NOT to validate:
- Internal functions (caller should ensure valid state)
- Properties that should always exist (let it crash to find the bug)
- Returning empty/default silently (masks the real problem)
This is a Factorio 2.0 project, not a Factorio 1.1 project. Factorio 2.0 comes with many API changes.
A complete reference (one file per class, concept, define, etc) is at ./llm-docs/api-reference. List this directory recursively for a "table of contents".
Read ./llm-docs/CLAUDE.md for more specific information on browsing this documentation.
You MUST double check that you understand APIs before using them. Your training knowledge cutoff was only barely after the Factorio 2.0 release and a vast majority of your training data refers to 1.1 APIs. In addition to changes, the 2.0 API adds a lot of new functions and objects which may also simplify mod tasks.
IMPORTANT: the docs are at the root of this repo, ./ relative to your invocation working directory. They are not at the root of the Factorio install. For example, you can run exactly this command to list all files (thousands of them!):
find ./llm-docs -type f
Due to the sheer number of files, searching with your built-in search tools is a better approach. This is just illustrating the locations of files--don't run it unless you're sure you want all that output!
- Before performing aggregations of items by quality to produce lists such as "legendary solar panel x 5", read scripts/item-stack-utils.lua to learn about aggregation functions that already exist.
- See
inventory-utils.luafor inventory-like list presentation helpers. - imports are CamelCase, not snake_case or camelCase.