World Server

View Source

Build large-session multiplayer games with spatial partitioning. The world server handles 1--500+ players in a shared continuous space, automatically splitting the world into zone processes for parallelized tick simulation and interest-based state broadcasting.

Use the world server when your game has players moving through a shared space (co-op dungeons, open worlds, large-scale survival). For arena-style games with smaller player counts, use the standard match server.

For massive tile-based worlds (10K+ zones), see Large Worlds for lazy zone loading, terrain data, and scaling configuration.

How It Works

A world is divided into a grid of zones -- each zone is a separate Erlang process that owns the entities in its region. Players only receive updates from zones they can see (interest management), and each zone runs its tick in parallel across CPU cores.

World (2000x2000 units, 10x10 grid)
 ...
 z0,0 z1,0 z2,0 z3,0
       P1           
 ...
 z0,1 z1,1 z2,1 z3,1
           P2       
 ...
 z0,2 z1,2 z2,2 z3,2
                    

P1 subscribes to the 9 zones around z1,0. P2 subscribes to the 9 zones around z2,1. They only overlap on 2 zones, so most of their traffic is independent.

Supervision Tree

Each world instance is its own supervisor:

asobi_world_sup (one_for_one)
 asobi_world_registry          tracks active worlds
 asobi_world_instance_sup      dynamic, one per world
     asobi_world_instance      one_for_all per world
         asobi_zone_sup        dynamic, one per zone cell
            asobi_zone        gen_server per grid cell
         asobi_world_ticker    coordinates ticks across zones
         asobi_world_server    gen_statem: world lifecycle

Tick Cycle

Every tick (default 20 Hz / 50ms):

  1. Ticker sends tick(N) to all zones in parallel
  2. Each zone: applies queued player inputs, runs zone_tick/2, computes deltas from previous state, broadcasts deltas to subscribers
  3. Each zone acks back to the ticker
  4. When all zones ack, ticker calls post_tick/2 on the world server for global game events (boss phases, quest triggers, vote requests)

Delta Compression

Zones only send what changed since the last tick:

{
  "type": "world.tick",
  "payload": {
    "tick": 1042,
    "updates": [
      {"op": "u", "id": "p_abc", "x": 451, "y": 312, "hp": 80},
      {"op": "a", "id": "npc_7", "x": 400, "y": 300, "type": "goblin"},
      {"op": "r", "id": "item_3"}
    ]
  }
}
  • u -- updated (only changed fields)
  • a -- added (full entity state)
  • r -- removed

Erlang Implementation

Implement the asobi_world behaviour:

-module(my_dungeon).
-behaviour(asobi_world).

-export([init/1, join/2, leave/2, spawn_position/2]).
-export([zone_tick/2, handle_input/3, post_tick/2]).

