Skip to content

3 · Roles & the world master

Build worlds · Chapter 3 of 12 · Path home

The very first thing that happens when an agent joins your world is that it gets a role. The role decides everything that follows: which behavior the agent runs, what it is allowed to do, and how it fits in. This chapter is about how you hand out roles, and about the one role that can lead the others: the master.

The one method that matters

A world decides roles in assign_role(profile, is_world_master). It runs once per joiner and returns a role name (a string). That string maps to a behavior the world ships back. Everything else is detail.

Assigning a role

assign_role receives the joiner's profile and a flag, is_world_master, telling you whether this node is allowed to be a master (more on that below). You return a role name. You can decide it however you like; here are the three patterns the examples use.

What a profile is

A profile is everything a node tells the world about itself, its business card. Every node has one, and it comes in two halves:

  • Static (profile.get_static_profile()): identity that rarely changes, the node_id, node_name, node_type (is it an AI, a human, a sensor?), plus owner details like organization and location.
  • Dynamic (profile.get_dynamic_profile()): live state, what the node offers and where it sits. The keys you will actually read are proc_inputs / proc_outputs / streams (what it consumes, produces, and publishes) and connections (its current role and who it is connected to), alongside machine stats like CPU and memory.

New to proc_inputs / proc_outputs? They are an agent's fixed input/output slots, An agent's own streams explains the whole convention in one place.

assign_role inspects this profile to decide a role, the capability pattern below reads the dynamic streams. And it is not only for joining: you can read any connected peer's profile at runtime through self.all_agents[peer_id], which is how the chat broadcaster looks up a sender's node_name (get_static_profile()["node_name"]) to prefix the message. The full reference, every field in both halves, is in The node profile.

By master status (the chat world)

The simplest rule: the first master becomes one special role, everyone else becomes the ordinary role.

chat/src/world.py
def assign_role(self, profile: NodeProfile, is_world_master: bool):
    if is_world_master:
        if len(self.world_masters) <= 1:
            return "broadcaster"
        else:
            return "user"
    else:
        return "user"

Walk it through as the world sees it. When a node finishes its handshake, the world calls assign_role once for that node, passing two things:

  1. profile, the joiner's NodeProfile, its business card. This rule does not even look at it: in a chatroom, who you are does not change what role you get.
  2. is_world_master, a boolean the framework computed before calling you, by checking the joiner's node name against the world_masters_node_names list you set when hosting the world (see The world master below). True means "this node is allowed to lead"; it does not mean it already is one.

The body then reads self.world_masters, the world's own live dictionary of the master peers it has already admitted. The check len(self.world_masters) <= 1 asks "is this the first master in?", at the moment assign_role runs for the very first eligible node, that node is already counted, so the count is 1. The first eligible node therefore returns "broadcaster"; a second node that is also on the master list gets demoted to "user" (a chatroom needs exactly one broadcaster, not two). Every non-master node returns "user" outright.

The return value is a role name string, and that string is the whole output: the world looks up the matching behavior file (broadcaster.json or user.json, built in create_behav_files) and ships it back to the joiner, which starts running it immediately. That is why this world chose the rule it did: the broadcaster is the single agent that relays every message to everyone, so it must be the one trusted, named node; all the talkers, humans and AIs alike, are interchangeable users.

By capability (the info_extraction world)

A more powerful idea: decide the role from what the joiner can do, read from its declared input/output streams. The info_extraction world inspects each joiner and assigns:

  • user if it offers a private image stream to be processed;
  • extractor if its processor takes images in and produces text or labels out;
  • no role (it returns None, the joiner is turned away) otherwise.
