Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
44 changes: 44 additions & 0 deletions config/tower_defence.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[base]
env_name = tower_defence
checkpoint_interval = 500
eval_episodes = 256

[vec]
total_agents = 128
num_buffers = 2
num_threads = 2

[env]
max_episode_steps = 25000
base_seed = 1
target_round = 500
invalid_action_reward = -1.0
reward_enemy_scale = 0.5
reward_round_clear_scale = 0.5
reward_tower_placement_scale = 0.0
reward_tower_investment_scale = 0.0
reward_sell_penalty_scale = 0.0
reward_trigger_ready_noop_penalty = 0.0
reward_round_advance_scale = 25.0
reward_score_advance_scale = 0.0
reward_leak_penalty_scale = 1.0
reward_completion_bonus = 50.0
reward_defeat_penalty = 25.0
reward_truncation_penalty = 10.0
reward_clamp_enabled = 0
reward_clamp_min = -1.0
reward_clamp_max = 1.0

[policy]
hidden_size = 128
num_layers = 2

[train]
gpus = 1
total_timesteps = 120_000_000
learning_rate = 0.0003
gamma = 0.995
gae_lambda = 0.95
horizon = 128
minibatch_size = 2048
ent_coef = 0.02
80 changes: 80 additions & 0 deletions ocean/tower_defence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Tower Defence

Single-agent native Ocean environment with a typed placement lattice, three tower classes,
multi-path upgrades, procedural late-game waves, and a raylib demo.

## Contract

The environment uses a typed-placement ABI with native action masking:

| Surface | Layout |
| --- | --- |
| Build sites | `30 x 17` lattice (`510` sites) |
| Actions | `3572` discrete actions |
| Observations | `5686` floats |
| Native action mask | `3572` bytes |
| Agents | `1` |

Actions are site-major: `0` is noop, `1..1530` place one of three tower types,
`1531..3060` upgrade one of three paths, `3061..3570` sell, and `3571` starts the
next round. Legality is exposed once through PufferLib's native `MY_ACTION_MASK` interface for
masked sampling; it is not duplicated in the float observation. Checkpoints trained against the
older private `9258`-observation prototype are intentionally incompatible and must remain in their
matching legacy evaluator.

The simulation advances by a fixed `0.25` seconds per environment step. It starts with 200 lives
and 10,000 cash, uses fixed waves through round 20, and generates deterministic scaled waves after
that. The public challenge defaults to round 500 with a 25,000-step limit. Episode reward
parameters, target round, and limit are configurable in `config/tower_defence.ini`.

## Build and play

From the repository root:

```bash
./build.sh tower_defence --fast
./tower_defence
```

The bundled `resources/tower_defence/tower_defence_weights.bin` is the recurrent lean120M-r4 policy,
trained from scratch for 119,996,416 native transitions. The demo performs live recurrent inference
with the same checkpoint, action mask, and categorical policy semantics as evaluation. Actions are
sampled from the current game state rather than replayed from a recording. If the file is absent or
incompatible, the demo remains idle until manual input. Keep Shift held while taking control:

- `1`, `2`, `3`: select dart, sniper, or cannon
- left click: place at the hovered legal site
- `Q`, `W`, `E`: upgrade the hovered tower path
- right click or `X`: sell the hovered tower
- Space or Enter: start the next round

Rendering runs at 60 FPS while simulation remains at its fixed 4 Hz cadence. Moving sprites are
projected between simulation ticks. Projectile trails retain the preceding simulated point so a
shot visibly connects to its tower even when it travels or impacts within one 0.25-second step.
Time-stamped shot and impact events are presentation metadata only: they never enter observations,
rewards, targeting, or other rollout dynamics.

## Validation

`tests/test_tower_defence.c` covers the ABI, mask oracle, invalid numeric actions, deterministic
rollouts, split conservation across enemy-slot reuse, spawn RNG preservation, dynamically
growing projectiles, strictly improving paid fire-rate tiers, idle cooldown reset, next-tick split
movement, exact endpoint leaks, seed wraparound, terminal resets, recurrent demo state,
projectile travel segments, age-aware animation catch-up and event-ring wraparound, and randomized
stress. Defining `TD_TEST_RENDER` adds raylib render-purity and multi-environment teardown smokes.

After `./build.sh tower_defence --fast` has installed the repository-local raylib bundle, run the
headless suite with:

```bash
cc -std=c11 -O2 -Iraylib-5.5_linux_amd64/include -Isrc \
tests/test_tower_defence.c raylib-5.5_linux_amd64/lib/libraylib.a \
-lm -ldl -lpthread -lrt -lX11 -o /tmp/test_tower_defence
/tmp/test_tower_defence
```

On Linux with Xvfb, add `-DTD_TEST_RENDER` to the compile command and run the binary with
`xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 /tmp/test_tower_defence`.

Asset origins and the contributor's redistribution-rights confirmation are recorded in
`resources/tower_defence/ASSETS.md`.
117 changes: 117 additions & 0 deletions ocean/tower_defence/binding.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#include "tower_defence.h"

#define OBS_TENSOR_T FloatTensor
#define OBS_SIZE TD_OBS_SIZE
#define NUM_ATNS 1
#define ACT_SIZES \
{ TD_NUM_ACTIONS }
#define MY_ACTION_MASK TD_NUM_ACTIONS

#define Env TowerDefence
#include "vecenv.h"

static void td_config_error(const char *key, const char *message) {
fprintf(stderr, "Invalid tower_defence config %s: %s\n", key, message);
exit(1);
}

