Skip to content

Commit dae5a6f

Browse files
Merge pull request #24 from capt-marbles/feat/gameye-config-and-region-routing
feat(gameye): configurable environments, DI config, multi-port, and region routing
2 parents 319af29 + 2bcd3c1 commit dae5a6f

5 files changed

Lines changed: 703 additions & 166 deletions

File tree

modules/GameyeAllocator/CONFIGURATION.md

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,95 @@ Add the following secret in the Unity Dashboard under **Administration > Secrets
1414

1515
## Code Configuration
1616

17-
Update the following constants in `Project/GameyeAllocator.cs`:
17+
All configuration is set in `ModuleConfig.Setup()` in `Project/GameyeAllocator.cs`, where a `GameyeAllocatorConfig` instance is registered with dependency injection. Open that file and edit the values in the config block.
1818

19-
### `ImageName` (line 25)
19+
### `Environment`
2020

21-
Set this to the name of your application image as configured in the Gameye Admin Panel. This must match the image name you registered during setup.
21+
Controls which Gameye API endpoint the allocator targets.
2222

23-
### `DefaultLocation` (line 26)
23+
| Value | API URL |
24+
|---|---|
25+
| `GameyeEnvironment.Sandbox` (default) | `https://api.sandbox-gameye.gameye.net` |
26+
| `GameyeEnvironment.Production` | `https://api-production-gameye.gameye.net` |
27+
28+
Use `Sandbox` during development and testing. Switch to `Production` before going live.
29+
30+
> ⚠️ **`Environment` defaults to `Sandbox`.** If you go live without setting `Environment = GameyeEnvironment.Production`, every allocation silently targets sandbox infrastructure. As a safeguard, the allocator logs a **warning on every allocation while running in Sandbox** (and an info line confirming `PRODUCTION` once switched) — watch your Cloud Code logs to confirm the environment before launch.
31+
32+
### `ImageName` (required)
33+
34+
The name of your application image as configured in the Gameye Admin Panel. Must match exactly.
35+
36+
### `DefaultLocation`
37+
38+
Your preferred default deployment region (e.g. `"europe"`, `"us-east-1"`). Used when the matched pool has no entry in `LocationByPool`. See [available locations](https://www.gameye.com/docs/api-v2/available-locations/) for the full list. Defaults to `"europe"`.
39+
40+
### `LocationByPool` (optional)
41+
42+
Maps Unity Matchmaker **pool names** to Gameye location IDs, enabling dynamic region selection per match. When the matched pool is found in this dictionary, that location is sent to Gameye instead of `DefaultLocation`. Pools not in the map fall through to `DefaultLocation`.
43+
44+
Unity Matchmaker can be configured to use pools for region routing — create one pool per region in your queue configuration, then mirror that mapping here.
45+
46+
```csharp
47+
LocationByPool = new Dictionary<string, string>
48+
{
49+
{ "eu-west-pool", "eu-west" },
50+
{ "us-central-pool", "us-central" },
51+
{ "ap-ne-pool", "asia-northeast" },
52+
{ "sa-east-pool", "southamerica" },
53+
},
54+
```
55+
56+
Leave empty (the default) to always use `DefaultLocation`.
57+
58+
### `GamePort`
59+
60+
The primary container port your game server listens on (e.g. `7777`). This must match the port exposed in your Dockerfile and configured in the Gameye Admin Panel. This is the port passed to Unity Matchmaker's `AssignmentData.IpPort()`.
61+
62+
### `AdditionalPorts` (optional)
63+
64+
A dictionary of additional named ports to include in allocation data. Use this when your game server exposes secondary ports (e.g. a query port, voice port, or RCON port).
65+
66+
Each entry is returned in `AllocationData` as `port_{name}` so game clients can access them.
67+
68+
```csharp
69+
AdditionalPorts = new Dictionary<string, int>
70+
{
71+
{ "query", 27015 },
72+
{ "rcon", 27020 },
73+
},
74+
```
75+
76+
### `Version` (optional)
77+
78+
A specific Docker image tag / version to use when starting sessions. When `null` (the default), Gameye uses the highest-priority tag configured in the Admin Panel.
2479

25-
Set this to your preferred default deployment region (e.g. `"europe"`, `"north-america"`). See [available locations](https://www.gameye.com/docs/api-v2/available-locations/) for the full list.
80+
```csharp
81+
Version = "v1.2.3",
82+
```
2683

27-
### `GamePort` (line 27)
84+
## Example Configuration
2885

29-
Set this to the container port your game server listens on (e.g. `7777`). This must match the port exposed in your Dockerfile and configured in the Gameye Admin Panel.
86+
```csharp
87+
config.Dependencies.AddSingleton(new GameyeAllocatorConfig
88+
{
89+
ImageName = "my-fps-server",
90+
Environment = GameyeEnvironment.Production,
91+
DefaultLocation = "eu-west",
92+
GamePort = 7777,
93+
Version = "v2.1.0",
94+
LocationByPool = new Dictionary<string, string>
95+
{
96+
{ "eu-west-pool", "eu-west" },
97+
{ "us-central-pool", "us-central" },
98+
{ "ap-ne-pool", "asia-northeast" },
99+
},
100+
AdditionalPorts = new Dictionary<string, int>
101+
{
102+
{ "query", 27015 },
103+
},
104+
});
105+
```
30106

31107
## How It Works
32108

modules/GameyeAllocator/Project/Client/Models/SessionRequest.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ public class SessionRequest
2828

2929
[JsonProperty("ttl")]
3030
public int? Ttl { get; set; }
31-
}
31+
32+
[JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)]
33+
public string? Version { get; set; }
34+
}

