Source Engine's Entity I/O: How a String-Based Event Bus Ships Gameplay Logic Without Recompiles
Source Engine's Entity I/O moves gameplay logic into map data via a string-based async event bus. Covers C++ declaration model, Hammer authoring, runtime dispatch, and debugging tools for typos, coercion bugs, and circular loops.
08 May 2026, 02:50 UTC

The Problem: Gameplay Logic Locked in C++
In the late 1990s, level designers building Half-Life maps had a frustrating workflow: every new door behavior, trigger sequence, or puzzle mechanic required a programmer to write and compile C++ code. Iteration cycles stretched from minutes to hours. Valve's solution was the Entity I/O system — a decoupled, string-based event bus that moves entity interactions out of compiled code and into map data. This article examines how that system works, the engineering trade-offs it accepts, and how to debug it when things go wrong.
How Entity I/O Works at Runtime
Each entity class declares named inputs (actions it can receive) and outputs (events it can fire) via DATADESC macros in C++. For example, a func_door exposes inputs like Open, Close, Lock, and outputs like OnOpen, OnClose. A func_button fires OnPressed and OnReleased.
At map load, the engine reads the BSP's entity lump, which contains a serialized connection graph: output X on entity A targets input Y on entity B (or multiple entities via wildcard targetnames). Connections can carry a single variant_t parameter — string, float, vector, entity handle, or color — with an optional delay and repeat count.
The key engineering decision: outputs fire asynchronously through a central event queue (CEntityIOOutput::FireOutput), not via direct function calls. When a button is pressed, it pushes an event onto the queue and returns immediately. The target entity's input processes on the next server frame. This prevents reentrancy bugs (where an input handler fires another output that circles back) and gives deterministic ordering.
Worked Example: Wiring a Button to a Door in Hammer
Open a .vmf in Hammer Editor. Select a func_button entity, go to the Outputs tab, and add:
- My Output:
OnPressed - Target Entity:
door_main(the targetname of yourfunc_door) - Target Input:
Open - Parameter: (leave empty)
- Delay:
0.5(seconds) - Only Once: No
Compile the map (File → Run Map). In-game, pressing the button waits 0.5 seconds, then the door opens. No code change, no recompile of the game DLL.
For branching logic, designers chain logic_relay (fan-out) or logic_case (switch on parameter value) entities. A single button press can trigger a relay that opens three doors, plays a sound, and enables a trigger — all authored in Hammer.
The Fragility You'll Hit in Practice
The system's flexibility comes with sharp edges:
- String-based naming is typo-prone. A targetname
door_mainvsdoor_manproduces no compile error — only a runtime developer console warning:Output OnPressed from button_1 targeting unknown entity door_man. - Parameter coercion is lossy. Passing a vector
"100 0 0"as a string, then reading it back as a vector, works. But an empty string becomes a zero vector silently. A color parameter passed as a string may parse incorrectly if the format differs. - Circular loops are caught by a hard depth limit (default 16). If A fires B fires A, the engine aborts the chain after 16 hops with no error message — the chain just stops.
- Mass output firing stalls the main thread. A
logic_timerat 0.01s firing 100 outputs per tick can spike frame time. The system is single-threaded on the server tick.
Debugging Tools That Actually Help
When connections misbehave, use these in a running Source game (TF2, CS:GO, HL2:DM, Garry's Mod):
developer 1+con_logfile io.log— captures all I/O warnings.ent_fire <targetname> <input> <parameter>— manually trigger any input. Example:ent_fire door_main Opentests the door without the button.ent_io— shows a live feed of every output fired and input received in the console.ent_dump— prints the full serialized entity lump, including every I/O connection, to the console (redirect to file for large maps).
For deeper inspection, the Source SDK 2013 code in server/entityoutput.h/.cpp and shared/variant_t.h shows the exact FireOutput implementation and variant_t type conversion logic.
Trade-off: Author-Time Safety vs. Runtime Flexibility
Entity I/O chooses runtime flexibility over compile-time safety. Connections are data, not code — enabling designers to iterate without programmers, but pushing validation to runtime. Source 2 retains this paradigm but adds VScript (Lua) and Schema system exposure, allowing script-driven connections and richer tooling. The core lesson: a string-based event bus with asynchronous dispatch scales for level design iteration, but demands disciplined naming conventions and runtime observability.
Actionable Next Step
Open a recent map in Hammer, pick one complex entity chain (e.g., a multi-stage puzzle), and run ent_io while triggering it in-game. Compare the live event stream against your Hammer Outputs tab. You'll likely find a missing connection, a typo, or a parameter mismatch that the console warned about but you missed. Fix it in Hammer, recompile only the BSP, and verify — no code rebuild needed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.