Skip to content

Environment API

The main EldenGymEnv class implements the Gymnasium environment interface.

EldenGymEnv

EldenGymEnv

Bases: Env

Elden Ring Gymnasium environment with non-blocking frame streaming.

Uses pysiphon's frame streaming for efficient polling-based observations.

Parameters:

Name Type Description Default
scenario_name str

Boss scenario name

required
keybinds_filepath str

Path to keybinds JSON file (v2 format: action → keys)

required
siphon_config_filepath str

Path to siphon TOML config

required
memory_attributes list[str]

List of memory attribute names to include in observation. Default: ["HeroHp", "HeroMaxHp", "NpcHp", "NpcMaxHp", "HeroAnimId", "NpcAnimId"]

None
actions list[str]

List of action names to include in action space. If None, all actions from keybinds file are used. Example: ["move_forward", "move_back", "move_left", "move_right", "dodge_roll/dash"]

None
host str

Siphon server host. Default: 'localhost:50051'

'localhost:50051'
reward_function RewardFunction

Custom reward function

None
frame_format str

Frame format for streaming ('jpeg' or 'raw'). Default: 'jpeg'

'jpeg'
frame_quality int

JPEG quality 1-100. Default: 85

85
max_steps int

Maximum steps per episode. Default: None

None
launch_game bool

Whether to launch the game automatically. Default: True Set to False if game is already running

True
save_file_name str

Name of backup save file to copy during reset (e.g., "margit_checkpoint.sl2"). If None, no save file copying occurs.

None
save_file_dir str

Directory containing save files. Required if save_file_name is provided. Typically: %APPDATA%/EldenRing//

None
use_device str

Preferred input device - 'key' for keyboard (default) or 'mouse'. When 'mouse' is selected, uses mouse binding if available, otherwise falls back to keyboard.