init(_Config) ->
    {ok, #{dungeon_level => 1, boss_hp => 10000}}.

join(PlayerId, State) ->
    {ok, State}.

leave(PlayerId, State) ->
    {ok, State}.

spawn_position(_PlayerId, _State) ->
    %% Random position in the first zone
    {ok, {50.0 + rand:uniform(100), 50.0 + rand:uniform(100)}}.

zone_tick(Entities, ZoneState) ->
    %% Run NPC AI, move projectiles, apply effects
    Entities1 = maps:map(fun(Id, E) ->
        case maps:get(type, E, ~"player") of
            ~"goblin" -> ai_wander(E);
            _ -> E
        end
    end, Entities),
    {Entities1, ZoneState}.

handle_input(PlayerId, #{~"action" := ~"move", ~"x" := X, ~"y" := Y}, Entities) ->
    case Entities of
        #{PlayerId := Entity} ->
            {ok, Entities#{PlayerId => Entity#{x => X, y => Y}}};
        _ ->
            {error, not_found}
    end;
handle_input(_PlayerId, _Input, Entities) ->
    {ok, Entities}.

post_tick(TickN, #{boss_hp := HP} = State) when HP =< 0 ->
    %% Boss defeated -- trigger an upgrade vote
    {vote, #{
        template => ~"boon_pick",
        options => [
            #{id => ~"shield", label => ~"Shield Boost"},
            #{id => ~"speed", label => ~"Speed Boost"},
            #{id => ~"damage", label => ~"Damage Boost"}
        ],
        method => ~"plurality",
        window_ms => 15000
    }, State#{boss_hp => 10000, dungeon_level => maps:get(dungeon_level, State) + 1}};
post_tick(TickN, State) when TickN >= 36000 ->
    %% 30 minutes at 20 Hz
    {finished, #{reason => ~"time_up"}, State};
post_tick(_TickN, State) ->
    {ok, State}.

Callbacks

CallbackRequiredDescription
init/1yesInitialize global game state
join/2yesPlayer joined the world
leave/2yesPlayer left the world
spawn_position/2yesReturn {ok, {X, Y}} for new player placement
zone_tick/2yesPer-zone simulation: (Entities, ZoneState) -> {Entities, ZoneState}
handle_input/3yesProcess player input within a zone's entities
post_tick/2yesGlobal post-tick: return {ok, State}, {vote, Config, State}, or {finished, Result, State}
generate_world/2noProcedural generation: (Seed, Config) -> {ok, #{Coords => ZoneState}}
get_state/2noPer-player state view
vote_resolved/3noHandle vote result (inherited from match voting)

Configuration

Register your world mode in sys.config:

{asobi, [
    {game_modes, #{
        ~"dungeon" => #{
            type => world,
            module => my_dungeon,
            match_size => 10,
            max_players => 500,
            grid_size => 10,        %% 10x10 = 100 zones
            zone_size => 200,       %% each zone covers 200x200 units
            tick_rate => 50,        %% 50ms = 20 Hz
            view_radius => 1,       %% subscribe to 1 zone in each direction (3x3 = 9 zones)
            strategy => fill
        }
    }}
]}
OptionDefaultDescription
typematchMust be world for world server mode
grid_size10Number of zones per axis (total = grid_size^2)
zone_size200Units per zone side (world size = grid_size * zone_size)
tick_rate50Milliseconds between ticks (50 = 20 Hz)
view_radius1Zones visible in each direction from player's zone
max_players500Maximum concurrent players per world

Procedural Generation

Implement generate_world/2 to provide initial state for each zone:

generate_world(Seed, _Config) ->
    rand:seed(exsss, {Seed, Seed, Seed}),
    ZoneStates = maps:from_list([
        {{X, Y}, #{
            biome => pick_biome(X, Y),
            npcs => generate_npcs(X, Y),
            loot => generate_loot(X, Y)
        }}
     || X <- lists:seq(0, 9), Y <- lists:seq(0, 9)
    ]),
    {ok, ZoneStates}.

Each zone receives its state via the zone_state field in zone_tick/2.

Lua Implementation

World scripts follow the same pattern as match scripts but with zone-specific callbacks. Set game_type = "world" in your mode globals.

Gotcha: the global is game_type, not type. The Erlang sys.config form (above) uses the key type, but the Lua loader reads game_type. A Lua script that sets type = "world" is silently ignored — the script registers as a match mode and world.find_or_create returns mode_not_found.

-- lua/world.lua

-- World mode config
game_type   = "world"
match_size  = 10            -- required by the loader for every mode,
                            -- including worlds. Use 1 for worlds that
                            -- don't gate on a minimum player count.
max_players = 500
grid_size   = 5
zone_size   = 400
tick_rate   = 50
view_radius = 1
strategy    = "fill"

function init(config)
    return {
        dungeon_level = 1,
        boss_hp = 10000,
        tick_count = 0
    }
end

function join(player_id, state)
    return state
end

function leave(player_id, state)
    return state
end

function spawn_position(player_id, state)
    return {
        x = 100 + math.random(200),
        y = 100 + math.random(200)
    }
end

function post_tick(tick, state)
    state.tick_count = tick

    -- Boss defeated: trigger a vote
    if state.boss_hp <= 0 then
        state.boss_hp = 10000
        state.dungeon_level = state.dungeon_level + 1
        state._vote = {
            template = "boon_pick",
            options = {
                { id = "shield", label = "Shield Boost" },
                { id = "speed",  label = "Speed Boost" },
                { id = "damage", label = "Damage Boost" }
            },
            method = "plurality",
            window_ms = 15000
        }
    end

    -- Time limit: 30 minutes at 20 Hz
    if tick >= 36000 then
        state._finished = true
        state._result = { reason = "time_up" }
    end

    return state
end

-- Optional: procedural generation
function generate_world(seed, config)
    local zones = {}
    for x = 0, 4 do
        for y = 0, 4 do
            local key = x .. "," .. y
            zones[key] = {
                biome = pick_biome(x, y, seed),
                spawners = {}
            }
        end
    end
    return zones
end

function get_state(player_id, state)
    return {
        dungeon_level = state.dungeon_level,
        boss_hp = state.boss_hp
    }
end

Lua Callbacks

FunctionRequiredDescription
init(config)yesReturn initial global game state
join(player_id, state)yesHandle player join, return state
leave(player_id, state)yesHandle player leave, return state
spawn_position(player_id, state)yesReturn {x=N, y=N} table
post_tick(tick, state)yesGlobal tick logic. Set _finished/_result or _vote on state
generate_world(seed, config)noReturn table keyed by "x,y" strings
get_state(player_id, state)noPlayer-visible state
vote_resolved(template, result, state)noHandle vote result

Finishing a World

Set _finished and _result on your state in post_tick():

function post_tick(tick, state)
    if all_quests_complete(state) then
        state._finished = true
        state._result = {
            status = "completed",
            dungeon_level = state.dungeon_level,
            survivors = count_alive(state)
        }
    end
    return state
end

Triggering Votes

Set _vote on your state in post_tick():

function post_tick(tick, state)
    if state.boss_hp <= 0 then
        state._vote = {
            template = "choose_path",
            options = {
                { id = "cave", label = "Dark Cave" },
                { id = "forest", label = "Enchanted Forest" }
            },
            method = "plurality",
            window_ms = 20000
        }
        state.boss_hp = nil  -- clear so vote doesn't re-trigger
    end
    return state
end

WebSocket Protocol

World messages use the world.* namespace. See the full WebSocket Protocol for envelope format.

Client to Server

TypePayloadDescription
world.join{"world_id": "..."}Join a specific world
world.leave{}Leave current world
world.input{"action": "move", "x": 100, "y": 200}Send input to your zone

Server to Client

TypePayloadDescription
world.joined{world_id, status, player_count, grid_size}Join confirmed
world.left{success: true}Leave confirmed
world.tick{tick, updates: [{op, id, ...}]}Zone delta broadcast
world.finished{world_id, result}World ended

Input Routing

When you send world.input, the message is routed to the zone process that currently owns your player entity. You don't need to specify which zone -- the server tracks your position and routes automatically.

Chat Channels

World chat is configuration-driven. Enable the channel types you need per game mode:

{asobi, [
    {game_modes, #{
        ~"galaxy" => #{
            type => world,
            module => my_game,
            chat => #{
                world => true,       %% global channel for everyone in the world
                zone => true,        %% auto-join/leave as players move between zones
                proximity => 2       %% chat with players within N zones of you
            }
        }
    }}
]}

