NationSim.Server runs the simulation as a background engine and exposes it to external
front ends (Avalonia, React, Unity, a browser dashboard, ...). The transport is
deliberately thin: the core simulation loop knows nothing about sockets.
dotnet run --project src/NationSim.Server -- --port 5000 --seed 42 --agents 2000| Option | Default | Description |
|---|---|---|
--port <int> |
5000 |
The port the server listens on (http://localhost:<port>) |
--seed <ulong> |
20260101 |
The seed of the deterministic random source |
--agents <int> |
1000 |
The number of citizens |
The same server can be started from the console application:
dotnet run --project src/NationSim.UI.Console -- server start --port 5000| Method | Route | Purpose |
|---|---|---|
GET |
/ws/state |
The WebSocket stream of the telemetry frames |
POST |
/api/command |
Queues a command for the next tick |
GET |
/api/regions/{id} |
The details of an administrative region |
GET |
/api/state |
The current indicators of the nation |
GET |
/health |
The liveness probe and the current tick |
All the responses are JSON.
{ "status": "ok", "tick": 1284 }{
"tick": 1284,
"paused": false,
"gdp": 5810351.30185,
"inflation": 0.010349115890519841,
"unemployment": 0.29515418502202643,
"treasuryBalance": 657445.9328158204,
"population": 292,
"regions": 16
}| Field | Type | Description |
|---|---|---|
tick |
number | The number of executed ticks; one tick is one simulated day |
paused |
bool | Whether the engine is paused |
gdp |
number | The nominal gross domestic product |
inflation |
number | The annual inflation rate as a fraction (0.02 is 2 %) |
unemployment |
number | The unemployment rate as a fraction |
treasuryBalance |
number | The balance of the treasury |
population |
number | The number of living citizens |
regions |
number | The number of administrative regions |
{
"regionId": 3,
"population": 16,
"employedCount": 10,
"localGdp": 345124.72435,
"infrastructureLevel": 0.5274,
"unemploymentRate": 0.277283982751,
"taxCollected": 34810.46823,
"neighbours": [7, 2]
}neighbours holds the identifiers of the regions that are connected by the transport
network. An unknown region answers with 404:
{ "error": "Region 999 does not exist." }{ "kind": "bill", "target": "income-tax", "value": 0.3 }| Field | Type | Description |
|---|---|---|
kind |
string | bill, budget, pause or resume |
target |
string | The bill (income-tax, corp-tax, spending, rate, subsidy) or the ministry (health, infrastructure, finance) |
value |
number | The numeric payload of the command |
The answer:
{ "accepted": true, "tick": 335, "message": "queued" }An unknown command answers with 400:
{ "accepted": false, "tick": 335, "message": "Unknown command 'foo'." }The command is queued: it is applied in the input phase of the next tick, exactly like the commands of the console application. A command that changes the state of the simulation (a bill or a budget) is recorded by the replay system, so a session that is driven from a front end can be replayed later.
GET /ws/state upgrades the connection and streams the telemetry frames. Every frame is
one JSON object followed by a newline (\n), so a client can read it line by line.
{
"tick": 1284,
"timestamp": "2026-05-15T00:00:00.0000000+00:00",
"gdp": 5810351.30185,
"inflation": 0.010349115890519841,
"unemployment": 0.29515418502202643,
"treasuryBalance": 657445.9328158204,
"totalPopulation": 292,
"activeFirmsCount": 148,
"averageCitizenWealth": 2398.35
}| Field | Type | Description |
|---|---|---|
tick |
number | The tick the sample belongs to |
timestamp |
string | The simulated time of the sample (ISO 8601), never the wall clock |
gdp |
number | The nominal gross domestic product |
inflation |
number | The annual inflation rate as a fraction |
unemployment |
number | The unemployment rate as a fraction |
treasuryBalance |
number | The balance of the treasury |
totalPopulation |
number | The number of living citizens |
activeFirmsCount |
number | The number of firms that employ somebody |
averageCitizenWealth |
number | The average wealth of a citizen |
The stream is fed by a bounded channel: when a client is too slow, the oldest frame is dropped instead of blocking the engine. The simulation never waits for a consumer.
A minimal browser client:
const socket = new WebSocket("ws://localhost:5000/ws/state");
socket.onmessage = (event) => {
const frame = JSON.parse(event.data);
console.log(frame.tick, frame.gdp, frame.unemployment);
};A .NET client:
using var client = new ClientWebSocket();
await client.ConnectAsync(new Uri("ws://localhost:5000/ws/state"), CancellationToken.None);
var buffer = new byte[8192];
var result = await client.ReceiveAsync(buffer, CancellationToken.None);
string payload = Encoding.UTF8.GetString(buffer, 0, result.Count);# submit a tax bill
curl -X POST http://localhost:5000/api/command \
-H "Content-Type: application/json" \
-d '{"kind":"bill","target":"income-tax","value":0.3}'
# fund the ministry of health
curl -X POST http://localhost:5000/api/command \
-H "Content-Type: application/json" \
-d '{"kind":"budget","target":"health","value":2500000}'
# stop and restart the engine
curl -X POST http://localhost:5000/api/command -H "Content-Type: application/json" -d '{"kind":"pause"}'
curl -X POST http://localhost:5000/api/command -H "Content-Type: application/json" -d '{"kind":"resume"}'The pause and the resume are handled by the engine host itself, because a paused engine does not run a tick and would therefore never consume them from the queue.
A session that is driven from the console application is recorded:
dotnet run --project src/NationSim.UI.Console -- --seed 42 replay record session.replay
# ... issue the commands ...
dotnet run --project src/NationSim.UI.Console -- replay play session.replay
# Recorded hash : 0x728C4D232ED79DE4
# Replayed hash : 0x728C4D232ED79DE4
# The replay reproduced the recorded session exactly.The .replay file is a MemoryPack payload that holds the seed, the initial configuration
and the commands with their ticks. A front end that wants reproducible sessions should
send the same semantic commands (bill, budget) - the replay injects them at the
tick they were issued at, so the reconstructed run is bit for bit identical.
| Situation | Answer |
|---|---|
| An unknown region | 404 with {"error": "..."} |
| An unknown command | 400 with {"accepted": false, ...} |
| A malformed body | 400 from the framework |
| A WebSocket request to another route | 400 |
The server never returns a 5xx for a client error: an invalid region or an invalid command is a normal answer.