'key'
Source code in eldengym/env.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
class EldenGymEnv(gym.Env):
    """
    Elden Ring Gymnasium environment with non-blocking frame streaming.

    Uses pysiphon's frame streaming for efficient polling-based observations.

    Args:
        scenario_name (str): Boss scenario name
        keybinds_filepath (str): Path to keybinds JSON file (v2 format: action → keys)
        siphon_config_filepath (str): Path to siphon TOML config
        memory_attributes (list[str]): List of memory attribute names to include in observation.
            Default: ["HeroHp", "HeroMaxHp", "NpcHp", "NpcMaxHp", "HeroAnimId", "NpcAnimId"]
        actions (list[str], optional): List of action names to include in action space.
            If None, all actions from keybinds file are used.
            Example: ["move_forward", "move_back", "move_left", "move_right", "dodge_roll/dash"]
        host (str): Siphon server host. Default: 'localhost:50051'
        reward_function (RewardFunction): Custom reward function
        frame_format (str): Frame format for streaming ('jpeg' or 'raw'). Default: 'jpeg'
        frame_quality (int): JPEG quality 1-100. Default: 85
        max_steps (int): Maximum steps per episode. Default: None
        launch_game (bool): Whether to launch the game automatically. Default: True
            Set to False if game is already running
        save_file_name (str, optional): Name of backup save file to copy during reset
            (e.g., "margit_checkpoint.sl2"). If None, no save file copying occurs.
        save_file_dir (str, optional): Directory containing save files. Required if
            save_file_name is provided. Typically: %APPDATA%/EldenRing/<steam_id>/
        use_device (str): Preferred input device - 'key' for keyboard (default) or 'mouse'.
            When 'mouse' is selected, uses mouse binding if available, otherwise falls back to keyboard.
    """

    def __init__(
        self,
        scenario_name,
        keybinds_filepath,
        siphon_config_filepath,
        memory_attributes=None,
        actions=None,
        host="localhost:50051",
        reward_function=None,
        frame_format="jpeg",
        frame_quality=85,
        max_steps=None,
        launch_game=True,
        save_file_name=None,
        save_file_dir=None,
        use_device="key",
    ):
        super().__init__()

        self.scenario_name = scenario_name
        self.client = EldenClient(host)
        self.keybinds_filepath = keybinds_filepath
        self.siphon_config_filepath = siphon_config_filepath
        self.step_count = 0
        self.max_steps = max_steps
        self.frame_format = frame_format
        self.frame_quality = frame_quality
        self.save_file_name = save_file_name
        self.save_file_dir = save_file_dir
        self.use_device = use_device

        # Memory attributes to poll (configurable, not hardcoded)
        self.memory_attributes = memory_attributes or [
            "HeroHp",
            "HeroMaxHp",
            "NpcHp",
            "NpcMaxHp",
            "HeroAnimId",
            "NpcAnimId",
        ]

        # Coordinate attributes (always polled for real coords computation)
        self._coord_attributes = [
            "HeroGlobalPosX",
            "HeroGlobalPosY",
            "HeroGlobalPosZ",
            "HeroLocalPosX",
            "HeroLocalPosY",
            "HeroLocalPosZ",
            "NpcGlobalPosX",  # Actually local coords, needs transform
            "NpcGlobalPosY",
            "NpcGlobalPosZ",
        ]

        # Real coord attribute names (added to obs)
        self._real_coord_attrs = [
            "player_x",
            "player_y",
            "player_z",
            "boss_x",
            "boss_y",
            "boss_z",
            "dist_to_boss",
            "boss_z_relative",
        ]

        # Load keybinds (v2 format: action → keys with index)
        with open(self.keybinds_filepath, "r") as f:
            keybinds_data = json.load(f)
            all_action_bindings = keybinds_data["actions"]

        # Filter actions if specified
        if actions is not None:
            # Validate requested actions exist
            invalid_actions = set(actions) - set(all_action_bindings.keys())
            if invalid_actions:
                raise ValueError(
                    f"Unknown actions: {invalid_actions}. "
                    f"Available: {list(all_action_bindings.keys())}"
                )
            # Filter to only requested actions, preserving order from actions list
            self._action_bindings = {a: all_action_bindings[a] for a in actions}
            # Use order from actions parameter
            sorted_actions = [(a, self._action_bindings[a]) for a in actions]
        else:
            self._action_bindings = all_action_bindings
            # Sort actions by index to ensure consistent ordering
            sorted_actions = sorted(
                self._action_bindings.items(), key=lambda x: x[1]["index"]
            )

        # Build action-to-key mapping based on use_device preference
        self._action_to_key = {}
        for action, bindings in sorted_actions:
            if self.use_device == "mouse" and "mouse" in bindings:
                self._action_to_key[action] = bindings["mouse"]
            else:
                self._action_to_key[action] = bindings["key"]

        # Create action space (multi-binary for selected actions)
        # action_keys preserves the order (index in MultiBinary = position in this list)
        self.action_keys = [action for action, _ in sorted_actions]
        self.action_space = gym.spaces.MultiBinary(len(self.action_keys))

        # Track current key states for toggling (using actual keys, not actions)
        self._active_keys = set(self._action_to_key.values())
        self._key_states = {key: False for key in self._active_keys}

        # Frame stream handle
        self._stream_handle = None

        # Reward function
        self.reward_function = reward_function or ScoreDeltaReward(
            score_key="player_hp"
        )
        if not isinstance(self.reward_function, RewardFunction):
            raise TypeError("reward_fn must inherit from RewardFunction")

        # State tracking
        self._prev_info = None

        # Initialize game and siphon
        if launch_game:
            print("Launching game...")
            self.client.launch_game()
            time.sleep(20)  # Wait for game to launch
            self.client.enter_game()
        else:
            print("Skipping game launch (launch_game=False)")

        print("Initializing Siphon...")
        self.client.load_config_from_file(self.siphon_config_filepath, wait_time=2)
        time.sleep(2)

        # Verify server is ready
        print("Checking server status...")
        status = self.client.get_server_status()
        print(f"Server status: {status}")

        if not status.get("memory_initialized", False):
            raise RuntimeError("Memory subsystem not initialized!")
        if not status.get("capture_initialized", False):
            raise RuntimeError("Capture subsystem not initialized!")

        print("Starting frame stream...")
        self._stream_handle = self.client.start_frame_stream(
            format=self.frame_format, quality=self.frame_quality
        )

        # Setup observation space (will be defined after first observation)
        self.observation_space = None

    def _poll_observation(self):
        """
        Poll for latest frame and memory attributes.

        Returns:
            dict: Observation with 'frame' and memory attributes
        """
        # Poll latest frame (non-blocking)
        frame_data = self.client.get_latest_frame(self._stream_handle)

        # If no new frame available, wait briefly and retry
        if frame_data is None:
            time.sleep(0.005)
            frame_data = self.client.get_latest_frame(self._stream_handle)

        # Decode frame from protobuf FrameData object
        if frame_data is not None:
            import cv2

            # Extract JPEG bytes from protobuf
            jpeg_bytes = frame_data.data

            if jpeg_bytes and len(jpeg_bytes) > 0:
                # Decode JPEG bytes to numpy array
                frame_array = np.frombuffer(jpeg_bytes, dtype=np.uint8)
                frame = cv2.imdecode(frame_array, cv2.IMREAD_COLOR)
            else:
                # Fallback: create black frame if no data
                frame = np.zeros(
                    (frame_data.height, frame_data.width, 3), dtype=np.uint8
                )
        else:
            # No frame available, create black placeholder
            frame = np.zeros((2160, 3840, 3), dtype=np.uint8)

        # Get memory attributes
        memory_data = {}
        for attr_name in self.memory_attributes:
            try:
                response = self.client.get_attribute(attr_name)
                # pysiphon returns dict with 'value' key
                if isinstance(response, dict):
                    value = response.get("value", 0)
                else:
                    value = response
                memory_data[attr_name] = value
            except Exception as e:
                print(f"Warning: Could not read attribute {attr_name}: {e}")
                print(
                    "  This might mean the game isn't fully loaded yet or the attribute doesn't exist."
                )
                memory_data[attr_name] = 0

        # Get coordinate attributes for real coords computation
        coord_data = {}
        for attr_name in self._coord_attributes:
            try:
                response = self.client.get_attribute(attr_name)
                if isinstance(response, dict):
                    value = response.get("value", 0)
                else:
                    value = response
                coord_data[attr_name] = value
            except Exception:
                coord_data[attr_name] = 0

        # Compute real coordinates
        real_coords = self._compute_real_coords(coord_data)

        # Combine into observation
        obs = {"frame": frame, **memory_data, **real_coords}

        return obs

    def _compute_real_coords(self, coord_data):
        """
        Compute real world coordinates from raw coordinate data.

        Player uses HeroGlobalPos directly (truly global).
        Boss uses transform: npc_local + (hero_global - hero_local).

        Args:
            coord_data: Dict with raw coordinate attributes

        Returns:
            Dict with player_x/y/z, boss_x/y/z, dist_to_boss, boss_z_relative
        """
        # Player coords (HeroGlobalPos is truly global)
        player_x = coord_data.get("HeroGlobalPosX", 0)
        player_y = coord_data.get("HeroGlobalPosY", 0)
        player_z = coord_data.get("HeroGlobalPosZ", 0)

        # Compute local→global transform
        hero_local_x = coord_data.get("HeroLocalPosX", 0)
        hero_local_y = coord_data.get("HeroLocalPosY", 0)
        hero_local_z = coord_data.get("HeroLocalPosZ", 0)

        transform_x = player_x - hero_local_x
        transform_y = player_y - hero_local_y
        transform_z = player_z - hero_local_z

        # Boss coords (NpcGlobalPos is actually local, apply transform)
        npc_local_x = coord_data.get("NpcGlobalPosX", 0)
        npc_local_y = coord_data.get("NpcGlobalPosY", 0)
        npc_local_z = coord_data.get("NpcGlobalPosZ", 0)

        boss_x = npc_local_x + transform_x
        boss_y = npc_local_y + transform_y
        boss_z = npc_local_z + transform_z

        # Derived values
        dx = player_x - boss_x
        dy = player_y - boss_y
        dist_to_boss = np.sqrt(dx * dx + dy * dy)  # XY distance only
        boss_z_relative = boss_z - player_z

        return {
            "player_x": player_x,
            "player_y": player_y,
            "player_z": player_z,
            "boss_x": boss_x,
            "boss_y": boss_y,
            "boss_z": boss_z,
            "dist_to_boss": dist_to_boss,
            "boss_z_relative": boss_z_relative,
        }

    def _toggle_keys(self, action):
        """
        Toggle keys based on multi-binary action and current key states.

        Translates semantic actions to actual keyboard/mouse keys.

        Args:
            action: Multi-binary array indicating desired action states
        """
        for i, desired_state in enumerate(action):
            semantic_action = self.action_keys[i]
            key = self._action_to_key[semantic_action]
            current_state = self._key_states[key]
            new_state = bool(desired_state)

            # Only toggle if state changed
            if new_state != current_state:
                self.client.input_key_toggle(key, new_state)
                self._key_states[key] = new_state

    def _release_all_keys(self):
        """Release all currently pressed keys."""
        for key in self._active_keys:
            if self._key_states.get(key, False):
                self.client.input_key_toggle(key, False)
                self._key_states[key] = False

    def reset(self, seed=None, options=None):
        """Reset environment - start new episode."""
        super().reset(seed=seed)

        # Release all keys from previous episode
        self._release_all_keys()

        # Reset game state
        self.client.quit_to_title()

        # Copy save file if configured
        if self.save_file_name and self.save_file_dir:
            print(f"Copying save file: {self.save_file_name}")
            self.client.copy_save_file(self.save_file_name, self.save_file_dir)

        self.client.enter_menu()
        self.client.start_scenario(self.scenario_name)

        # Reset tracking
        self.step_count = 0
        self._prev_info = None

        # Get initial observation
        obs = self._poll_observation()

        # Define observation space on first reset if not already defined
        if self.observation_space is None:
            self.observation_space = gym.spaces.Dict(
                {
                    "frame": gym.spaces.Box(
                        low=0,
                        high=255,
                        shape=obs["frame"].shape,
                        dtype=np.uint8,
                    ),
                    # User-configured memory attributes
                    **{
                        attr: gym.spaces.Box(
                            low=-np.inf, high=np.inf, shape=(), dtype=np.float32
                        )
                        for attr in self.memory_attributes
                    },
                    # Real coordinate attributes (always included)
                    **{
                        attr: gym.spaces.Box(
                            low=-np.inf, high=np.inf, shape=(), dtype=np.float32
                        )
                        for attr in self._real_coord_attrs
                    },
                }
            )

        info = self._get_info(obs)
        self._prev_info = info.copy()

        return obs, info

    def step(self, action):
        """
        Execute one step with key toggling.

        Args:
            action: Multi-binary array [0/1] for each semantic action in self.action_keys
                e.g., [1, 0, 0, 1, ...] to activate move_forward and dodge_roll/dash

        Returns:
            tuple: (observation, reward, terminated, truncated, info)
        """
        # Toggle keys based on action
        self._toggle_keys(action)

        # Brief wait for game to process input
        time.sleep(0.016)  # ~1 frame at 60fps

        # Poll observation
        obs = self._poll_observation()
        info = self._get_info(obs)

        # Calculate reward
        reward = self.reward_function.calculate(obs, info, self._prev_info)

        # Check termination
        terminated = self.reward_function.is_done(obs, info)
        truncated = (
            self.step_count >= self.max_steps if self.max_steps is not None else False
        )

        # Update tracking
        self.step_count += 1
        self._prev_info = info.copy()

        return obs, reward, terminated, truncated, info

    def _get_info(self, obs):
        """
        Extract info dict from observation.

        Args:
            obs: Observation dict

        Returns:
            dict: Info with normalized/processed values
        """
        info = {}

        # Add normalized HP values if available
        if "HeroHp" in obs and "HeroMaxHp" in obs:
            info["player_hp_normalized"] = (
                obs["HeroHp"] / obs["HeroMaxHp"] if obs["HeroMaxHp"] > 0 else 0
            )

        if "NpcHp" in obs and "NpcMaxHp" in obs:
            info["boss_hp_normalized"] = (
                obs["NpcHp"] / obs["NpcMaxHp"] if obs["NpcMaxHp"] > 0 else 0
            )

        # Add animation IDs
        if "HeroAnimId" in obs:
            info["player_animation"] = obs["HeroAnimId"]

        if "NpcAnimId" in obs:
            info["boss_animation"] = obs["NpcAnimId"]

        # Add real coords as tuples for convenience (debugging)
        info["player_xyz"] = (
            obs.get("player_x", 0),
            obs.get("player_y", 0),
            obs.get("player_z", 0),
        )
        info["boss_xyz"] = (
            obs.get("boss_x", 0),
            obs.get("boss_y", 0),
            obs.get("boss_z", 0),
        )

        return info

    def close(self):
        """Close environment and clean up resources."""
        # Stop frame stream
        if self._stream_handle is not None:
            self.client.stop_frame_stream(self._stream_handle)
            self._stream_handle = None

        # Release all keys
        self._release_all_keys()

        # Close client
        self.client.close()

    def render(self):
        """Render is handled by the game itself."""
        pass

