Skip to content

Commit 16b83c1

Browse files
authored
feat: zone snapshots, broadcast throttling, persistent worlds (#56)
* feat: zone snapshots, broadcast throttling, persistent worlds - Zone sends immediate entity snapshot to new subscribers so late-joining clients see existing entities - Broadcast deltas every Nth tick (configurable broadcast_interval, default 3) to reduce WebSocket traffic - Add persistent flag to world config — prevents world from finishing when all players leave - Pass persistent through game_modes world_config - Convert phase start_condition tuples to maps for JSON safety - Ignore asobi_message in world_server running state (zone snapshots delivered to self in tests) * fix: suppress dialyzer warning for zone snapshot send * fix: format asobi_zone
1 parent 14d2095 commit 16b83c1

5 files changed

Lines changed: 63 additions & 20 deletions

File tree

src/asobi_game_modes.erl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ world_config(Mode) ->
3838
grid_size => maps:get(grid_size, ModeConfig, 10),
3939
zone_size => maps:get(zone_size, ModeConfig, 200),
4040
tick_rate => maps:get(tick_rate, ModeConfig, 50),
41-
view_radius => maps:get(view_radius, ModeConfig, 1)
41+
view_radius => maps:get(view_radius, ModeConfig, 1),
42+
persistent => maps:get(persistent, ModeConfig, false)
4243
}};
4344
{error, _} = Err ->
4445
Err

src/timers/asobi_phase.erl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ info(#{current_started := false} = PS) ->
191191
#{
192192
status => waiting,
193193
phase => maps:get(name, Phase),
194-
start_condition => maps:get(start, Phase, prev_ended)
194+
start_condition => format_condition(maps:get(start, Phase, prev_ended))
195195
};
196196
info(PS) ->
197197
Phase = current_phase_def(PS),
@@ -203,6 +203,13 @@ info(PS) ->
203203
timers => timers_info(PS)
204204
}.
205205

206+
format_condition({Type, Value}) ->
207+
#{type => Type, value => Value};
208+
format_condition(Atom) when is_atom(Atom) ->
209+
Atom;
210+
format_condition(Other) ->
211+
Other.
212+
206213
%% -------------------------------------------------------------------
207214
%% Internal — waiting for phase start condition
208215
%% -------------------------------------------------------------------

