Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions modules/GameyeAllocator/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,93 @@ Add the following secret in the Unity Dashboard under **Administration > Secrets

## Code Configuration

Update the following constants in `Project/GameyeAllocator.cs`:
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.

### `ImageName` (line 25)
### `Environment`

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.
Controls which Gameye API endpoint the allocator targets.

### `DefaultLocation` (line 26)
| Value | API URL |
|---|---|
| `GameyeEnvironment.Sandbox` (default) | `https://api.sandbox-gameye.gameye.net` |
| `GameyeEnvironment.Production` | `https://api-production-gameye.gameye.net` |

Use `Sandbox` during development and testing. Switch to `Production` before going live.

### `ImageName` (required)

The name of your application image as configured in the Gameye Admin Panel. Must match exactly.

### `DefaultLocation`

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"`.

### `LocationByPool` (optional)

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`.

Unity Matchmaker uses pools for region routing — create one pool per region in your queue configuration, then mirror that mapping here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Unity Matchmaker uses pools for region routing — create one pool per region in your queue configuration, then mirror that mapping here.
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.

Using this pool-to-region mapping is a decision made by the game dev to not use QoS. The original statement can be understood as something Unity Matchmaker strictly does, which is not true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right that pool-based routing is a developer decision (an alternative to Unity QoS), not something Matchmaker strictly does. Reworded to "can be configured to use pools for region routing" in 4a05258. Thanks!


```csharp
LocationByPool = new Dictionary<string, string>
{
{ "eu-west-pool", "eu-west" },
{ "us-central-pool", "us-central" },
{ "ap-ne-pool", "asia-northeast" },
{ "sa-east-pool", "southamerica" },
},
```

Leave empty (the default) to always use `DefaultLocation`.

### `GamePort`

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()`.

### `AdditionalPorts` (optional)

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).

Each entry is returned in `AllocationData` as `port_{name}` so game clients can access them.

```csharp
AdditionalPorts = new Dictionary<string, int>
{
{ "query", 27015 },
{ "rcon", 27020 },
},
```

### `Version` (optional)

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.

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.
```csharp
Version = "v1.2.3",
```

### `GamePort` (line 27)
## Example Configuration

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.
```csharp
config.Dependencies.AddSingleton(new GameyeAllocatorConfig
{
ImageName = "my-fps-server",
Environment = GameyeEnvironment.Production,
DefaultLocation = "eu-west",
GamePort = 7777,
Version = "v2.1.0",
LocationByPool = new Dictionary<string, string>
{
{ "eu-west-pool", "eu-west" },
{ "us-central-pool", "us-central" },
{ "ap-ne-pool", "asia-northeast" },
},
AdditionalPorts = new Dictionary<string, int>
{
{ "query", 27015 },
},
});
```

## How It Works

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ public class SessionRequest

[JsonProperty("ttl")]
public int? Ttl { get; set; }
}

[JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)]
public string? Version { get; set; }
}
152 changes: 126 additions & 26 deletions modules/GameyeAllocator/Project/GameyeAllocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,72 @@ namespace GameyeAllocatorModule;