close

close()

Close environment and clean up resources.

Source code in eldengym/env.py
def close(self):
    """Close environment and clean up resources."""
    # Stop frame stream
    if self._stream_handle is not None:
        self.client.stop_frame_stream(self._stream_handle)
        self._stream_handle = None

    # Release all keys
    self._release_all_keys()

    # Close client
    self.client.close()

render

render()

Render is handled by the game itself.

Source code in eldengym/env.py
def render(self):
    """Render is handled by the game itself."""
    pass

reset

reset(seed=None, options=None)

Reset environment - start new episode.

Source code in eldengym/env.py
def reset(self, seed=None, options=None):
    """Reset environment - start new episode."""
    super().reset(seed=seed)

    # Release all keys from previous episode
    self._release_all_keys()

    # Reset game state
    self.client.quit_to_title()

    # Copy save file if configured
    if self.save_file_name and self.save_file_dir:
        print(f"Copying save file: {self.save_file_name}")
        self.client.copy_save_file(self.save_file_name, self.save_file_dir)

    self.client.enter_menu()
    self.client.start_scenario(self.scenario_name)

    # Reset tracking
    self.step_count = 0
    self._prev_info = None

    # Get initial observation
    obs = self._poll_observation()

    # Define observation space on first reset if not already defined
    if self.observation_space is None:
        self.observation_space = gym.spaces.Dict(
            {
                "frame": gym.spaces.Box(
                    low=0,
                    high=255,
                    shape=obs["frame"].shape,
                    dtype=np.uint8,
                ),
                # User-configured memory attributes
                **{
                    attr: gym.spaces.Box(
                        low=-np.inf, high=np.inf, shape=(), dtype=np.float32
                    )
                    for attr in self.memory_attributes
                },
                # Real coordinate attributes (always included)
                **{
                    attr: gym.spaces.Box(
                        low=-np.inf, high=np.inf, shape=(), dtype=np.float32
                    )
                    for attr in self._real_coord_attrs
                },
            }
        )

    info = self._get_info(obs)
    self._prev_info = info.copy()

    return obs, info

