Skip to content

Manual split setup: build Elura without the CLI ​

The elura command is only a scaffold generator. This guide creates the same minimum split application by hand so you can see every dependency, composition root, and configuration boundary.

Choose single-process setup when one process is enough, or distributed setup when Gateway and World replicas need shared state and dynamic registration.

You will create this layout:

text
my-game/
├── Cargo.toml
├── config/
│   ├── gateway.json
│   └── world.json
└── src/bin/
    ├── gateway.rs
    └── world.rs

1. Create the package ​

You need Rust 1.97 or newer. Create an empty application directory and add this manifest:

bash
mkdir -p my-game/src/bin my-game/config
cd my-game
toml
[package]
name = "my-game"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
publish = false

[dependencies]
prost = "0.14"
elura = { version = "0.3.1", features = ["adapters"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }

The adapters feature exposes DNS discovery. It does not select Redis, SQL, Kubernetes, or a provider automatically.

2. Write the World ​

The World owns typed game handlers. This example registers one protobuf route with ID 100:

rust
use std::{env, fs};

use elura::prelude::*;
use prost::Message;
use serde::Deserialize;

#[derive(Clone, PartialEq, Message)]
struct HelloRequest {
    #[prost(string, tag = "1")]
    name: String,
}

#[derive(Clone, PartialEq, Message)]
struct HelloResponse {
    #[prost(string, tag = "1")]
    message: String,
}

struct Hello;

impl Route for Hello {
    const ID: u32 = 100;
    const NAME: &'static str = "example.hello";

    type Request = HelloRequest;
    type Response = HelloResponse;
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AppConfig {
    runtime: WorldConfig,
    admin: AdminServerConfig,
}

impl AppConfig {
    fn load() -> elura::Result<Self> {
        let path = env::var("APP_WORLD_CONFIG")
            .unwrap_or_else(|_| "config/world.json".into());
        let mut config: Self = serde_json::from_slice(&fs::read(path)?)?;
        config.runtime.internal_token = Some(required_env("APP_INTERNAL_TOKEN")?);
        config.admin.token = optional_env("APP_ADMIN_TOKEN");
        Ok(config)
    }
}

#[tokio::main]
async fn main() -> elura::Result<()> {
    let app = AppConfig::load()?;
    World::new(app.runtime)
        .route(Hello, |_context, request| async move {
            Ok(HelloResponse {
                message: format!("Hello, {}!", request.name),
            })
        })
        .run(app.admin)
        .await
}

fn required_env(name: &str) -> elura::Result<String> {
    env::var(name).map_err(|_| elura::Error::InvalidConfig(format!("{name} is required")))
}

fn optional_env(name: &str) -> Option<String> {
    env::var(name).ok().filter(|value| !value.trim().is_empty())
}

The runtime decodes HelloRequest, supplies an authenticated WorldContext, and encodes HelloResponse. Application route IDs must be at least 100.

3. Write the Gateway ​

The Gateway accepts client connections and discovers the World. The concrete DNS adapter stays visible in the import and application configuration:

rust
use std::{env, fs, sync::Arc};

use elura::adapters::discovery::{DnsWorldDiscovery, DnsWorldDiscoveryConfig};
use elura::prelude::*;
use serde::Deserialize;

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AppConfig {
    runtime: GatewayConfig,
    admin: AdminServerConfig,
    tcp: TcpConfig,
    discovery: DnsWorldDiscoveryConfig,
}

impl AppConfig {
    fn load() -> elura::Result<Self> {
        let path = env::var("APP_GATEWAY_CONFIG")
            .unwrap_or_else(|_| "config/gateway.json".into());
        let mut config: Self = serde_json::from_slice(&fs::read(path)?)?;
        config.runtime.ticket.key = required_env("APP_TICKET_KEY")?;
        config.runtime.internal_token = Some(required_env("APP_INTERNAL_TOKEN")?);
        config.admin.token = optional_env("APP_ADMIN_TOKEN");
        Ok(config)
    }
}

#[tokio::main]
async fn main() -> elura::Result<()> {
    let app = AppConfig::load()?;
    let discovery = Arc::new(DnsWorldDiscovery::new(app.discovery)?);
    let tcp = TcpTransport::new(app.tcp)?;
    Gateway::new(app.runtime)
        .transport(tcp)
        .world_discovery(discovery)
        .run(app.admin)
        .await
}

fn required_env(name: &str) -> elura::Result<String> {
    env::var(name).map_err(|_| elura::Error::InvalidConfig(format!("{name} is required")))
}

fn optional_env(name: &str) -> Option<String> {
    env::var(name).ok().filter(|value| !value.trim().is_empty())
}

Elura does not read JSON or environment variables itself. AppConfig::load() is ordinary application code and can be replaced with another configuration or secret system.

4. Add local configuration ​

Use loopback listeners and point DNS discovery at the local World process:

json
{
  "runtime": {
    "listen": "127.0.0.1:18000"
  },
  "admin": {
    "listen": "127.0.0.1:18001",
    "component": "world",
    "instance_id": "world-local"
  }
}
json
{
  "runtime": {
    "ticket": {
      "issuer": "game-login",
      "audience": "game-gateway",
      "login_ttl": { "secs": 60, "nanos": 0 },
      "reconnect_ttl": { "secs": 1800, "nanos": 0 }
    },
    "world_routing": {
      "pool_size": 1,
      "max_in_flight_per_connection": 64
    }
  },
  "admin": {
    "listen": "127.0.0.1:17001",
    "component": "gateway",
    "instance_id": "gateway-local"
  },
  "tcp": {
    "listen": "127.0.0.1:17000"
  },
  "discovery": {
    "endpoint": "127.0.0.1:18000",
    "region_id": 1,
    "realm_id": 1,
    "route": 0,
    "refresh_interval": { "secs": 1, "nanos": 0 }
  }
}

Serde fills omitted runtime settings from their development defaults and rejects unknown fields.

5. Create secrets and run ​

Run openssl rand -hex 32 twice and put the two different results in a local environment file:

dotenv
APP_TICKET_KEY=replace-with-the-first-random-value
APP_INTERNAL_TOKEN=replace-with-the-second-random-value

Add config/elura.env to .gitignore and never commit real secrets. Start the World first, then start the Gateway in another terminal. Load the same file in both terminals:

bash
set -a
. config/elura.env
set +a
cargo run --bin world
bash
set -a
. config/elura.env
set +a
cargo run --bin gateway

Check the private admin endpoints after discovery has completed its first refresh:

bash
curl -i http://127.0.0.1:18001/healthz
curl -i http://127.0.0.1:17001/healthz
curl -i http://127.0.0.1:17001/readyz

Healthy endpoints return 204 No Content.

What the CLI adds ​

The manual application above uses the same public APIs as generated code. The CLI additionally creates environment examples, a business configuration example, Docker files, Kubernetes manifests, and safe regeneration controls. It does not add a hidden runtime or become a production dependency.

Continue with World modules and routes to organize handlers, or use the CLI reference when you want the complete application scaffold.

Released under the MIT License.