/// <summary>
/// Module configuration for dependency injection.
/// Registers IGameApiClient as a singleton for accessing Unity services like Secret Manager.
/// Edit the <see cref="GameyeAllocatorConfig"/> instance below to configure the allocator
/// for your project — image name, environment, region, ports, and version.
/// </summary>
public class ModuleConfig : ICloudCodeSetup
{
public void Setup(ICloudCodeConfig config)
{
config.Dependencies.AddSingleton(GameApiClient.Create());
config.Dependencies.AddScoped<IGameyeHttpClientFactory, GameyeHttpClientFactory>();

// ──────────────────────────────────────────────────────────────
// Gameye allocator configuration — edit the values below.
// ──────────────────────────────────────────────────────────────
config.Dependencies.AddSingleton(new GameyeAllocatorConfig
{
// Required — the application image name registered in the Gameye Admin Panel.
ImageName = "test_nginx",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this name map to onboarding steps, in other allocators we use "your-image-name" to help make it clear users need to change this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, test_nginx reads like a concrete onboarding value. Updated it to your-image-name in 2bcd3c1 so it is clearly a placeholder users need to replace.


// The API environment. Use Sandbox for development, Production for live.
Environment = GameyeEnvironment.Sandbox,

// Default deployment region — used when no pool-to-location mapping matches.
DefaultLocation = "eu-west",

// Option A — Unity QoS automatic region selection (recommended).
// Maps the value Unity puts in MatchProperties["Region"] to a Gameye location.
// LocationByRegion = new Dictionary<string, string>
// {
// { "eu-west", "eu-west" },
// { "us-central", "us-central" },
// { "asia-northeast", "asia-northeast" },
// },

// Option B — pool-name mapping (use when Unity QoS is not configured).
// LocationByPool = new Dictionary<string, string>
// {
// { "eu-west-pool", "eu-west" },
// { "us-central-pool", "us-central" },
// { "ap-ne-pool", "asia-northeast" },
// },

// Primary game server port (must match your Dockerfile EXPOSE / Admin Panel config).
GamePort = 80,

// Optional — pin a specific Docker image tag / version.
// When null, Gameye uses the highest-priority tag configured in the Admin Panel.
// Version = "v1.2.3",

// Optional — additional ports to expose to game clients (e.g. voice, query, RCON).
// These are returned in AllocationData as "port_{name}" alongside the primary port.
// AdditionalPorts = new Dictionary<string, int>
// {
// { "query", 27015 },
// { "rcon", 27020 },
// },
});
}
}

public class GameyeAllocator(IGameApiClient gameApiClient, IGameyeHttpClientFactory httpClientFactory, ILogger<GameyeAllocator> logger) : IMatchmakerAllocator
public class GameyeAllocator(
IGameApiClient gameApiClient,
IGameyeHttpClientFactory httpClientFactory,
GameyeAllocatorConfig allocatorConfig,
ILogger<GameyeAllocator> logger) : IMatchmakerAllocator
{
// Configuration - users should modify these constants for their setup
private const string ImageName = "MyGame"; // TODO: Replace with your Gameye application image name
private const string DefaultLocation = "europe"; // TODO: Replace with your preferred region
private const int GamePort = 7777; // TODO: Replace with your game server port

// Gameye Constants
private const string GameyeApiUrl = "https://api.gameye.io";

// Secret names - these must match the secrets stored in Unity Dashboard
// Secret names — these must match the secrets stored in Unity Dashboard
private const string GameyeApiTokenSecretName = "GAMEYE_API_TOKEN";

[CloudCodeFunction("Matchmaker_AllocateServer")]
Expand All @@ -50,11 +94,14 @@ public async Task<AllocateResponse> Allocate(IExecutionContext context, Allocate
Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName);
using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value);

var resolvedLocation = ResolveLocation(request.MatchmakingResults);

