Screen-reading auto-aim assistant for the game Graphwar.
It captures the game window, recognises the coordinate field, the black obstacles and every soldier, and computes a single y = f(x) that destroys as many enemies as possible while dodging obstacles, allies and the field border. The formula is copied to the clipboard — paste it into the game and fire.
Русская версия · Українська версія
- Features
- Installation
- Quick start
- Controls
- Modes
- How it works
- Test results
- Project layout
- Configuration
- Limitations
- License
| 🎯 Multi-target planning | One function through the centres of as many enemies as physically possible; obstacles and the field border are avoided with configurable margins. |
| 👁 Computer vision | Locates the plot, splits overlapping black discs into individual circles, finds soldier balls even when the game's shot trail crosses them or two soldiers stand stacked. |
| 🤝 Allies | Right-click a soldier to mark it as a friend — the line keeps a guaranteed distance from it. |
| 🎯 Sniper | Ctrl-click an enemy to plan a path to that one target only. |
| ✍️ Manual mode | Draw the path with the mouse; the program follows your line exactly and detours only where it would hit an obstacle. |
| 😇 Legit mode | Short human-looking formulas (0.9*(x+10) - 0.047*(x+10)^2, 4*sin(0.35*(x-3))) instead of long abs() chains. |
| 🧱 Manual marks | Shift-click to add obstacles the detector missed, Alt-click to add enemies. |
| 🌍 Three languages | English (default), Ukrainian, Russian — switchable at runtime. |
| 💻 Cross-platform | Python + Tk + OpenCV. Window picker on Windows; full-screen capture with automatic plot detection everywhere. |
Requirements: Python 3.10+. Dependencies: numpy, opencv-python, scipy, matplotlib, Pillow (all pure pip wheels).
# Windows
run.bat
# Linux / macOS
chmod +x run.sh && ./run.shgit clone https://github.com/RE22GDV/graphwar-aim.git
cd graphwar-aim
pip install -r requirements.txt
python main.pyor as a package:
pip install .
graphwar-aim # console entry pointLinux note:
PIL.ImageGrabneeds an X11 session (orgnome-screenshot/scroton Wayland). Analysing saved screenshots (--image) works everywhere.
- Start Graphwar and wait for your turn.
- Run
python main.py(orrun.bat/./run.sh). Pick the game window in the Window list (Windows) or leave (entire screen). - Press Capture. Obstacles appear as grey discs, soldiers as blue dots.
- Left-click your own soldier. The orange line is the planned shot; red dots are enemies it will hit.
- The formula is already in the clipboard (Auto-copy). Paste it into the game's
y =field and press Fire.
python main.py --lang uk # start in Ukrainian
python main.py --image shot.png # analyse a saved screenshot (debugging, tests)| Mouse | Action |
|---|---|
| LMB on a soldier | select your soldier (the shooter) |
| RMB on a soldier | toggle ally (never targeted, always avoided) |
| Ctrl + LMB on an enemy | sniper: plan only for this target (again to cancel) |
| Shift + LMB / Shift + RMB | add / remove a manual obstacle (radius in Obstacle R) |
| Alt + LMB / Alt + RMB | add / remove an enemy the detector missed |
| LMB drag on empty space (Manual mode) | draw the path yourself |
| Control | Meaning |
|---|---|
| Obstacle margin | clearance kept from obstacle edges (world units). Smaller squeezes through narrower gaps; larger is safer. Relaxed automatically if no path exists. |
| Edge margin | clearance from the top/bottom border where the shot dies. Waived next to enemies standing at the border. |
| Flip direction | auto (both sides tried) → right → left |
| Clear marks | forget allies, sniper target, manual obstacles/enemies and the drawn path |
| Debug → files | write the capture, detection overlay and masks into debug/ |
| Mode | What it does | Typical time |
|---|---|---|
| Auto (default) | coarse grid; if a reachable target was missed, re-plans on a fine grid; both directions; margin relaxation | 50–600 ms |
| Fast | coarse grid only | 20–150 ms |
| Precise | fine grid (0.25 × 0.3 units, slopes up to 20) | 100–500 ms |
| Legit | picks the simplest human-looking formula (line / parabola / cubic / sine) that still hits; falls back to Auto if none exists | 100–250 ms |
| Manual | you draw, the program corrects around obstacles and converts to a formula | instant |
flowchart LR
A["Screen / window capture<br/>PIL.ImageGrab"] --> B["Plot rectangle<br/>connected components"]
B --> C["Obstacles<br/>black mask → distance transform peaks"]
B --> D["Soldiers<br/>saturation mask → blob filter → merge halves"]
C & D --> E[Scene in world units]
E --> F{Mode}
F -->|Auto/Fast/Precise| G[Grid DAG planner]
F -->|Legit| H[Template fitting]
F -->|Manual| I[Guided DP along the drawing]
G & H & I --> J["Shot simulation<br/>death at 1st obstacle"]
J --> K["Formula emission<br/>abs() polyline or short template"]
K --> L["Clipboard → paste into Graphwar"]
The game frame (client area of the window on Windows, whole screen elsewhere) is analysed in three steps.
| Raw capture | Detection overlay |
|---|---|
![]() |
![]() |
Green — plot rectangle, blue — obstacle circles, red — soldiers.
Plot rectangle. Pixels with grey ≥ 235 form the white plot background. After a 3×3 closing, connected components are computed; the component with the most white pixels and an aspect ratio near 50:30 is the plot. (The white log panel below the plot is rejected by aspect; the earlier "largest bounding box" approach leaked into it.)
Obstacles. Mask = dark (grey ≤ 70) ∧ unsaturated (S ≤ 60), so dark-coloured soldier balls are excluded. A 5-px opening erases axes and label text. Overlapping discs are separated with the distance transform of the mask: each disc has a local maximum at its own centre whose value is its own radius (the union boundary on the outer side is the disc's own arc). Peaks are picked in descending order; peaks closer than ½·r to an accepted disc are its shoulders and are suppressed.
Soldiers. Mask = saturation ≥ 115 ∧ value ≥ 70 — soldier balls are vivid while name plates are pale, so the plates drop out entirely. A 5-px opening removes the thin in-flight shot trail the game draws (it used to merge with a ball and hide it). Blobs are filtered by area (18–600 px²) and size (≤ 42 px) and the two colour halves of one ball are merged within 16 px — two soldiers standing stacked are ≥ 20 px apart and stay separate.
| Obstacle mask (left) and soldier mask (right) |
|---|
![]() |
Pixels are converted to world units with the linear map x = -25 + 50·(px - x0)/w, y = 15 - 30·(py - y0)/h.
The shot is the graph of a function, so the path must be x-monotone — one y per x. Planning happens in a shooter-local frame: p = forward distance along the facing direction, v = lateral offset from the shooter.
flowchart TD
S["shooter, targets, obstacles, allies"] --> F["for facing in auto → both sides"]
F --> M["for margin in m, 0.6m, 0.25, 0.12"]
M --> G["for grid in coarse, fine"]
G --> P["DAG dynamic programming<br/>rows × columns, exact segment-vs-disc edges"]
P --> R["RDP simplify<br/>keep only if clearance and hits survive"]
R --> T["re-anchor through hit-target centres"]
T --> V["simulate: hits before death, clearance"]
V --> B{"best so far?<br/>hits › feasible › clearance"}
B -->|all targets on this side hit| STOP["stop early"]
B --> G
Grid DAG. Columns every dp along p, rows every dy along y. A cell is free if it lies outside every disc inflated by the obstacle margin and outside the border strip (edge margin). Edges join consecutive columns with a bounded slope |Δrows| ≤ maxdj; an edge is valid when the segment between the two cell centres clears every nearby disc, computed exactly and vectorised over rows:
t* = clamp( -((x0-cx)·dx + (y0-cy)·dy) / (dx² + dy²), 0, 1 )
dist² = (x0-cx + t*·dx)² + (y0-cy + t*·dy)² ≥ (r + margin)²
Rasterised edge checks (the first implementation) inflated obstacles along diagonals and closed narrow slanted gaps at any resolution; the exact test fixed a live scene where the only way to the target was such a gap.
Reward. A target adds +1000 to the cells of its own column within hit_radius rows. Because the path visits each column exactly once, a target can never be counted twice. A tiny smoothness penalty 0.05·|Δrows| breaks ties toward straighter paths. The DP score[i][j] = max_dj(score[i-1][j-dj] - 0.05·|dj|) + reward[i][j] is globally optimal for the discretisation; the best terminal cell is back-tracked into knots.
Simplification and re-anchoring. Ramer–Douglas–Peucker (ε = 0.4, then 0.2) shortens the polyline only while every hit and the clearance survive. Then the exact target centres are inserted as knots and the detour knots are kept only between targets, so the line passes through each target within ~0.003 units instead of "somewhere inside the hit radius".
Escalation. Both facings are tried; if a reachable target on a side was missed, the fine grid (0.25 × 0.3, slope 20) is used; if merged obstacles wall the field off, the margin is relaxed step by step (0.7 → 0.42 → 0.25 → 0.12 — the projectile is a point, real gaps are usually passable). The edge strip is waived within 2.5 units of a target so enemies standing at the border remain reachable.
Allies become keep-out discs of radius 1.2 (+ margin) — larger than the hit radius, so the line can never kill a friend.
Manual mode reuses the same DP with a different reward: -|y - y_drawn(p)| per cell. The result follows the drawing wherever the drawing is legal and detours only where it is not.
Instead of a polyline, a compact formula is fitted (u = x - x_shooter, g(0) = 0):
| Template | Formula | Degrees of freedom |
|---|---|---|
| line | a·u |
1 |
| parabola | a·u + b·u² |
2 |
| cubic | a·u + b·u² + c·u³ / a·u + c·u³ |
3 / 2 |
| sine | A·sin(B·u) |
1 + frequency grid |
| sine + slope | a·u + A·sin(B·u) |
2 + frequency grid |
Coefficients are solved exactly through 1–3 chosen targets (linear systems). For a single target the second degree of freedom is swept on a fine grid — curvature b ∈ ±[0.003, 0.13] step 0.0035, cubic c, sine amplitude/frequency — so the curve can bend through narrow corridors. Every candidate is simulated; the winner is chosen by (hits, clearance ≥ margin, −Σ centre miss, −complexity, −max|coef|). If nothing reaches a target, the standard planner takes over and says so.
| Auto mode (polyline) | Legit mode (parabola) |
|---|---|
![]() |
![]() |
Confirmed with live shots: Graphwar takes the origin at the soldier and adds the soldier's position itself; the aim angle is not applied to the function; x in the formula is the world field X. Hence the emitted text must satisfy f(x_shooter) = 0 and must not add y_shooter.
The planner's polyline v(p) = s₀p + Σᵢ (sᵢ − sᵢ₋₁)·relu(p − pᵢ) with relu(t) = (t + |t|)/2 and p = facing·(x − x_shooter) collapses to
y = C0 + L·x + Σᵢ Bᵢ·|x − Xᵢ|
which the game parses natively. With ~20 terms, 4-significant-digit rounding can drift half a unit at the far end of the field, so the emitted text is re-evaluated and the precision is raised (5…8 digits) until the drift is below 0.03 units.
Example (Auto, live scene): -9.1298 + 5.0338*x - 3.1555*abs((x+7.5818)) + 0.45829*abs((x+6.1568)) - 0.54506*abs((x-0.16824)) - …
Examples (Legit): 2.396*(x+8.332), 0.897*(x+10) - 0.04697*(x+10)^2, 0.8282*(x+6.157) - 0.0012*(x+6.157)^3
Every mode validates its result with one shared simulation (simulate.py): sample the curve along p, find the first obstacle contact (real radius) or exit through the top/bottom border — the projectile dies there — count only targets passed before that point, flag any ally within the hit radius, and measure the minimal clearance up to the last hit. "Hit 3 of 4" in the status bar therefore means three kills in the game, not three geometric intersections.
pytest — 19 tests, all passing (tests/): geometry of the emitted formula, obstacle and ally avoidance, both-facing selection, margin relaxation through walls, border-hugging prevention, enemies at the border, a sharp dip that must reach the target centre (with and without a disc under the target), guided manual paths, Legit fitting and fallback, synthetic-frame detection (plot rectangle within 2 px, all obstacles incl. an overlapping pair, all soldiers incl. a stacked pair and one crossed by a shot trail).
$ python -m pytest -q
................... [100%]
19 passed in 0.78s
tools/benchmark.py generates random fields (4 enemies, 4–20 obstacles of radius 1–4) and runs every mode 30 times per density. Hit rate is the share of the 4 enemies destroyed according to the shot simulation; time is per planning call on a laptop CPU.
| Mode | 4 obst. | 8 obst. | 12 obst. | 16 obst. | 20 obst. | avg time |
|---|---|---|---|---|---|---|
| Auto | 97% | 92% | 92% | 92% | 88% | 257 ms |
| Fast | 92% | 88% | 88% | 88% | 84% | 79 ms |
| Precise | 97% | 92% | 89% | 92% | 88% | 243 ms |
| Legit | 68% | 58% | 49% | 50% | 45% | 167 ms |
30 random fields per density, 4 enemies each; hit rate = destroyed enemies / 4 according to the shot simulation with the real ball radius (0.5 units); Intel laptop CPU, single thread.
| Hit rate | Planning time | Clearance kept |
|---|---|---|
![]() |
![]() |
![]() |
Not every enemy is reachable in principle — a single y = f(x) cannot pass two points with the same x, and dense fields can wall a target off — so the theoretical maximum is below 100 %. Auto/Precise track that maximum closely; Legit trades hits for prettier formulas.
The running example in this README is a real capture (docs/img/sample_capture.png): 19 obstacles, 4 soldiers, one enemy standing 0.3 units from the bottom border. Auto mode reaches all 3 enemies with a centre miss of 0.001–0.011 units (border strip waived next to the low enemy). Legit, which only accepts kills within the real ball radius, settles for a straight line to one of them — simple formulas cannot thread this field.
graphwar-aim/
├─ main.py launcher (python main.py [--image f.png] [--lang en|uk|ru])
├─ run.bat / run.sh one-click launchers (create venv, install deps, run)
├─ requirements.txt
├─ pyproject.toml pip install . → console script `graphwar-aim`
├─ graphwar_aim/
│ ├─ app.py Tkinter + matplotlib GUI, mouse tools, modes, language switch
│ ├─ capture.py screen/window capture, DPI awareness, unicode-safe image read
│ ├─ vision.py plot rectangle, obstacle and soldier detection (OpenCV)
│ ├─ geometry.py pixel <-> world transforms
│ ├─ solver.py grid DAG planner, guided planner, RDP, re-anchoring, solve()
│ ├─ legit.py simple-function fitting
│ ├─ simulate.py shared projectile simulation (death, hits, clearance, allies)
│ ├─ formula.py abs() emission with drift-controlled precision, safe evaluator
│ ├─ i18n.py en / uk / ru strings
│ ├─ settings.py persisted user settings (~/.graphwar_aim/settings.json)
│ └─ config.py all tunables (VisionParams, SolverParams)
├─ tests/ pytest suite (solver, formula, vision)
├─ tools/
│ ├─ diagnose.py run detection + planning on a screenshot, save an overlay
│ ├─ benchmark.py random-field benchmark → charts + Markdown table
│ └─ make_docs.py regenerate the images in docs/img
└─ docs/img/ screenshots and charts used here
flowchart BT
config --> geometry --> vision
vision --> simulate & solver
config & i18n --> solver
solver & simulate & i18n --> legit
solver --> formula
capture & vision & solver & legit & formula & i18n & settings --> app
Everything tunable lives in graphwar_aim/config.py:
| Parameter | Default | Meaning |
|---|---|---|
SolverParams.hit_radius |
0.5 | distance at which a target counts as destroyed (a soldier ball is ≈0.45 units; the curve is re-anchored through the centres anyway) |
SolverParams.obstacle_margin |
0.7 | clearance from obstacle edges (GUI spinbox) |
SolverParams.edge_margin |
1.0 | clearance from the top/bottom border (GUI spinbox) |
SolverParams.grid_dp / grid_dy / grid_max_slope |
0.5 / 0.5 / 9 | coarse grid |
SolverParams.fine_dp / fine_dy / fine_slope |
0.25 / 0.3 / 20 | fine grid used by Auto/Precise |
SolverParams.ally_avoid_radius |
1.2 | keep-out radius around allies |
VisionParams.soldier_min_saturation |
115 | vivid-ball threshold (drops name plates) |
VisionParams.soldier_merge_dist |
16 px | merge distance for the two halves of one ball |
VisionParams.obstacle_min_radius_px |
8 px | smallest obstacle accepted |
COORD_MODE |
GLOBAL |
x = world X (LOCAL = measured from the soldier) |
Language and margins are remembered between runs in ~/.graphwar_aim/settings.json.
- A single
y = f(x)cannot hit two enemies standing at (nearly) the samex; the planner maximises the total instead. - Detection thresholds were tuned on the default Graphwar look (white field, black discs, vivid balls). Custom themes may need
VisionParamsadjustments — use Debug → files to inspect the masks. - Window enumeration is Windows-only; on other systems capture the whole screen or load a screenshot.
- The mapping of the typed function was verified on one Graphwar build (no angle rotation, world-X coordinates). If a shot has the right shape but is shifted horizontally on your build, set
COORD_MODE = "LOCAL".
MIT — use it, learn from it, break it, improve it.