step

step(action)

Execute one step with key toggling.

Parameters:

Name Type Description Default
action

Multi-binary array [0/1] for each semantic action in self.action_keys e.g., [1, 0, 0, 1, ...] to activate move_forward and dodge_roll/dash

required

Returns:

Name Type Description
tuple

(observation, reward, terminated, truncated, info)

Source code in eldengym/env.py
def step(self, action):
    """
    Execute one step with key toggling.

    Args:
        action: Multi-binary array [0/1] for each semantic action in self.action_keys
            e.g., [1, 0, 0, 1, ...] to activate move_forward and dodge_roll/dash

    Returns:
        tuple: (observation, reward, terminated, truncated, info)
    """
    # Toggle keys based on action
    self._toggle_keys(action)

    # Brief wait for game to process input
    time.sleep(0.016)  # ~1 frame at 60fps

    # Poll observation
    obs = self._poll_observation()
    info = self._get_info(obs)

    # Calculate reward
    reward = self.reward_function.calculate(obs, info, self._prev_info)

    # Check termination
    terminated = self.reward_function.is_done(obs, info)
    truncated = (
        self.step_count >= self.max_steps if self.max_steps is not None else False
    )

    # Update tracking
    self.step_count += 1
    self._prev_info = info.copy()

    return obs, reward, terminated, truncated, info

