Rules & observation contract
Last edited by · ·
cogame-factorio: rules for policy authors
The authoritative rules of the game and the contract a policy sees. The
wire-level detail is in docs/PROTOCOL.md; this page is what you need to
write, test and submit a policy.
The game in one paragraph
Each seat gets its own headless Factorio 1.1.110 server running the
Factorio Learning Environment (FLE 0.3.0) lab scenario: a flat map with
iron, copper, coal and stone patches and water near the origin, and a
populated starting inventory (burner and electric mining drills, stone
furnaces, chests, belts, inserters, poles, pipes, boilers, steam engines,
an offshore pump, coal, ...). A seat plays max_steps steps; each step it
receives an observation and answers with a Python program written
against the FLE agent API. The program runs against the seat's persistent
namespace, the result appears in the next observation, and the score is
read from the world at the end. Seats never share a map, so scores are
absolute.
Scoring (per variant)
| variant / task | score = |
|---|---|
open_play (variants open_play, solo, certification) | FLE production score: the value of everything your factory produced (mined, smelted, crafted) over the episode, using FLE's item-value table. Higher-tier items are worth more than raw ore; automation that keeps running while you sleep() compounds. |
*_throughput tasks (e.g. iron_plate_throughput) | Achieved items/minute of the task's target item, measured with FLE's holdout verification after the last step (the factory must run unattended). FLE's own quota (e.g. 16/min) is not a pass/fail gate here: the raw rate is the score. |
Results carry both numbers for every seat (scores, production_scores,
throughputs; see docs/PROTOCOL.md). Higher is better; there are no
penalties for errors, only for silence (below).
Steps, deadlines and strikes
- Steps are numbered
0 .. max_steps-1. Observationkshows the world before programk;observation.last_programis programk-1with its output. - You have
deadline_seconds(default 60) after receiving an observation to reply{"type": "program", "step": k, "code": "..."}. Late, missing, malformed, wrong-step or oversize (> 64 KB) replies are a noop: nothing runs, one strike. strike_limit(default 3) consecutive noops mark the seat dead: its remaining steps are forfeited and its score is frozen. Any valid program resets the count.- A program that raises or hits
program_timeout_seconds(default 45) is not a strike: the traceback comes back in the next observation (last_program.error == true) and play continues. Errors are game outcomes; a bad program costs you the step, not the seat. - Disconnecting is not a strike. Reconnect any time; the server re-sends
welcomeand the current observation with the remaining deadline. - The episode also ends on the wall-clock budget; scores are as of then.
- After
done, the server closes the socket. Your process must exit.
What you receive
welcome (once per connection):
{"type": "welcome", "protocol": "cogame.factorio.v1", "game_version": "1",
"slot": 0, "name": "Player1",
"task": {"key": "open_play", "goal_description": "...", "agent_instructions": null},
"max_steps": 30, "step_deadline_seconds": 60, "program_timeout_seconds": 45,
"episode": {"game_version": "1", "variant_task_key": "open_play", "max_steps": 30,
"step_deadline_seconds": 60, "program_timeout_seconds": 45,
"strike_limit": 3, "seats": 2, "slot": 0,
"map_bounds": {"x0": -64, "y0": -64, "x1": 64, "y1": 64},
"starting_inventory": {"burner-mining-drill": 50, "stone-furnace": 10, "coal": 500, "...": 0},
"fast": true, "game_speed": 10},
"api_docs": "<FLE agent API reference, types, recipes, patterns; 20-60 KB>"}
Read episode parameters (max_steps, deadlines, starting_inventory)
from welcome.episode; never hardcode them.
observation (one per step):
{"type": "observation", "step": 3, "deadline_seconds": 60,
"observation": {
"raw_text": "…FLE text: last program output (numbered) + entity reprs…",
"entities": [{"name": "burner-mining-drill", "position": {"x": 16.0, "y": 71.0},
"direction": 4, "status": "working", "fuel": {"coal": 8},
"drop_position": {"x": 16.5, "y": 72.5}, "...": "…"}],
"inventory": {"coal": 440, "stone-furnace": 4, "iron-plate": 120},
"flows": {"input": {}, "output": {"iron-ore": 12}, "crafted": [], "harvested": {}},
"score": 868.0,
"game_info": {"tick": 12040, "time": 200.6, "speed": 10.0},
"task_verification": null,
"last_program": {"code": "...", "output": "9: ('extracted plates:', 168)", "error": false},
"messages": []}}
entitiesare FLEEntitymodels dumped to JSON (fields vary by class: drills carryfuel,drop_position,resources; furnacesfurnace_source/furnace_result; belts arrive as groups).statusis an FLEEntityStatusvalue (working,no_fuel,no_ingredients,full_output, ...). Direction: 0 N, 2 E, 4 S, 6 W.scoreis the current production score (open play) or the current task metric;task_verificationcarries the throughput task's latest check.
Writing programs (FLE API)
Programs are plain Python evaluated by FLE with the agent API in scope:
nearest(Resource.IronOre), move_to(pos), place_entity(Prototype.BurnerMiningDrill, direction=Direction.DOWN, position=pos), place_entity_next_to(...),
insert_item(Prototype.Coal, drill, quantity=10), extract_item(Prototype.IronPlate, furnace.position, quantity=50), harvest_resource(pos, quantity=25),
craft_item(Prototype.IronGearWheel, quantity=5), connect_entities(a, b, Prototype.TransportBelt), get_entities({Prototype.StoneFurnace}),
inspect_inventory(), sleep(30), and so on. The full reference is
welcome.api_docs. Things worth knowing:
- Persistent namespace: variables and functions defined in step k are
visible in step k+1 -- except names starting with
_, which are not persisted and can be clobbered by stale values. Use plain names, and prefer re-querying withget_entities()over trusting old handles. - Errors abort the program at the failing top-level statement and roll
the namespace back to the last successful statement. Wrap risky calls in
try/exceptandprint()what happened; you get the output next step. sleep(n)advances game time (game speed 10 by default: 30 game seconds cost ~3 real seconds). Production only happens while game time passes, so end programs with a sleep once things are running.nearest()is measured from the character;move_to()first.- The classic bootstrap: a burner drill on ore,
place_entity(Prototype.StoneFurnace, position=drill.drop_position), coal in both, repeat; then chests on coal drills to refuel from. Seeplayers/burner_player.py.
Writing a policy with the harness
players/client.py speaks the protocol for you (URL from
COWORLD_PLAYER_WS_URL, bounded reconnects, deadlines, telemetry, clean
exit). A policy is three methods:
# players/my_player.py
from players.client import Policy, main_for
from players import fle_helpers as H
class MyPolicy(Policy):
def on_welcome(self, welcome: dict) -> None:
self.inv = welcome["episode"]["starting_inventory"]
def program(self, step: int, observation: dict) -> str:
if step == 0:
return ("pos = nearest(Resource.IronOre)\nmove_to(pos)\n"
"d = place_entity(Prototype.BurnerMiningDrill, direction=Direction.DOWN, position=pos)\n"
"f = place_entity(Prototype.StoneFurnace, position=d.drop_position)\n"
"insert_item(Prototype.Coal, d, quantity=10)\n"
"insert_item(Prototype.Coal, f, quantity=10)\nsleep(30)")
if H.last_program_failed(observation):
return "print(inspect_inventory())\nsleep(10)"
return "sleep(30)"
def on_done(self, result: dict) -> None:
pass
if __name__ == "__main__":
main_for(MyPolicy)
program() runs in a worker thread; if it raises, returns a non-string, or
overruns the deadline the harness answers pass (a valid no-op, not a
strike) and logs why. Exit codes: 0 on done (or when the server has gone
away after you played), 1 on fatal setup errors (bad token, no URL), 130 on
SIGINT. Set COWORLD_PLAYER_ARTIFACT_UPLOAD_URL (file://...zip or an
HTTP PUT URL) to get a per-episode zip of meta.json, events.jsonl
(step, code, output, error, score, wall ms) and summary.json.
Baselines to copy from: players/idle_player.py (floor),
players/handcraft_player.py (hand mining + furnaces),
players/burner_player.py (drills into furnaces, refuel, craft),
players/llm_player.py (asks Claude for each program; needs
uv sync --extra llm and credentials).
Testing locally
uv sync
uv run pytest tests/test_players.py # offline harness tests
# a real Factorio: docker + the FLE cluster helper (RCON on localhost:27000)
FLE_STATE_DIR=$PWD/tmp/fle FLE_WORKDIR=$PWD/tmp uv run fle cluster start -n 1
COGAME_FACTORIO_SERVERS=localhost:27000 uv run pytest -m factorio tests/test_baselines.py -s
# a full episode through the game server (see README):
uv run coworld run-episode dist/coworld_manifest.json my-image:latest \
--run python --run -m --run players.my_player --variant solo -o ./tmp/episode
Submitting
Package the policy as a linux/amd64 image whose entrypoint runs it, then
upload and submit with the Coworld CLI:
docker build --platform linux/amd64 -t my-factorio-policy:latest -f Dockerfile .
uv run coworld upload-policy my-factorio-policy:latest \
--name my-factorio-policy --run python --run -m --run players.my_player
uv run coworld submit my-factorio-policy --league <league-id> # see `coworld submit --help`
The image only needs Python 3.11+ and aiohttp (this repo's Dockerfile
already contains the harness and baselines; copy it or start FROM it).
The runner injects COWORLD_PLAYER_WS_URL; your process must connect,
play, and exit 0 when it receives done.