var sessionRequest = new SessionRequest
{
Id = request.MatchId,
Location = DefaultLocation,
Image = ImageName,
Location = resolvedLocation,
Image = allocatorConfig.ImageName,
Version = allocatorConfig.Version,
Env = new Dictionary<string, string>
{
{ "MATCH_ID", request.MatchId },
Expand All @@ -67,7 +114,7 @@ public async Task<AllocateResponse> Allocate(IExecutionContext context, Allocate
};

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

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

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

return new AllocateResponse(AllocateStatus.Created)
var allocationData = new Dictionary<string, object>
{
AllocationData = new Dictionary<string, object>
{ "sessionId", sessionResponse?.Id ?? request.MatchId },
{ "host", sessionResponse?.Host ?? string.Empty },
{ "port", FindPort(sessionResponse?.Ports, allocatorConfig.GamePort) },
{ "location", resolvedLocation },
};

// Include additional named ports so game clients can access them.
foreach (var (name, containerPort) in allocatorConfig.AdditionalPorts)
{
int hostPort = FindPort(sessionResponse?.Ports, containerPort);
if (hostPort > 0)
{
{ "sessionId", sessionResponse?.Id ?? request.MatchId },
{ "host", sessionResponse?.Host ?? string.Empty },
{ "port", FindGamePort(sessionResponse?.Ports) },
},
allocationData[$"port_{name}"] = hostPort;
}
}

return new AllocateResponse(AllocateStatus.Created)
{
AllocationData = allocationData,
};
}
catch (Exception e)
Expand Down Expand Up @@ -128,7 +188,7 @@ public async Task<PollResponse> Poll(IExecutionContext context, PollRequest requ
{
Secret gameyeApiToken = await gameApiClient.SecretManager.GetSecret(context, GameyeApiTokenSecretName);
using HttpClient client = httpClientFactory.Create(gameyeApiToken.Value);
HttpResponseMessage response = await client.GetAsync($"{GameyeApiUrl}/session/{sessionId}");
HttpResponseMessage response = await client.GetAsync($"{allocatorConfig.ApiBaseUrl}/session/{sessionId}");
string responseContent = await response.Content.ReadAsStringAsync();

if (!response.IsSuccessStatusCode)
Expand All @@ -155,7 +215,7 @@ public async Task<PollResponse> Poll(IExecutionContext context, PollRequest requ
{
AssignmentData = AssignmentData.IpPort(
sessionResponse.Host ?? string.Empty,
FindGamePort(sessionResponse.Ports)
FindPort(sessionResponse.Ports, allocatorConfig.GamePort)
),
},
"created" or "restarting" => new PollResponse(PollStatus.Pending),
Expand All @@ -177,15 +237,55 @@ public async Task<PollResponse> Poll(IExecutionContext context, PollRequest requ
}

/// <summary>
/// Finds the host port mapped to the configured game port from the session's port mappings.
/// Falls back to the first available port if the configured port is not found.
/// Resolves the Gameye location for this match using a three-tier priority:
/// 1. <c>MatchProperties["Region"]</c> → <see cref="GameyeAllocatorConfig.LocationByRegion"/>
/// (Unity QoS has already picked the best region — use it directly)
/// 2. <c>PoolName</c> → <see cref="GameyeAllocatorConfig.LocationByPool"/>
/// (fallback for studios using per-region pools without QoS)
/// 3. <see cref="GameyeAllocatorConfig.DefaultLocation"/>
/// </summary>
private string ResolveLocation(MatchmakingResults results)
{
// Priority 1 — Unity QoS resolved region
if (results.MatchProperties.TryGetValue("Region", out var regionObj))
{
var region = regionObj?.ToString();
if (!string.IsNullOrEmpty(region) &&
allocatorConfig.LocationByRegion.TryGetValue(region, out var regionLocation))
{
logger.LogInformation("Region resolved via QoS: MatchProperties[Region]={Region} → {Location}", region, regionLocation);
return regionLocation;
}

if (!string.IsNullOrEmpty(region))
{
logger.LogWarning("MatchProperties[Region]={Region} has no entry in LocationByRegion — falling through", region);
}
}

// Priority 2 — pool name mapping
var pool = results.PoolName;
if (!string.IsNullOrEmpty(pool) &&
allocatorConfig.LocationByPool.TryGetValue(pool, out var poolLocation))
{
logger.LogInformation("Region resolved via pool: PoolName={Pool} → {Location}", pool, poolLocation);
return poolLocation;
}

// Priority 3 — static default
return allocatorConfig.DefaultLocation;
}

/// <summary>
/// Finds the host port mapped to the given container port from the session's port mappings.
/// Falls back to the first available port if the target port is not found.
/// </summary>
private static int FindGamePort(List<PortMapping>? ports)
private static int FindPort(List<PortMapping>? ports, int containerPort)
{
if (ports == null || ports.Count == 0)
return 0;

var match = ports.FirstOrDefault(p => p.Container == GamePort);
var match = ports.FirstOrDefault(p => p.Container == containerPort);
return match?.Host ?? ports[0].Host;
}
}
Loading