src/world/asobi_world_server.erl

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ init(Config) ->
8282
ViewRadius = maps:get(view_radius, Config, ?DEFAULT_VIEW_RADIUS),
8383
VetoTokensPerPlayer = maps:get(veto_tokens_per_player, Config, 0),
8484
FrustrationBonus = maps:get(frustration_bonus, Config, 0.5),
85+
Persistent = maps:get(persistent, Config, false),
8586
InstanceSup = maps:get(instance_sup, Config, undefined),
8687
PhaseState =
8788
case erlang:function_exported(GameMod, phases, 1) of
@@ -117,6 +118,7 @@ init(Config) ->
117118
veto_tokens_per_player => VetoTokensPerPlayer,
118119
frustration_bonus => FrustrationBonus,
119120
active_votes => #{},
121+
persistent => Persistent,
120122
chat_state => ChatState,
121123
phase_state => PhaseState
122124
},
@@ -210,7 +212,10 @@ running(info, {vote_resolved, VoteId, Template, Result}, State) ->
210212
handle_vote_resolved(VoteId, Template, Result, State);
211213
running(info, {vote_vetoed, VoteId, _Template}, State) ->
212214
Active = maps:remove(VoteId, maps:get(active_votes, State, #{})),
213-
{keep_state, State#{active_votes => Active}}.
215+
{keep_state, State#{active_votes => Active}};
216+
running(info, {asobi_message, _}, _State) ->
217+
%% Zone snapshots/deltas forwarded here in tests — ignore
218+
keep_state_and_data.
214219

215220
%% --- finished state ---
216221

@@ -343,8 +348,8 @@ handle_leave(
343348
leave_chat(PlayerId, State),
344349
State1 = remove_player_from_zones(PlayerId, State),
345350
Players1 = maps:remove(PlayerId, Players),
346-
case map_size(Players1) of
347-
0 ->
351+
case {map_size(Players1), maps:get(persistent, State, false)} of
352+
{0, false} ->
348353
{next_state, finished, State1#{
349354
players => Players1, game_state => GS1, result => #{status => ~"empty"}
350355
}};

src/world/asobi_zone.erl

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ init(Config) ->
7373
game_module => GameModule,
7474
entities => #{},
7575
prev_entities => #{},
76+
broadcast_entities => #{},
77+
broadcast_interval => maps:get(broadcast_interval, Config, 3),
7678
subscribers => #{},
7779
zone_state => ZoneState,
7880
input_queue => [],
@@ -98,8 +100,17 @@ handle_cast({add_entity, EntityId, EntityState}, #{entities := Entities} = State
98100
{noreply, State#{entities => Entities#{EntityId => EntityState}}};
99101
handle_cast({remove_entity, EntityId}, #{entities := Entities} = State) ->
100102
{noreply, State#{entities => maps:remove(EntityId, Entities)}};
101-
handle_cast({subscribe, PlayerId, PlayerPid}, #{subscribers := Subs} = State) ->
103+
handle_cast({subscribe, PlayerId, PlayerPid}, #{subscribers := Subs, entities := Entities} = State) ->
102104
MonRef = monitor(process, PlayerPid),
105+
%% Send immediate snapshot so new subscribers see all current entities
106+
_ =
107+
case map_size(Entities) of
108+
0 ->
109+
ok;
110+
_ ->
111+
Snapshot = [E#{~"op" => ~"a", ~"id" => Id} || {Id, E} <- maps:to_list(Entities)],
112+
PlayerPid ! {asobi_message, {zone_delta, 0, Snapshot}}
113+
end,
103114
{noreply, State#{subscribers => Subs#{PlayerId => {PlayerPid, MonRef}}}};
104115
handle_cast({unsubscribe, PlayerId}, #{subscribers := Subs} = State) ->
105116
case maps:get(PlayerId, Subs, undefined) of
@@ -138,7 +149,9 @@ do_tick(
138149
#{
139150
game_module := GameMod,
140151
entities := Entities,
141-
prev_entities := PrevEntities,
152+
prev_entities := _PrevEntities,
153+
broadcast_entities := BroadcastEntities,
154+
broadcast_interval := BroadcastInterval,
142155
zone_state := ZoneState,
143156
input_queue := Queue,
144157
subscribers := Subs,
@@ -151,10 +164,18 @@ do_tick(
151164
Now = erlang:system_time(millisecond),
152165
{TimerEvents, ET1} = asobi_entity_timer:tick(Now, ET),
153166
Entities3 = apply_timer_events(TimerEvents, Entities2),
154-
Deltas = compute_deltas(PrevEntities, Entities3),
155-
broadcast_deltas(TickN, Deltas, Subs),
167+
%% Only broadcast every Nth tick to reduce network traffic
168+
State1 =
169+
case TickN rem BroadcastInterval of
170+
0 ->
171+
Deltas = compute_deltas(BroadcastEntities, Entities3),
172+
broadcast_deltas(TickN, Deltas, Subs),
173+
State#{broadcast_entities => Entities3};
174+
_ ->
175+
State
176+
end,
156177
asobi_world_ticker:tick_done(TickerPid, self(), TickN),
157-
State#{
178+
State1#{
158179
entities => Entities3,
159180
prev_entities => Entities3,
160181
zone_state => ZoneState1,

test/asobi_zone_tests.erl

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,24 +69,33 @@ subscribe_unsubscribe() ->
6969

7070
tick_broadcasts() ->
7171
Pid = start_zone(),
72-
asobi_zone:subscribe(Pid, {<<"p1">>, self()}),
7372
asobi_zone:add_entity(Pid, <<"e1">>, #{x => 0, y => 0, type => ~"player"}),
7473
timer:sleep(10),
75-
%% First tick — entity appears as added
76-
asobi_zone:tick(Pid, 1),
74+
asobi_zone:subscribe(Pid, {<<"p1">>, self()}),
75+
timer:sleep(10),
76+
%% Subscribe sends immediate snapshot
7777
receive
78-
{asobi_message, {zone_delta, 1, Deltas}} ->
79-
?assertEqual(1, length(Deltas)),
80-
[Delta] = Deltas,
81-
?assertEqual(~"a", maps:get(~"op", Delta)),
82-
?assertEqual(<<"e1">>, maps:get(~"id", Delta))
78+
{asobi_message, {zone_delta, 0, Snapshot}} ->
79+
?assertEqual(1, length(Snapshot)),
80+
[S] = Snapshot,
81+
?assertEqual(~"a", maps:get(~"op", S)),
82+
?assertEqual(<<"e1">>, maps:get(~"id", S))
8383
after 1000 ->
8484
?assert(false)
8585
end,
86-
%% Second tick — no changes, no delta message
86+
%% Broadcast interval is 3, so tick 3 broadcasts
87+
asobi_zone:tick(Pid, 1),
8788
asobi_zone:tick(Pid, 2),
89+
asobi_zone:tick(Pid, 3),
90+
receive
91+
{asobi_message, {zone_delta, 3, _Deltas}} -> ok
92+
after 1000 ->
93+
?assert(false)
94+
end,
95+
%% Tick 4 does not broadcast (4 rem 3 = 1)
96+
asobi_zone:tick(Pid, 4),
8897
receive
89-
{asobi_message, {zone_delta, 2, _}} ->
98+
{asobi_message, {zone_delta, 4, _}} ->
9099
?assert(false)
91100
after 100 ->
92101
ok

0 commit comments

Comments
 (0)