-
Notifications
You must be signed in to change notification settings - Fork 528
Add Tower Defence environment #617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 4.0
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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`. |
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. weird defensive error checking |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assuming LLM gen. Models default to this terrible defensive style of programming where they assume you are controlling the mars rover and any error is the end of the world. It always takes at least some manual attention to get rid of this crap, because they always default back to it after a while even when corrected. If it unclear, the reason that this is bad is that it introduces a lot of bloat code and extra paths that obscure functionality in order to catch a relatively unimportant class of bugs. In this case, we don't particularly care if someone wants to configure the env with some stupid value. Places where setup issues are likely can be guarded with a simple assert. |
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already, the first 50 lines are pure bloat |
||
| 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 = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would do a plain puf_get on all these to load |
||
| 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); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oops