Docs / World server

World server

Spatial partitioning for large-session multiplayer. 1–500+ players in a shared continuous space, split into zone processes for parallel tick simulation and interest-based broadcasting. Use this over the match server when players move through a shared space (co-op dungeons, open worlds, survival).

How it works

The world is a grid of zones. Each zone is a separate Erlang process owning entities in its region. Players subscribe to nearby zones (interest management) and receive updates only from those. Zones tick in parallel across CPU cores.

World (2000x2000 units, 10x10 grid)
┌─────┬─────┬─────┬─────┐
│ z0,0│ z1,0│ z2,0│ ... │   P1 subscribes to 9 zones around z1,0
│     │  P1 │     │     │   P2 subscribes to 9 zones around z2,1
├─────┼─────┼─────┼─────┤   Most traffic is independent.
│ z0,1│ z1,1│ z2,1│ ... │
│     │     │ P2  │     │
└─────┴─────┴─────┴─────┘

Tick cycle (default 20 Hz)

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

Delta compression

Zones broadcast only 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 (changed fields only)
  • a — added (full entity state)
  • r — removed

Implementing the behaviour

Implement asobi_world — six callbacks.

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

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

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

spawn_position(_PlayerId, _State) ->
    {ok, {50.0 + rand:uniform(100), 50.0 + rand:uniform(100)}}.

zone_tick(Entities, ZoneState) ->
    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 := E} -> {ok, Entities#{PlayerId => E#{x => X, y => Y}}};
        _                -> {error, not_found}
    end.

post_tick(_TickN, #{boss_hp := HP} = State) when HP =< 0 ->
    {vote, #{
        template  => <<"boon_pick">>,
        options   => [#{id => <<"shield">>}, #{id => <<"speed">>}, #{id => <<"damage">>}],
        window_ms => 15000
    }, State#{boss_hp => 10000, dungeon_level => maps:get(dungeon_level, State) + 1}};
post_tick(TickN, State) when TickN >= 36000 ->    %% 30 min @ 20 Hz
    {finished, #{reason => <<"time_up">>}, State};
post_tick(_TickN, State) ->
    {ok, State}.

Lua equivalent

local game = {}

function game.init(_cfg)
    return { dungeon_level = 1, boss_hp = 10000 }
end

function game.spawn_position(_player_id, _state)
    return { x = 50 + math.random() * 100, y = 50 + math.random() * 100 }
end

function game.zone_tick(entities, zone_state)
    for id, e in pairs(entities) do
        if e.type == "goblin" then ai_wander(e) end
    end
    return entities, zone_state
end

function game.handle_input(player_id, input, entities)
    if input.action == "move" then
        entities[player_id].x = input.x
        entities[player_id].y = input.y
    end
    return entities
end

function game.post_tick(tick_n, state)
    if state.boss_hp <= 0 then
        return { vote = { template = "boon_pick", window_ms = 15000,
                          options = { "shield", "speed", "damage" } },
                 state = { boss_hp = 10000, dungeon_level = state.dungeon_level + 1 } }
    end
    if tick_n >= 36000 then
        return { finished = true, result = { reason = "time_up" } }
    end
    return state
end

Large worlds

For 10K+ zones (128K×128K tile maps, persistent planets), zones lazy-spawn on first access and reap when empty. Terrain chunks are served on zone entry and cached. Benchmarked at 500 real WebSocket players on a 128K×128K tile map at 208MB RAM.

Lazy zones

asobi_zone_manager keeps an ETS table of active zones. When a player enters an unloaded zone, it spawns one via asobi_zone_sup:start_zone/2. When the last subscriber leaves, a release_zone/2 cast triggers reaping after an idle timeout.

Terrain

Terrain chunks are bytes (compressed tile arrays) served via asobi_terrain_store. Providers load from disk, procedural generation, or a tile DB. Clients receive chunk blobs on zone entry; servers can fetch via asobi_terrain_store:get_chunk/2 when they need to reason about terrain.

{asobi, [
    {world, #{
        zone_size       => 256,      %% units per side
        lazy_zones      => true,
        zone_idle_ms    => 60000,
        terrain_provider => my_terrain_module
    }}
]}

Subscriptions

By default a player subscribes to their 3×3 zone neighborhood. When they move, the world recomputes membership, sends enter/leave events to new/old zones, and streams snapshots for newly-visible entities.

Snapshots

asobi_zone_snapshotter periodically saves the state of each active zone (entities + zone state). On restart, zones restore from snapshot before accepting new subscribers. Tune via snapshot_interval_ms in world config.

Where next?