mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-17 22:22:03 +00:00
Compare commits
7 Commits
379ef9910e
...
772f98b7fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 772f98b7fe | |||
| 6f1a840249 | |||
| 5480b3cf05 | |||
| 6406867911 | |||
| f4722dbd8d | |||
| 1b3c2e063d | |||
| 07217c3517 |
+2
-3
@@ -1,8 +1,7 @@
|
||||
.DS_Store
|
||||
|
||||
# RL training artifacts (training/ code is committed; outputs are not)
|
||||
# RL training: checkpoints and logs ARE committed (training results must
|
||||
# survive any single machine); only the env and scratch files are not.
|
||||
training/.venv/
|
||||
training/logs/
|
||||
training/checkpoints/
|
||||
training/smoke_run.log
|
||||
training/__pycache__/
|
||||
|
||||
@@ -56,4 +56,4 @@ See `TRAINING.md` for the full workflow (training, exporting, evaluating, diffic
|
||||
- `scripts/ship_observations.gd` is the shared observation builder used by both training and in-game inference — never fork or diverge these two paths. Team 1's observations are mirrored (180° about Y) so one policy plays both sides.
|
||||
- In-game bots: `scripts/ai_ship_controller.gd` (a `ShipController`) runs the exported policy JSON via `scripts/policy_network.gd` (pure-GDScript MLP) — no .NET/ONNX/Python at runtime. Models live in `Game/bots/`; Match mode's `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports configure the opponent.
|
||||
- Python side lives in `training/` (venv, not committed): `train.py` (SB3 PPO, launches parallel headless Godot instances from source), `export_policy.py` (checkpoint → JSON with parity check), `evaluate.py` (head-to-head eval, appends `training/eval_history.json`).
|
||||
- The flattened action space is Box(7): thrust xyz, rotation xyz, turbo (>0 = on) — this is `ShipAction` verbatim; change either only deliberately and together.
|
||||
- The flattened action space is Box(7) in gymnasium's **sorted-key order**: rotation xyz, thrust xyz, turbo (>0 = on). The fields are `ShipAction`'s, but gymnasium alphabetizes Dict spaces, so the flat order is NOT ShipAction's thrust-first declaration order — `AIShipController._decide` consumes exported policies in sorted order; change the action space only deliberately and everywhere together.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,5 +39,11 @@ custom_minimum_size = Vector2(330, 60)
|
||||
layout_mode = 2
|
||||
text = "Match"
|
||||
|
||||
[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(330, 60)
|
||||
layout_mode = 2
|
||||
text = "Spectate (Bot vs Bot)"
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/SpectateButton" to="." method="_on_spectate_pressed"]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/spectate_mode.gd" id="1_s"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/arena_01.tscn" id="2_s"]
|
||||
[ext_resource type="PackedScene" uid="uid://c8kak2l3m4n5" path="res://scenes/HUD.tscn" id="3_s"]
|
||||
|
||||
[node name="Spectate" type="Node3D"]
|
||||
script = ExtResource("1_s")
|
||||
bot_a_model_path = "res://bots/run02.json"
|
||||
bot_b_model_path = "res://bots/run01.json"
|
||||
|
||||
[node name="Arena" parent="." instance=ExtResource("2_s")]
|
||||
|
||||
[node name="HUD" parent="." instance=ExtResource("3_s")]
|
||||
@@ -49,14 +49,15 @@ func get_action() -> ShipAction:
|
||||
func _decide() -> void:
|
||||
var obs := ShipObservations.build(_ship, _opponent, _ball, _attack_goal_position)
|
||||
var out := _policy.forward(obs)
|
||||
# Output layout matches the flattened training action space (Box(7)):
|
||||
# thrust xyz, rotation xyz, turbo (> 0 means on).
|
||||
_action.thrust = Vector3(
|
||||
# Output layout is the trainer's flattened action space (Box(7)), which
|
||||
# gymnasium orders by SORTED key name — rotation xyz, thrust xyz, turbo
|
||||
# (> 0 means on) — NOT ShipAction's thrust-first declaration order.
|
||||
_action.rotation = Vector3(
|
||||
_axis(out[0]),
|
||||
_axis(out[1]),
|
||||
_axis(out[2])
|
||||
)
|
||||
_action.rotation = Vector3(
|
||||
_action.thrust = Vector3(
|
||||
_axis(out[3]),
|
||||
_axis(out[4]),
|
||||
_axis(out[5])
|
||||
|
||||
@@ -10,3 +10,7 @@ func _on_free_play_pressed() -> void:
|
||||
|
||||
func _on_match_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/match.tscn")
|
||||
|
||||
|
||||
func _on_spectate_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/spectate.tscn")
|
||||
|
||||
@@ -15,9 +15,14 @@ extends AIController3D
|
||||
# Reward shaping weights. Dense terms accrue per physics tick (60 sim-ticks
|
||||
# per sim-second); event terms fire once. Exported so tuning needs no code
|
||||
# edits. Goal rewards are added by TrainingMode, which owns goal events.
|
||||
@export var ball_touch_reward := 0.1
|
||||
@export var ball_touch_reward := 0.25
|
||||
@export var velocity_to_ball_weight := 0.001
|
||||
@export var ball_velocity_to_goal_weight := 0.004
|
||||
# Per-tick penalty while touching the arena enclosure (walls/floor/ceiling).
|
||||
# At 60 ticks/sim-second this is -0.3/s: a ship parked on a wall for a full
|
||||
# 30 s episode loses ~9 — comparable to conceding — while a brief graze
|
||||
# costs almost nothing.
|
||||
@export var wall_contact_penalty := 0.005
|
||||
|
||||
var ship: Ship
|
||||
var rl_controller: RLShipController
|
||||
@@ -84,6 +89,15 @@ func _physics_process(delta):
|
||||
var ball_progress := ball.linear_velocity.dot(ball_to_goal.normalized())
|
||||
reward += ball_velocity_to_goal_weight * ball_progress / ShipObservations.BALL_SPEED_SCALE
|
||||
|
||||
# Dense penalty: every tick spent in contact with the arena enclosure
|
||||
# (contact monitoring is already on for the ball-touch reward). Ships
|
||||
# bumping each other or the ball is fine — only the boundary counts.
|
||||
if wall_contact_penalty > 0.0:
|
||||
for body in ship.get_colliding_bodies():
|
||||
if body is ArenaBoundary:
|
||||
reward -= wall_contact_penalty
|
||||
break
|
||||
|
||||
|
||||
func _on_ship_body_entered(body: Node) -> void:
|
||||
if body.is_in_group("ball"):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
extends GameMode
|
||||
|
||||
# Spectate: bot vs bot exhibition — both ships are AI-driven so two trained
|
||||
# policies can be watched playing each other. Score tracking and kickoff
|
||||
# resets like Match, but no timer. R resets the ball, Esc returns to the menu.
|
||||
|
||||
signal score_changed(score: Dictionary)
|
||||
|
||||
@export_group("Team 0 bot")
|
||||
@export_file("*.json") var bot_a_model_path: String = ""
|
||||
@export_range(1, 60) var bot_a_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_a_action_noise: float = 0.0
|
||||
|
||||
@export_group("Team 1 bot")
|
||||
@export_file("*.json") var bot_b_model_path: String = ""
|
||||
@export_range(1, 60) var bot_b_reaction_ticks: int = 8
|
||||
@export_range(0.0, 1.0) var bot_b_action_noise: float = 0.0
|
||||
|
||||
var score := {0: 0, 1: 0}
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
spawn_ball()
|
||||
var ship_a := spawn_ship(0, 0, _make_bot(bot_a_model_path, bot_a_reaction_ticks, bot_a_action_noise))
|
||||
spawn_ship(1, 0, _make_bot(bot_b_model_path, bot_b_reaction_ticks, bot_b_action_noise))
|
||||
spawn_camera_rig(ship_a)
|
||||
|
||||
|
||||
func _make_bot(model_path: String, reaction_ticks: int, action_noise: float) -> ShipController:
|
||||
if not model_path.is_empty() and FileAccess.file_exists(model_path):
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = model_path
|
||||
bot.reaction_ticks = reaction_ticks
|
||||
bot.action_noise = action_noise
|
||||
return bot
|
||||
if not model_path.is_empty():
|
||||
push_warning("SpectateMode: bot model not found at %s, spawning inert ship" % model_path)
|
||||
return ShipController.new() # inert placeholder
|
||||
|
||||
|
||||
func _on_goal_scored(conceding_team: int) -> void:
|
||||
var scoring_team := 1 - conceding_team
|
||||
score[scoring_team] += 1
|
||||
score_changed.emit(score.duplicate())
|
||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||
reset_ball()
|
||||
reset_ships()
|
||||
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event.is_action_pressed("reset_ball"):
|
||||
reset_ball()
|
||||
else:
|
||||
super(event)
|
||||
@@ -0,0 +1 @@
|
||||
uid://r86jia8qr60x
|
||||
@@ -38,6 +38,10 @@ The environment is our own headless Godot sim — fully cross-platform:
|
||||
There is no hard GPU requirement (unlike Rocket League tooling) — a GPU
|
||||
mainly speeds up learning updates on long runs.
|
||||
|
||||
For the Linux/3090 remote-training workflow (setup, throughput tuning,
|
||||
auto-copying results back to the Mac, dashboard over the network), see
|
||||
[TRAINING_LINUX.md](TRAINING_LINUX.md).
|
||||
|
||||
## Setup
|
||||
|
||||
Needs Python 3.10+ and a Godot 4.7 binary.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Training on the Linux / RTX 3090 box
|
||||
|
||||
Remote-training workflow: run long training sessions on the Linux machine and
|
||||
watch the dashboard from any machine on the network. **All training artifacts
|
||||
(checkpoints, TensorBoard logs, exported bots) are committed to git** — no
|
||||
result ever depends on a single machine, and moving models between the box
|
||||
and the Mac is just `git pull`. General training concepts and the
|
||||
export/evaluate workflow live in [TRAINING.md](TRAINING.md) — this doc is
|
||||
only what differs on the Linux box.
|
||||
|
||||
Both workflows below are wrapped in idempotent scripts in `training/` —
|
||||
re-running either is always safe.
|
||||
|
||||
## One-time setup
|
||||
|
||||
GitHub auth first (git-over-HTTPS no longer accepts account passwords, so
|
||||
clone over SSH — this key also lets `run_training.sh` push results):
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 # accept the defaults
|
||||
cat ~/.ssh/id_ed25519.pub # add at github.com/settings/keys → "New SSH key"
|
||||
|
||||
cd ~/ai-training
|
||||
git clone git@github.com:jcreek/CosmicClash.git
|
||||
# (submodules are editor tooling only — training doesn't need them)
|
||||
|
||||
~/ai-training/CosmicClash/training/setup_linux.sh
|
||||
```
|
||||
|
||||
`setup_linux.sh` is safe to re-run any time (after a Godot upgrade, a broken
|
||||
venv, a fresh clone — it checks each step before acting). It:
|
||||
|
||||
- downloads the Godot 4.7.1 Linux binary to `~/ai-training/godot/` if missing
|
||||
(override the location by exporting `GODOT_BIN`);
|
||||
- creates `training/.venv` if missing and installs requirements;
|
||||
- verifies CUDA torch, reinstalling from the CUDA wheel index if the box got
|
||||
a CPU-only build;
|
||||
- runs the Godot import pass (fresh clones have no `.godot/` cache, so
|
||||
`class_name` scripts aren't registered until the project imports once);
|
||||
- finishes with the headless smoke test — the game must boot without
|
||||
rendering.
|
||||
|
||||
## Maximising throughput
|
||||
|
||||
Env stepping is CPU-bound (each `--n-parallel` instance is one headless Godot
|
||||
process simulating 2 agents); the 3090 only accelerates the PPO updates. So
|
||||
the two levers are instance count and in-engine speedup:
|
||||
|
||||
- **`--n-parallel`**: start at `nproc` minus 2 (leave headroom for the
|
||||
trainer process itself). The Mac mini sustains 6; a many-core box should
|
||||
take considerably more. Each instance opens its own TCP port upward from
|
||||
`--port` (default 11008).
|
||||
- **`--speedup`**: in-engine physics time-scale. 16 is proven; try 24–32 and
|
||||
keep raising while `time/fps` in the console/TensorBoard still scales up.
|
||||
Back off if fps stops improving (CPU saturated) or physics glitches appear
|
||||
(ball tunnelling, ships escaping the arena — watch for respawn warnings in
|
||||
the Godot output).
|
||||
|
||||
Tune by watching `time/fps`: run a 2-minute smoke run per setting and keep
|
||||
the best. Reference: 6 instances × speedup 16 ≈ 1,385 steps/s on an M4 Mac
|
||||
mini — a 20M-step run in ~4 h. Doubling fps halves that.
|
||||
|
||||
## Run training
|
||||
|
||||
One command does everything (needs `tmux`: `sudo apt install tmux`):
|
||||
|
||||
```bash
|
||||
cd ~/ai-training/CosmicClash/training
|
||||
./start_training.sh run03 --timesteps 20000000 --n-parallel 14 --speedup 24
|
||||
```
|
||||
|
||||
`start_training.sh` launches a detached tmux session with two windows —
|
||||
**training survives SSH disconnects** — and prints the dashboard URL:
|
||||
|
||||
- `train` runs `run_training.sh`: `git pull` → train → export the policy
|
||||
JSON → commit and push checkpoints, logs, and the exported bot;
|
||||
- `dashboard` serves TensorBoard on `0.0.0.0:6006` for the whole network
|
||||
(reused if one is already running).
|
||||
|
||||
Re-running `start_training.sh` while a session exists just attaches you to
|
||||
it (detach again with `Ctrl-B` then `D`) — it will never start a second
|
||||
trainer. To stop training, attach and Ctrl-C: the trainer writes `final.zip`
|
||||
on the way out and the script still exports, commits, and pushes what it has.
|
||||
|
||||
Resuming a previous policy (continues its timestep counter; `--timesteps` is
|
||||
*additional* steps). Lessons from run01/run02 hard-coded into flags:
|
||||
|
||||
```bash
|
||||
./start_training.sh run03 --timesteps 20000000 --n-parallel 14 --speedup 24 \
|
||||
--resume checkpoints/run02/final.zip --ent-coef 0.001 --reset-std 0.3
|
||||
```
|
||||
|
||||
- `--ent-coef` — entropy bonus. `0.0001` collapsed the policy std to 0.075 by
|
||||
20M steps (no exploration left); `0.005` blew it up to 3.0 (random play).
|
||||
`0.001` is the current middle. Healthy `train/std` drifts between ~0.2 and
|
||||
~1.0 — check it 30–45 min in before committing to a long run.
|
||||
- `--reset-std` — on resume, restores exploration a collapsed checkpoint lost.
|
||||
|
||||
## Results travel via git
|
||||
|
||||
`run_training.sh` commits and pushes everything a run produces:
|
||||
|
||||
- `training/checkpoints/<exp>/` — periodic checkpoints + `final.zip`, for
|
||||
future `--resume`, evaluation, and difficulty tiers (an early checkpoint
|
||||
*is* an easy bot);
|
||||
- `training/logs/` — TensorBoard history;
|
||||
- `Game/bots/<exp>.json` — the exported policy, immediately playable (point
|
||||
Match or Spectate mode at `res://bots/<exp>.json`);
|
||||
- `training/eval_history.json` — if evaluations ran.
|
||||
|
||||
On the Mac (or anywhere), collecting the results is just `git pull`. A
|
||||
20M-step run adds roughly 40 MB of checkpoints — acceptable growth for the
|
||||
guarantee that training is never lost with a machine.
|
||||
|
||||
## Dashboard over the network
|
||||
|
||||
`start_training.sh` already serves TensorBoard on all interfaces — browse to
|
||||
the URL it prints (`http://10.0.0.20:6006`) from the Mac or anything on
|
||||
the LAN.
|
||||
|
||||
- If `ufw` is active on the box: `sudo ufw allow 6006/tcp`.
|
||||
- If you'd rather not open a port, tunnel instead:
|
||||
`ssh -L 6006:localhost:6006 10.0.0.20` from the Mac, then browse
|
||||
`http://localhost:6006`.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user