Lua equivalent:

-- In your world script globals
chat_world     = true
chat_zone      = true
chat_proximity = 2

Channel Types

TypeScopeLifecycle
WorldAll players in the world instanceJoin on world join, leave on world leave
ZonePlayers in the same zone cellAuto-swap when crossing zone boundaries
ProximityPlayers within N zonesFollows your interest radius, updates on zone change
FederationFederation members onlyManaged by the social system (works automatically)

How It Works

Chat channels use the existing asobi_chat_channel system. The world server automatically manages subscriptions:

  • On join: player is added to world chat and their spawn zone's chat
  • On zone change: old zone chat is left, new zone chat is joined. Proximity channels diff the old and new interest areas so only the delta is updated
  • On leave: all world/zone/proximity channels are cleaned up

No extra client code needed. Chat messages arrive via the same WebSocket as chat.message events. Clients just need to know the channel IDs, which follow a predictable format:

  • World: world:{world_id}
  • Zone: zone:{world_id}:{x},{y}
  • Proximity: prox:{world_id}:{x},{y}

No Chat Config

If you omit the chat key entirely, no chat channels are created. The world server runs without any chat overhead. Add channels later by updating your mode config.

Clustering

Zones are regular Erlang processes. In a multi-node cluster, they distribute across nodes automatically via pg. A player on Node A can be subscribed to a zone on Node B -- Erlang distribution handles the message routing transparently.

For large worlds, zones are distributed round-robin across cluster nodes:

Node A: zones {0,0}..{4,4}  (25 zones)
Node B: zones {5,0}..{9,4}  (25 zones)
Node C: zones {0,5}..{4,9}  (25 zones)
Node D: zones {5,5}..{9,9}  (25 zones)

Next Steps