diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs
new file mode 100644
index 0000000..1ada5af
--- /dev/null
+++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs
@@ -0,0 +1,50 @@
+using System.Linq;
+using System.Net;
+using System.Net.Http.Json;
+using NeonSprawl.Server.Game.Encounters;
+using Xunit;
+
+namespace NeonSprawl.Server.Tests.Game.Encounters;
+
+public class EncounterDefinitionsWorldApiTests
+{
+ /// Frozen prototype encounter id. Keep in sync with Bruno.
+ public const string PrototypeCombatPocket = "prototype_combat_pocket";
+
+ [Fact]
+ public async Task GetEncounterDefinitions_ShouldReturnSchemaV1_WithFrozenEncounterAndNestedRewardTable()
+ {
+ // Arrange
+ await using var factory = new InMemoryWebApplicationFactory();
+ var client = factory.CreateClient();
+ // Act
+ var response = await client.GetAsync("/game/world/encounter-definitions");
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ Assert.Equal(EncounterDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
+ Assert.NotNull(body.Encounters);
+ Assert.Single(body.Encounters);
+
+ var encounter = body.Encounters[0];
+ Assert.Equal(PrototypeCombatPocket, encounter.Id);
+ Assert.Equal("Prototype Combat Pocket", encounter.DisplayName);
+ Assert.NotNull(encounter.CompletionCriteria);
+ Assert.Equal("defeat_all_targets", encounter.CompletionCriteria.Kind);
+ Assert.Equal(
+ ["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
+ encounter.RequiredNpcInstanceIds);
+
+ Assert.NotNull(encounter.RewardTable);
+ Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTable.Id);
+ Assert.Equal("Prototype Combat Pocket Clear", encounter.RewardTable.DisplayName);
+ Assert.Equal(2, encounter.RewardTable.FixedGrants.Count);
+
+ var scrapGrant = encounter.RewardTable.FixedGrants.Single(g => g.ItemId == "scrap_metal_bulk");
+ Assert.Equal(10, scrapGrant.Quantity);
+
+ var tokenGrant = encounter.RewardTable.FixedGrants.Single(g => g.ItemId == "contract_handoff_token");
+ Assert.Equal(1, tokenGrant.Quantity);
+ }
+}
diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs
new file mode 100644
index 0000000..d8bb908
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs
@@ -0,0 +1,65 @@
+using System.Text.Json.Serialization;
+
+namespace NeonSprawl.Server.Game.Encounters;
+
+/// JSON body for GET /game/world/encounter-definitions (NEO-103).
+public sealed class EncounterDefinitionsListResponse
+{
+ public const int CurrentSchemaVersion = 1;
+
+ [JsonPropertyName("schemaVersion")]
+ public int SchemaVersion { get; init; } = CurrentSchemaVersion;
+
+ /// Loaded encounters ordered by stable id (ordinal), matching .
+ [JsonPropertyName("encounters")]
+ public required IReadOnlyList Encounters { get; init; }
+}
+
+/// One row in the read-only encounter definition projection.
+public sealed class EncounterDefinitionJson
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("displayName")]
+ public required string DisplayName { get; init; }
+
+ [JsonPropertyName("completionCriteria")]
+ public required EncounterCompletionCriteriaJson CompletionCriteria { get; init; }
+
+ [JsonPropertyName("requiredNpcInstanceIds")]
+ public required IReadOnlyList RequiredNpcInstanceIds { get; init; }
+
+ [JsonPropertyName("rewardTable")]
+ public required EncounterRewardTableJson RewardTable { get; init; }
+}
+
+/// Completion rule summary for one encounter row.
+public sealed class EncounterCompletionCriteriaJson
+{
+ [JsonPropertyName("kind")]
+ public required string Kind { get; init; }
+}
+
+/// Nested reward table summary linked from an encounter row.
+public sealed class EncounterRewardTableJson
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("displayName")]
+ public required string DisplayName { get; init; }
+
+ [JsonPropertyName("fixedGrants")]
+ public required IReadOnlyList FixedGrants { get; init; }
+}
+
+/// One deterministic item grant in a nested reward table summary.
+public sealed class EncounterRewardGrantJson
+{
+ [JsonPropertyName("itemId")]
+ public required string ItemId { get; init; }
+
+ [JsonPropertyName("quantity")]
+ public required int Quantity { get; init; }
+}
diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs
new file mode 100644
index 0000000..a7ab60a
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs
@@ -0,0 +1,68 @@
+namespace NeonSprawl.Server.Game.Encounters;
+
+/// Maps GET /game/world/encounter-definitions (NEO-103).
+public static class EncounterDefinitionsWorldApi
+{
+ public static WebApplication MapEncounterDefinitionsWorldApi(this WebApplication app)
+ {
+ app.MapGet(
+ "/game/world/encounter-definitions",
+ (IEncounterDefinitionRegistry encounterRegistry, IRewardTableDefinitionRegistry rewardTableRegistry) =>
+ {
+ var defs = encounterRegistry.GetDefinitionsInIdOrder();
+ var encounters = new List(defs.Count);
+ foreach (var d in defs)
+ {
+ if (!rewardTableRegistry.TryGetDefinition(d.RewardTableId, out var rewardTable) ||
+ rewardTable is null)
+ {
+ throw new InvalidOperationException(
+ $"Encounter definition '{d.Id}' references reward table '{d.RewardTableId}' that is not in the loaded catalog.");
+ }
+
+ encounters.Add(
+ new EncounterDefinitionJson
+ {
+ Id = d.Id,
+ DisplayName = d.DisplayName,
+ CompletionCriteria = new EncounterCompletionCriteriaJson
+ {
+ Kind = d.CompletionCriteriaKind,
+ },
+ RequiredNpcInstanceIds = d.RequiredNpcInstanceIds,
+ RewardTable = MapRewardTable(rewardTable),
+ });
+ }
+
+ return Results.Json(
+ new EncounterDefinitionsListResponse
+ {
+ SchemaVersion = EncounterDefinitionsListResponse.CurrentSchemaVersion,
+ Encounters = encounters,
+ });
+ });
+
+ return app;
+ }
+
+ private static EncounterRewardTableJson MapRewardTable(RewardTableDefRow rewardTable)
+ {
+ var grants = new List(rewardTable.FixedGrants.Count);
+ foreach (var grant in rewardTable.FixedGrants)
+ {
+ grants.Add(
+ new EncounterRewardGrantJson
+ {
+ ItemId = grant.ItemId,
+ Quantity = grant.Quantity,
+ });
+ }
+
+ return new EncounterRewardTableJson
+ {
+ Id = rewardTable.Id,
+ DisplayName = rewardTable.DisplayName,
+ FixedGrants = grants,
+ };
+ }
+}
diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs
index 27e062d..7b3c050 100644
--- a/server/NeonSprawl.Server/Program.cs
+++ b/server/NeonSprawl.Server/Program.cs
@@ -66,6 +66,7 @@ app.MapCombatTargetsWorldApi();
app.MapNpcRuntimeSnapshotWorldApi();
app.MapPlayerCombatHealthApi();
app.MapResourceNodeDefinitionsWorldApi();
+app.MapEncounterDefinitionsWorldApi();
app.MapPlayerInventoryApi();
app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();