modules/GameyeAllocator/Project/GameyeAllocator.cs

Lines changed: 138 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,43 +18,102 @@ namespace GameyeAllocatorModule;
1818

1919
/// <summary>
2020
/// Module configuration for dependency injection.
21-
/// Registers IGameApiClient as a singleton for accessing Unity services like Secret Manager.
21+
/// Edit the <see cref="GameyeAllocatorConfig"/> instance below to configure the allocator
22+
/// for your project — image name, environment, region, ports, and version.
2223
/// </summary>
2324
public class ModuleConfig : ICloudCodeSetup
2425
{
2526
public void Setup(ICloudCodeConfig config)
2627
{
2728
config.Dependencies.AddSingleton(GameApiClient.Create());
2829
config.Dependencies.AddScoped<IGameyeHttpClientFactory, GameyeHttpClientFactory>();
30+
31+
// ──────────────────────────────────────────────────────────────
32+
// Gameye allocator configuration — edit the values below.
33+
// ──────────────────────────────────────────────────────────────
34+
config.Dependencies.AddSingleton(new GameyeAllocatorConfig
35+
{
36+
// Required — the application image name registered in the Gameye Admin Panel.
37+
ImageName = "your-image-name",
38+
39+
// The API environment. Use Sandbox for development, Production for live.
40+
Environment = GameyeEnvironment.Sandbox,
41+
42+
// Default deployment region — used when no pool-to-location mapping matches.
43+
DefaultLocation = "eu-west",
44+
45+
// Option A — Unity QoS automatic region selection (recommended).
46+
// Maps the value Unity puts in MatchProperties["Region"] to a Gameye location.
47+
// LocationByRegion = new Dictionary<string, string>
48+
// {
49+
// { "eu-west", "eu-west" },
50+
// { "us-central", "us-central" },
51+
// { "asia-northeast", "asia-northeast" },
52+
// },
53+
54+
// Option B — pool-name mapping (use when Unity QoS is not configured).
55+
// LocationByPool = new Dictionary<string, string>
56+
// {
57+
// { "eu-west-pool", "eu-west" },
58+
// { "us-central-pool", "us-central" },
59+
// { "ap-ne-pool", "asia-northeast" },
60+
// },
61+
62+
// Primary game server port (must match your Dockerfile EXPOSE / Admin Panel config).
63+
GamePort = 80,
64+
65+
// Optional — pin a specific Docker image tag / version.
66+
// When null, Gameye uses the highest-priority tag configured in the Admin Panel.
67+
// Version = "v1.2.3",
68+
69+
// Optional — additional ports to expose to game clients (e.g. voice, query, RCON).
70+
// These are returned in AllocationData as "port_{name}" alongside the primary port.
71+
// AdditionalPorts = new Dictionary<string, int>
72+
// {
73+
// { "query", 27015 },
74+
// { "rcon", 27020 },
75+
// },
76+
});
2977
}
3078
}
3179

