diff --git a/client/Program.cs b/client/Program.cs
new file mode 100644
index 0000000..8f6aca0
--- /dev/null
+++ b/client/Program.cs
@@ -0,0 +1,5 @@
+// Entry point required only by the experimental .NET web (WASM) export: the static-linked mono
+// runtime needs an assembly entry point. An empty top-level statement is sufficient — the game is
+// still hosted by the Godot engine, not by this Main. Compiled only in Export* configs (see
+// TankGame.csproj), so desktop/Android/test builds are unaffected.
+{ }
diff --git a/client/TankGame.csproj b/client/TankGame.csproj
index 7875996..2c42ea9 100644
--- a/client/TankGame.csproj
+++ b/client/TankGame.csproj
@@ -18,16 +18,43 @@
-
+
-
+
+
+
+ Exe
+
+ true
+
+ $(DefineConstants);GODOT_WEB
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/TankGame.sln b/client/TankGame.sln
new file mode 100644
index 0000000..1f97c64
--- /dev/null
+++ b/client/TankGame.sln
@@ -0,0 +1,21 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TankGame", "TankGame.csproj", "{A3B5C7D9-1234-5678-9ABC-DEF012345678}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ ExportDebug|Any CPU = ExportDebug|Any CPU
+ ExportRelease|Any CPU = ExportRelease|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A3B5C7D9-1234-5678-9ABC-DEF012345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A3B5C7D9-1234-5678-9ABC-DEF012345678}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
+ {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
+ {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
+ {A3B5C7D9-1234-5678-9ABC-DEF012345678}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/client/audio/sfx/fire.ogg b/client/audio/sfx/fire.ogg
index 7952865..8fb9117 100644
Binary files a/client/audio/sfx/fire.ogg and b/client/audio/sfx/fire.ogg differ
diff --git a/client/src/GameLogic/Airstrike.cs b/client/src/GameLogic/Airstrike.cs
index f2071c3..dd0e874 100644
--- a/client/src/GameLogic/Airstrike.cs
+++ b/client/src/GameLogic/Airstrike.cs
@@ -33,7 +33,7 @@ public sealed class Airstrike : IAirstrike
public Airstrike(IWorld world, IReadOnlyList zones, int callerTeam, float zoneRadius,
float armWindow, float delay, int damage)
{
- Id = Guid.NewGuid();
+ Id = EntityId.Next();
_world = world;
_callerTeam = callerTeam;
_damage = damage;
diff --git a/client/src/GameLogic/EntityId.cs b/client/src/GameLogic/EntityId.cs
new file mode 100644
index 0000000..f89fc54
--- /dev/null
+++ b/client/src/GameLogic/EntityId.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace TankGame.GameLogic;
+
+/// Allocates unique entity ids.
+///
+/// On desktop/Android this is just . On the experimental .NET WASM web
+/// runtime, however, the cryptographic RNG that backs Guid.NewGuid() is unavailable (the
+/// editor fork's README flags crypto APIs as non-working), so every call returns the same value.
+/// That collapses every entity's identity, which silently breaks combat: CombatResolver skips
+/// a tank when tank.Id == shot.Owner and the AI skips a tank when tank.Id == self.Id —
+/// with all ids equal, every shot spares every target and every enemy looks like "me", so nothing
+/// takes damage and the AI never fires. A monotonic counter gives guaranteed-unique ids with no
+/// crypto dependency on the web build; desktop keeps the random Guid (netcode relies on it).
+public static class EntityId
+{
+#if GODOT_WEB
+ private static long _counter;
+#endif
+
+ public static Guid Next()
+ {
+#if GODOT_WEB
+ var bytes = new byte[16];
+ BitConverter.TryWriteBytes(bytes, System.Threading.Interlocked.Increment(ref _counter));
+ return new Guid(bytes);
+#else
+ return Guid.NewGuid();
+#endif
+ }
+}
diff --git a/client/src/GameLogic/NetPowerup.cs b/client/src/GameLogic/NetPowerup.cs
index ff43d40..2f4b7df 100644
--- a/client/src/GameLogic/NetPowerup.cs
+++ b/client/src/GameLogic/NetPowerup.cs
@@ -11,7 +11,7 @@ namespace TankGame.GameLogic;
/// by diffing snapshot ids, not by reaping).
public sealed class NetPowerup : IPowerup
{
- public Guid Id { get; } = Guid.NewGuid();
+ public Guid Id { get; } = EntityId.Next();
public Vector2 Position { get; set; }
public PowerupKind Kind { get; set; }
public bool IsAvailable { get; set; } = true;
diff --git a/client/src/GameLogic/NetProjectile.cs b/client/src/GameLogic/NetProjectile.cs
index 168eb7d..7efec17 100644
--- a/client/src/GameLogic/NetProjectile.cs
+++ b/client/src/GameLogic/NetProjectile.cs
@@ -11,7 +11,7 @@ namespace TankGame.GameLogic;
/// always true (the scene shows or hides shots by rebuilding from each snapshot, not by reaping).
public sealed class NetProjectile : IProjectile
{
- public Guid Id { get; } = Guid.NewGuid();
+ public Guid Id { get; } = EntityId.Next();
public Vector2 Position { get; set; }
public Vector2 Direction { get; set; }
public int Team { get; set; }
diff --git a/client/src/GameLogic/NetTank.cs b/client/src/GameLogic/NetTank.cs
index 416fad2..6026368 100644
--- a/client/src/GameLogic/NetTank.cs
+++ b/client/src/GameLogic/NetTank.cs
@@ -14,7 +14,7 @@ public sealed class NetTank : ITank
{
public NetTank(int maxHp = 3) => MaxHp = maxHp;
- public Guid Id { get; } = Guid.NewGuid();
+ public Guid Id { get; } = EntityId.Next();
public Vector2 Position { get; set; }
public float Rotation { get; set; }
public float TurretRotation { get; set; }
diff --git a/client/src/GameLogic/Powerup.cs b/client/src/GameLogic/Powerup.cs
index 904b07c..e1d3bab 100644
--- a/client/src/GameLogic/Powerup.cs
+++ b/client/src/GameLogic/Powerup.cs
@@ -47,7 +47,7 @@ public Powerup(IWorld world, Vector2 position, PowerupKind kind, IPickupEffect e
float pickupRadius, bool dropOnCarrierDeath = false, float respawnCooldown = 0f,
float despawnAfter = 0f)
{
- Id = Guid.NewGuid();
+ Id = EntityId.Next();
_world = world;
Position = position;
Kind = kind;
diff --git a/client/src/GameLogic/PowerupDirector.cs b/client/src/GameLogic/PowerupDirector.cs
index 383a450..8435178 100644
--- a/client/src/GameLogic/PowerupDirector.cs
+++ b/client/src/GameLogic/PowerupDirector.cs
@@ -74,7 +74,7 @@ public PowerupDirector(IWorld world, int seed, IReadOnlyList<(int X, int Y)> flo
_untilNextSpawn = NextInterval();
}
- public Guid Id { get; } = Guid.NewGuid();
+ public Guid Id { get; } = EntityId.Next();
public Vector2 Position => Vector2.Zero; // bookkeeping entity — nowhere on the field
public bool IsAlive => true;
diff --git a/client/src/GameLogic/Projectile.cs b/client/src/GameLogic/Projectile.cs
index 9641def..c6d7b72 100644
--- a/client/src/GameLogic/Projectile.cs
+++ b/client/src/GameLogic/Projectile.cs
@@ -32,7 +32,7 @@ public Projectile(IArena arena, Vector2 spawn, Vector2 direction, float speed, i
int team = 0, IProjectileBehaviour? behaviour = null, int pierce = 0, Guid owner = default,
ProjectileStyle style = ProjectileStyle.Normal, int layer = 0)
{
- Id = Guid.NewGuid();
+ Id = EntityId.Next();
Layer = layer;
_arena = arena;
_state = new ProjectileState
diff --git a/client/src/GameLogic/Tank.cs b/client/src/GameLogic/Tank.cs
index 14e8c5a..e6ddd43 100644
--- a/client/src/GameLogic/Tank.cs
+++ b/client/src/GameLogic/Tank.cs
@@ -68,7 +68,7 @@ public Tank(
string displayName = "",
Func? respawnPoint = null)
{
- Id = Guid.NewGuid();
+ Id = EntityId.Next();
MaxHp = maxHp;
Hp = maxHp;
Team = team;
diff --git a/client/src/Infrastructure/TranslationLoader.cs b/client/src/Infrastructure/TranslationLoader.cs
index 69033e1..8d75094 100644
--- a/client/src/Infrastructure/TranslationLoader.cs
+++ b/client/src/Infrastructure/TranslationLoader.cs
@@ -17,7 +17,9 @@ public static void EnsureLoaded()
var file = FileAccess.Open(CsvPath, FileAccess.ModeFlags.Read);
if (file is null)
{
- GD.PushError($"TranslationLoader: cannot open {CsvPath}");
+ // Exported builds (e.g. web) don't ship the raw CSV — Godot imports it to
+ // per-locale .translation resources and only those are packed. Load them.
+ LoadImportedTranslations();
return;
}
@@ -69,6 +71,21 @@ public static void EnsureLoaded()
_loaded = true;
}
+ // The CSV header order is keys,en,es,dk; the importer emits one .translation per locale.
+ private static void LoadImportedTranslations()
+ {
+ foreach (var locale in new[] { "en", "es", "dk" })
+ {
+ var translation = GD.Load($"res://i18n/strings.{locale}.translation");
+ if (translation is not null)
+ {
+ TranslationServer.AddTranslation(translation);
+ }
+ }
+
+ _loaded = true;
+ }
+
private static string[] ParseCsvLine(string line)
{
var cells = new System.Collections.Generic.List();
diff --git a/client/src/Presentation/Arena/SfxPool.cs b/client/src/Presentation/Arena/SfxPool.cs
index c877d96..af8cdc4 100644
--- a/client/src/Presentation/Arena/SfxPool.cs
+++ b/client/src/Presentation/Arena/SfxPool.cs
@@ -18,10 +18,11 @@ public partial class SfxPool : Node
// louder but still subdued.
private const float HoverOffsetDb = -16f;
private const float UiClickOffsetDb = -6f;
- // Cannon shots fire constantly during a fight; at full volume they drown everything else out
- // (owner feedback 2026-06-30: too loud). −20 dB ≈ 10% of the previous amplitude, the level asked
- // for. Applied per-shot to the Fire kind only, so explosions/pickups/voice keep their volume.
- private const float FireOffsetDb = -20f;
+ // Cannon shots fire constantly during a fight, so Fire sits below the other gameplay SFX.
+ // The old loud clip needed −20 dB (owner feedback 2026-06-30); the current cartoon pew is
+ // intrinsically much quieter, so it only gets a small trim. Provisional pending owner's ear —
+ // adjust this constant alone to re-level. Applied per-shot to the Fire kind only.
+ private const float FireOffsetDb = -6f;
private static readonly Dictionary SfxFiles = new()
{
@@ -96,9 +97,15 @@ public override void _Ready()
private static AudioStream? LoadOgg(string resPath)
{
using var file = FileAccess.Open(resPath, FileAccess.ModeFlags.Read);
- if (file is null) return null;
- var bytes = file.GetBuffer((long)file.GetLength());
- return AudioStreamOggVorbis.LoadFromBuffer(bytes);
+ if (file is not null)
+ {
+ var bytes = file.GetBuffer((long)file.GetLength());
+ return AudioStreamOggVorbis.LoadFromBuffer(bytes);
+ }
+
+ // Exported builds (e.g. web) don't ship the raw .ogg, only the imported
+ // AudioStream resource — load that instead of giving up (no sound).
+ return GD.Load(resPath);
}
/// Set the SFX volume for every player in the pool (dB; 0 = full, negative = quieter).
diff --git a/client/src/Presentation/Bootstrap.cs b/client/src/Presentation/Bootstrap.cs
index c55ada5..3febd9d 100644
--- a/client/src/Presentation/Bootstrap.cs
+++ b/client/src/Presentation/Bootstrap.cs
@@ -1,6 +1,6 @@
using Godot;
using TankGame.Infrastructure;
-#if DEBUG
+#if DEBUG && !GODOT_WEB
using System.Reflection;
using Chickensoft.GoDotTest;
#endif
@@ -14,13 +14,13 @@ namespace TankGame.Presentation;
// the runner. Test wiring is DEBUG-only, so ExportRelease builds exclude it.
public partial class Bootstrap : Node
{
-#if DEBUG
+#if DEBUG && !GODOT_WEB
private TestEnvironment _environment = default!;
#endif
public override void _Ready()
{
-#if DEBUG
+#if DEBUG && !GODOT_WEB
_environment = TestEnvironment.From(OS.GetCmdlineArgs());
if (_environment.ShouldRunTests)
{
@@ -29,7 +29,9 @@ public override void _Ready()
}
#endif
// App init happens once here (the composition root), then the play scene loads.
- SentryBootstrap.Init();
+#if !GODOT_WEB
+ SentryBootstrap.Init(); // Sentry's .NET SDK isn't WASM-compatible; web builds skip it.
+#endif
TranslationLoader.EnsureLoaded();
// Deferred: the scene tree is mid-add during _Ready and rejects an
@@ -38,7 +40,7 @@ public override void _Ready()
SceneTree.MethodName.ChangeSceneToFile, "res://src/Presentation/Title.tscn");
}
-#if DEBUG
+#if DEBUG && !GODOT_WEB
private void RunTests()
=> _ = GoTest.RunTests(Assembly.GetExecutingAssembly(), this, _environment);
#endif
diff --git a/client/src/Presentation/GameMode.cs b/client/src/Presentation/GameMode.cs
index 1e55227..f1ff9ce 100644
--- a/client/src/Presentation/GameMode.cs
+++ b/client/src/Presentation/GameMode.cs
@@ -105,7 +105,9 @@ public static void StartNewMatch(GameMode mode)
{
Mode = mode;
Series = new SeriesTracker(RoundsToWin);
- ArenaSeed = Guid.NewGuid().GetHashCode();
+ // Guid.NewGuid is constant on WASM (no crypto RNG) and EntityId's web counter restarts at 0
+ // every page load, so mix in wall-clock ticks or every web session rolls the same arena.
+ ArenaSeed = HashCode.Combine(EntityId.Next(), DateTime.UtcNow.Ticks);
CustomMap = null; // a fresh match defaults to the built-in arena; My Maps sets it back after
}
diff --git a/docs/web-export.md b/docs/web-export.md
new file mode 100644
index 0000000..138937d
--- /dev/null
+++ b/docs/web-export.md
@@ -0,0 +1,97 @@
+# Web (WASM) export + Lundrea Arcade deploy
+
+Status as of 2026-07-17: the web layer is **reconciled into `main`** — the WASM export builds
+from `main` alone, no side branch. All web-only code is `#if GODOT_WEB`-guarded or additive, so
+desktop/Android/test builds are unaffected. The live single-player build is at
+ (arcade home: ).
+
+The old branches `feat/web-export`, `web-export-deploy`, `web-reconcile`, and `web-reconcile-256`
+are fully reconciled (ported or superseded) and can be deleted.
+
+---
+
+## 1. Toolchain (what another machine needs)
+
+- **Custom Godot editor with C# web export** — stock Godot can't export .NET to web. Use the
+ ComplexRobot fork: . Download the
+ `*_mono_web_export_win64` release, extract it, run its `install.bat` (adds the web export
+ templates to `%AppData%\Godot\export_templates\4.6.2.stable.mono\` and a local NuGet source). On
+ the reference machine it lives at `C:\godot-web-export\Godot_v4.6.2-stable_mono_web_export_win64\`.
+- **.NET `wasm-tools` workload** — `dotnet workload install wasm-tools`. **VERIFY with
+ `dotnet workload list`** — a prior install silently failed to take; without it the export still
+ produces a bundle but the managed C# is never compiled into WASM (tiny ~4 MB `.pck`, black screen).
+ When present, `.pck` is ~48 MB.
+
+## 2. Build & export
+
+```sh
+EDITOR="C:/godot-web-export/.../Godot_v4.6.2-stable_mono_web_export_win64.exe"
+# 1. Reimport ONCE after a fresh pull — regenerates .import/.uid/.translation (all gitignored):
+"$EDITOR" --headless --path client --import
+# 2. Export (output goes to build/web/, gitignored):
+"$EDITOR" --headless --path client --export-release "Web" "$(pwd)/build/web/index.html"
+```
+
+Serve locally with COOP/COEP to test (the build needs cross-origin isolation): any server sending
+`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` works.
+
+## 3. The web layer on `main` (what makes the WASM build work)
+
+| File | Change | Why |
+|---|---|---|
+| `client/TankGame.csproj` | web `PropertyGroup`: `OutputType=Exe` + `Program.cs`, `WasmEnableThreads`, `GODOT_WEB` define, Sentry+GoDotTest excluded, `TrimmerRootAssembly` for `System.Private.CoreLib`, `System.Runtime`, **`TankGame`, `GodotSharp`** | wasm needs an entry point; the trimmer would otherwise prune the whole game (the engine calls `GodotPlugins.Game.Main` natively, so it's invisible to the trimmer) |
+| `client/Program.cs`, `client/TankGame.sln` | `{}` entry point + hand-written solution | the static-mono runtime needs a top-level entry + a solution to publish |
+| `client/src/GameLogic/EntityId.cs` | crypto-free counter-based entity ids on web (`#if GODOT_WEB`; desktop keeps `Guid.NewGuid`) | **`Guid.NewGuid()` returns the SAME value every call on this runtime** (no crypto RNG) → all tanks shared one id → `tank.Id == shot.Owner` / `== self.Id` always true → no damage + AI never fires. This was *the* combat bug. Every entity id call site (`Tank`, `Projectile`, `Airstrike`, `Powerup`, `NetTank`, `NetProjectile`, `NetPowerup`, `PowerupDirector`) goes through `EntityId.Next()`. |
+| `client/src/Presentation/GameMode.cs` | `ArenaSeed` from `HashCode.Combine(EntityId.Next(), DateTime.UtcNow.Ticks)` | `Guid.NewGuid` is constant on WASM, and EntityId's web counter restarts at 0 every page load — either alone rolls the identical "random" arena every web session |
+| `client/src/Infrastructure/TranslationLoader.cs`, `SfxPool.LoadOgg` | `GD.Load` fallback when raw `FileAccess` fails | exports ship only the *imported* resources, not raw source files (csv/ogg) |
+| `client/src/Presentation/Bootstrap.cs` | Sentry + test-runner skipped on web | neither works on the WASM runtime |
+| `client/src/Infrastructure/PlatformExit.cs` | Exit → `JavaScriptBridge.Eval` back to the arcade on web | `GetTree().Quit()` is a no-op in a browser (button reads "Back to Arcade") |
+| `client/src/Infrastructure/Net/GodotHttpLobbyClient.cs` | web lobby HTTP over Godot `HttpRequest` nodes | .NET `HttpClient` dies with an NRE in `BrowserHttpInterop` on the threaded WASM runtime; desktop keeps `HttpLobbyClient`, both parse via the shared `LobbyWire` |
+| `client/export_presets.cfg` | `[preset.1]` Web preset, `canvas_resize_policy=2` (adaptive) | policy 1 locked the canvas to base resolution — rendered in a corner of the iframe |
+| `client/audio/sfx/fire.ogg` | short cartoon "pew" (4.4 KB) | replaced a harsh 40 KB continuous tone; also shrinks the web bundle. `SfxPool.FireOffsetDb` re-levelled −20 → −6 dB for the quieter clip (final level owner-ear-gated) |
+
+### The 5 web-only bugs that had to be fixed (in order discovered)
+1. `wasm-tools` not actually installed → managed C# never compiled → black screen.
+2. Trimmer pruned the game (rooted `TankGame`/`GodotSharp`).
+3. Exports drop raw `FileAccess` resources → untranslated menu, no SFX (GD.Load fallbacks).
+4. `Guid.NewGuid()` collision → no combat damage, AI never fires (counter-based `EntityId`).
+5. GitHub LFS bandwidth quota broke the CI deploy → store the bundle as **plain git blobs** (both files < 100 MB).
+
+Superseded-along-the-way (never ported; `main`'s versions won): the old branch's inline
+fixed-timestep loop (main has swept-collision fixed-step combat, #243), its time-less
+`KillStreakTracker`, its TitleScene web gating (main's slim menu + `PlatformExit`, #248),
+and its .NET-HttpClient-era lobby plumbing (host-authoritative redesign, ADR-0019/0021, #245+).
+
+## 4. Deploy to Lundrea Arcade (ProjectX repo)
+
+Deploy is **CI-on-push to ProjectX `main`** (GitHub Actions → `vite build` → Firebase Hosting). To
+ship a new bundle:
+
+```sh
+cp -r build/web/* C:/programmering/ProjectX/public/tank/ # overwrite the vendored bundle
+# in ProjectX: branch off main, commit, PR, merge → auto-deploys
+```
+
+ProjectX integration (already in place on `main`): `public/tank/` bundle as **plain git blobs**
+(NOT LFS — quota); `firebase.json` scoped `/tank/**` COOP/COEP headers; vite PWA
+`globIgnores:['tank/**']`; `src/arcade/games.ts` `tank` entry with `external:true`; contract/e2e
+suites skip `external` games; tile `public/tiles/tank.webp`.
+
+**Caveats:** Spark free tier ≈ 15–20 cold loads/day (≈58 MB bundle; repeat visits cached/free).
+One harmless web-audio `sample_set_pause` console warning. Map editor is desktop-only.
+
+## 5. Multiplayer status
+
+The 2026-06-27 multiplayer spec is **delivered on `main`**: slim title menu
+(Solo · Multiplayer · Settings · Back-to-Arcade), lobby browser + lobby room for up to 8 players
+(`LobbyProtocol.MaxPlayers`), countdown start, host-authoritative relay netcode over the
+`tankgame-worker` Cloudflare Durable Objects (ADR-0019 relay, ADR-0021 lobby directory), net
+pickups, victory screen + online rematch (#259/#262). See those ADRs for design; this doc only
+covers the web build/deploy.
+
+## 6. Resume checklist (fresh machine)
+
+1. Pull TankGame `main`; pull ProjectX `main`.
+2. Install the ComplexRobot Godot web-export editor + `dotnet workload install wasm-tools` (verify with `dotnet workload list`).
+3. `--import` once, then `--export-release "Web"` (section 2).
+4. To deploy: copy `build/web/*` → `ProjectX/public/tank/`, then PR→main in ProjectX (section 4).