{"id":51491931,"url":"https://github.com/tmwclaxton/clashofdots","last_synced_at":"2026-07-07T12:01:38.556Z","repository":{"id":363189879,"uuid":"1253916193","full_name":"tmwclaxton/clashofdots","owner":"tmwclaxton","description":"Clash of Dots - An open-source RTS based on War of Dots - Built with Irish Love ☘️ ","archived":false,"fork":false,"pushed_at":"2026-06-18T22:22:02.000Z","size":1826,"stargazers_count":1,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-18T22:22:20.659Z","etag":null,"topics":["game","linesonmaps","rts","war"],"latest_commit_sha":null,"homepage":"https://www.clashofdots.com","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tmwclaxton.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-30T00:08:05.000Z","updated_at":"2026-06-18T22:22:05.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tmwclaxton/clashofdots","commit_stats":null,"previous_names":["tmwclaxton/warofspheres","tmwclaxton/clashofdots"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/tmwclaxton/clashofdots","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmwclaxton%2Fclashofdots","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmwclaxton%2Fclashofdots/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmwclaxton%2Fclashofdots/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmwclaxton%2Fclashofdots/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tmwclaxton","download_url":"https://codeload.github.com/tmwclaxton/clashofdots/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmwclaxton%2Fclashofdots/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35226918,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-07T02:00:07.222Z","response_time":90,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["game","linesonmaps","rts","war"],"created_at":"2026-07-07T12:01:33.715Z","updated_at":"2026-07-07T12:01:38.501Z","avatar_url":"https://github.com/tmwclaxton.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"public/apple-touch-icon.png\" alt=\"Clash of Dots\" width=\"120\" height=\"120\" /\u003e\n\u003c/p\u003e\n\n\u003ch1 align=\"center\"\u003eClash of Dots\u003c/h1\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003ePlan like a diagram. Fight like an RTS.\u003c/strong\u003e\u003cbr /\u003e\n  \u003cstrong\u003eClash of Dots\u003c/strong\u003e is a server-authoritative multiplayer strategy game - tactical canvas,\n  procedural battlefields, and a \u003cstrong\u003eMap Builder\u003c/strong\u003e you can publish to the community. Gameplay\n  is inspired by the classic browser RTS\n  \u003ca href=\"https://warofdots.net/\"\u003eWar of Dots\u003c/a\u003e.\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/tmwclaxton/clashofdots\"\u003e\u003cimg src=\"https://img.shields.io/badge/GitHub-source-181717?style=for-the-badge\u0026logo=github\u0026logoColor=white\" alt=\"GitHub\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://laravel.com\"\u003e\u003cimg src=\"https://img.shields.io/badge/Laravel-13-FF2D20?style=for-the-badge\u0026logo=laravel\u0026logoColor=white\" alt=\"Laravel 13\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://vuejs.org\"\u003e\u003cimg src=\"https://img.shields.io/badge/Vue-3-42b883?style=for-the-badge\u0026logo=vuedotjs\u0026logoColor=white\" alt=\"Vue 3\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://inertiajs.com\"\u003e\u003cimg src=\"https://img.shields.io/badge/Inertia-3-9553E9?style=for-the-badge\u0026logo=inertia\u0026logoColor=white\" alt=\"Inertia 3\" /\u003e\u003c/a\u003e\n  \u003ca href=\"https://tailwindcss.com\"\u003e\u003cimg src=\"https://img.shields.io/badge/Tailwind-4-38bdf8?style=for-the-badge\u0026logo=tailwindcss\u0026logoColor=white\" alt=\"Tailwind 4\" /\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"https://img.shields.io/badge/PHP-8.3+-777BB4?style=flat\u0026logo=php\u0026logoColor=white\" alt=\"PHP\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/TypeScript-5-3178C6?style=flat\u0026logo=typescript\u0026logoColor=white\" alt=\"TypeScript\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Pinia-3-ffd859?style=flat\u0026logo=vue.js\u0026logoColor=black\" alt=\"Pinia\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/Canvas-2D-111?style=flat\u0026logo=html5\u0026logoColor=white\" alt=\"Canvas 2D\" /\u003e\n  \u003cimg src=\"https://img.shields.io/badge/WorkOS-auth-6363F1?style=flat\u0026logo=workos\u0026logoColor=white\" alt=\"WorkOS\" /\u003e\n\u003c/p\u003e\n\n---\n\n## Game Wiki\n\nThe in-app **Game Wiki** (`/wiki`) is the live reference for balance and map rules. Every number on the page is served from **`App\\Game\\GameSpecs`** on the backend—the same source the engine and Map Builder draw from, not hand-maintained copy in Vue.\n\n| Section | What it covers |\n|--------|----------------|\n| **Combat units** | Infantry vs tank—health, spawn cost, upkeep, defense, and role summaries |\n| **Settlements \u0026 economy** | Capitals and outposts (income per tick, healing), plus economy notes on income, spawn cost, upkeep, debt damage, recruitment, and water crossing |\n| **Terrain types** | All 13 editor terrains with color swatches, infantry/tank speed \u0026 attack stats, and tactical notes |\n| **Map generation styles** | Mixed, Islands, Desert, Mountains, Jungle, Volcanic, Tundra, and Grassland—traits, descriptions, and deterministic preview renders |\n\nOpen the wiki from the landing page header, the app top bar, or directly at `/wiki` once the app is running. The Map Builder card at the top links straight into authoring.\n\nWiki and README preview images live under `public/images/wiki/` (terrain swatches and map-generation renders) and can be regenerated with `npm run wiki:map-previews`.\n\n### Terrain palette\n\nThirteen brush types paint the vertex grid in the Map Builder and appear on the wiki terrain table. Swatches match the in-game editor colors.\n\n\u003cdiv align=\"center\"\u003e\n\n\u003ctable\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\" width=\"25%\"\u003e\n      \u003cimg alt=\"Plains\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-plains.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003ePlains\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eOpen grassland\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\" width=\"25%\"\u003e\n      \u003cimg alt=\"Meadow\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-meadow.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eMeadow\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eSoft rolling grass\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\" width=\"25%\"\u003e\n      \u003cimg alt=\"Forest\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-forest.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eForest\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eLight woodland\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\" width=\"25%\"\u003e\n      \u003cimg alt=\"Dense forest\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-dense_forest.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eDense forest\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eThick woodland\u003c/sub\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Hill\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-hill.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eHill\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eHigh ground\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Mountain\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-mountain.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eMountain\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eImpassable\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Desert\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-desert.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eDesert\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eTank-friendly dunes\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Beach\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-beach.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eBeach\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eCoastal sand\u003c/sub\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Water\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-water.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eWater\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eShallow · damage over time\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Deep water\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-deep_water.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eDeep water\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eOcean · heavy penalties\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"River\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-river.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eRiver\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eNarrow chokepoints\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Swamp\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-swamp.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eSwamp\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eBoggy wetland\u003c/sub\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cimg alt=\"Snow\" width=\"56\" height=\"56\" src=\"public/images/wiki/terrain-snow.svg\" /\u003e\u003cbr /\u003e\n      \u003cstrong\u003eSnow\u003c/strong\u003e\u003cbr /\u003e\n      \u003csub\u003eFrozen tundra\u003c/sub\u003e\n    \u003c/td\u003e\n    \u003ctd colspan=\"3\" /\u003e\n  \u003c/tr\u003e\n\u003c/table\u003e\n\n\u003c/div\u003e\n\nInfantry generally keeps speed in forests and hills; tanks excel on plains, desert, and beach but bog down in woodland, water, and snow. Snow slows all units and chills attack output—tanks are hit hardest. Full speed, attack, and defense multipliers for every tile are on the wiki terrain table.\n\n### Win conditions\n\n#### Free-for-all (no teams)\n\nA player wins when **both** of the following are true at the same time:\n\n1. They own **all enemy capitals** — every opposing headquarters has been captured.\n2. They control **≥ 80 %** of all cities on the map (the `VICTORY_CITY_THRESHOLD`).\n\nThe engine checks this every tick. The match ends immediately when the condition is met.\n\n#### Team mode\n\nWhen players are assigned to teams the conditions adjust:\n\n1. All players on the **opposing side** must have **zero troops and zero cities**.\n2. The winning team collectively holds **≥ 80 %** of all cities.\n\n#### No-winner draw\n\nIf every commander is inactive (no orders submitted) for **120 seconds** (`MATCH_ALL_PLAYERS_INACTIVE_SECONDS`) the match is ended without a winner.\n\n---\n\n### Economy — credits \u0026 income\n\nEach commander has a credit balance tracked in Redis alongside the simulation state.\n\n| Constant | Value | Description |\n|----------|-------|-------------|\n| Starting credits | 220 | Credits each commander begins the match with |\n| Income per owned city per tick | 1 credit | Earned every 30 Hz tick for every outpost or capital you hold |\n| Infantry spawn cost | 200 credits | One-time cost; deducted when the troop spawns |\n| Tank spawn cost | 400 credits | One-time cost; deducted when the troop spawns |\n| Upkeep per troop per tick | 1 credit | Applies to every troop on the field, regardless of type or health |\n| Debt damage scaling | 1 HP per 10 credits | HP drained per tick per 10 credits of negative balance |\n\n**Income math:** at 30 Hz, 1 credit/tick/city ≈ **30 credits/second** per owned city (e.g. 3 cities → 90 credits/s). Holding more cities snowballs income quickly.\n\n**Army size is unlimited** — there is no hard cap. Your sustainable army size is governed entirely by the economy: each troop costs 1 credit/tick in upkeep, so the break-even force is roughly `net income / 1`. Saving up credits lets you field a larger army temporarily, but if credits go negative your troops start taking damage (see below).\n\n---\n\n### Recruitment\n\nTroops spawn automatically at any city or outpost you designate as a **recruitment city**. Open the **Recruit panel** in the HUD to manage this:\n\n- **Per-city toggle** — click any owned city or outpost in the list (or on the map while the panel is open) to enable (green ring) or disable (red ring) it as a spawn point.\n- **Speed slider** — global control from Off (no spawning) to Max (spawn as soon as a city is available and affordable).\n- **Infantry / Tank slider** — global ratio applied across all recruitment cities; 0 = all infantry, 100 = all tanks, 50 = equal mix.\n\nSpawn rules for each enabled city on each tick:\n1. The city's internal timer advances based on the speed slider (cubic scaling — faster at the high end).\n2. When the timer threshold is reached the engine checks: city unoccupied by own troops, sufficient credits for the chosen unit type.\n3. On success the troop spawns at the city's position with **25% of its maximum HP** and the spawn cost is deducted immediately. The timer resets.\n4. If the city is occupied or credits are insufficient, the tick is skipped and the timer continues.\n\n---\n\n### Army upkeep \u0026 debt\n\nEvery tick the engine deducts `troopCount × 1 credit` from your balance (upkeep) before adding income.\n\n- If the resulting balance is **negative**, the `applyDebtDamage` routine runs: `hpDrain = floor(abs(credits) / 10)` HP is distributed across your troops.\n- Damage is applied **tanks first**, then **newest infantry first** within each type.\n- Recapture cities, let some troops die, or reduce recruitment speed to climb back into the black.\n\n---\n\n### Combat \u0026 morale\n\nUnits fight automatically when within **32 world units** of an enemy (`TROOP_COMBAT_RANGE`).\n\n| Mechanic | Detail |\n|----------|--------|\n| **Base attack** | Terrain-dependent (see wiki terrain table); infantry baseline 0.08, tank up to 2.0 on plains |\n| **Warmup bonus** | Fresh units get up to **1.45× attack** for the first 120 ticks (~4 s), decaying linearly |\n| **Morale** | Ranges 15–100. Drains **0.35/tick** in combat and **0.5/tick** when supply-cut in enemy territory. Recovers **0.22/tick** when resting in own territory. Low morale directly reduces attack power (capped at 0.25× floor) |\n| **Healing** | 1 HP/tick when in own territory and not in combat; suppressed during active fighting |\n| **Mountains** | Impassable — no unit can enter; attack multiplier 0 |\n\n---\n\n### Planning \u0026 executing orders\n\nClash of Dots uses a **draw-then-commit** order system. Nothing moves until you press **Space**; you can revise paths freely before committing.\n\n#### Drawing paths\n\n| Action | What happens |\n|--------|-------------|\n| **Click + drag from a unit** | Begins a movement path for that troop. Drag to the destination and release. |\n| **Re-drag the same unit** | Replaces its existing draft path. |\n| **Click + drag on empty ground** | Draws a lasso rectangle. All own troops inside are selected as a group. |\n| **Drag from any selected troop** | Extends draft paths for every troop in the group simultaneously. |\n| **Right-click + drag** (or two-finger drag on touch) | Pans the camera without affecting drafts. |\n| **Scroll / pinch** | Zooms. |\n\nDrafted paths are shown as coloured lines from each unit to its destination. The path is stored locally in the `draftStore` — it has **not** reached the server yet.\n\n#### Committing orders\n\n| Input | Effect |\n|-------|--------|\n| **Space** | Submits all drafted paths to the server via `POST /games/{uuid}/orders`. Paths are cleared from the draft store on success. |\n| **C** | Clears all local drafts without submitting anything. |\n| **S** | Halts all own troops immediately — sends an empty-path order for every unit you own. |\n\n#### What happens on the server\n\n1. `SubmitOrdersRequest` validates the payload (path point arrays, optional water-mode strings).\n2. `GameManager::submitOrders` merges the incoming orders with any previously stored orders for that player in Redis — a second submit for the same troop *replaces* that troop's path.\n3. The merged paths are written back to Redis without running any simulation.\n4. The `game:tick --daemon` process (running at **30 Hz**) picks up the new paths on the next tick, advances the simulation, and broadcasts a `GameStateUpdated` event via **Reverb**.\n\n#### Seeing the result\n\nThe Vue canvas receives state in two ways:\n\n- **Reverb / Echo push** — `GameStateUpdated` events arrive in ~33 ms for the lowest latency.\n- **HTTP snapshot polling** — if WebSockets are unavailable the canvas polls `GET /games/{uuid}/snapshot` every ~1.8 s as a fallback.\n\nBoth paths call `applySnapshotPayload` in `gameStore`, which updates the Pinia state and triggers the RAF render loop to redraw troop positions, paths, health bars, and territory.\n\n### Water crossing — Wade vs Embark\n\nWhenever a drafted path crosses **water**, **river**, or **deep water** tiles a modal prompts you to pick a crossing mode before the orders are submitted.\n\n| Mode | How it works | HP drain | Deep water | Visual cue |\n|------|-------------|----------|-----------|------------|\n| **Wade** | Unit moves through water immediately as a normal troop | 1 HP / tick while in water | Blocked — troops are halted at the deep-water boundary | None |\n| **Embark** | Unit pauses at the shore for ~3 s (90 ticks) while converting to a ship | No drain once converted; damage applies during the conversion window | Accessible — ships can cross deep water freely | Pulsing dashed ring during conversion; pointed-oval (boat) hull once converted |\n\nShips revert to troops the moment they step back onto dry land, resetting the `waterTicks` counter and `isShip` flag.\n\n**Tactical summary:** Use **Wade** for short river crossings where speed matters and you can afford the attrition. **Embark** for ocean or deep-water crossings, island-hopping, or any route where you need to keep the unit at full health.\n\n### Map generation previews\n\nProcedural **Map Builder** styles (deterministic previews, same seed). These match the **Map generation styles** section on the wiki.\n\n\u003ctable\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\" width=\"50%\"\u003e\n      \u003cstrong\u003eMix\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-mix.svg\" alt=\"Mix terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\" width=\"50%\"\u003e\n      \u003cstrong\u003eIslands\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-islands.svg\" alt=\"Islands terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cstrong\u003eDesert\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-desert.svg\" alt=\"Desert terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cstrong\u003eMountains\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-mountains.svg\" alt=\"Mountains terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cstrong\u003eJungle\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-jungle.svg\" alt=\"Jungle terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cstrong\u003eVolcanic\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-volcanic.svg\" alt=\"Volcanic terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n  \u003ctr\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cstrong\u003eTundra\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-tundra.svg\" alt=\"Tundra terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n    \u003ctd align=\"center\"\u003e\n      \u003cstrong\u003eGrassland\u003c/strong\u003e\u003cbr /\u003e\n      \u003cimg src=\"public/images/wiki/map-generation-grassland.svg\" alt=\"Grassland terrain preview\" width=\"100%\" /\u003e\n    \u003c/td\u003e\n  \u003c/tr\u003e\n\u003c/table\u003e\n\n---\n\n## Why this project\n\n| Pillar | What you get |\n|--------|----------------|\n| **Visual language** | Flat, diagrammatic battlefields - readable at a glance, inspired by *Historia Civilis*–style maps |\n| **Planning loop** | Draw movement and attack paths, commit orders, then resolve - simplified grand-strategy cadence |\n| **Fair play** | Game logic on the **Laravel** backend; the Vue canvas is a view, not the source of truth |\n| **Community maps** | **Explore** published designs, fork copies into your builder, start lobbies with attribution |\n\n---\n\n## Feature map\n\n```mermaid\nflowchart TB\n  subgraph client [Vue 3 + Inertia]\n    UI[Pages and HUD]\n    Canvas[Battlefield canvas]\n    Editor[Map builder canvas]\n  end\n  subgraph server [Laravel]\n    API[HTTP + policies]\n    Engine[Game engine / ticks]\n  end\n  subgraph realtime [Live state]\n    Redis[(Redis)]\n    Reverb[Reverb / Echo]\n  end\n  UI --\u003e API\n  Canvas --\u003e API\n  Editor --\u003e API\n  API --\u003e Engine\n  Engine --\u003e Redis\n  Reverb --\u003e Canvas\n```\n\n- **Lobbies \u0026 matches** - create/join games, host flow, match history  \n- **Wiki** - live unit, terrain, economy, and map-generation specs at `/wiki` (backed by `GameSpecs`)  \n- **Map Builder** - vertex terrain grid, markers, undo/redo, random generate, autosave  \n- **Explore** - published maps, likes/dislikes, fork to your library, lobby from a map  \n- **Icons** - [Lucide](https://lucide.dev) (tree-shaken per view) + [Font Awesome 7](https://fontawesome.com) (global solid/regular/brands)\n\n---\n\n## Stack at a glance\n\n| Layer | Choices |\n|-------|---------|\n| **Backend** | Laravel 13, WorkOS auth, policies \u0026 form requests |\n| **Frontend** | Vue 3, Inertia 3, Vite 8, Tailwind CSS 4, Reka UI primitives |\n| **State \u0026 UX** | Pinia, VueUse, vue-sonner toasts |\n| **Realtime** | Laravel Reverb, Echo, Pusher protocol client |\n| **Quality** | PHPUnit, Pint, ESLint 9, Prettier 3, Laravel Wayfinder (typed routes) |\n\n---\n\n## Quick start\n\n### Prerequisites\n\n- PHP **8.3+** with extensions used by this app (including **pcntl** if you run `php artisan reverb:start` on the host), [Composer](https://getcomposer.org/)\n- Node **22+** and npm  \n- [PostgreSQL](https://www.postgresql.org/) (primary app database; configure `DB_*` in `.env`)\n- [Redis](https://redis.io/) (live match state; required for lobbies and matches)\n- [Docker](https://www.docker.com/) (recommended for [Laravel Sail](https://laravel.com/docs/sail))\n\n### Install\n\n```bash\ngit clone https://github.com/tmwclaxton/clashofdots.git\ncd clashofdots\ncomposer install\ncp .env.example .env\nphp artisan key:generate\n```\n\nCopy **`.env.example` → `.env`** and run `php artisan key:generate`. Defaults match **Sail**: `DB_HOST=pgsql`, `REDIS_HOST=redis`, `REVERB_HOST=reverb`, and `VITE_REVERB_HOST=localhost`. **`compose.yaml`** also injects `REDIS_HOST`, `REVERB_HOST`, and `VITE_REVERB_*` into PHP containers so live matches work after **`./vendor/bin/sail up`** even if an older `.env` still had `127.0.0.1`. Configure **`WORKOS_*`** when you use login. Matches need **Redis**, **Reverb**, and the **`game-tick`** service (all included in Sail). **PHPUnit** uses **in-memory SQLite** (`phpunit.xml`) unless you change it.\n\n**PHP on the host (no Docker):** set `DB_HOST=127.0.0.1`, `REDIS_HOST=127.0.0.1`, `REVERB_HOST=127.0.0.1`, and keep `VITE_REVERB_HOST=localhost` with Reverb’s port published to the host.\n\n**Guests:** you can open **Lobbies**, join with a code, and fight without signing in. The app stores a stable guest UUID in the Laravel session (`wod_guest_key`) so the same browser can use **Ongoing** to return after a disconnect. Creating a lobby and **Past matches** still require login.\n\n### Run with Sail\n\n```bash\ncp .env.example .env   # first time only; then: php artisan key:generate (host or sail)\n./vendor/bin/sail up -d\n./vendor/bin/sail artisan migrate\n./vendor/bin/sail npm install\n./vendor/bin/sail npm run dev\n```\n\n[`compose.yaml`](compose.yaml) brings up **app** (nginx + PHP), **pgsql**, **redis**, **reverb** (port **8080** on the host by default), **`game-tick`** (`php artisan game:tick --daemon`), **queue-worker**, and **scheduler**. Postgres and Redis use **health checks** before the app container is considered ready; PHP services get **`REDIS_HOST=redis`**, **`REVERB_HOST=reverb`**, and **`VITE_REVERB_HOST=localhost`** so Redis, server-side broadcasting, and Vite all resolve correctly inside Docker.\n\nOpen **`APP_URL`** (often `http://localhost` with `APP_PORT=80`).\n\nIf **`reverb`** keeps **Restarting** (`./vendor/bin/sail ps`), read `./vendor/bin/sail logs reverb`. Often the Sail **image is stale** (PHP without **pcntl**): run `./vendor/bin/sail build` then `./vendor/bin/sail up -d`. Check with `./vendor/bin/sail exec laravel.test php -r \"var_export(extension_loaded('pcntl'));\"` — expect `true`.\n\nRebuild the image after Dockerfile changes: `./vendor/bin/sail build --no-cache`.\n\n### Run without Sail\n\n```bash\nphp artisan migrate\nnpm install\ncomposer run dev\n```\n\n`composer run dev` runs the HTTP server, queue worker, logs, Vite, **Reverb**, and **`game:tick --daemon`** together. Ensure **Redis** is running and `REDIS_*` in `.env` points at it.\n\n### Live match checklist (troops must move)\n\nSubmitted orders are stored in **Redis**, but **units only advance when `php artisan game:tick --daemon` is running** (included in Sail’s `game-tick` service and `composer run dev`). If troops never move after you press **Space**:\n\n1. **Redis** — `REDIS_*` must match a running instance (Sail: `redis` host).\n2. **Tick worker** — start `game:tick` or use `composer run dev` / full Sail stack. If `worldTick` never moves, run `./vendor/bin/sail logs game-tick --tail 50`: a failing **lobby expiry** DB query used to abort the whole daemon before ticks ran; that is now isolated, and **`compose.yaml` injects `DB_HOST=pgsql`** into PHP workers so Postgres resolves on the Sail network. The tick loop also **re-syncs `games:active` from the database** periodically so matches are not stuck if Redis dropped the set. **Without the daemon**, the first JSON snapshot in each poll cycle can still advance the sim by **one tick** (heartbeat key `games:tick:daemon-heartbeat` is absent), so the HUD is not stuck at 0; for real-time play you still want `game:tick --daemon` at full tick rate.\n3. **Reverb** — optional for movement; the play page still **polls JSON snapshots every ~1.8s** so you see positions without websockets. Reverb adds lower-latency `GameStateUpdated` pushes.\n\nRegression: `php artisan test tests/Feature/Games/GameTickOrdersTest.php` (requires Redis; skipped otherwise). Inspect tick registration: `./vendor/bin/sail artisan game:active-set` (add `--repair` to re-sync from the DB). **Raw `redis-cli SMEMBERS games:active` is often empty** because Laravel prefixes keys with `slug(APP_NAME)-database-`; use the command output or `redis-cli KEYS \"*games:active*\"`. **Match snapshots must not be cached** — if the HUD still claims time is frozen after Redis looks healthy, hard-refresh the play page once (we send `Cache-Control: no-store` on `/games/{game}/snapshot` and `fetch(..., { cache: 'no-store' })` in the client).\n\nIf you prefer to run pieces yourself:\n\n```bash\nphp artisan serve\nnpm run dev\nphp artisan reverb:start\nphp artisan game:tick --daemon\n```\n\n---\n\n## Production deploy\n\nCI builds the **`Dockerfile`**, pushes **`ghcr.io/\u003clowercase github.repository\u003e:latest`**, then SSHs to your host, uploads **`compose.prod.yaml`** into `DEPLOY_DIR`, and runs **`docker compose pull`**, **`up -d`**, and **`php artisan migrate --force`**. SSH uses **Cloudflare Access** (`cloudflared access ssh`) when `CF_ACCESS_CLIENT_*` secrets are set.\n\n### Shared host: only clashofdots\n\nThe workflow and compose file are scoped to **project name `clashofdots`** and **`DEPLOY_DIR`** only. It does **not** run host-wide `docker prune` or other commands that would affect other stacks.\n\nWhen operating manually on a server that runs multiple apps:\n\n- Work only under your **`DEPLOY_DIR`** (e.g. `/opt/clashofdots`).\n- Always pass **`-p clashofdots`** and **`-f compose.prod.yaml`** (and **`--env-file .env`**) so Docker Compose never touches another project’s containers or volumes.\n- Do not run **`docker volume prune`**, **`docker image prune -a`**, or **`docker system prune`** unless you intend to clean **the whole host**; prefer removing only compose-managed resources for this stack after **`docker compose -p clashofdots … down`**, and only volumes whose names you recognize as belonging to this project.\n\n### Flow\n\n1. **Triggers:** push to `main` or **Actions → Production Deploy → Run workflow**.\n2. **Build:** checkout → login to GHCR → `docker build` → `docker push`.\n3. **Deploy:** `cloudflared` → SSH key + config (including `ProxyCommand` when using Access) → upload `compose.prod.yaml` → remote `docker login`, `compose pull`, `up -d`, `migrate`.\n\n### Deploy target: **Secrets** or **Variables**\n\nUse **either** the **Secrets** tab **or** the **Variables** tab for `DEPLOY_HOST`, `DEPLOY_USER`, and `DEPLOY_DIR`. If both are set for the same name, the **Secret** value wins.\n\n| Name | Example | Purpose |\n|------|---------|---------|\n| `DEPLOY_HOST` | `ssh.example.com` | SSH hostname. |\n| `DEPLOY_USER` | `deploy` | SSH user. |\n| `DEPLOY_DIR` | `/opt/clashofdots` | Remote directory with `.env` and `compose.prod.yaml`. |\n\nNon-sensitive values are fine as **Variables**; using **Secrets** (as in your screenshot) is also valid.\n\n### Other repository **Secrets**\n\n| Secret | Purpose |\n|--------|---------|\n| `DEPLOY_SSH_PRIVATE_KEY` | Private key for `DEPLOY_USER` on `DEPLOY_HOST`. |\n| `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` | Optional: Cloudflare Access service token for `cloudflared access ssh`. Omit only if you use plain SSH without Access. |\n| `GHCR_TOKEN` | PAT with `read:packages` so the server can **`docker login ghcr.io`** and pull the app image. |\n\nWorkflow: [`.github/workflows/prod_deploy.yml`](.github/workflows/prod_deploy.yml).\n\n### One-time server prep\n\n```bash\nssh YOUR_USER@YOUR_HOST\nsudo mkdir -p /opt/clashofdots    # same path as DEPLOY_DIR; skip if it already exists\nsudo chown YOUR_USER:YOUR_USER /opt/clashofdots\ncd /opt/clashofdots\ncp /path/to/.env.example .env     # edit: APP_URL, DB_*, WorkOS, Redis, Reverb, etc.\n```\n\nThe deploy job **does not** create `DEPLOY_DIR`; it only writes `compose.prod.yaml` there. The directory must exist and be writable by `DEPLOY_USER`.\n\nProduction `.env` should use **`DB_CONNECTION=pgsql`**, **`DB_HOST=pgsql`**, **`REDIS_HOST=redis`** to match `compose.prod.yaml`. The app is exposed on the host as **`8091` → container `80`** (change **8091** in `compose.prod.yaml` if it conflicts). Reverb is **`${FORWARD_REVERB_PORT:-8092}` → `8080`**. Point `VITE_REVERB_*` (in the built frontend) and public `REVERB_*` at the hostname and port clients use to reach Reverb (often your reverse proxy or host port **8092**). The **`game-tick`** and **`reverb`** services use the same image as `app` and must stay up for live matches.\n\n### After deploy\n\nPoint DNS or a reverse proxy at the web port you mapped (default **8091**). Terminate TLS and route WebSocket upgrades to Reverb’s published port when you need live pushes from browsers outside plain `ws://` to the container.\n\n---\n\n## Useful scripts\n\n| Command | Purpose |\n|---------|---------|\n| `npm run dev` | Vite dev server + HMR |\n| `npm run build` | Production frontend build |\n| `npm run wiki:map-previews` | Regenerate wiki/README preview SVGs under `public/images/wiki/` |\n| `npm run verify:troops` | Sanity-check generated troop layouts |\n| `php artisan test --compact` | PHPUnit suite |\n\n---\n\n## Project roots\n\n- **Original game:** [warofdots.net](https://warofdots.net/)  \n- **Reference clone (Python):** [gamepycoder/War-of-dots](https://github.com/gamepycoder/War-of-dots)  \n- **Visual inspiration:** [Historia Civilis](https://www.youtube.com/c/HistoriaCivilis) (diagram-style battles)\n\n---\n\n## License\n\nThis repository is **free to read, fork, modify, and share**, but **not for commercial use or private monetary gain** (including running paid services, selling hosting, or otherwise monetizing a derivative as a product).\n\nThe legal terms are the [**PolyForm Noncommercial License 1.0.0**](LICENSE) ([summary](https://polyformproject.org/licenses/noncommercial/1.0.0/)). That keeps the codebase open while barring others from **making money off forks** without a separate agreement from the copyright holders.\n\n\u003e **Note:** The [Open Source Initiative](https://opensource.org/osd) definition of “open source” *includes* the right to use software commercially. So this project is best described as **source-available** or **non-commercial open**, not OSI “Open Source™”. If you need a commercial license, contact the maintainers.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftmwclaxton%2Fclashofdots","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftmwclaxton%2Fclashofdots","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftmwclaxton%2Fclashofdots/lists"}