16 KiB
Plan 3c: JavaScript Frontend Design
Purpose
Plan 3c of the four-plan BattleForge build. Closes the gap between the Plan 3a+3b PHP backend and the in-browser experience by adding the small JavaScript surface that Plan 3a's web layer was waiting for. Plan 3c is the third of four plans and the last one in the original "Plan 3" deliverable.
Plan 3a's web layer (the front controller, the editor handlers, the views) shipped without any JavaScript beyond a <script type="module" src="..."> placeholder. Plan 3c delivers the JS that pre-fills the team editor from localStorage, runs the battlefield grid interactions, and wires the home page's "Recent scenarios" list. The web app is currently functional from the browser with HTML-only form submits; Plan 3c adds the JS-driven UX that the spec calls for.
Success Criteria
Plan 3c succeeds when a new user, without developer assistance, can:
- Visit
http://localhost:8000/and see the home page with a "Recent scenarios" list populated fromlocalStorage. - Click "New scenario", fill the team editor, save — the page reloads, the recent list shows the new scenario, and clicking the new scenario in the recent list opens the team editor pre-filled with the saved data.
- Navigate to the battlefield editor, paint a small grid, place deployment zones, place an objective, save — the saved state is in
localStorageunder the samescenario:{id}key, and the home page's recent list shows the updated scenario. - Refresh the page or come back tomorrow and find their scenarios still in their browser.
- Open the browser dev tools and see no console errors, no
eval, no unhandled promise rejections, no mixed-content warnings.
Out of Scope (deferred to Plan 4)
- The hot-seat battle interface (Plan 4 ships a real battle page that reads
match:currentfromlocalStorageand renders the turn-based UI). - Three bundled scenarios (Plan 4 ships them as JSON fixtures in
public/scenarios/). - An end-to-end smoke test against a real browser. The spec calls for it under Plan 4, not Plan 3c.
- Drag-and-drop on the grid (the spec says it's optional; the click-paint approach in Plan 3c is sufficient for the MVP).
- Service worker / offline support (the spec explicitly excludes this).
- Animation of combat, terrain, or transitions (Plan 4).
- The "Start match" button on the team editor navigates to the home page after a successful match start; Plan 4 adds the route that shows the match in progress.
In Scope
Two JavaScript files
public/js/storage.js — the localStorage helper. Exposes window.bfStorage with eight methods:
window.bfStorage = {
// Read a scenario by id. Returns the parsed object or null if missing/corrupt.
load(id) -> object | null,
// Write a scenario to localStorage under `scenario:{id}`.
// The argument is the canonical Plan-3a scenario shape (the same shape
// ScenarioSerializer::scenarioToArray produces).
save(id, scenario) -> void,
// Remove a scenario from localStorage.
delete(id) -> void,
// List all stored scenarios, sorted by last-modified descending.
// Returns [{id, name, lastModified}].
listAll() -> [{id, name, lastModified}],
// Read/write the in-progress match.
currentMatch() -> object | null,
setCurrentMatch(match) -> void,
removeCurrentMatch() -> void,
};
The module handles corrupt JSON gracefully: load(id) returns null if JSON.parse throws, and listAll() skips keys that fail to parse (showing a "corrupt scenario" placeholder in the home page).
public/js/grid-editor.js — the battlefield editor grid helper. Runs on DOMContentLoaded. Reads data-scenario-id from the form element, calls bfStorage.load(id), and populates the grid. Manages three modes selected by the existing name="zoneMode" radio buttons (alpha, bravo, objective, plus the default terrain-paint mode which is implicit when none of the zone radios are selected). On tile click, applies the current mode to that tile. Shift-click removes the paint or zone membership. The form's submit is intercepted; the JS builds the canonical scenario JSON, fetches POST /scenarios/{id}/edit/battlefield with Content-Type: application/json and X-CSRF-Token: <token> (read from the layout's <meta name="csrf-token">), parses the response, and writes the returned scenario to localStorage. On 400 with {ok: false, errors: [...]}, displays the errors inline in the form.
One JSON asset
public/assets/archetypes.json — the four ArchetypeTemplate definitions, served at /assets/archetypes.json. Format:
{
"defender": { "minHealth": 10, "maxHealth": 14, "minAttack": 2, "maxAttack": 4, "minDefense": 3, "maxDefense": 5, "minSpeed": 1, "maxSpeed": 3, "allowedAbilities": ["buff"] },
"striker": { "minHealth": 6, "maxHealth": 10, "minAttack": 4, "maxAttack": 6, "minDefense": 1, "maxDefense": 2, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["area_damage"] },
"support": { "minHealth": 5, "maxHealth": 9, "minAttack": 1, "maxAttack": 3, "minDefense": 1, "maxDefense": 3, "minSpeed": 2, "maxSpeed": 4, "allowedAbilities": ["heal", "buff"] },
"scout": { "minHealth": 4, "maxHealth": 7, "minAttack": 2, "maxAttack": 4, "minDefense": 0, "maxDefense": 2, "minSpeed": 4, "maxSpeed": 6, "allowedAbilities": [] }
}
The ArchetypeCatalog::templates() from Plan 3a is the canonical source for these values. Plan 3c's JSON must stay in sync with Plan 3a's PHP enum values. A future task could generate this JSON from the PHP source, but Plan 3c ships a hand-maintained copy.
Four placeholder images
public/assets/placeholders/{defender,striker,support,scout}.png — 64×64 (or 32×32) transparent PNGs with a simple silhouette representing each archetype. Served at /assets/placeholders/{archetype}.png (no userToken prefix). The team editor's <img> tags for unit art default to these URLs when no uploaded image is set. The exact pixel art is up to the implementer; the test just checks the file exists and is a valid PNG.
Updated view templates
Three templates get new <script type="module"> tags:
src/Views/home.php— adds<script type="module" src="/js/storage.js"></script>. The<div id="recent">is populated bystorage.jsonDOMContentLoaded.src/Views/team-editor.php— adds<script type="module" src="/js/storage.js"></script>. The form fields are pre-filled bystorage.jsonDOMContentLoaded. Adds a "Start match" button at the bottom of the form that callsbfStorage.load(id), fetchesPOST /scenarios/{id}/start, callsbfStorage.setCurrentMatch(match), and navigates toGET /.src/Views/battlefield-editor.php— adds<script type="module" src="/js/grid-editor.js"></script>. The grid is populated bygrid-editor.jsonDOMContentLoaded.
Node toolchain
package.json— declareseslint ^8.57.0,eslint-config-airbnb-base ^15.0.0,eslint-plugin-import ^2.29.0,prettier ^3.3.0. Scripts:lint(eslint public/js),format(prettier --check public/js)..eslintrc.json— extendsairbnb-base, envbrowser + es2022, sourceTypemodule..prettierrc—{ "singleQuote": true, "trailingComma": "all", "printWidth": 100 }(matches the existing PHPCS PSR-12 style).
CI update
.github/workflows/ci.yml— addsnpm ci,npm run lint,npm run formatsteps after the existingcomposer checkstep..gitignore— addsvar/secret.key(Plan 3b'sFullFlowTestcreates it on first run; it shouldn't be committed) andnode_modules/.
System Boundaries
Plan 3c introduces no new boundaries. The Plan 3a+3b web layer's four boundaries still hold:
- Content library (Domain, unchanged):
ArchetypeCatalogis the canonical source;archetypes.jsonis a hand-maintained mirror used by the JS. - Scenario editor (web, now JS-driven): Plan 3c adds the JS layer that pre-fills forms and runs grid interactions. The form submit still goes to the same PHP handlers.
- Rules engine (Domain, unchanged).
- Battle interface (Plan 4).
The JS layer reads and writes the canonical scenario shape (Plan 3a's ScenarioSerializer::scenarioToArray). It does not introduce a separate "draft" shape or a UI-only data model. The home page's "Recent scenarios" list parses the same shape for display.
Data and Action Flow
Cold-start flow
- Browser hits
GET /.GetHomePagereturns 200 with the home page (which now includes<script type="module" src="/js/storage.js"></script>). storage.jsruns.bfStorage.listAll()enumerateslocalStoragekeys matchingscenario:*, parses each value, returns an array sorted bylastModifieddescending. The home page's<div id="recent">is populated with one<a>per item, pointing to/scenarios/{id}/edit/team.- User clicks "New scenario" →
GET /scenarios/new/edit/team.
Team editor flow
- Browser hits
GET /scenarios/{id}/edit/team.GetTeamEditorreturns 200 with the team editor template (which now includes<script type="module" src="/js/storage.js"></script>). storage.jsreadswindow.location.pathname, extracts{id}(or'new'for the new-scenario case), callsbfStorage.load(id). If a scenario exists, the JS pre-fills each form field by name (e.g.teamA[units][0][archetype]→document.querySelector('[name="teamA[units][0][archetype]"]').value = scenario.units[0].archetype). If the id is'new', the form stays empty.- The archetype dropdowns are populated from
/assets/archetypes.json(fetched once on script load). - User edits, then presses Save. The form's submit triggers a normal
POST /scenarios/{id}/edit/team(no JS intercept). - The server returns 200 with a
<script type="module">block that callslocalStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson)). The browser runs the script and writes. - On a validation error, the server returns 200 with the form re-rendered and the validator's error in
<div class="bf-errors">. No JS write happens; the form keeps the user's in-progress values.
Battlefield editor flow
- Browser hits
GET /scenarios/{id}/edit/battlefield.GetBattlefieldEditorreturns 200 with the battlefield editor template (which now includes<script type="module" src="/js/grid-editor.js"></script>). grid-editor.jsreadsbfStorage.load(id), populates thetileMapfromscenario.battlefieldTerrain, populates the deployment zones (extracted fromscenario.deploymentZones), and populates the objective position (ifHoldObjective).- The grid is repainted: each tile button's
data-paintattribute is updated, the objective tile getsdata-objective="1", and the zone tiles getdata-zone="alpha"ordata-zone="bravo". - User clicks a palette button to select terrain paint mode, then clicks tiles. Each click updates the in-memory
tileMapand updates the button'sdata-paint. User clicks one of thename="zoneMode"radios (alpha / bravo / objective) and clicks tiles to add to that zone or place the objective. Shift-click in any mode removes the paint or zone membership. - User presses Save. The JS intercepts the form submit (
event.preventDefault()), builds the canonical scenario JSON, POSTs to/scenarios/{id}/edit/battlefieldwithContent-Type: application/jsonandX-CSRF-Token: <token>(read from the layout's<meta name="csrf-token">), parses the response, and writes the returnedscenariotolocalStorage. - On 400 with
{ok: false, errors: [...]}, the JS shows the errors inline in the form (e.g. "Deployment zone alpha includes impassable terrain at (3,4)").
Start-match flow
- From the team editor, user clicks "Start match" (a new button at the bottom of the form). The JS reads
bfStorage.load(id), fetchesPOST /scenarios/{id}/startwithX-CSRF-Token, parses the response, callsbfStorage.setCurrentMatch(match), and navigates toGET /. - The home page (when reloaded) shows a "Match in progress" banner at the top, driven by
bfStorage.currentMatch(). Plan 4 adds a real battle page that this banner links to.
Validation and Failure Handling
bfStorage.load(id)returnsnullif the value is missing or fails to JSON-parse. The home page'slistAll()skips keys that fail to parse, and the team editor's pre-fill gracefully no-ops onnull.- The team editor's form submit falls back to the server's re-render path (Plan 3a's
PostTeamEditorre-renders the form on validation failure with the user's in-progress values and the validator's error message). The JS does not intercept the submit. - The battlefield editor's fetch fallback path: on 400, the JS shows the validator's errors inline and keeps the user's in-progress state in the
tileMapand zone maps. On network failure (e.g. fetch throws), the JS shows a "Network error" message and the form stays editable. - The "Start match" button's failure path: on 400 (invalid scenario), the JS shows the validator's errors inline. On network failure, shows a "Network error" message and keeps the form editable.
Security and Quality Requirements
Inherited from the Plan 3 spec:
- All rendered output is escaped for its output context. The PHP templates use
Escape::html/Escape::attr/Escape::url. The JS usestextContentandvaluesetters, neverinnerHTML(with one exception: building the home page's recent-scenarios list usesappendChildon<a>elements withtextContent, neverinnerHTML). - The single small JS file passes ESLint
airbnb/baseand Prettier checks. CI fails if either fails. composer validate --strictand the existingcomposer checkcontinue to pass.
Plan 3c-specific:
- No third-party JS libraries. The two files are pure ESM, no dependencies, no
eval, noFunctionconstructor. - The
archetypes.jsonis served from the same origin. The home page's "Recent scenarios" list is read fromlocalStorage, never from the network. var/secret.keyis added to.gitignoreso the Plan 3b full-flow test's generated secret file is not committed.
Verification Strategy
Lint and format (CI gate)
npm run lint— ESLintairbnb-baseoverpublic/js/*.js. Fails on any rule violation, including unused variables, missing semicolons, and unhandled promise rejections.npm run format— Prettier--check public/js. Fails on any formatting drift.
Smoke test (optional, recommended)
- One Playwright test that boots the dev server with
php -S, loads the home page, asserts the empty "Recent scenarios" list, callsbfStorage.save('demo', {id: 'demo', name: 'Demo'})viapage.evaluate, reloads, and asserts the list shows "Demo". This is the Plan 3c analog ofFullFlowTestfor Plan 3b. - If Playwright is too heavy for Plan 3c, the spec's CI requirement (ESLint + Prettier) is sufficient, and the Playwright test lands in Plan 4 with the battle interface.
Manual usability check
A new tester with no BattleForge context can: open the dev URL, see the home page, click "New scenario", fill the team editor, save, see the recent list show the new scenario, click it, see the team editor pre-filled, paint a small grid, save, see the battlefield state persist, and refresh to find their work still there — without leaving the browser or seeing any error from the validator that wasn't explained inline.
Release Boundary
Plan 3c is releasable when every in-scope capability and success criterion is met, the verification suite is green (composer check, npm run lint, npm run format), and no out-of-scope capability (the battle interface, bundled scenarios, the end-to-end Playwright smoke test) is required to exercise the local creation flow. The local dev server runs the full app on a single port with php -S, and the JS layer loads from /js/.
The next plan (Plan 4) will replace the home page's "Match in progress" banner with the real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that this plan's spec leaves as a follow-up.