static double td_get_finite_double(Dict *kwargs, const char *key, double fallback) {
DictItem *item = dict_get_unsafe(kwargs, key);
double value = item == NULL ? fallback : item->value;
if (!isfinite(value)) {
td_config_error(key, "expected a finite number");
}
return value;
}

static float td_get_finite_float(Dict *kwargs, const char *key, float fallback) {
double value = td_get_finite_double(kwargs, key, (double)fallback);
if (value < -(double)FLT_MAX || value > (double)FLT_MAX) {
td_config_error(key, "outside float range");
}
return (float)value;
}

static float td_get_reward_float(Dict *kwargs, const char *key, float fallback) {
float value = td_get_finite_float(kwargs, key, fallback);
if (fabsf(value) > 1000000.0f) {
td_config_error(key, "reward magnitude must not exceed 1e6");
}
return value;
}

static int td_get_int(Dict *kwargs, const char *key, int fallback) {
double value = td_get_finite_double(kwargs, key, (double)fallback);
if (value < (double)INT_MIN || value > (double)INT_MAX || floor(value) != value) {
td_config_error(key, "expected an integer");
}
return (int)value;
}

static int td_get_positive_int(Dict *kwargs, const char *key, int fallback) {
int value = td_get_int(kwargs, key, fallback);
if (value < 1) {
td_config_error(key, "expected a positive integer");
}
return value;
}

static int td_get_binary_int(Dict *kwargs, const char *key, int fallback) {
int value = td_get_int(kwargs, key, fallback);
if (value != 0 && value != 1) {
td_config_error(key, "expected 0 or 1");
}
return value;
}

void my_init(Env *env, Dict *kwargs) {
memset(&env->log, 0, sizeof(env->log));
td_init(env);
env->max_episode_steps =
td_get_positive_int(kwargs, "max_episode_steps", TD_DEFAULT_MAX_EPISODE_STEPS);
env->base_seed = td_get_int(kwargs, "base_seed", TD_DEFAULT_BASE_SEED);
env->target_round = td_get_positive_int(kwargs, "target_round", TD_DEFAULT_TARGET_ROUND);
env->invalid_action_reward =
td_get_reward_float(kwargs, "invalid_action_reward", TD_DEFAULT_INVALID_ACTION_REWARD);
env->reward_enemy_scale =
td_get_reward_float(kwargs, "reward_enemy_scale", TD_DEFAULT_REWARD_ENEMY_SCALE);
env->reward_round_clear_scale = td_get_reward_float(kwargs, "reward_round_clear_scale",
TD_DEFAULT_REWARD_ROUND_CLEAR_SCALE);
env->reward_tower_placement_scale = td_get_reward_float(
kwargs, "reward_tower_placement_scale", TD_DEFAULT_REWARD_TOWER_PLACEMENT_SCALE);
env->reward_tower_investment_scale = td_get_reward_float(
kwargs, "reward_tower_investment_scale", TD_DEFAULT_REWARD_TOWER_INVESTMENT_SCALE);
env->reward_sell_penalty_scale = td_get_reward_float(kwargs, "reward_sell_penalty_scale",
TD_DEFAULT_REWARD_SELL_PENALTY_SCALE);
env->reward_trigger_ready_noop_penalty = td_get_reward_float(
kwargs, "reward_trigger_ready_noop_penalty", TD_DEFAULT_REWARD_TRIGGER_READY_NOOP_PENALTY);
env->reward_round_advance_scale = td_get_reward_float(kwargs, "reward_round_advance_scale",
TD_DEFAULT_REWARD_ROUND_ADVANCE_SCALE);
env->reward_score_advance_scale = td_get_reward_float(kwargs, "reward_score_advance_scale",
TD_DEFAULT_REWARD_SCORE_ADVANCE_SCALE);
env->reward_leak_penalty_scale = td_get_reward_float(kwargs, "reward_leak_penalty_scale",
TD_DEFAULT_REWARD_LEAK_PENALTY_SCALE);
env->reward_completion_bonus =
td_get_reward_float(kwargs, "reward_completion_bonus", TD_DEFAULT_REWARD_COMPLETION_BONUS);
env->reward_defeat_penalty =
td_get_reward_float(kwargs, "reward_defeat_penalty", TD_DEFAULT_REWARD_DEFEAT_PENALTY);
env->reward_truncation_penalty = td_get_reward_float(kwargs, "reward_truncation_penalty",
TD_DEFAULT_REWARD_TRUNCATION_PENALTY);
env->reward_clamp_enabled =
td_get_binary_int(kwargs, "reward_clamp_enabled", TD_DEFAULT_REWARD_CLAMP_ENABLED);
env->reward_clamp_min =
td_get_reward_float(kwargs, "reward_clamp_min", TD_DEFAULT_REWARD_CLAMP_MIN);
env->reward_clamp_max =
td_get_reward_float(kwargs, "reward_clamp_max", TD_DEFAULT_REWARD_CLAMP_MAX);
if (env->reward_clamp_enabled && env->reward_clamp_min > env->reward_clamp_max) {
td_config_error("reward_clamp_min", "must be less than or equal to reward_clamp_max");
}
}

void my_log(Log *log, Dict *out) {
dict_set(out, "score", log->score);
dict_set(out, "perf", log->perf);
dict_set(out, "episode_return", log->episode_return);
dict_set(out, "episode_length", log->episode_length);
dict_set(out, "invalid_action_rate", log->invalid_action_rate);
}
Loading