Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

machin-escouade

A top-down tactical game whose units are triangles, squares and rectangles — and whose soldiers have faces.

a firefight

The board is abstract on purpose and the roster is not. Every unit you command is a person with a photograph, a surname and a rank, and when one dies its card does not disappear from the strip: it goes dark, the bar empties, and the state reads K.I.A. under a face you have been looking at for ten minutes.

the dossier

Nothing in assets/ was drawn. All 74 images — 32 portraits, 8 unit sprites, 25 scenery objects, 9 ground textures — come out of tools/gen/, which drives the Gemini web app through linux-use on a private X display. There is no API key and no language model in the loop: seven job files, one command, and it is resumable.

Built on machin-ressort's engine, in machin (MFL).

git clone https://github.com/javimosch/machin-ressort
git clone https://github.com/javimosch/machin-escouade
cd machin-escouade && ./build.sh          # RESSORT=/path to override
./build.sh                  # build bin/esc
./build.sh test             # 113 headless assertions, no window
./bin/esc                   # play
./bin/esc --seed 12         # another battlefield
./bin/esc --demo            # both sides fight it out by themselves
./bin/esc --prims           # units as primitives
./bin/esc --shot s.png --at 900 --sel 3   # one frame, then quit

LMB select, drag a box, SHIFT to add · RMB move · H halt · TAB cycle · SHIFT+A select all · WASD pan · wheel zoom · F1 primitives · P pause · ESC quit. Cards and the minimap are controls, not readouts: click a card to select and jump to that unit.

Why a strategy game made of primitives

Because at the scale a tactical map is actually read — forty units, one screen — a silhouette can carry three bits of information and no more: what it is, whose it is, and what it is doing. Spending art on the board buys nothing that a shape and a colour do not already say, and it costs the thing that is actually scarce, which is the player's ability to tell at a glance that the left flank is pinned.

So the shapes are chosen for what they say rather than for what they resemble:

shape why
rifle section triangle a triangle points, so infantry shows you its facing
scout car square no orientation to speak of, which is right for a soft-skin
armour rectangle + turret + gun long, so it shows you its flank — the thing you want to get to

Side sets the hue (cold cyan / warm amber) and state sets the brightness, so the two channels never collide.

The sprites did not replace any of that; they were laid over it. F1 switches the units back and it is still the more legible rendering when the fight is dense. Under every sprite there is a disc in the side's hue, a ring in the state's, a bar for strength and a fan of sparks for suppression — so the art cannot lie about the simulation, and deleting all of it does not hide anything:

with and without the generated art

Same battle, same seed, same frame. Right: assets/ emptied. That is not a compatibility claim, it is a screenshot — the game lists its asset directories at startup and falls back per missing file.

The rules, as numbers

Three units, and the whole rock-paper-scissors is one design decision: armour is subtracted from damage, not multiplied into it.

speed strength reach damage rate armour
rifle section 34 100 240 9 0.55 s
scout car 96 (×1.45 on road) 70 180 6 0.34 s 2
armour 52 (×1.45 on road) 260 420 46 2.6 s 7.5

A rifle round does 9 × (0.7…1.3) and a tank subtracts 7.5, so rifles scratch armour and can never kill it. A tank hits five times harder and five times more slowly, so a section that closes the distance is not simply food. And a tank cannot enter a hedgerow at all, which is what makes the bocage a tactical problem rather than scenery.

Terrain is four values doing three separate jobs, deliberately not one:

  • movementgrid_pass tests the mask bit indexed by the cell value, so infantry passes open+road+scrub and tracks pass open+road.
  • sight — stops at stone and nothing else. The village is the only feature on the map that can hide a tank from a tank.
  • cover — a unit in scrub is 45% harder to hit; a unit on the road is 10% easier. So the fast lane is also the killing ground.

Suppression is a second health bar and had to be, because "hurt" and "pinned" are different problems: fire lands suppression whether or not it hits, past 0.55 a unit stops shooting and goes to ground, and it bleeds off slowly enough that sustained fire holds a section down and fast enough that a lull is a chance to move.

The ground

Bocage: small fields walled in by hedgerows, a crossroads, and a village at the junction. Generated rather than authored, because terrain you did not place yourself is terrain you have to actually read.

Fields are a patchwork, not a lawn. Blocks of eleven cells share a crop — pasture, flowering meadow, wheat stubble, ploughed earth, bare sand — because a map where every field is the same green reads as a golf course however good the grass texture is.

An orchard is the one piece of terrain that is a different tactical object. Single scrub cells on a three-cell lattice: infantry has cover everywhere and armour can still weave through, so it is the one place a section can fight a tank on level terms. A lattice also cannot enclose anything, so it costs the connectivity guarantee nothing.

The village is a street, not a scatter. Buildings are placed along the two roads, set back one cell from the verge, alternating sides — so the crossroads has frontages and the gaps between buildings are gaps you can see down. Some get a walled yard, which gives infantry something to fight over that armour cannot drive across.