32-
public class GameyeAllocator(IGameApiClient gameApiClient, IGameyeHttpClientFactory httpClientFactory, ILogger<GameyeAllocator> logger) : IMatchmakerAllocator
80+
public class GameyeAllocator(
81+
IGameApiClient gameApiClient,
82+
IGameyeHttpClientFactory httpClientFactory,
83+
GameyeAllocatorConfig allocatorConfig,
84+
ILogger<GameyeAllocator> logger) : IMatchmakerAllocator
3385
{
34-
// Configuration - users should modify these constants for their setup
35-
private const string ImageName = "MyGame"; // TODO: Replace with your Gameye application image name
36-
private const string DefaultLocation = "europe"; // TODO: Replace with your preferred region
37-
private const int GamePort = 7777; // TODO: Replace with your game server port
38-
39-
// Gameye Constants
40-
private const string GameyeApiUrl = "https://api.gameye.io";
41-
42-
// Secret names - these must match the secrets stored in Unity Dashboard
86+
// Secret names — these must match the secrets stored in Unity Dashboard
4387
private const string GameyeApiTokenSecretName = "GAMEYE_API_TOKEN";
4488

4589
[CloudCodeFunction("Matchmaker_AllocateServer")]
4690
public async Task<AllocateResponse> Allocate(IExecutionContext context, AllocateRequest request)
4791
{
4892
try
4993
{
94+
// Surface which Gameye environment this allocation targets. Environment defaults to
95+
// Sandbox, so a production deployment that forgot to set it will emit a warning on
96+
// every allocation rather than silently routing live traffic to sandbox infrastructure.
97+
if (allocatorConfig.Environment == GameyeEnvironment.Sandbox)
98+
{
99+
logger.LogWarning("GameyeAllocator is running in SANDBOX ({ApiBaseUrl}) — sandbox infrastructure is not intended for production traffic. Set Environment = GameyeEnvironment.Production in ModuleConfig.Setup() before going live.", allocatorConfig.ApiBaseUrl);
100+
}
101+
else
102+
{
103+
logger.LogInformation("GameyeAllocator is running in PRODUCTION ({ApiBaseUrl}).", allocatorConfig.ApiBaseUrl);
104+
}
105+
50106
Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName);
51107
using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value);
52108

109+
var resolvedLocation = ResolveLocation(request.MatchmakingResults);
110+
53111
var sessionRequest = new SessionRequest
54112
{
55113
Id = request.MatchId,
56-
Location = DefaultLocation,
57-
Image = ImageName,
114+
Location = resolvedLocation,
115+
Image = allocatorConfig.ImageName,
116+
Version = allocatorConfig.Version,
58117
Env = new Dictionary<string, string>
59118
{
60119
{ "MATCH_ID", request.MatchId },
@@ -67,7 +126,7 @@ public async Task<AllocateResponse> Allocate(IExecutionContext context, Allocate
67126
};
68127

69128
var content = new StringContent(JsonConvert.SerializeObject(sessionRequest), Encoding.UTF8, "application/json");
70-
HttpResponseMessage response = await client.PostAsync($"{GameyeApiUrl}/session", content);
129+
HttpResponseMessage response = await client.PostAsync($"{allocatorConfig.ApiBaseUrl}/session", content);
71130

72131
string responseContent = await response.Content.ReadAsStringAsync();
73132
if (!response.IsSuccessStatusCode)
@@ -81,14 +140,27 @@ public async Task<AllocateResponse> Allocate(IExecutionContext context, Allocate
81140

82141
var sessionResponse = JsonConvert.DeserializeObject<SessionResponse>(responseContent);
83142

84-
return new AllocateResponse(AllocateStatus.Created)
143+
var allocationData = new Dictionary<string, object>
144+
{
145+
{ "sessionId", sessionResponse?.Id ?? request.MatchId },
146+
{ "host", sessionResponse?.Host ?? string.Empty },
147+
{ "port", FindPort(sessionResponse?.Ports, allocatorConfig.GamePort) },
148+
{ "location", resolvedLocation },
149+
};
150+
151+
// Include additional named ports so game clients can access them.
152+
foreach (var (name, containerPort) in allocatorConfig.AdditionalPorts)
85153
{
86-
AllocationData = new Dictionary<string, object>
154+
int hostPort = FindPort(sessionResponse?.Ports, containerPort);
155+
if (hostPort > 0)
87156
{
88-
{ "sessionId", sessionResponse?.Id ?? request.MatchId },
89-
{ "host", sessionResponse?.Host ?? string.Empty },
90-
{ "port", FindGamePort(sessionResponse?.Ports) },
91-
},
157+
allocationData[$"port_{name}"] = hostPort;
158+
}
159+
}
160+
161+
return new AllocateResponse(AllocateStatus.Created)
162+
{
163+
AllocationData = allocationData,
92164
};
93165
}
94166
catch (Exception e)
@@ -128,7 +200,7 @@ public async Task<PollResponse> Poll(IExecutionContext context, PollRequest requ
128200
{
129201
Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName);
130202
using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value);
131-
HttpResponseMessage response = await client.GetAsync($"{GameyeApiUrl}/session/{sessionId}");
203+
HttpResponseMessage response = await client.GetAsync($"{allocatorConfig.ApiBaseUrl}/session/{sessionId}");
132204
string responseContent = await response.Content.ReadAsStringAsync();
133205

134206
if (!response.IsSuccessStatusCode)
@@ -155,7 +227,7 @@ public async Task<PollResponse> Poll(IExecutionContext context, PollRequest requ
155227
{
156228
AssignmentData = AssignmentData.IpPort(
157229
sessionResponse.Host ?? string.Empty,
158-
FindGamePort(sessionResponse.Ports)
230+
FindPort(sessionResponse.Ports, allocatorConfig.GamePort)
159231
),
160232
},
161233
"created" or "restarting" => new PollResponse(PollStatus.Pending),
@@ -177,15 +249,55 @@ public async Task<PollResponse> Poll(IExecutionContext context, PollRequest requ
177249
}
178250

179251
/// <summary>
180-
/// Finds the host port mapped to the configured game port from the session's port mappings.
181-
/// Falls back to the first available port if the configured port is not found.
252+
/// Resolves the Gameye location for this match using a three-tier priority:
253+
/// 1. <c>MatchProperties["Region"]</c> → <see cref="GameyeAllocatorConfig.LocationByRegion"/>
254+
/// (Unity QoS has already picked the best region — use it directly)
255+
/// 2. <c>PoolName</c> → <see cref="GameyeAllocatorConfig.LocationByPool"/>
256+
/// (fallback for studios using per-region pools without QoS)
257+
/// 3. <see cref="GameyeAllocatorConfig.DefaultLocation"/>
258+
/// </summary>
259+
private string ResolveLocation(MatchmakingResults results)
260+
{
261+
// Priority 1 — Unity QoS resolved region
262+
if (results.MatchProperties.TryGetValue("Region", out var regionObj))
263+
{
264+
var region = regionObj?.ToString();
265+
if (!string.IsNullOrEmpty(region) &&
266+
allocatorConfig.LocationByRegion.TryGetValue(region, out var regionLocation))
267+
{
268+
logger.LogInformation("Region resolved via QoS: MatchProperties[Region]={Region} → {Location}", region, regionLocation);
269+
return regionLocation;
270+
}
271+
272+
if (!string.IsNullOrEmpty(region))
273+
{
274+
logger.LogWarning("MatchProperties[Region]={Region} has no entry in LocationByRegion — falling through", region);
275+
}
276+
}
277+
278+
// Priority 2 — pool name mapping
279+
var pool = results.PoolName;
280+
if (!string.IsNullOrEmpty(pool) &&
281+
allocatorConfig.LocationByPool.TryGetValue(pool, out var poolLocation))
282+
{
283+
logger.LogInformation("Region resolved via pool: PoolName={Pool} → {Location}", pool, poolLocation);
284+
return poolLocation;
285+
}
286+
287+
// Priority 3 — static default
288+
return allocatorConfig.DefaultLocation;
289+
}
290+
291+
/// <summary>
292+
/// Finds the host port mapped to the given container port from the session's port mappings.
293+
/// Falls back to the first available port if the target port is not found.
182294
/// </summary>
183-
private static int FindGamePort(List<PortMapping>? ports)
295+
private static int FindPort(List<PortMapping>? ports, int containerPort)
184296
{
185297
if (ports == null || ports.Count == 0)
186298
return 0;
187299

188-
var match = ports.FirstOrDefault(p => p.Container == GamePort);
300+
var match = ports.FirstOrDefault(p => p.Container == containerPort);
189301
return match?.Host ?? ports[0].Host;
190302
}
191303
}

0 commit comments

Comments
 (0)