A free, open 3D factory simulator for learning PLC programming — a modern, customizable open replacement for Factory I/O.
Author & Creator: Mahamed Algaroshy (محمد الجروشي)
Repository: github.com/malgaroshy-maker/factoryforge
FactoryForge allows students, automation engineers, and software developers to write PLC logic (Ladder Diagram, SCL, Function Block Diagram) in TIA Portal, OpenPLC, Node-RED, or Ignition SCADA, and watch it drive a real-time 3D physics-based factory in Godot 4.7.
No accounts, no per-seat subscription fees, and 100% open for custom part & driver creation.
┌────────────────────────────┐ ┌──────────────────────────┐
│ SIM ENGINE (Godot 4.7/C#) │ │ DRIVER SIDECAR (Python) │
│ │ │ │
│ 3D render + Jolt physics │ tag bus │ asyncua (OPC UA) │
│ scene editor / voxel grid │ ◄──────► │ pythonnet (PLCSIM) │
│ 29-part library │ WS │ python-snap7 (S7) │
│ tag registry (authority) │ JSON │ built-in (Modbus) │
└────────────────────────────┘ └──────────────────────────┘
- 🧲 Material-aware sensing: items carry a material, so an inductive sensor sorts metal from cardboard instead of being a second presence sensor.
⚠️ Break the machine on purpose (⚠ Fault): arm the fault tool and click a conveyor or a pusher. The drive stops while its command is still on — the belt disobeys, its fault beacon lights, and<part>.faultgoes true for the PLC to read. A jammed cylinder freezes mid-stroke rather than returning home, so the limit switches are the only honest thing to read, and a seized tank valve holds its opening — the nastier failure, because the process keeps moving and the controller's own output cannot tell you. Until this existed every actuator did exactly what it was told, which made half of real PLC work unteachable: an interlock exists precisely because the plant does not always obey.- 🎚️ Analog I/O: Float tags end to end — a modulating valve, a level transmitter, a heater against a first-order thermal plant, a variable-speed drive whose actual speed lags the reference you gave it, and a needle gauge to read any of them in the scene. Enough to write a real PID against a nonlinear process, and to measure why the integral term exists rather than being told.
- ⏯️ Run / Pause / Reset & time scale (0.25×–4×): freeze the line mid-cycle to read every sensor and actuator at that instant, or slow a fast sequence down to watch an interlock. The PLC stays connected while paused.
- 🎮 Godot 4.7 C# 3D Engine & Jolt Physics: 60 FPS 3D rendering with 4× MSAA, soft shadows, SSAO, glow on the parts that are meant to be lights, and continuous collision detection. Conveyor drums turn at true surface speed, cylinders cushion into their end stops, stack-light lamps cast real light onto what is beside them, and the camera frames the scene you just opened instead of leaving you looking at a control panel.
- 📦 Real rigid-body cartons: mass from carton density, friction tuned per material pair (rubber belt, cardboard, steel chute), boxes that accumulate behind a blocked diverter instead of passing through it.
- 🏠 Start screen with eight templates: open on a chooser rather than cold into one demo. Each template teaches a different thing — momentary buttons and a latching E-stop, analog level control with a nonlinear process, sorting on a measurement instead of two bits, a checkweigher with metal detection, a pick-and-place gantry you sequence on feedback rather than timers, a thermal loop where proportional control alone visibly parks short of setpoint, and a buffer where product accumulates behind a blade stop and is released by belt travel rather than by a timer — plus recent scenes and the full key list.
F12brings that key list back once a scene is open. - 🕹️ Operate any component by hand (
F1): switch the toolbar from✎ Buildto👆 Operateand click a conveyor, a pusher, a stack light lamp or a tank valve directly — not just the operator panel's Start/Stop/Reset/E-stop. Every shipped scene answers to its panel: Start runs the line, Stop stops it, the mushroom latches a trip that only Reset clears, and the panel's setpoint pot is the one number that scene is about — the level to hold, the height that counts as tall, the weight that counts as a reject, the batch to make. Drag the knob mid-run and the line changes what it does, with no code edited. A banner names what's clickable, hovering outlines it, and every part's own property panel carries a live toggle or slider for its I/O too, so you can see what a part does before writing a line of PLC code against it. - 🏭 A shop to build it in: the line stands on poured concrete with a control joint around every two-metre bay, inside clad walls five metres to the eaves. Both are generated in code — this project ships no image assets — and both are there for a reason beyond looking better: with nothing around it, a conveyor could be two metres long or twenty, and a floor bay is a ruler lying under the machine.
- 📋 Every template tells you what to build: press
Tand the scene says what it is asking of you — the task, the tags your program drives and reads, and how you know it works. The lesson used to live only in the Python test harness, which is the last place somebody learning PLC programming will look. - 🔎 A tag list you can find things in: search it, collapse it by machine, or show just the half the PLC writes. A tag you have forced by hand marks its own name and raises a count with a one-click release — because a forgotten force is a value that disagrees with the simulation on purpose, and it explains more "why is my program not working" than anything else.
- 🛠️ 3D Scene Editor Suite: a searchable palette that tells you what each part does and which tags it will register. The part stays in your hand after you place it, so a line of six conveyors is six clicks rather than six trips back to the palette — the lit palette button says what you are holding, and
Escputs it down.Ctrl+Dlands its copy clear of the original and selects it, so pressing it again walks a line across the grid; the arrow keys nudge the selection one cell at a time. Click a placed part and drag it to a new cell — one gesture, oneCtrl+Z— with grid snapping, rotation (R), a selection wireframe gizmo, and undo/redo throughout. A selection can be several parts:Shift-click to add,Ctrl-drag a box over the floor to take everything inside it,Ctrl+Afor all of them, and then move, nudge, rotate, duplicate or delete the whole group as one undo step.Ctrl+C/Ctrl+Vcarries a section into another scene.Nfloats every part's name over it — and a part's name is its tag prefix, so that is the list your PLC program is written against.1-4snap the camera to iso, top, front and side without losing what you were looking at. The property panel names what the selected part responds to, so none of it has to be guessed. - 🔌 Visual I/O Driver Wiring Panel (
F4): Centered split-screen modal — click a PLC address (%I0.0,%Q0.0), then click the component tag to map it to. Auto-map suggests an address for every tag in the loaded scene, and Export writesio_mapping.jsonandio_tags.csvfor the sidecar and for whoever is building the PLC side. - 🏷️ Live tag inspection and forcing: the Tag Inspector lists every tag the loaded scene owns with its live value, and forces any of them — bit, int and float alike — with a typed value. A
🔓 N forcedchip in the toolbar shows what is being held by hand and releases it all in one click. The parts that measure something — the light curtain, the level tank, the digital display — also read out in 3D on the part itself. - 🧪 A built-in exercise per scene:
python tools/try_scene.py --scene <id>(or the toolbar's 🧪 Try button) spawns or attaches to the engine and drives the scene the way a PLC would — pressing the panel's own buttons, timing the E-stop against a 200 ms limit, turning the setpoint pot mid-run to prove the line follows it, and failing a drive under it to check the controller trips and refuses to reset while the fault stands — then reports pass/fail. The thing to run before writing a real program against it. - 🏭 Native Siemens Integration: all three Siemens paths verified driving the 3D scene from a virtual S7-1500 — PLCSIM Advanced Simulation Runtime API (shared memory, no network, no OPC UA licence), OPC UA client, and Snap7 ISO-on-TCP. Belt, emitter, sensors, diverter and counters all run off the CPU's own program.
- 📊 Multi-Protocol SCADA Support: Built-in OPC UA client/server, Modbus TCP server, and Node-RED integration.
The tag ids below are the built-in scene's names. A part's Name is its tag
prefix — rename a pusher to reject in the property panel and its tags become
reject.extend, reject.extended, reject.retracted. That is the whole naming
rule, and it is what makes a scene you build addressable from a PLC.
| Component | Description | Tag Bus Interface |
|---|---|---|
| Conveyor Belt | Surface-velocity belt with side rails and legs, plus a drive-fault beacon | conveyor.rotate (Bit, Output) · conveyor.fault (Bit, Input) |
| Photoelectric Sensor | Diffuse beam sensor, reflects off the item itself | sensor.detect (Bit, Input) |
| Retroreflective Sensor | Beams to a reflector post across the lane; sees matt and dark items a diffuse sensor misses | sensor.detect (Bit, Input) |
| Inductive Sensor | Responds to metal only — cardboard passes it as if the lane were empty | sensor.detect (Bit, Input) |
| Light Curtain | Light curtain of 12 beams; reports the height of the tallest blocked beam, so one part replaces a low/high sensor pair | lightarray.height (Float, Input), .blocked (Bit, Input) |
| Pneumatic Pusher | Cylinder housing, chrome shaft & orange face plate; a jam freezes it mid-stroke | pusher.extend, pusher.extended, pusher.retracted, pusher.fault |
| Ramp (Chute) | 30° gravity chute with guide rails; incline and friction are a matched pair so cartons actually slide | Physical static body |
| Stack Light | 3-stage industrial tower light (Green, Yellow, Red) | stacklight.green, yellow, red |
| Digital Display | 3D 7-segment LED panel displaying live integer counts | display.value (Int, Output) |
| Roller Conveyor | Driven roller deck for pallets and totes that would scuff a belt; rollers spin at the true surface speed | rollerconveyor.rotate (Bit, Output) · .fault (Bit, Input) |
| Weigh Conveyor | Integrated load cell scale reading the carton's mass in grams — 720 g for a short carton, 2160 g for a tall one, 12960 g for a metal one — and showing it on the scale | weighconveyor.weight (Int, Input) |
| Box Emitter | Spawner emitting tall & short rigid cartons, optionally every Nth in metal | emitter.emit (Bit, Output) |
| Box Remover | Area3D zone despawning items & incrementing a counter; the counted tag is pickable, so two removers can feed one total | remover.count (Int, Input) |
| Control Panel | Operator station you can actually press. Start/Stop/Reset are momentary — one clean scan per click, however long you hold the mouse — and the mushroom is a maintained E-stop wired normally closed, so its tag is true while the circuit is healthy. The setpoint pot is dragged, reads out in the scene's own units on its scale plate, and turns itself to match a tag driven from a PLC | panel.start, .stop, .reset, .estop (Bit, Input) · panel.setpoint (Float, Input) · panel.green, .red (Bit, Output) |
| Level Tank | Analog process tank; outflow follows Torricelli, so process gain varies with level and a PID tuned full overshoots when empty. A seized valve holds its opening — the process keeps moving while the command reads zero | tank.fill, tank.drain (Float, Output), tank.level, tank.fault (Input) |
| VFD Conveyor | A belt behind a variable-frequency drive. The reference ramps, so commanded and actual speed genuinely disagree while the drive is moving between them — a controller that treats the reference as instantly true is wrong here in a way you can measure | vfd.run, vfd.speed (Float, Output) · vfd.actual (Float, Input), .fault |
| Pivot Diverter | A blade on a rotary actuator that deflects a moving carton across the lane without stopping the line. Two limit switches and a swing time are the whole exercise; a seizure freezes it mid-sweep, across a running lane | div.divert (Output) · div.diverted, div.home, div.fault (Input) |
| Pick & Place Gantry | A portal with a travelling carriage, a telescoping Z column and a vacuum cup that really picks a carton up and drops it with the carriage's own velocity. Three motions to sequence, and a grip that reports honestly when it caught nothing | arm.target (Float, Output), .lower, .grip (Output) · .position (Float, Input), .inposition, .lowered, .raised, .holding, .fault |
| Barcode Scanner | Overhead reader that says what an item is, not just that it is there — 101 short carton, 102 tall, 201 metal — as a code plus a one-scan read pulse a program has to latch | scan.enable (Output) · scan.code (Int), scan.read, scan.present (Input) |
| Heating Station | First-order thermal plant with ambient loss: heat fast, cool only as fast as the room allows. Pure proportional control leaves a standing offset you can measure. A failed element still reports full power while the plate cools | oven.heater (Float, Output) · oven.temperature (Float), oven.attemp, oven.fault (Input) |
| Analog Gauge | Needle instrument with a graduated plate, a red band and a digital sub-readout — somewhere for a float to be read in the scene rather than only in the tag list | gauge.value (Float, Output) |
| Alarm Beacon | Rotating beacon that sweeps a real light across the machines near it, plus a horn with a visible diaphragm. What you notice from the other end of the building | beacon.beacon, beacon.horn (Bit, Output) |
| Selector Switch | The third kind of operator input: not a pulse and not a latch, but a knob that stays where it is put. The controller reads a position, not an edge — which is what an Auto/Manual program is built around | selector.position (Int, Input) |
| Guard Door | An interlocked guard whose switch is closed while the door is shut (normally closed, like the mushroom). The solenoid lock makes it a two-way contract: the controller decides whether the door may be opened at all, and until it releases the lock the handle does nothing | guard.lock (Bit, Output) · guard.closed, guard.locked (Bit, Input) |
| Blade Stop | A blade that rises through the lane to hold cartons on a belt that keeps running. The only way to build an accumulation buffer here: the queue behind it builds because the solver says so, and releasing one lets the rest close up on their own. A seizure is the nasty one — frozen halfway, it stops short cartons and lets tall ones ride over | stop.raise (Bit, Output) · stop.up, stop.down, stop.fault (Bit, Input) |
| Transfer Turntable | A rotary index that turns a carton into a new lane. The load is held on by friction, not by being parented to the deck, so a deck told to index too fast genuinely throws it — which is the real constraint on how fast a transfer can run | xfer.index (Bit, Output) · xfer.athome, xfer.atindex, xfer.fault (Bit, Input) |
| Measuring Encoder | A wheel riding the belt it is placed over, counting pulses per metre of travel. Product tracked by distance instead of by a timer, so the logic survives anybody turning the drive up. Place it away from a conveyor and it counts nothing and does not turn — the honest failure, and a visible one | enc.reset (Bit, Output) · enc.count (Int), enc.rate (Float, Input) |
| Cooling Fan | A ducted fan that adds to the loss term of any heating station within reach, giving the thermal plant a second actuator pulling the other way. One plant, two actuators — which is split-range control, and the first place a deadband exists for a reason | fan.run, fan.speed (Float, Output) · fan.airflow (Float), fan.fault (Bit, Input) |
| Two-Hand Control | Two palm buttons whose permissive is not left AND right: the relay also requires that the two presses arrived within half a second of each other, so taping one button down defeats nothing. A program that ANDs the two bits itself passes its own test and fails the real device |
hands.left, hands.right, hands.valid (Bit, Input) |
| Motor Starter | The device between the PLC and the motor, which the library did not have. Your program energises a coil; the contactor runs the machine, and the auxiliary contact is what tells you it actually did — a scan later than you commanded it. A sustained overload trips and stays tripped while the coil is still called | starter.coil (Bit, Output) · starter.aux, starter.overload (Bit, NC) · starter.current (Float, Input) |
| Safety Relay | Dual-channel cross-monitoring with a real discrepancy window: two contacts that stop agreeing latch a fault, which is what a welded contact looks like from the relay's side. Its reset is edge-triggered, so a taped-down button restarts nothing, and it permits rather than commands — closing it must start no machine | relay.reset (Bit, Output) · relay.cha, relay.chb, relay.fault (Bit, Input) |
| Area Scanner | Warning and protective fields as separate outputs, and muting that expires. A mute is a real hole in a guard; the timeout is what stops a bridge request becoming a permanent defeat, and the scanner refuses to honour one past its limit | scanner.mute (Bit, Output) · scanner.stop, scanner.warn, scanner.muted (Bit, Input) |
| Double-Acting Cylinder | Two coils on a 5/2 valve with no spring return, and two reeds with a genuine gap between them — so mid-stroke neither is made. Dropping both coils leaves the rod coasting where it was going, because a spool without a spring stays where it was put | cyl.extend, cyl.retract (Bit, Output) · cyl.extended, cyl.retracted, cyl.fault (Bit, Input) |
| Dosing Pump | Gives the tank an inflow it does not own, which is what makes cascade control possible: a fast flow loop inside a slow level loop. The speed reference ramps, so commanded and delivered flow genuinely disagree while it gets there | pump.run, pump.speed (Float, Output) · pump.flow (Float), pump.fault (Bit, Input) |
| Flow Meter | A rate and a resettable totaliser — a process variable and a batch counter in one part. The reset is a level, not an edge, so a program that pulses it clears nothing. Ending a batch on the total rather than on a timer is the lesson: halve the flow and the litres stay the same | meter.reset (Bit, Output) · meter.rate (Float), meter.total (Int, Input) |
| Articulated Arm | Joint angles, not a tip target — the gantry already takes one of those, and an arm that did too would teach pick-and-place twice and hide the nonlinearity. A command past a stop is refused rather than clamped silently; the tool travels arcs, not chords (176 mm off the chord on a two-joint move); and radial authority collapses at full stretch — 7.0 mm of reach per degree of elbow at 90°, 0.061 mm at full extension, a factor of 115 | arm.waist, arm.shoulder, arm.elbow (Float, Output) · arm.grip (Bit, Output) · arm.atwaist, arm.atshoulder, arm.atelbow, arm.reach, arm.height, arm.stretch (Float) · arm.inposition, arm.limit, arm.holding, arm.fault (Bit, Input) |
| Pallet Station | A pattern generator rather than a robot, so it composes with the gantry, the arm or a plain pusher: it publishes where the next carton goes and counts what has landed. Indexing through a layer, a quarter-turn interlock between layers, and a pallet that fills and then refuses while still holding its published position | pallet.index, pallet.change (Bit, Output) · pallet.nextx, pallet.nexty, pallet.nextz (Float) · pallet.slot, pallet.layer, pallet.count (Int) · pallet.layerdone, pallet.full (Bit, Input) |
| Vertical Lift | Height as a routing dimension — everything else in the library lives on one plane. Mutual exclusion is enforced by the machine, not by the program: a blade across the infeed mouth is down only while the carriage is at that level, empty and healthy, so a second carton is physically held on a belt that keeps running | lift.target (Int, Output) · lift.transfer (Bit, Output) · lift.level (Int) · lift.height (Float) · lift.atlevel, lift.occupied, lift.ready, lift.fault (Bit, Input) |
Grab the archive for your platform from
Releases, extract
it, and run FactoryForge. No Godot, no .NET SDK and no Python needed — the
sidecar that speaks every PLC protocol ships frozen alongside the engine, and
F5's Apply & Connect finds it automatically.
Every release is gated on the headless self-tests in
tools/packaging/check_release.py run against the exported binary, not
against a source checkout — a checkout can pass everything while the release
fails, because two of those tests read fixtures that System.IO cannot reach
once packed into a .pck.
Windows will warn you on first run. These builds are not code-signed, so SmartScreen shows "Windows protected your PC" — click More info → Run anyway. That warning means the binary has no purchased certificate attached, not that anything is wrong with it. See PACKAGING.md for why this project does not buy one.
Everything below builds it yourself, the same path GETTING_STARTED.md walks in detail. You need Godot 4.7 mono, the .NET 8 SDK and Python 3.11+.
git clone https://github.com/malgaroshy-maker/factoryforge.git
cd factoryforge
pip install -e "sidecar[dev,opcua]"python -m pytest -q73 pass as of 2026-09-21. Treat the command's output as the count, not this line — four files used to quote three different numbers between them.
Or the full plan — build, the Python suite, the engine's own self-tests, determinism, the engine↔sidecar seam and robustness. No PLC needed:
python tools/test_plan.py # --gui adds the two display-dependent checkspython run.pyrun.py finds Godot (or tells you exactly what to install and where), builds
the C# engine, and launches it — no separate dotnet build step, and it works
the same on Windows and Linux. (Windows users can also double-click
run_factoryforge.bat, which just calls run.py.)
This runs the physics scene: Jolt rigid-body cartons, real collisions, and components whose properties genuinely change how the line behaves — speed up the belt and boxes outrun the diverter; hold the pusher out and the line backs up behind it.
# Fixed-timestep scene instead: reproducible, and the regression contract.
python run.py -- --deterministicBoth scenes expose the same 16 tags and report the same scene name, so a PLC
program, Node-RED flow or SCADA client drives either one unchanged. Use
--deterministic whenever you need repeatable counts — CI and
tools/drive_engine.py rely on it.
The engine speaks only its own tag bus; every PLC protocol lives in the Python
sidecar. connect attaches to a running engine — that is the one to use with
the 3D view. (demo starts its own headless Python scene instead, which is
for checking a driver with no Godot in the picture.) The F5 Driver dialog
runs these for you and copies the command.
cd sidecar
# Siemens PLCSIM Advanced (Shared Memory API — Zero Licence Cost)
python -m factoryforge_sidecar connect --driver plcsim-advanced -o instance Sorting_PLC
# Siemens S7 ISO-on-TCP (Snap7)
python -m factoryforge_sidecar connect --driver s7-snap7 -o host 192.168.1.20 -o db 1
# OPC UA Client (connecting to an S7-1500 @ 192.168.1.20)
python -m factoryforge_sidecar connect --driver opcua-client \
-o url opc.tcp://192.168.1.20:4840 --mapping io_mapping.json
# OPC UA Server (exposing the scene to Node-RED / SCADA)
python -m factoryforge_sidecar connect --driver opcua-serverBuilding your own scene? Name your parts in the inspector, then F4 → Export
writes io_mapping.json and io_tags.csv for the tags that scene actually has.
See Getting Started.
| Document | Description |
|---|---|
| 🚀 GETTING_STARTED.md | Step-by-step setup for PLCSIM Advanced, TIA Portal & Node-RED |
| 🛠️ PART_AUTHORING.md | Guide & template for building custom 3D factory components |
| 🔌 DRIVER_AUTHORING.md | Guide for adding custom Python protocol drivers |
| ✅ TEST_PLAN.md | What is tested, what is not, and the last run's results |
| 📦 PACKAGING.md | Building a distributable release — verified end to end, never published |
| 🔨 HARDENING_PLAN.md | The open work list, HP-01…HP-52, and the release gate inside it |
| 🗺️ ROADMAP.md | Milestone completion tracking |
| 📑 PRD.md | Problem statement, target audience, and success criteria |
| ⚡ tag-bus.md | WebSocket tag bus protocol specification |
| 🤖 AGENTS.md | Developer cheat sheet, paths, and hardware gotchas |
| 📚 docs/history/ | Thirteen completed plan documents — why each piece was built the way it was. History, not instructions |
Nobody outside the project has contributed yet, so CONTRIBUTING.md is written to tell you what it actually costs rather than to sell you on it. The short version: a driver is cheap — one Python module, no Godot and no C# — and a part is not, because it still touches eight shared files besides its own class. That is a design problem, it is written down, and HP-34 in the hardening plan is the work to reduce it to one file plus a catalog entry.
Issue templates for a bug, a part and a driver. Security issues go through SECURITY.md, not the issue tracker — it also lists what is already known, including a Modbus bind default that is wrong today and being fixed under HP-22. Behaviour here: CODE_OF_CONDUCT.md.
Distributed under the MIT License. See LICENSE for more information.
