2 · Anatomy of a world project¶
Build worlds · Chapter 2 of 12 · Path home
In Chapter 1 a world was an idea: streams + roles +
behaviors. Now let's see it as real files. We'll use the chat example,
the simplest complete world, as our map. Everything else in this path is this
same skeleton with more in it.
By the end of this chapter
You'll recognize every file in a world project and know what each is for. The deep chapters that follow zoom into one piece at a time.
The folder¶
A world is a folder. The chat example
(unaiverse-examples/worlds/chat)
looks like this:
chat/
├── src/
│ ├── world.py # the World subclass: assign_role + create_behav_files
│ ├── user.py # the "user" role's agent class (custom @action methods)
│ ├── broadcaster.py # the "broadcaster" role's agent class
│ └── stats.py # optional: how this world scores participation
├── run_w.py # hosts the world in a Node
├── run_1.py # the "Broadcaster" node (the session master) joins
├── run_2.py # an AI (LLM) joins as a user
├── run_demo_a.py, ... # interactive (human) joiners
└── user.json, broadcaster.json # behaviors, GENERATED by create_behav_files()
Three kinds of thing live here:
- One
Worldsubclass (src/world.py): where you decide who becomes what and build each role's behavior. - One agent class per role (
src/user.py,src/broadcaster.py): where each role's custom actions live. - Run scripts (
run_*.py): tiny launchers that either host the world or join it as an agent.
The *.json files are generated by your code, never hand-written. Here is why
that split matters. Your World subclass describes each role's behavior in Python,
states, transitions, the actions each step runs. When create_behav_files() runs,
it serialises each of those descriptions into a flat *.json file. That file is the
portable contract the world hands to a joiner the instant it assigns them a role:
the joiner does not need your source, only the JSON. Keeping the JSON generated (not
hand-edited) means the Python is the single source of truth, you change the
behavior in code, regenerate, and every joiner gets the new version.
The World subclass¶
src/world.py subclasses World and does exactly two jobs:
assign_role (who becomes what) and create_behav_files (what each role
does). Here it is, trimmed to its shape:
import os
from .stats import WStats
from unaiverse.world import World
from unaiverse.hsm import HybridStateMachine
from unaiverse.networking.node.profile import NodeProfile
class WWorld(World):
def __init__(self, **kwargs):
world_folder = os.path.dirname(os.path.abspath(__file__))
stats = WStats(is_world=True, db_path=f"{world_folder}/stats/world_stats.db")
super().__init__(world_folder=world_folder, stats=stats, **kwargs)
def assign_role(self, profile: NodeProfile, is_world_master: bool):
# The first world master becomes the broadcaster; everyone else is a user
if is_world_master:
if len(self.world_masters) <= 1:
return "broadcaster"
else:
return "user"
else:
return "user"
def create_behav_files(self):
# ... build one HSM per role and save it (see below) ...
...
Read top to bottom, this class does three things, and only the second and third are where the world's logic lives:
__init__wires the world to disk.world_folderis computed from this file's own location (os.path.dirname(os.path.abspath(__file__))), so the world always knows where its generated*.jsonbehaviors and its stats database sit, no matter where you launch it from. It then builds a custom stats object and hands both up toWorld.__init__viasuper().__init__(...). Everything else, networking, the agent registry, the role machinery, is inherited.assign_roleruns once per joiner, the moment they arrive. The host calls it for every node that joins and passes two things: the joiner'sprofile(who they are) andis_world_master(whether this node is allowed to lead the session, decided by the host'sworld_masters_node_names, which we set in the run script). The method's job is to return a role name as a plain string. Here the logic is "the first world-master to arrive becomes the"broadcaster"; any later master falls back to"user", and everyone non-master is a"user"." That returned string is the key the world uses to look up which behavior file to ship. The full story of masters and roles is Chapter 3.create_behav_filesbuilds the behaviors, once, before anyone joins. It is shown in full next, and explained in depth in Chapter 4.
Building the behaviors¶
create_behav_files builds one state machine per role and
saves it to JSON. The same shape repeats for each role: import the role's agent
class, wrap it in a HybridStateMachine, name the role, declare states, declare
transitions, save. Here it is for both roles, lightly trimmed:
def create_behav_files(self):
# ROLE 1/2: user
from .user import WAgent as UserAgent
dummy_agent = UserAgent(proc=None)
behav = HybridStateMachine(dummy_agent)
behav.set_welcome_message(welcome_msg)
behav.set_role("user")
behav.add_state("init", blocking=True)
behav.add_state("waiting_handshake", blocking=False)
behav.add_state("message_sent", blocking=False)
behav.add_state("ready", action="check_messages",
args={"max_silence_seconds": 25.0, "talk_probability": 0.01, "history_len": 3},
msg="👍 Ready!")
behav.add_transit("init", "waiting_handshake",
action="connect_to_broadcaster", args={"role": "broadcaster"})
behav.add_transit("waiting_handshake", "ready",
action="connected", args={"handshake_completed": True})
behav.add_transit("ready", "message_sent",
action="generate_and_send", args={"samples": 1},
ready=True, avoid_changing_ready=True)
behav.add_transit("message_sent", "ready", action="nop")
# ... a couple of "disconnected" transitions back to init ...
behav.save(os.path.join(self.world_folder, 'user.json'), only_if_changed=dummy_agent)
# ROLE 2/2: broadcaster
from .broadcaster import WAgent as BroadcasterAgent
dummy_agent = BroadcasterAgent(proc=None)
behav = HybridStateMachine(dummy_agent)
behav.set_role("broadcaster")
behav.add_state("ready", blocking=True)
behav.add_transit("ready", "ready", action="broadcast_message", args={}, ready=False)
behav.save(os.path.join(self.world_folder, 'broadcaster.json'), only_if_changed=dummy_agent)
Walking the user block in order:
from .user import WAgent as UserAgentthenUserAgent(proc=None). The behavior is built against the agent class that will eventually play this role. Thedummy_agentis a throwaway instance (proc=None, no model loaded) used only so the state machine can introspect the class, it never runs or joins anything. This is the key idea of the whole file: because the machine holds a realUserAgent, it can verify at build time that every action name the user behavior references (check_messages,connect_to_broadcaster,generate_and_send) actually exists as an@actionon that class. A typo fails here, while you build, not later on a remote joiner's node.set_welcome_message/set_role. The welcome string is shown to anyone who takes this role;set_role("user")stamps the machine with the role name thatassign_rolereturns, so the world knows this JSON is theuserbehavior.add_state(...)declares the nodes of the graph.initisblocking=True(the agent pauses there until a transition fires), and thereadystate carriesaction="check_messages", a state action that runs every tick while the agent sits inready, with its tuning passed asargs. The transitions (add_transit) are the edges: each names a source state, a target state, theactionthat must succeed to fire, and theargshanded to that action.connect_to_broadcasteris custom (it lives onuser.py);connected,nopanddisconnectedare built-in. The full grammar of states, transitions,blocking, andreadyis Chapter 4.behav.save(..., only_if_changed=dummy_agent)serialises the machine touser.json.only_if_changedmeans the file is rewritten only if the behavior actually differs from what is already on disk, so re-running the host does not needlessly churn the file (and thedummy_agentis whatsaveintrospects to compute that comparison).
The broadcaster block is the same pattern, far smaller: a single ready state and
one self-loop transition that runs the broadcaster's custom broadcast_message
action and returns to ready. Two roles, two classes, two JSON files, and each
behavior was validated against its own class.
The output, user.json and broadcaster.json, is what the world ships to a
joiner the moment assign_role hands them that role name.
The per-role agent classes¶
Built-in actions like connected, process, and send come for free. The
custom ones your behaviors reference (connect_to_broadcaster,
broadcast_message, check_messages) live on a small Agent subclass, each
decorated with @action:
New to proc_output_0 / self.stdin?
These are an agent's fixed input/output slots. The everyday way to touch them is
just self.stdin.get() to read and self.stdout.set(value) to write, with no
slot name at all. The uuid and requested_by you see below are refinements
this relay needs to scope the read to one specific request, not something every
action requires. If the names are unfamiliar, read
An agent's own streams first, it explains
the whole convention simplest form first.
from unaiverse.agent import Agent, action
from unaiverse.interaction import Interaction
class WAgent(Agent):
@action
async def broadcast_message(self, interaction: Interaction | None = None) -> bool:
"""Relay the sender's message to every other user. proc stays None."""
requester = interaction.requester
sender = self.all_agents[requester].get_static_profile()['node_name']
# Read the text the requesting user forwarded under this interaction's uuid
input_data = self.stdin.get(uuid=interaction.uuid, requested_by="broadcast_message")
if input_data is None:
return False
msg = input_data[0] if isinstance(input_data, (tuple, list)) else input_data
prefixed_msg = f"**{sender}:** {msg}"
# Fan out to every other user (excluding the original requester and self)
other_users = list(set(self.world_agents.keys()) - {requester, self.get_peer_id()})
return await self.send(target=other_users,
data_samples={"proc_output_0": prefixed_msg},
num_steps=1)
This single action is the broadcaster. It carries no model, the class is launched
with proc=None, so the relay is fully manual. Step by step:
- It opts into the request that triggered it by declaring an
interactionparameter. The framework injects the liveInteraction(you never pass it fromargs).interaction.requesteris the peer that asked the broadcaster to relay, andinteraction.uuidis the unique id of this exchange. - It looks up a friendly name for the requester via
get_static_profile(), so the relayed message can be prefixed with who said it. - It reads the payload with
self.stdin.get(uuid=interaction.uuid, ...). Passing the interaction'suuidscopes the read to this request's data, the text the user forwarded, rather than some unrelated stream. If nothing has arrived yet it returnsFalse, which simply asks the framework to retry next tick (the success/retry contract from Chapter 5). - It fans the message out.
self.world_agents.keys()is every agent in the world; subtracting the requester and the broadcaster's own peer id leaves "everyone else". Theself.send(...)call pushes the prefixed text into each recipient's copy of the broadcaster's output slot (proc_output_0). Because noaction_nameis given, this is a pure data push, recipients just store the new value, no behavior step fires on their side.send()is the whole of Chapter 6.
Each role is built against its own class (UserAgent, BroadcasterAgent), which
is exactly why the user behavior can safely reference check_messages and the
broadcaster behavior broadcast_message: each name was checked against the class
that owns it back in create_behav_files. We dig into @action in
Chapter 5 and into send() in
Chapter 6.
The run scripts¶
Every launcher is the same two lines of plumbing: build the thing you want to host
(a World or an Agent), wrap it in a Node, and call node.run(...). A Node is
the network presence, it owns the identity, the clock, and the P2P connections.
What differs between scripts is what you host and how you start running. One
script hosts the world; the rest join it.
from src.world import WWorld
from unaiverse.networking.node.node import Node
world = WWorld()
node = Node(world, node_name="ChatRoom", hidden=True, clock_delta=1. / 20.,
world_masters_node_names=["Broadcaster"])
node.run(show_senders=False)
world = WWorld()instantiates the subclass fromsrc/world.py. Building it runs__init__(wiring it to its folder and stats) and triggers the generation ofuser.json/broadcaster.jsonfromcreate_behav_files.Node(world, ...)hosts the world like any other participant, a world is hosted in aNodeexactly as an agent is.node_name="ChatRoom"is the name joiners look up;hidden=Truekeeps it visible only to your account;clock_delta=1/20sets the tick rate (20 Hz).world_masters_node_names=["Broadcaster"]is the load-bearing argument: it declares which joining node names are allowed to lead the session. When a node namedBroadcasterjoins,assign_roleseesis_world_master=Trueand gives it thebroadcasterrole; everyone else getsuser. More in Chapter 3.node.run(...)starts the host loop and blocks, ticking the clock and accepting joiners until you stop it.
from unaiverse.agent import Agent
from unaiverse.networking.node.node import Node
from unaiverse.streams.dataprops import StreamType
agent = Agent(proc=None,
proc_inputs=[StreamType(data_type="text", private_only=True)],
proc_outputs=[StreamType(data_type="text", private_only=True)])
node = Node(agent, node_name="Broadcaster", hidden=True, clock_delta=1. / 20.)
node.run(join_world="ChatRoom")
This is the node that becomes the broadcaster, because its node_name matches
the name the host listed in world_masters_node_names. It hosts a plain Agent
with proc=None (no model, the relay is manual) and declared text in/out slots
so its streams type-match the others. The only difference from any other joiner is
its name: node.run(join_world="ChatRoom") asks to join the same world, and the
world's assign_role does the rest. Note it carries no broadcaster.py code
locally, it receives broadcaster.json from the world on assignment, and the
custom actions that JSON names are resolved on the host side.
from unaiverse.agent import Agent
from unaiverse.modules.networks import Phi
from unaiverse.networking.node.node import Node
agent = Agent(proc=Phi(), proc_inputs=["text"], proc_outputs=["text"])
node = Node(agent, node_name="ChatAI", hidden=True, clock_delta=1. / 25.)
node.run(join_world="ChatRoom")
A normal agent, here an LLM, supplied as proc=Phi() with text in and out,
joins by name. Its name is not in the masters list, so assign_role gives it
the user role and the world ships it user.json. The joiner wrote no
world-specific code: the behavior it will run arrives entirely from the world.
agent = Agent(proc=None,
proc_inputs=[StreamType(data_type="text", private_only=True)],
proc_outputs=[StreamType(data_type="text", private_only=True)])
node = Node(agent, node_name="Test0", hidden=True, clock_delta=1. / 20.)
node.run(join_world="ChatRoom", interact_mode=True)
A human joins exactly like the AI, same Agent, same join_world, same user
role, with two differences: proc=None (the "processor" is your keyboard) and
interact_mode=True on run, which lets you type into the session. To the world,
a human and an AI are the same kind of participant.
Run the whole thing on one machine¶
You don't need several terminals or a public network to try a world. Every
example ships a run_synch.py that stitches the world runner and all the agent
runners into a single synchronized process:
This runs the world (run_w.py) plus the non-interactive run_*.py joiners
together locally in one synchronized process, ideal while you build and debug. The
interactive human runners (run_demo_*.py) are skipped, since they expect a keyboard
of their own, launch those in their own terminals when you want to type. Going
online later is just running the same scripts on real nodes. (More in
Chapter 10.)
What just happened¶
graph TD
RW["run_w.py hosts WWorld in Node 'ChatRoom'"] -->|on join| AR[assign_role]
AR -->|first master| BC["role: broadcaster (broadcaster.py + .json)"]
AR -->|everyone else| US["role: user (user.py + .json)"]
R1["run_1.py: Broadcaster (named in masters list)"] -->|join_world| BC
R2["run_2.py: ChatAI"] -->|join_world| US
RD["run_demo_a.py: a human"] -->|join_world| US
You met every moving part of a world: a World subclass that assigns roles and
builds behaviors, an agent class per role carrying its custom actions, and tiny
run scripts to host and join. The next chapters open each part fully, starting
with the one that runs first when anyone joins: roles.
Where next¶
-
assign_role, masters, custom roles, and changing roles at runtime. -
How
create_behav_filesbuilds each role's state machine. -
The short reference overview of the same anatomy.