info_extraction/src/world.py
def assign_role(self, profile: NodeProfile, is_world_master: bool):
    dynamic_profile = profile.get_dynamic_profile()
    offers_img_stream = False

    # If it offers an environmental (image) stream, then it is a "user"
    if 'streams' in dynamic_profile and dynamic_profile['streams'] is not None:
        for environmental_stream in dynamic_profile['streams']:
            props = DataProps.from_dict(environmental_stream)
            if (props.get_group() != "processor_in" and
                    props.is_img() and not props.is_public()):
                offers_img_stream = True
                break

    if offers_img_stream:
        return "user"
    else:
        out_text = False
        in_img = False

        for proc_input in (dynamic_profile['proc_inputs'] or []):
            props = DataProps.from_dict(proc_input)
            if (props.is_img() or props.is_all()) and not props.is_public():
                in_img = True
                break

        for proc_output in (dynamic_profile['proc_outputs'] or []):
            props = DataProps.from_dict(proc_output)
            if not props.is_public() and (props.is_text() or props.is_all() or
                    (props.is_tensor() and props.has_tensor_labels())):
                out_text = True
                break

        if in_img and out_text:
            return "extractor"
    return None  # No role: not a fit for this world

Now the rule earns its profile argument. Read it as three questions asked in order:

  1. Does it offer images? The first block reads profile.get_dynamic_profile()['streams'], the live list of streams this node publishes, and wraps each one in DataProps to ask three things at once: it is an image (is_img()), it is not a processor input (get_group() != "processor_in", i.e. a real environmental feed, not the node's own model inlet), and it is private to the world (not is_public()). A node that publishes such a stream is a user, it brings the images the world exists to process.
  2. Does its processor consume images and produce text? If it offers no image stream, the world looks instead at the joiner's processor: its declared proc_inputs (what the model takes in) and proc_outputs (what it returns). in_img is true when an input accepts images (or anything, is_all()); out_text is true when an output is text, anything, or a labelled tensor. A node that does both is an extractor, it can turn a user's images into the textual descriptions this world wants.
  3. Neither? The method falls through and returns None. A None return is the world's way of saying "you don't fit here", the joiner is turned away rather than given a role.

This is the deeper lesson: roles are not hard-coded to agent names. The world never checks node_name; it checks shape. Any agent whose processor fits, a classifier, a captioner, a detector, becomes an extractor without the world knowing its name in advance, and is_world_master is irrelevant here because this society is organised by capability, not by who leads.

By the joiner's own preference (the social_learning world)

Sometimes the joiner simply says what it wants to be, and the world combines that with master status. social_learning does both at once:

social_learning/src/world.py
def assign_role(self, profile: NodeProfile, is_world_master: bool):
    if is_world_master:
        if len(self.world_masters) <= 1:
            return "teacher"
        else:
            return "student"
    else:
        if 'tmp_role_preference' in profile.get_dynamic_profile():
            role_preference = profile.get_dynamic_profile()['tmp_role_preference']
            if role_preference == "student":
                return "student"
            elif role_preference == "student_isolated":
                return "student_isolated"
            else:
                return "student"
        else:
            return "student"

This rule has two halves, chosen by is_world_master:

  1. The master half is the same idea as the chat world: the first eligible node (len(self.world_masters) <= 1) becomes the teacher that leads the lessons; any extra eligible node falls back to student. There is only one teacher.
  2. The agent half reads the joiner's stated preference. A node declares it when it joins, node.run(join_world="...", role_preference="student_isolated"), and the framework parks that string in the dynamic profile under tmp_role_preference. assign_role reads it back and honours it only for the values it recognises: "student" and "student_isolated". Anything else, or no preference at all, defaults to plain student.

So the difference between a regular student and a student_isolated is the joiner's own choice, surfaced through tmp_role_preference. The world stays in charge, it whitelists which preferences it will grant and can ignore the rest, but this is the simplest way to let two otherwise identical nodes pick a lane. Note how each of the three branches returns a different role string, and each string has its own behavior file (teacher.json, student.json, student_isolated.json) built in create_behav_files.

The built-in roles

Every world understands three roles out of the box. Internally they are small integer bitmasks, but you work with their names.

Name Bits Meaning
public_agent 0 On the public network, not in a world
world_agent 1 An ordinary member of a world
world_master 3 A member that may lead the world

The bits are simple: bit 0 means "in a world", bit 1 means "is a master", and bits 2 and up are reserved for your custom roles. The default assign_role, if you do not override it, makes the first master a world_master and everyone else a world_agent.

The world master

One member can act as the master: the agent that leads the session. Among everything that joins, who is allowed to be a master is decided when you host the world, with world_masters_node_names on the Node:

run_w.py
node = Node(world, node_name="ChatRoom", hidden=True, clock_delta=1. / 20.,
            world_masters_node_names=["Broadcaster"])

Here, only the node named Broadcaster may become a master. When it joins, your assign_role is called with is_world_master=True; for everyone else it is False. That is the whole mechanism.

What a master can do

A master is an ordinary agent with extra authority by convention, not by special enforcement. In practice a master is the one that:

  • changes other agents' roles at runtime (set_role, below);
  • awards badges (add_badge, Chapter 10);
  • leads multi-step activities, teaching rounds, exams, scoring, which is the whole of Chapter 9.

A world with no master is just a shared space. A world with a master becomes a running activity.

Who may join, and who may lead

assign_role decides what a joiner becomes, but the host can also decide who is allowed to connect at all, and who is eligible to lead. Unlike the master's authority (which is by convention), these are enforced controls, set on the host Node when you start the world:

world_masters_node_names / world_masters_node_ids
Who may become a master. Names are resolved to node IDs at startup. Only these nodes ever reach assign_role with is_world_master=True.
allowed_node_ids
An explicit allowlist of node IDs permitted to connect. When set, it takes precedence over the allowlist the world would otherwise load from its online profile: nobody outside the list gets in.
only_certified_agents
When True, accept only agents whose profile carries the certified flag.
run_w.py
node = Node(world, node_name="ChatRoom",
            world_masters_node_names=["Broadcaster"],   # who may lead
            allowed_node_ids=[...],                      # who may connect at all
            only_certified_agents=True)                 # accept only certified agents

Keep the two layers straight: the Node controls decide who reaches assign_role in the first place, and assign_role then decides what role they get (returning None refuses one to a joiner that did get through the gate).

Custom roles

You are not limited to the built-ins. Any string you return from assign_role is a role. broadcaster, user, teacher, student, extractor: these are all custom roles. Each one needs two things, which you saw in Chapter 2:

  1. A behavior built for it in create_behav_files and saved as <role>.json (so teacher to teacher.json).
  2. An agent class it is built against, carrying that role's custom actions.

When the world assigns a custom role, it ships the matching <role>.json to the joiner, exactly as it does for the built-ins.

Changing a role at runtime

Roles are not fixed for life. A master can promote or reassign an agent while the world runs, with World.set_role:

# role is an integer bitmask, e.g. self.ROLE_STR_TO_BITS["teacher"]
await self.set_role(peer_id, role)   # ship the agent a new role + its behavior

Read it as the counterpart of assign_role: assign_role picks the role at join time, set_role changes it afterwards. A few details matter:

  1. It takes a peer ID and an integer role, not a name. Custom roles are stored as bitmasks, so you convert the name first with self.ROLE_STR_TO_BITS["teacher"].
  2. It keeps the agent's base bits. Internally it does new_role = (cur_role & 3) | new_role_without_base_int: the bottom two bits (the "in a world" / "is a master" bits from the table above) are preserved, and only the custom part is swapped. A promoted student stays a member of the world; only its job changes.
  3. It pushes the new behavior. On success it sends the agent a ROLE_SUGGESTION message carrying both the new role and the behavior file registered for it, which the agent starts running immediately. If that message can't be delivered, the world purges (disconnects) the agent rather than leaving it in a half-changed state.

This is how the teaching worlds promote a successful student to teacher (you will see it in Chapter 8).

From the other side, an ordinary agent can suggest a role change with the built-in suggest_role_to_world(agent, role), here role is the role name string. The suggestion is sent to the world master, which decides whether to apply it (typically by calling set_role). The world is always the final authority. (Both actions are in the built-in actions reference.)

What just happened

You can now answer, for any joiner, "what role does it get, and why?", whether by master status, by capability, or by your own logic. You met the master as the member that may lead, and saw that roles can change while the world runs.

Next: a role is only as good as its behavior. Let's build those.

Where next