Methods

Core Gymnasium Methods

reset(seed=None, options=None)

Reset the environment to initial state.

This method: 1. Releases all keys from previous episode 2. Quits to title screen 3. Copies save file (if configured) 4. Re-enters game and starts scenario

Args: - seed (int, optional): Random seed - options (dict, optional): Additional options

Returns: - observation (dict): Initial observation with keys 'frame' and memory attributes - info (dict): Additional information

Example:

obs, info = env.reset()
print(f"Frame shape: {obs['frame'].shape}")
print(f"Starting HP: {obs['HeroHp']}")

step(action)

Execute one step in the environment with key toggling.

Args: - action (np.ndarray): Multi-binary array where each element is 0 or 1 representing key states

Returns: - observation (dict): New observation with 'frame' and memory attributes - reward (float): Reward for the action - terminated (bool): Whether episode ended (determined by reward function) - truncated (bool): Whether episode was truncated (max steps reached) - info (dict): Additional information including normalized values

Example:

action = env.action_space.sample()  # Random multi-binary action
obs, reward, terminated, truncated, info = env.step(action)
print(f"Reward: {reward}, HP: {obs['HeroHp']}")
if terminated:
    print(f"Episode ended!")

close()

Clean up environment resources.

env.close()

Rendering

render()

Return current game frame from the latest observation.

