Skip to content

Commit d9ffb98

Browse files
committed
Fix view angle control and add frame synchronization
- Move state publishing to after_frame hook so published state reflects our view angles instead of bot AI's angles - Add state_frame_id for debugging sync issues - Add frame synchronization in client (wait for new frame_id) - Track decision tick timing in env (avg_step_dt_ms, decision_hz) - Tune settings for 40 Hz: VIEW_SENSITIVITY=3.0, max_engagement_reward=0.2 - Add set_view_angles command for direct testing
1 parent 3b61903 commit d9ffb98

File tree

4 files changed

+200
-76
lines changed

4 files changed

+200
-76
lines changed

QuakeLiveInterface/client.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,62 @@ def __init__(self, redis_host='localhost', redis_port=6379, redis_db=0):
1919
self.game_state_channel = 'ql:game:state'
2020
self.game_state_pubsub = self.connection.subscribe(self.game_state_channel)
2121

22-
def update_game_state(self):
22+
# Frame synchronization - ensure we only process each server frame once
23+
self._last_frame_id = -1
24+
self._last_state_time_ms = 0
25+
26+
def update_game_state(self, timeout_ms=250, require_new_frame=True):
2327
"""
2428
Gets the latest game state from Redis and updates the local game state.
2529
Uses GET on ql:agent:last_state for reliable polling instead of pubsub.
30+
31+
Args:
32+
timeout_ms: Maximum time to wait for a new frame (default 250ms)
33+
require_new_frame: If True, wait until state_frame_id changes
34+
35+
Returns:
36+
True if state was updated, False on timeout
2637
"""
27-
# Poll the stored state instead of using pubsub (more reliable)
28-
state_data = self.connection.get('ql:agent:last_state')
29-
if state_data:
30-
self.game_state.update_from_redis(state_data)
31-
return True
32-
return False
38+
import time
39+
start_time = time.time()
40+
timeout_sec = timeout_ms / 1000.0
41+
42+
while True:
43+
state_data = self.connection.get('ql:agent:last_state')
44+
if state_data:
45+
# Parse to check frame_id before full update
46+
import json
47+
try:
48+
raw_state = json.loads(state_data)
49+
frame_id = raw_state.get('state_frame_id', 0)
50+
51+
# If we require a new frame, check if this is different
52+
if require_new_frame and frame_id == self._last_frame_id:
53+
# Same frame, keep waiting (unless timeout)
54+
if time.time() - start_time > timeout_sec:
55+
logger.warning(f"Frame sync timeout: stuck on frame {frame_id}")
56+
return False
57+
time.sleep(0.005) # 5ms poll interval
58+
continue
59+
60+
# New frame (or we don't require new frame)
61+
self._last_frame_id = frame_id
62+
self._last_state_time_ms = raw_state.get('server_time_ms', 0)
63+
self.game_state.update_from_redis(state_data)
64+
return True
65+
66+
except json.JSONDecodeError:
67+
logger.error("Failed to parse game state JSON")
68+
return False
69+
70+
# No state data yet
71+
if time.time() - start_time > timeout_sec:
72+
return False
73+
time.sleep(0.005)
74+
75+
def get_frame_timing(self):
76+
"""Returns (last_frame_id, last_state_time_ms) for debugging."""
77+
return self._last_frame_id, self._last_state_time_ms
3378

3479
def send_command(self, channel, command, args=None):
3580
"""

QuakeLiveInterface/env.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
]
2121

2222
# View sensitivity: degrees per frame at max input
23-
VIEW_SENSITIVITY = 5.0 # Max degrees to turn per step
23+
# At 40 Hz: 3°/step = 120°/sec max turn rate (reasonable for Quake, less jitter)
24+
VIEW_SENSITIVITY = 3.0
2425

2526

2627
class QuakeLiveEnv(gym.Env):
@@ -101,20 +102,32 @@ def __init__(self, redis_host='localhost', redis_port=6379, redis_db=0,
101102
self._consecutive_bad_states = 0 # Track consecutive "terminated" conditions
102103
self._BAD_STATE_THRESHOLD = 3 # Require N bad states before terminating
103104

105+
# Decision tick timing for monitoring
106+
self._last_step_time = None
107+
self._step_dt_sum = 0.0
108+
self._step_dt_count = 0
109+
104110
def step(self, action):
105111
"""
106112
Run one timestep of the environment's dynamics.
107113
"""
114+
# Track decision tick timing
115+
step_start = time.time()
116+
if self._last_step_time is not None:
117+
dt = step_start - self._last_step_time
118+
self._step_dt_sum += dt
119+
self._step_dt_count += 1
120+
self._last_step_time = step_start
121+
108122
self.last_action = action
109123
self._apply_action(action)
110124
self.step_count += 1
111125

112-
# Wait for the next game state update
126+
# Wait for the next game state update (blocks until new frame_id)
113127
if not self.client.update_game_state():
114-
# Handle case where no update is received
115-
# For now, we'll just return the current state with no reward
128+
# Handle case where no update is received (timeout)
116129
obs = self._get_observation()
117-
return obs, 0, False, False, {}
130+
return obs, 0, False, False, {'frame_sync_timeout': True}
118131

119132
new_game_state = self.client.get_game_state()
120133

@@ -147,6 +160,10 @@ def step(self, action):
147160
info = {}
148161
if terminated or truncated:
149162
tracker = self.performance_tracker
163+
# Calculate decision tick rate
164+
avg_dt_ms = (self._step_dt_sum / self._step_dt_count * 1000) if self._step_dt_count > 0 else 0
165+
decision_hz = 1000 / avg_dt_ms if avg_dt_ms > 0 else 0
166+
150167
info['terminal_info'] = {
151168
'damage_dealt': tracker.damage_dealt,
152169
'damage_taken': tracker.damage_taken,
@@ -159,11 +176,13 @@ def step(self, action):
159176
'health_pickups': tracker.items_collected.get('Health', 0),
160177
'armor_pickups': tracker.items_collected.get('Armor', 0),
161178
'distance_traveled': tracker.total_distance_traveled,
179+
'avg_step_dt_ms': avg_dt_ms,
180+
'decision_hz': decision_hz,
162181
}
163182
# Quick validation print
164183
logger.info(f"Episode {self.episode_num} end: frags={tracker.kills} deaths={tracker.deaths} "
165184
f"dmg_dealt={tracker.damage_dealt} dmg_taken={tracker.damage_taken} "
166-
f"accuracy={info['terminal_info']['accuracy']:.1f}%")
185+
f"accuracy={info['terminal_info']['accuracy']:.1f}% hz={decision_hz:.1f}")
167186

168187
return obs, reward, terminated, truncated, info
169188

@@ -196,6 +215,9 @@ def reset(self, seed=None, options=None, reset_timeout=15.0):
196215
self.performance_tracker.reset()
197216
self.game_state = GameState() # Reset game state
198217
self._consecutive_bad_states = 0 # Reset termination counter
218+
self._last_step_time = None # Reset timing stats
219+
self._step_dt_sum = 0.0
220+
self._step_dt_count = 0
199221

200222
import time as time_module
201223

QuakeLiveInterface/rewards.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(self, reward_weights=None, high_value_items=None):
3737

3838
# Engagement shaping (prevents wandering)
3939
self.engagement_scale = 0.01 # Small reward for closing distance
40-
self.max_engagement_reward = 0.5 # Cap per step
40+
self.max_engagement_reward = 0.2 # Cap per step (lowered to avoid "distance farming")
4141

4242
# Item/map control (secondary objectives)
4343
self.item_pickup_scale = 0.1 # Reduced from before

0 commit comments

Comments
 (0)