Performance Tuning

View Source

Optimisation features for high-throughput world servers. These are all opt-in and backward compatible -- existing configurations work unchanged.

Spatial Grid Index

By default, spatial queries (query_radius, query_rect) scan all entities in a zone via maps:fold -- O(n). For zones with many entities, enable the cell-based spatial grid for O(1) cell lookup + local scan.

Config = #{
    game_module => my_world,
    spatial_grid_cell_size => 16  %% units per cell
}.

The grid is maintained automatically as entities are added, removed, or moved during ticks. Query through the zone API:

Results = asobi_zone:query_radius(ZonePid, {100.0, 200.0}, 50.0).
%% Returns [{EntityId, {X, Y}}, ...]

When no spatial_grid_cell_size is configured, the zone falls back to the brute-force scan (existing behaviour).

Cell Size Guidelines

Entity DensityRecommended Cell Size
Sparse (< 50/zone)Don't enable (overhead not worth it)
Medium (50-200/zone)32-64 units
Dense (200+/zone)8-16 units

Broadcast Batching

Zone deltas are JSON-encoded once and the pre-encoded binary is sent to all subscribers. This replaces N json:encode calls with exactly 1.

This is automatic -- no configuration needed. Subscribers receive zone_delta_raw messages containing pre-encoded JSON, which the WebSocket handler forwards directly without re-encoding.

Match State Broadcast (Shared vs. Per-Player)

By default the match server calls Mod:get_state(PlayerId, GameState) once per player per tick and JSON-encodes each result. For games where every player sees the same world (FFA shooters, racing, party games), implement the optional get_state/1 callback instead:

-callback get_state(GameState) -> SharedState.

When the match server detects get_state/1 is exported, it calls it once per tick, JSON-encodes once, and broadcasts the same pre-encoded binary to every player. At 200 players / 10 ticks/sec this drops 2000 encodes/sec to 10. Games that need per-player filtering (fog of war, hidden hand) keep get_state/2 and pay the per-player cost.

Lua match scripts opt in by declaring state_strategy = "shared" and defining a one-arg get_state(state). The asobi_lua bridge then routes through asobi_lua_match_shared, which exports get_state/1.

Adaptive Tick Rates

Zones with no subscribers tick at a reduced rate to save CPU:

Config = #{
    cold_tick_divisor => 10  %% cold zones tick 10x less often (default)
}.
Zone StateTick RateWhen
HotFull (every tick)Has subscribers
Cold1/N (every Nth tick)Entities but no subscribers
EmptyNot tickedNo entities, no subscribers

The ticker provides manual controls:

asobi_world_ticker:promote_zone(TickerPid, ZonePid).  %% → hot
asobi_world_ticker:demote_zone(TickerPid, ZonePid).   %% → cold
asobi_world_ticker:remove_zone(TickerPid, ZonePid).   %% → stop ticking

Zone promotion/demotion is typically handled automatically by the zone manager based on subscriber presence.

Binary Protocol

For maximum throughput, clients can negotiate binary WebSocket frames instead of JSON. Set binary_protocol: true in the session.connect payload.

Binary frames use a Tag-Length-Value format:

[type:u8][length:u32be][payload:bytes]

Message Types

TagTypePayload
0x01Terrain chunkcoords (2x i32) + compressed data
0x02Entity deltastick (u64) + counted delta list
0x03Match stateReserved

Size Savings

Terrain chunks save ~25% vs JSON+base64 encoding. Entity deltas save varies based on field count but typically 15-30%.

Use asobi_ws_binary for encoding/decoding:

Bin = asobi_ws_binary:encode_terrain_chunk({5, 10}, CompressedData).
{{5, 10}, Data} = asobi_ws_binary:decode_terrain_chunk(Bin).

JSON remains the default protocol. Binary is opt-in per connection.