Returns: - np.ndarray: RGB frame (H, W, 3) in uint8 format

Example:

import matplotlib.pyplot as plt

frame = env.render()
plt.imshow(frame)
plt.show()

Note: The frame is captured asynchronously using pysiphon's frame streaming.

Properties

Action Space

The environment uses MultiBinary action space where each element represents a key state:

env.action_space  # MultiBinary(n)
# Each element is 0 (key released) or 1 (key pressed)
# The keys are loaded from keybinds.json

# Example with default keybinds:
env.action_keys  # ['W', 'A', 'S', 'D', 'SPACE', 'E', 'Q', 'R']
action = [1, 0, 0, 0, 1, 0, 0, 0]  # Press W and SPACE

Keys are toggled intelligently - only changed when the action differs from current state.

Observation Space

The environment uses Dict observation space with frame, memory attributes, and computed real coordinates:

env.observation_space  # Dict({
#   'frame': Box(0, 255, (H, W, 3), uint8),
#
#   # Memory attributes (configurable)
#   'HeroHp': Box(-inf, inf, (), float32),
#   'HeroMaxHp': Box(-inf, inf, (), float32),
#   'NpcHp': Box(-inf, inf, (), float32),
#   'NpcMaxHp': Box(-inf, inf, (), float32),
#   'HeroAnimId': Box(-inf, inf, (), float32),
#   'NpcAnimId': Box(-inf, inf, (), float32),
#
#   # Real coordinates (computed automatically)
#   'player_x': Box(-inf, inf, (), float32),  # Player global X
#   'player_y': Box(-inf, inf, (), float32),  # Player global Y
#   'player_z': Box(-inf, inf, (), float32),  # Player global Z
#   'boss_x': Box(-inf, inf, (), float32),    # Boss global X
#   'boss_y': Box(-inf, inf, (), float32),    # Boss global Y
#   'boss_z': Box(-inf, inf, (), float32),    # Boss global Z
#   'dist_to_boss': Box(0, inf, (), float32), # 2D distance to boss
#   'boss_z_relative': Box(-inf, inf, (), float32),  # Boss Z relative to player
# })

