Capture cities. Build your army. Outsmart the AI or a friend.
A pixel-art, turn-based strategy game for desktop and mobile. Choose your battlefield, secure its economy and combine infantry, armor and air power to win.
Play in your browser · No installation required
- 12 battlefields: roads, forests, mountain positions and narrow water crossings.
- 9 unit types: capture with infantry, cover advances with artillery and contest the skies.
- 4 AI difficulties: Easy, Medium, Hard and Expert, or local two-player battles on one device.
- Online duels: invite a friend on another device through a direct WebRTC connection.
- Desktop and mobile: keyboard, mouse and touch controls, with interactive minimaps on narrow screens.
- English, French and German: language, audio and keyboard settings in Options and help.
Destroy every opposing unit to win. During your turn, you can move and use the available actions of all your units, in any order. End the turn when finished. Blue plays first; in AI matches, the AI commands blue and you command red.
Select a unit, then click or tap diamond-marked neighboring cells to move, or an eligible enemy to attack. Confirm finishes the selection; cancel restores movement to the last committed position. Attacking or capturing commits that position, so cancelling cannot undo those actions.
Both friendly and enemy attack ranges use the same striped overlay, in the unit's team color. Movement diamonds remain visible on cells within that range.
With no friendly unit selected, click or tap an enemy to inspect its attack range. Striped cells show the range from its current position, including mountain bonuses and minimum-range exclusions. Unit targeting restrictions still apply; the overlay does not predict movement or damage. Click the enemy again, an empty cell, or press Escape to dismiss. Inspecting an out-of-range enemy preserves any pending friendly movement; an eligible attack still takes priority when a friendly unit is selected.
| Action | Keyboard |
|---|---|
| Move | Arrow keys or the selected ZQSD / WASD layout |
| Confirm | Enter |
| Cancel / close | Escape |
| Capture / secure | Space |
On mobile, use the contextual action buttons, swipe the battlefield horizontally, or tap/drag either minimap to reposition the view. Minimap navigation appears when the board overflows its container. The camera follows AI and online opponent actions. Settings are stored in browser session cookies; English is the default language. Turning sound off also disables music.
Choose a map, then Play 1v1 online:
- The host creates an invitation and sends its link to a friend.
- The friend opens the link and clicks Join. The connection is automatic; there is no response code to send back.
- The host commands blue and plays first; the guest commands red.
Keep both game pages open. A temporary connection loss pauses play and reconnecting resynchronizes the match. Closing or refreshing either page ends the session; matches are not saved. After victory, both players must agree to a rematch.
The browsers exchange game data through WebRTC, directly or through a TURN relay. The host validates actions and supplies the shared game state. This is intended for friends: the host can technically alter that state. No account or game server is required. PeerJS Cloud exchanges the connection details automatically. When configured, Metered supplies STUN/TURN servers so WebRTC can use a relay when a direct path is unavailable. Without Metered settings, development uses Google's public STUN service only. Creating or joining contacts these services, but browsing maps or opening an invitation does not. Network restrictions, provider outages and exhausted relay quotas can still prevent connections.
The invitation contains a random session identifier, map and game fingerprint;
it no longer contains the full WebRTC session description. Share it only with your
opponent. A session accepts one guest, and expired, occupied or incompatible
invitations show a recoverable error. Existing manual PW1 invitations must be recreated.
The terms, privacy and cookies page explains session cookies, third-party services and connection limits in all three languages. The invitation screen focuses on creating or joining a game.
Use the published site when inviting someone on another device. A development
link containing 127.0.0.1 points to the recipient's own computer.
All three infantry types can capture and secure buildings. Buildings have 20 capture points, and a capture removes 10. Each infantry unit can capture once per turn, so two units can finish a capture together. Securing a building you already own restores all its capture points in one action.
| Building | Benefit |
|---|---|
| City | Adds 200$ at the start of each owner's turn |
| Hospital | Heals a friendly unit standing on it by up to 50 HP at the start of the owner's turn |
| Army base | Produces ground units |
| Airport | Produces helicopters and planes |
Healing never exceeds maximum health; a floating label shows the HP actually recovered at the start of the turn. To buy a unit, you must own the production building, leave its cell empty and have enough money. New units can act immediately.
Movement is a points budget, not a number of cells. Attack and defense are inputs to the damage formula below; attack is not the final damage dealt.
| Unit | Cost | HP | Movement | Attacks / turn | Attack | Defense | Range |
|---|---|---|---|---|---|---|---|
| Infantry | 200$ | 100 | 5 | 2 | 40 | 10 | 1 |
| Rocket | 400$ | 100 | 4 | 1 | 40 | 10 | 1 |
| Sniper | 500$ | 100 | 4 | 1 | 60 | 10 | 2–3 |
| Jeep | 600$ | 125 | 8 | 2 | 50 | 20 | 1 |
| Tank | 1200$ | 180 | 6 | 2 | 70 | 40 | 1 |
| Artillery | 1600$ | 120 | 4 | 1 | 60 | 30 | 2–4 |
| Anti-air | 1000$ | 120 | 6 | 2 | 70 | 30 | 1–2 |
| Helicopter | 1800$ | 110 | 8 | 1 | 65 | 15 | 1 |
| Plane | 3000$ | 120 | 10 | 1 | 80 | 25 | 1 |
Infantry captures objectives. Rockets counter vehicles; snipers counter infantry. Jeeps are fast and effective against infantry, while tanks combine armor and two attacks. Artillery softens ground targets from a distance, but cannot fire at adjacent cells. Anti-air attacks only flying units. Helicopters hunt infantry; planes can attack both ground and air units. Other ground units cannot target flying units.
Movement is orthogonal and pays the destination cell's cost. One unit occupies a cell at a time. Attack ranges are square, including diagonals.
| Terrain | Ground movement cost | Defense |
|---|---|---|
| Road | 1 | 0 |
| Grass | 2 | 0 |
| Building | 2 | 40 |
| Forest | 3 | 30 |
| Mountain | 4 | 50 |
| Water | Impassable | 0 |
Flying units spend 1 movement point per cell regardless of terrain, and receive no terrain defense. There are no playable naval units yet.
Ranged ground units on a mountain gain +1 maximum range, keeping their minimum range: sniper 2–4, artillery 2–5, anti-air 1–3. Melee and flying units gain no range.
On grass, infantry can cross two cells per turn, tanks three and jeeps four. Roads stretch these distances; forests and mountains shorten them.
Damage is calculated before rounding the defender's remaining health:
base damage = max(0, attack − (unit defense + terrain defense) / 10)
damage = base damage × (attacker HP / attacker maximum HP) × matchup multiplier
remaining HP = max(0, round(defender HP − damage))
A surviving defender retaliates if its own range and matchup allow it. Retaliation uses its remaining health and does not spend an attack point. Snipers and artillery cannot retaliate at contact. A forbidden matchup cannot be selected as a target.
A full-health artillery shot against a full-health target on grass or road gives:
| Target | Matchup multiplier | Starting HP | Remaining HP |
|---|---|---|---|
| Infantry, Rocket or Sniper | ×1.35 | 100 | 20 |
| Jeep | ×1.45 | 125 | 41 |
| Anti-air | ×1.25 | 120 | 49 |
| Tank | ×1.60 | 180 | 90 |
| Artillery | ×1.00 | 120 | 63 |
All infantry types take the same artillery damage under the same conditions. Terrain protection and damage to the attacker reduce these losses. Artillery cannot destroy any of these full-health targets with one shot; its single attack, price and adjacent blind spot leave room for faster units to close in.
The complete matchup table lives in combat-rules.ts. Unit and terrain statistics live in catalog.ts.
Easy, Medium, Hard and Expert use the same units, budgets and rules as the player. Higher levels improve targeting, positioning and purchases. Expert also evaluates short sequences of future actions and economic opportunities; it is a bounded search, not an exhaustive solution of the game.
See AI rules and implementation notes for each difficulty's behavior.
Built with SvelteKit 2, Svelte 5 runes and Paraglide. Requires Node.js 22.14 or newer:
npm ci
npm run devOpen the Vite address. The home route is /; games use /play/1/ through
/play/12/. Add ?ai=easy, ?ai=medium, ?ai=hard or ?ai=expert
to play against the AI, or ?online=1 for online setup. Without either parameter,
the game is local two-player.
npm run check
npm run format:check
npm test
npm run build
npm run previewThe production site is generated in build/; serve it over HTTP.
npm run check generates Paraglide messages and declarations before checking
Svelte. npm run generate:i18n runs generation separately. Both generation and
Vite use paraglide.config.js; generated src/lib/paraglide/ files are ignored.
node node_modules/@playwright/test/cli.js install chromium
npm run build
npm run test:e2ePlaywright covers desktop/mobile play, map navigation, movement, combat, capture,
economy, settings, minimaps, AI and real WebRTC matches between two browser contexts.
Online browser tests start a real local PeerServer and use local ICE candidates;
they do not depend on the public signaling service or verify connectivity across
different internet connections. Their browser contexts bypass CSP only to allow
the temporary local signaling port. Production CSP allows the PeerJS Cloud
WebSocket endpoint explicitly. To use installed Chrome, set
PW_CHANNEL=chrome (PowerShell: $env:PW_CHANNEL = 'chrome').
| Location | Responsibility |
|---|---|
src/routes/ |
Map selection and game routes |
src/lib/components/ |
Board, controls, stats, dialogs and other UI |
src/lib/game/game.svelte.ts, model.ts |
Per-game state and rule queries |
src/lib/game/actions.ts, controller.ts |
Actions, input and asynchronous combat |
src/lib/game/catalog.ts, combat-rules.ts |
Statistics, terrain, attack geometry and damage |
src/lib/game/ai*.ts, expert-ai.ts |
AI decisions, economy and bounded lookahead |
src/lib/game/peer.ts, online.ts |
WebRTC invitations, authoritative commands and snapshots |
src/lib/data/board-N.json |
Explicit, editable map layouts |
messages/ |
English, French and German source translations |
assets/, src/lib/app.css |
Artwork/audio sources, global styles and sprite mappings |
tests/ |
Node rule tests and Playwright browser tests |
Components use Svelte runes. Each game owns its state; navigation disposes pending combat and audio. The DOM displays state rather than storing game rules. Board measurements control scrolling, focus and minimap positioning.
Online peers check a fingerprint of the map, unit statistics, terrain and protocol
version before connecting. Bump protocolVersion in peer.ts when changing command
semantics or combat rules so incompatible clients cannot start a match together.
To add a unit, extend the catalog, provide sprites and translations, and define non-neutral matchups. Production menus enumerate the catalog automatically.
Use tabs, single quotes, no JavaScript semicolons and no trailing commas. Keep
Svelte script, markup and style sections distinct. Nest CSS variants and relevant
media queries under their selector, with blank lines between rules.
Run npm run format before committing; CI checks formatting.
Assets include SVGs, original audio and bitmap sources stored as .png.base64
or .gif.base64. Prepare, predev and prebuild synchronize these into ignored
static/assets/, decoding Base64 files and copying other sources. Native image
files take precedence over their Base64 equivalents. The favicon is generated
from favicon.png.base64.
Visual assets are preloaded when entering a game and reused across map navigation. To refresh generated assets after editing sources:
node --experimental-strip-types scripts/sync-assets.tsAll units have a healthy sprite and four damage stages for both teams. Sniper
vectors live in assets/temp/; node scripts/export-sniper-sprites.mjs
regenerates their sprites and docs/sniper-damage-variants.png. Scratches use
single outline-colored pixel blocks, with darker red wounds for contrast.
Synchronize public assets afterward. Do not commit generated static assets.
Create a TURN credential in the Metered dashboard. Copy .env.example to .env.local
and set VITE_METERED_APP to the app name (or its .metered.live hostname) and
VITE_METERED_TURN_API_KEY to that credential's API key. Restart Vite after changes.
Use the credential-scoped key, never the account Secret Key or Project API Key.
This integration needs no additional SDK or backend. See the
Metered TURN reference.
The browser retrieves ICE servers when creating or joining a game. It keeps the provider's UDP, TCP and TLS endpoints, so the dashboard controls the available relay region. A failed credential request shows an error instead of silently turning the relay off. Keep the credential enabled and valid throughout your tests.
In GitHub Settings → Secrets and variables → Actions, add:
| Type | Name | Value |
|---|---|---|
| Repository variable | METERED_APP |
Metered app name or hostname |
| Repository secret | METERED_TURN_API_KEY |
Credential-scoped TURN API key |
The workflow passes these values to Vite at build time. .env files on your PC
are not uploaded by Git. The Pages workflow stops if either setting is missing.
The TURN key is intentionally included in the public
browser bundle, even when supplied through a GitHub secret. Anyone can extract
and use the relay credential, so monitor its quota and revoke/replace it if abused.
For wider distribution, consider issuing rotating credentials from a backend.
For a local connectivity check, run npm run test:turn. It uses the .env values
and two browser peers forced through TURN, exchanges a small payload and verifies
the selected relay candidates. It consumes a small amount of relay bandwidth.
Alternatively, set VITE_TURN_RELAY_ONLY=true locally and restart Vite to test a
full game through TURN. Keep it false for normal play to allow direct connections.
Provider quotas and credential expiry can interrupt or prevent relayed games.
- In Settings → Pages, choose GitHub Actions as the source.
- In Actions → Deploy GitHub Pages → Run workflow, select the branch to publish.
The manual workflow builds with BASE_PATH=/pixelswars and publishes the generated
build/ artifact. Publishing the source branch instead can display the README
rather than the app. For a custom domain at its root, use an empty base path.
To test a repository-path build in PowerShell:
$env:BASE_PATH = '/pixelswars'
npm run build
Remove-Item Env:BASE_PATHCI checks formatting, components, rules, the build and browser behavior separately. Pushing or merging does not automatically deploy the site.
Programming: John Does it. Sprites: Kenney. Sounds: Pixabay. Music: Monolith. QA: Gauthier Miessen.
Feedback and contributions: hello@johndoesit.be.