12 KiB
Stacks & unit management — design brief
A working document for a session on unit stacks: how a player splits them ergonomically, and how a stack moves as one body through the world.
1. Shared project context (read this first)
Battle for 'Tismo is an HTML5 port of a turn-of-the-millennium strategy game. Pure JavaScript, no build step, no runtime dependencies.
- Server-authoritative.
shared/game_state.js(GameState) is a framework-free model;server/game_server.js(GameServer) owns one and validates orders;server/server.jsis the transport. - Shared logic runs in Node and the browser.
shared/stays framework-free (no Node built-ins, no DOM). - GameState is mixins.
shared/game_state.jsimports eachshared/game_state/<topic>.jsandObject.assigns its methods onto the prototype. A new method file must be imported and added there. - Client mirroring. Any pathfinding or movement-cost change must be mirrored
between
shared/game_state/movement.jsand the browser prediction inclient/js/map_view/motion.js, or prediction fights the server. - Data lives in
shared/data/(barrel:shared/data.js).
Commands
- Install once:
npm install. - One suite:
node tests/run_tests.js --file <name>.js. Never run the full suite during development — the commit hook does that. - Server:
node server/server.js --port 27015 --bind 127.0.0.1(--testingadds free/instant buttons).
Conventions
- 2-space indent; no new libraries (only vendored jQuery).
- Missing icon → dummy SVG
dummy icon - <name>. - Tests in
tests/<topic>_test.jsextendingTestCase; fixtures intests/framework/helpers.js(smallState(),defaultState(),adjacentLand(),grantBuilding()). Force RNG withstate._random = () => value. - Visual changes are previewed as a small standalone HTML page linking
client/css/style.css, opened in Firefox, before commit. - Commit only when asked; messages are one imperative sentence.
2. What exists today
- The stack panel was just reworked (commit "Grouped the unit stack by type and
let ranged units pick a target"): one line per unit type, tri-state group
checkbox, opaque icon cards with healthbars, overlap-to-fit, cards to the left
on top, deselected cards stay and sort to the back —
client/js/game_screen/panels.js(_renderStackList,_layoutStackDeck,_stackSelectionChanged) and the_stackMemberswhole-stack concept inclient/js/game_screen.js. - Selecting a unit is by left-click on the map (
client/js/map_view/input.js_onLeftClick) or from the right-click stack menu:client/js/game_screen/panels.js_openStackMenulists each unit on a tile ("Move here", per-unit select, "Select all"). - Right-click only moves now (the ranged Attack action is a panel button); moving onto a hostile stack triggers the fight server-side.
- Group movement:
_groupMovePlanand_groupSpeed(slowest member sets the pace);find_pathis called per unit with a per-type route cache. - Territory rules:
shared/game_state/movement.js_canUnitEntertreats foreign territory as a wall on through-routes except the civilisation targeted by the move (targetCiv= owner of the goal)._zoneOfControldenies the ring around hostile ground units. Mirrored inclient/js/map_view/motion.js_canEnter/_zoneOfControl/_targetCivFor. - The client has the diplomacy matrix in
this.snapshot.diplomacyand a helper_isAtWar(a, b)inclient/js/game_screen/panels.js, but the MapView does not hold relations yet (needed for prediction — see §5).
3. Requirements (from ROADMAP.md)
- Easy and ergonomic way to split unit stacks.
- Give stacks with range units a dedicated button to perform ranged attacks, right-clicking will now ONLY move (obviously triggers a battle when moving on the same tile) (the ranged button is done; this line can be ticked)
- Stacks must behave as single units when it comes to pathfinding
- Cannot go through neutral third-party territory, allied and at-war territory can be walked through
- Airplanes move in straight lines as they are not bound by tiles. (see
AIR_MOVEMENT.md)
4. Splitting a stack — design space (settle before coding)
The problem: a player wants two units out of a five-unit stack to go one way and the rest another, without hunting each one down in the right-click menu.
Candidate mechanics (pick one primary, maybe one shortcut):
- Split button + destination. With a tile or stack selected, a "Split" action opens the stack panel with per-type count steppers ("move 2 of 3 infantry"), then a target pick. Good for precise splits.
- Shift/drag cards. In the stack panel, drag a card (or shift-click) to detach it into a new sub-selection, then right-click to move the detached group. Fast, but needs clear feedback for "this card is now moving".
- Halve / split by type. Buttons "Split in half" and "Split by type" (each type into its own stack) — one-click, no destination.
- Stack menu "Split off". Extend
_openStackMenu: checkboxes per row to pick the units to detach, then a "Move selected" action.
Open questions:
- Does a "split" create a persistent sub-stack, or is it just a temporary multi-selection that disperses after one order? (There is no persistent stack entity today — units sharing a tile are the stack.)
- How does splitting interact with the group-speed rule (slowest member sets the pace for the whole move)? If you split off a slow artillery and send the fast infantry alone, the infantry should move at its own speed — confirm the group move only groups the selected units.
- Where does the split land if the destination is one tile (all units together) versus a route (do they arrive as a stack)?
- Undo/confirm: splitting should not be a mis-click trap.
5. Stack pathfinding as one unit
Goal: a stack routes as a body and never cuts through a neutral third party, while allied and at-war territory may be crossed.
Current rule (_canUnitEnter, through-route, non-air):
if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
Desired rule: block only neutral third parties; allow allied and at-war territory.
Open questions:
- Where does the alliance/war state come from on the server?
GameStatehas relations and (with the treaties work) alliance treaties. A helper like_canTraverseTerritory(civ, owner)reading_relation(a, b)/ alliance membership would centralise it. Decide whether "allied" means an alliance treaty specifically or also non-aggression/peace. - Client mirror. The browser predicts paths, so
MapViewneeds the same answer.this.snapshot.diplomacyis available when a snapshot is applied; add a relations/at-war/allied structure toMapView(set in_setupWorldoronState) and have_canEnterconsult it. Without this, prediction will draw a route the server refuses. - "Stacks behave as single units." Today each unit is pathfound separately with a shared route cache per type and the slowest speed wins. Decide whether the whole stack should share one route (computed once, followed by all) so units never fan out, and what happens if a unit is blocked mid-route by a zone of control that another unit is exempt from.
- Zone of control vs stack. Should a stack be able to force a crossing a single unit could not, or does the ZoC apply per unit? The roadmap is silent; keep per-unit unless asked.
- War declaration side effect.
_groupMovePlanalready records the owners a route crosses and_orderMovedeclares war on them. With allied/at-war traversal allowed, make sure a permitted crossing does not declare war on an ally (and that crossing an at-war nation is already legal). - Air and spies are exceptions. Aircraft ignore territory entirely; spies cross foreign land (already implemented). The new rule should not regress either.
6. Suggested order of work
- Decide the split interaction (§4) and build it in the stack panel + menu, with client tests.
- Add the server helper for traversable territory and wire
_canUnitEnter. - Mirror it in
MapView(_canEnter) with the relations the snapshot ships; add a prediction test intests/map_view_input_test.js/map_view_test.js. - Decide the one-route-per-stack question; if yes, change
_groupMovePlanand the server's group move together. - Tick the roadmap lines.
7. Tests to add
- Split: selecting a subset splits correctly; the split group moves at its own (slowest member) speed; a mis-click is recoverable.
- Territory: a route crosses an ally's land but not a neutral's; crossing an ally's land does not declare war; crossing an at-war nation's land is allowed; client prediction matches the server for each case.
- Regression: spies and aircraft keep their exceptions; zone of control still applies.
8. Gotchas
- Mirror server and client or prediction fights the server (this is the single most common bug in this area).
- Do not add a per-broadcast walk. If relations are shipped, they are small; if you add a derived structure, memoise it against the diplomacy version.
- The stack panel's
_stackMembers(whole stack) vsselectedUnitIds(ticked) distinction is load-bearing; readclient/js/game_screen/panels.js_stackSelectionChangedbefore changing selection semantics. _orderMove/_groupMovePlanalso handle transport (embark/disembark) and escort; check those paths when changing group movement.
9. Relevant files
client/js/game_screen/panels.js— stack panel, stack menu,_groupMovePlan,_groupSpeed,_isAtWar.client/js/game_screen.js—_stackMembers, target/selection state.client/js/map_view/input.js— click handling,_onRightClick.client/js/map_view/motion.js— prediction_canEnter,_zoneOfControl,_targetCivFor,planMove.client/js/map_view.js— where relations would be stored for prediction.shared/game_state/movement.js—_canUnitEnter,_zoneOfControl, pathfinding.shared/game_state/diplomacy.js,shared/game_state/treaties.js— relations and alliances.- Tests:
tests/game_screen_test.js,tests/map_view_input_test.js,tests/map_view_test.js,tests/stacking_test.js,tests/orders_test.js.
10. Related design docs
INTELLIGENCE.md, AIR_MOVEMENT.md, ECONOMY_BALANCE.md,
POLITICS_PERFORMANCE.md, TRAINING_UI.md, ICONS.md (repo root).
11. Decisions taken (implemented)
- Split (§4): shift-click detach. A plain left-click still takes the whole
stack; shift + left-click on a map unit (or a panel card, which already toggled)
adds or removes just that unit from the moving group. Right-click moves the
ticked subset at its own slowest-member pace; unticked cards stay in the panel
so a mis-click is recoverable. No persistent sub-stack entity was introduced.
client/js/map_view/input.js(_onLeftClick) forwards the modifier andclient/js/game_screen.js(map.onUnitToggled) editsselectedUnitIdsin place. - Territory (§5): neutral third parties are the only wall.
GameState._canTraverseTerritory(civ, owner, targetCiv)allows a nation's own land, the land of the nation a route is aimed at, an ally's, and a nation already at war;_canUnitEnteruses it and_orderMoveno longer declares war on an ally or an at-war nation it crosses. "Allied" means an alliance treaty specifically (areAllied). The browser mirrors this inmap_view/motion.js_canTraverseTerritory, with the alliance/war pairs read from the snapshot inmap_view/entities.js_applyRelations. - One route per stack (§5.3): deliberately not changed. Mixed stacks (land and naval, say) have different traversable terrain, so a single shared path would be wrong; the existing per-type route cache already collapses identical searches, and every member is pinned to the slowest speed. "Behave as a single unit" is therefore the shared traversal policy plus formation speed, not a literal one-path-fits-all.