The memory attributes are configurable via the memory_attributes parameter.

Real Coordinates

The environment automatically computes global coordinates for both player and boss using the local-to-global transform:

  • Player coordinates (player_x, player_y, player_z): Directly from HeroGlobalPos
  • Boss coordinates (boss_x, boss_y, boss_z): Computed as NpcLocalPos + (HeroGlobalPos - HeroLocalPos)
  • Distance (dist_to_boss): 2D Euclidean distance (ignores Z)
  • Relative Z (boss_z_relative): boss_z - player_z (positive = boss above player)

This transform is necessary because the NPC coordinate system resets at map boundaries, while the Hero global position remains stable.

Info Dictionary

The info dict returned by step() and reset() contains normalized values:

Key Type Description
normalized_hero_hp float Player HP normalized (0-1)
normalized_npc_hp float Boss HP normalized (0-1)
Memory attributes float Raw values from observation
step_count int Steps in current episode

Example:

obs, info = env.reset()
print(f"Player HP %: {info['normalized_hero_hp'] * 100:.1f}%")
print(f"Boss HP %: {info['normalized_npc_hp'] * 100:.1f}%")

Configuration

Using eldengym.make()

import eldengym

env = eldengym.make(
    "Margit-v0",  # Registered environment

    # Optional overrides:
    launch_game=False,  # Don't launch if already running
    memory_attributes=["HeroHp", "NpcHp", "HeroAnimId"],  # Custom attributes
    frame_format="jpeg",  # Or "raw"
    frame_quality=85,  # JPEG quality (1-100)
    max_steps=1000,  # Max steps per episode

    # Save file management (for reset)
    save_file_name="margit_checkpoint.sl2",
    save_file_dir=r"C:\Users\...\AppData\Roaming\EldenRing\76561198...",

    # Custom reward function
    reward_function=eldengym.ScoreDeltaReward(),
)

Direct Instantiation

from eldengym.env import EldenGymEnv
from eldengym.rewards import ScoreDeltaReward

env = EldenGymEnv(
    scenario_name="Margit-v0",
    keybinds_filepath="path/to/keybinds.json",
    siphon_config_filepath="path/to/er_siphon_config.toml",
    memory_attributes=["HeroHp", "HeroMaxHp", "NpcHp", "NpcMaxHp", "HeroAnimId", "NpcAnimId"],
    host="localhost:50051",
    reward_function=ScoreDeltaReward(),
    frame_format="jpeg",
    frame_quality=85,
    max_steps=None,
    launch_game=True,
    save_file_name=None,
    save_file_dir=None,
)

Parameters

Parameter Type Default Description
scenario_name str Required Boss scenario name
keybinds_filepath str Required Path to keybinds JSON
siphon_config_filepath str Required Path to Siphon TOML config
memory_attributes list[str] Default set Memory values to poll
host str "localhost:50051" Siphon server address
reward_function RewardFunction ScoreDeltaReward() Reward calculator
frame_format str "jpeg" Frame format ("jpeg" or "raw")
frame_quality int 85 JPEG quality (1-100)
max_steps int None Max steps before truncation
launch_game bool True Auto-launch game on init
save_file_name str None Backup save to copy on reset
save_file_dir str None Directory with save files