Scenery is derived from the grid at draw time, never stored: every object is a pure function of its own cell coordinates and the map seed, so there is no list to keep, nothing to invalidate, and culling is free. The classifier is the interesting part — a scrub cell is hedgerow if its scrub neighbours line up on one axis and a tree otherwise, which turns the generator's hedge lines into continuous treeline and its copses and orchards into woodland without either ever being labelled.

Wall cells are recovered back into rectangles once, at load, so a farmhouse can wear a roof. A roof sprite per cell was the first attempt and looked like a housing estate of identical bungalows.

The assets

everything in assets/

Seven job files. Each is data — a prompt, a grid, tile names, how to cut — so adding an asset means adding a file:

tools/gen/session.sh up
tools/gen/run.py tools/gen/jobs/*.json
tools/gen/session.sh down

One prompt yields sixteen assets. That is not a quota trick: a model will not honour an aspect ratio and will not return sixteen files, but a square canvas cut into N×N squares is N×N squares whatever it drew inside them, and the cutting is arithmetic. Sprites come back on flat magenta and are chroma-keyed locally, with the key measured off each tile's own border rather than assumed — asking for #FF00FF gets you approximately magenta.

The full account of what it took to make that reliable — refs that go stale between two commands, a Download button that cannot be fired synthetically, a page that only scrolls for wheel events, and a capture that once photographed the game instead of the browser — is in tools/gen/README.md. Two things are worth repeating here because they shaped the design:

Sheets are kept, so a bad cut never costs a generation. --recut re-slices what is already in docs/sheets/. The keyer left a magenta fringe and the ground tiler produced a chequerboard; both fixes cost seconds instead of five generations.

A run is resumable by construction. A job whose outputs all exist is skipped, so a queue stopped by a quota limit, a refusal or a closed laptop restarts with the same command.

What came from the engine

Almost all of the hard part. machin-ressort is a side-view platformer engine, and this is a top-down strategy game, and the useful half turned out to be entirely orientation-agnostic: flow-field pathfinding, grid_los, the camera with cam_unproject, particles, HUD primitives, and the C boundary including the PNG loader.

Three FFI functions were added to it here — DrawTexturePro (the general blit; DrawTextureEx rotates about the top-left corner, so anything that turns has to go through Pro), SetTextureFilter, and DrawCircleLines. All additive.

What the tests are for

A tactical game is a set of claims about numbers — this weapon reaches that far, that terrain stops this vehicle, being shot at slows you down — and every one is invisible in a screenshot. So they are asserted headlessly against the same simulation the game steps.

They earned their place immediately, by failing. Three of them, on the first run, found two bugs that no amount of looking at the game would have found:

Units walked into buildings, and it was the line-of-sight test's fault for being right. The mover steered in a straight line toward a waypoint up to nine cells away, having first checked the line was clear. grid_los samples the ray every third of a cell, which cannot miss a cell the ray passes through — but a diagonal ray that clips the corner of a wall spends less than a third of a cell inside it and is sampled straight past. So the steer was honestly reported clear while the path shaved the corner, the unit's centre ended up inside the stone where the flood is −1 and there is no way downhill, it recovered to the nearest legal cell, aimed at the same waypoint, and clipped the same corner again — forever, five cells short of its destination. The fix is to slide along whichever axis is still free, which is also what makes a unit follow a wall instead of sticking to it.

One battlefield in twelve began with a tank that could not reach the fight. Two wrong fixes went in before the right one. Hedge gaps were being plugged, so they are now re-cut last as a structural guarantee; approach lanes were added from each deployment corner to the trunk road, which was fine and did not fix it; and the actual cause was that copses were painted after the roads and overwrote them, severing the lane just added to prevent exactly this. Blobs now only paint onto open field, and the assertion runs over forty seeds.

The rest cover determinism (same seed, same battle — the rolls are hashes of the tick and the two unit indices, not draws from a stream, so a shot cannot land differently because an unrelated unit died first), that the dead stop firing and moving and counting, that hit chance stays a probability past maximum range, and that every name and rank index resolves.

Known limits

  • Red is not clever and is not trying to be. It advances, it stops to shoot rather than walking into fire, and pinned infantry breaks for the nearest hedge. An opponent that re-plans every tick produces units that vibrate, which reads as less intelligent than one that commits to a bad plan for two seconds.
  • Ground textures are mirror-tiled to make them seamless, so they are symmetric about their own middle. On grass and meadow it is invisible; on the wheat stubble you can find the axis if you look for it.
  • Two of the eight unit sprites came back on a diagonal rather than facing up as asked. The offset is measured once in art_face() rather than argued about with the model at a generation per attempt.
  • Selection is a box and a click, and there are no control groups. Fine for twelve units and would not be for forty.
  • sim_separate is O(n²) every tick. At 24 units that is nothing; it is the first thing that would need a grid.
  • The asset pipeline needs a signed-in Chrome and a display. It is not CI-able and is not meant to be — what it produces is committed.

About

A top-down tactical game whose units are triangles, squares and rectangles -- and whose soldiers have faces. Built on machin-ressort's engine, in machin (MFL). Every image is generated by a no-API-key pipeline that drives Gemini through linux-use.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages