Imported from FS-GG/FS.GG.Game (
template/product-skills/fs-gg-model-swap/SKILL.md). Install upstream withnpx skills add FS-GG/FS.GG.Game --skill fs-gg-model-swap. Copyright stays with the author.
Model-Swap Capability
Scope
Use this skill when you replace the generated product's starter model (the minimal,
replaceable Pong-style state machine) with your own game/UI model. The scaffold is built
so the swap touches only a handful of files and every governance/evidence scan stays green
across it. Read docs/scaffold-map.md first — it is the source of truth for what survives;
this skill is the how-to for the swap itself.
The re-export spine — why a swap is a 4-file change
Program.fs is the host/CLI entry point. It does not call your model directly through
long qualified paths; instead it re-exports the model/host surface by name at the top:
// in Program.fs — the seam that decouples the large host file from your model
type Model = Product.Model.Model
type Msg = Product.Model.Msg
let initialModel = Product.Model.initialModel
let update = Product.Model.update
let view = Product.View.view
let tick = Product.EvidenceCommands.tick
let generatedHost = Product.EvidenceCommands.generatedHost
Everything below those bindings (the entrypoint, the evidence-command dispatch, the window
diagnostics) refers to the local update / view / generatedHost names. So as long as
your swapped model keeps those source-module members present and same-shaped
(Product.Model.update : Model -> Msg -> Model, Product.View.view : …,
Product.EvidenceCommands.generatedHost : …), Program.fs and WindowOptions.fs compile
unchanged — you never touch them. That is the whole reason a model swap is a ~4-file edit,
not a rewrite of the host.
The contract you must not break: keep the re-exported names alive in their source modules, with the same types. Rename
Product.Model.updateor change its arity and the spine breaks and the swap grows to touchProgram.fs.
Which files you touch (durable vs replaceable vs re-point)
From docs/scaffold-map.md, three classes — you only edit the last two:
- Replaceable — rewrite freely (they define/return the starter model):
src/<ProductDir>/Model.fs— the starterModel/Msg/update. Your state machine goes here. Entity positions/velocities are the collision-safeGeometry.Vec2;stepSimadvances them via theFixedStep.drainaccumulator onTick(see [[fs-gg-game:fs-gg-game-core]]).src/<ProductDir>/Vec2.fs(game / sample-pack) — the collision-safe vector helper (Geometry.Vec2,Vx/Vy,toPoint/toRect). Use it for your own positions/sizes so you never reuseScene'sX/Y/Width/Heightlabels (see the record-label pitfall below). Rename/extend it freely; but because the shipped starterModel.fsdepends on it, delete it only together with (or after) swapping the starter model off it — then itsExists-guarded compile item keeps the build green.src/<ProductDir>/View.fs— the starterview : Model -> SceneNode. Your rendering goes here.src/<ProductDir>/Collision.fs(game / sample-pack) — the adaptable collision helper (see [[fs-gg-game:fs-gg-collision]]). Edit the response rule, add layers, or delete it entirely: its compile item isExists-guarded, so the build stays green andProduct.fsprojstays durable.src/<ProductDir>/Visibility.fs(game / sample-pack) — the adaptable 2D-visibility helper (see [[fs-gg-game:fs-gg-visibility]]). Edit the sight radius, cone the field of view, swap the polygon for a fog-of-war mask, or delete it entirely: its compile item isExists-guarded, so the build stays green andProduct.fsprojstays durable.src/<ProductDir>/Grids.fs(game / sample-pack) — the adaptable grid-parts helper (see [[fs-gg-game:fs-gg-grids]]). Edit the edge/vertex addressing, move the grid origin, extend it toward hex grids, or delete it entirely: its compile item isExists-guarded, so the build stays green andProduct.fsprojstays durable.src/<ProductDir>/LineDrawing.fs(game / sample-pack) — the adaptable grid line-drawing helper (see [[fs-gg-game:fs-gg-line-drawing]]). Switch the thin line for the supercover, cap the length for a limited-range beam, or delete it entirely: its compile item isExists-guarded, so the build stays green andProduct.fsprojstays durable.tests/Product.Tests/BehaviorTests.fs— the replaceable behaviour tests that drive the starter'sview/update/host directly. Rewrite these to drive your model.
- Re-point — keep the file + its scanned tokens, re-aim the model-field reads:
src/<ProductDir>/LayoutEvidence.fs— HUD/gameplay region bounds. Re-point the region computations at your own layout; keep the evidence tokens.src/<ProductDir>/EvidenceCommands.fs— the deterministicSceneEvidence.rendercommand and the host wiring (generatedHost,tick,mapKey,viewerOptions). Re-point it at your ownview/update; keep the command surface and tokens.
- Durable — do not touch:
Program.fs,WindowOptions.fs,Product.fsproj,tests/Product.Tests/GovernanceTests.fs. The re-export spine keeps these compiling.
A purely additive swap (e.g. adding a model field the re-point files never read) can leave the
re-point files untouched too — then the swap is just Model.fs + View.fs + BehaviorTests.fs.
Doing the swap — a checklist
- Rewrite
Model.fs: yourModel,Msg,initialModel,update. Keep those names. - Rewrite
View.fs: yourview : Model -> SceneNode(see [[fs-gg-rendering:fs-gg-scene]] for the primitives). - Re-point
LayoutEvidence.fsandEvidenceCommands.fsat the fields your new model exposes, preserving every must-survive source-scan token (SceneEvidence.render,RendererMode = "deterministic-scene", the visual-evidence vocabulary — seescaffold-map.md). - Rewrite
BehaviorTests.fsto drive your model. - Leave
Program.fs/WindowOptions.fs/Product.fsproj/GovernanceTests.fsalone. ./fake.sh build -t Devthen-t Testthen-t Verify. The governance gate proves the durable spine survived; the behaviour tests prove your model works.
Common pitfalls
- Renaming a re-exported member. If you rename
Product.Model.update(or change its signature),Program.fs'slet update = Product.Model.updateno longer resolves and the swap leaks into the durable host file. Keep the re-exported names and shapes; add new members alongside them rather than renaming the spine. - The governance substring trap (see the compile-order gate +
scaffold-map.md). The compile-order scan is anchored to the<Compile Include="X.fs" />item form, so an additive file whose name embeds one of the six scanned names — aBulwarkView.fs, aGameModel.fs— and even a code comment mentioningView.fs/Model.fsare safe. You do not need to rename additive files or scrub comments to avoid the six substrings. (This closed an earlier bare-IndexOffootgun that flaggedBulwarkView.fsas "view before model" and broke the build.) - Framework-vs-consumer record-label collision — use the shipped
Geometry.Vec2.SceneexposesPoint = { X: float; Y: float }andRect = { X; Y; Width; Height }. The durableLayoutEvidence.fsopens bothSceneand your model and buildsRectwith bare labels, so if your model also declares a record withX/Y/Width/Height, those bare{ X = …; Y = …; Width = …; Height = … }literals mis-resolve to your record — a wall of errors in a durable file you were told not to touch, surfacing only after a whole model is written. The scaffold ships the fix: the collision-safeGeometry.Vec2(Vx/Vy— zero overlap withPoint/Rect) insrc/<ProductDir>/Vec2.fs. Build your positions on it and cross into the scene with itstoPoint/toRect(express a size viatoRect, neverWidth/Heightlabels on your record):// Keep `Geometry` QUALIFIED. `open Geometry` does not compile in a file that also opens // `FS.GG.Game.Core` — the sim ships its own `[<RequireQualifiedAccess>]` Geometry, and the open // resolves to that one (FS0892). Qualified access reaches both halves; this is that same // two-Geometry seam, one level up. type Enemy = { Pos: Geometry.Vec2 // Vec2 = { Vx: float; Vy: float } — collision-safe Velocity: Geometry.Vec2 } // NO X/Y/Width/Height labels on your record let bounds (e: Enemy) : Rect = Geometry.toRect e.Pos 24.0 24.0 // centered size, no Width/Height labels - Consumer-vs-consumer record-label collision. Distinct from the clash above: if two of
your own records share a label (a
Creepand aTowerboth carrying.Pos/.Id/.Hp), a barelet posOf x = x.Posmakes F# infer the last-declared record forx, so the helper silently type-checks against the wrong type. Annotate the parameter —let posOf (c: Creep) = c.Pos— at every such shared-label access. Plan your record label names up front (see [[fs-gg-game:fs-gg-game-core]]'s grid-sim recipe — it is far cheaper than reworking the model after the inference errors appear).
Build Commands
Run ./fake.sh build -t Dev then ./fake.sh build -t Verify in this product.
Test Commands
Run ./fake.sh build -t Test — GovernanceTests.fs proves the durable spine survived the swap;
BehaviorTests.fs proves your replaced model behaves.
Evidence
Record swap evidence (before/after governance-scan green, behaviour cases for the new model) under
this product's readiness/ paths. Do not copy framework readiness reports into the product.
Package Boundary
A model swap is pure product code — it references only the capability packages your profile already
carries (fs-gg-scene for geometry, fs-gg-skiaviewer for host wiring, fs-gg-game-core for
simulation). It introduces no new package edge; keep host wiring in fs-gg-skiaviewer.
Generated Product
The starter is deliberately minimal so it is cheap to replace. Rewrite Model.fs/View.fs,
re-point the two evidence files, rewrite BehaviorTests.fs, and the re-export spine carries the
durable host across unchanged.
Persistent problems
When a problem outlasts reasonable in-repo attempts, extensive external research is mandatory —
consult official online docs first (the F#/.NET docs and the driven library's own reference),
then community sources. If your product uses Spec Kit, record findings and resolving links under the
feature's specs/<feature>/feedback/ folder; otherwise record them in this skill's Sources line
and any product-local docs/ location. Offline, the mandate degrades to recording
"research blocked — " rather than hard-failing the phase.
Related
- [[fs-gg-rendering:fs-gg-scene]] — the
Scene/Point/Rectprimitives your newviewbuilds; owns the framework geometry records the collision note is about. - [[fs-gg-game:fs-gg-game-core]] — the grid-sim recipe and the consumer-vs-consumer record-label guidance.
- [[fs-gg-rendering:fs-gg-skiaviewer]] — the host boundary the re-exported
generatedHostdrives. - [[fs-gg-rendering:fs-gg-layout]] — the HUD + gameplay regions the re-pointed
LayoutEvidence.fscomputes.
Sources / links
- F#/.NET docs: https://learn.microsoft.com/en-us/dotnet/fsharp/