Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active July 1, 2026 01:45
Show Gist options
  • Select an option

  • Save lardratboy/9cd228cbb777473fc0291c30484ee3ca to your computer and use it in GitHub Desktop.

Select an option

Save lardratboy/9cd228cbb777473fc0291c30484ee3ca to your computer and use it in GitHub Desktop.
RulesEngine, templates and game variations, v 0.1.0
"use strict";
// ████ MATH EASING CURVES — intrinsically invertible pure functions ████
const EASE = {
cubicInOut: t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
cubicOut: t => 1 - Math.pow(1 - t, 3),
backOut: t => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
bounceOut: t => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
let t2 = t - 1.5 / d1;
return n1 * t2 * t2 + 0.75;
} else if (t < 2.5 / d1) {
let t2 = t - 2.25 / d1;
return n1 * t2 * t2 + 0.9375;
} else {
let t2 = t - 2.625 / d1;
return n1 * t2 * t2 + 0.984375;
}
}
};
// ████ ENGINE — pure rules, no DOM, no THREE. ████
const GRAVITY={down:{dr:1,dc:0},up:{dr:-1,dc:0},right:{dr:0,dc:1},left:{dr:0,dc:-1},none:{dr:0,dc:0}};
const ADJACENCY={moore:[[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]],
vonNeumann:[[-1,0],[1,0],[0,-1],[0,1]]};
function grid(r,c,fn){return Array.from({length:r},(_,i)=>Array.from({length:c},(_,j)=>fn(i,j)));}
class RulesEngine{
constructor(def,rng){this.def=def;this.rng=rng;this._nextId=1;this.reset();}
// Derive the cached rule fields from the definition. Called by reset() AND after any live edit
// that mutates def.rules (e.g. Test Board applying the editor's meta) so these never go stale —
// the input dispatch reads engine.interaction, settle reads engine.gravityDir, etc. Without the
// post-edit re-sync, a definition swapped to swapAdjacent kept the old cached 'tapGroup' value.
_syncRules(){
this.gravityDir = this.def.rules.gravity || 'down';
// interaction → input/swapAt (P4); matchShape → findAllMatches (P2/P5);
// refillMode → _resolve/refill (P3); resolveMode → cascade loop (P2);
// swapAdjacency → swapAt/solvability (P4); comboMode/comboMax → scoring (P7).
this.interaction = this.def.rules.interaction || 'tapGroup';
this.matchShape = this.def.rules.matchShape || 'connected';
this.refillMode = this._refillMode(); // endless ⇒ 'fromSource' (see _refillMode)
this.resolveMode = this.def.rules.resolve || 'single';
this.swapAdjacency = this.def.rules.swapAdjacency || 'vonNeumann';
// swapToEmpty (default false): when interaction==='swapAdjacent', permit a piece to slide into
// an adjacent EMPTY playable cell if the relocated piece forms a match at the destination. The
// source cell becomes empty (this is a move, not a true swap). Liveness (hasValidSwap) also
// considers slide-into-empty moves so a board with empty playable cells can stay alive.
this.swapToEmpty = !!this.def.rules.swapToEmpty;
this.comboMode = this.def.rules.comboMultiplier || 'linear';
this.comboMax = this.def.rules.comboMax ?? Infinity;
}
reset(){
const L=this.def.lattice;
this.rows=L.rows;this.cols=L.cols;this.topology=L.topology||'plane';
this._syncRules(); // gravityDir + the match-3 axes, derived from def
this.mask=grid(this.rows,this.cols,(r,c)=>this.def.layers.mask.grid[r][c]);
this.gravityField=this._normGravity(this.def.layers.gravityZones
?grid(this.rows,this.cols,(r,c)=>this.def.layers.gravityZones.grid[r][c]):null);
this.grid=grid(this.rows,this.cols,()=>null);
this.pieceData=new Map();
this.score=0;this.collected=0;this.totalPrizes=0;
const fill=this.def.layers.pieces.grid;
const rfill=this.def.layers.randomFill?this.def.layers.randomFill.grid:null;
const prizeCells=new Set((this.def.layers.prizes?.cells||[]).map(([r,c])=>r+','+c));
// ── Markers (marked.md): substrate-anchored collectibles, stored as a per-cell grid where
// null = not a marker, false = uncollected, true = collected. Storing per-cell rather
// than as a sparse list lets the existing _apply() transform routine remap them for free
// alongside the mask and gravityField — markers ride D4 rotates and collapses; scroll
// deliberately leaves them put (world-frame, §3 of marked.md).
const markerCells=new Set((this.def.layers.markers?.cells||[]).map(([r,c])=>r+','+c));
this.markers=grid(this.rows,this.cols,(r,c)=>markerCells.has(r+','+c)?false:null);
this.totalMarkers=0; this.collectedMarkers=0;
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
if(this.markers[r][c]===false && this.mask[r][c]) this.totalMarkers++;
else if(this.markers[r][c]!==null && !this.mask[r][c]) this.markers[r][c]=null; // strip markers off non-playable cells defensively
}
const preventInitialMatches = (this.interaction === 'swapAdjacent');
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
if(!this.mask[r][c])continue;
let pal=fill[r][c];
if(pal===null&&rfill&&rfill[r][c]){
if (preventInitialMatches) {
const candidates = [];
const numPalettes = this.def.palette.length;
for (let p = 0; p < numPalettes; p++) {
candidates.push(p);
}
// Shuffle candidates to keep randomness
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(this.rng.next() * (i + 1));
const tmp = candidates[i];
candidates[i] = candidates[j];
candidates[j] = tmp;
}
let chosenPal = candidates[0];
for (const candidate of candidates) {
// Temporarily place to run flood fill / line scan checks
const tempId = this._spawn(candidate, false);
this.grid[r][c] = tempId;
const matches = this.findAllMatches();
// Clean up
this.grid[r][c] = null;
this.pieceData.delete(tempId);
this._nextId--;
if (matches.length === 0) {
chosenPal = candidate;
break;
}
}
pal = chosenPal;
} else {
pal=Math.floor(this.rng.next()*this.def.palette.length);
}
}
if(pal===null||pal===undefined)continue;
const prize=prizeCells.has(r+','+c); if(prize)this.totalPrizes++;
this.grid[r][c]=this._spawn(pal,prize);
}
}
_spawn(p,z){const id=this._nextId++;this.pieceData.set(id,{palette:p,prize:!!z});return id;}
palAt(r,c){const id=this.grid[r][c];return id==null?null:this.pieceData.get(id).palette;}
prizeAt(r,c){const id=this.grid[r][c];return id==null?false:this.pieceData.get(id).prize;}
effGravity(r,c){if(this.gravityField&&this.gravityField[r][c])return GRAVITY[this.gravityField[r][c]];return GRAVITY[this.gravityDir];}
// A gravityField with no actual direction in any cell is just scalar gravity everywhere. The
// editor materializes an all-null field for rendering, and serializing it yields an all-null
// gravityZones layer; left as a non-null grid it would trip per-cell-gravity guards (refill and
// lane-collapse both defer when a field is present). Collapse all-null/empty fields to null so
// those guards only fire on REAL per-cell gravity.
_normGravity(field){
if(!field)return null;
for(const row of field)for(const v of row)if(v)return field;
return null;
}
inBounds(r,c){return r>=0&&r<this.rows&&c>=0&&c<this.cols;}
findGroup(r,c){
const sid=this.grid[r][c];if(sid==null)return[];
const target=this.pieceData.get(sid).palette;
const offs=ADJACENCY[this.def.rules.matchAdjacency]||ADJACENCY.moore;
const seen=new Set([r+','+c]),out=[{r,c}],st=[{r,c}];
while(st.length){const{r:cr,c:cc}=st.pop();
for(const[dr,dc]of offs){let nr=cr+dr,nc=cc+dc;
if(this.def.rules.wrapMatch){nr=(nr+this.rows)%this.rows;nc=(nc+this.cols)%this.cols;}
if(!this.inBounds(nr,nc))continue;const k=nr+','+nc;if(seen.has(k))continue;
if(this.palAt(nr,nc)===target){seen.add(k);out.push({r:nr,c:nc});st.push({r:nr,c:nc});}}}
return out;
}
settle(){
const origin=new Map();
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++)if(this.grid[r][c]!=null)origin.set(this.grid[r][c],{r,c});
let moved=true,guard=0;
while(moved&&guard++<this.rows*this.cols+5){moved=false;
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
const id=this.grid[r][c];if(id==null)continue;
const g=this.effGravity(r,c);if(g.dr===0&&g.dc===0)continue;
const nr=r+g.dr,nc=c+g.dc;
if(!this.inBounds(nr,nc)||!this.mask[nr][nc]||this.grid[nr][nc]!=null)continue;
const ng=this.effGravity(nr,nc);if(ng.dr!==g.dr||ng.dc!==g.dc)continue;
this.grid[nr][nc]=id;this.grid[r][c]=null;moved=true;}}
const moves=[];
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){const id=this.grid[r][c];if(id==null)continue;
const o=origin.get(id);if(o.r!==r||o.c!==c)moves.push({id,toR:r,toC:c});}
return moves;
}
collapse(){
if(this.def.rules.laneCollapse===false)return null;
if(this.gravityField)return null;
const g=GRAVITY[this.gravityDir];if(g.dr===0&&g.dc===0)return null;
const vertical=g.dr!==0;
if(vertical){const keep=[];
for(let c=0;c<this.cols;c++){let m=false,p=false;
for(let r=0;r<this.rows;r++){if(this.mask[r][c])m=true;if(this.grid[r][c]!=null)p=true;}
if(!(m&&!p))keep.push(c);}
if(keep.length===this.cols)return null;
this._reindex(this.rows,keep.length,(r,c)=>[r,keep[c]]);
}else{const keep=[];
for(let r=0;r<this.rows;r++){let m=false,p=false;
for(let c=0;c<this.cols;c++){if(this.mask[r][c])m=true;if(this.grid[r][c]!=null)p=true;}
if(!(m&&!p))keep.push(r);}
if(keep.length===this.rows)return null;
this._reindex(keep.length,this.cols,(r,c)=>[keep[r],c]);}
return this._snapshotMoves();
}
rotate(dir){
const R=this.rows,C=this.cols,nR=C,nC=R;
const src=(r,c)=>dir==='cw'?[R-1-c,r]:[c,C-1-r];
this._apply(nR,nC,src);
return this._snapshotMoves();
}
_reindex(nR,nC,src){this._apply(nR,nC,src);}
_apply(nR,nC,src){
const nm=grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.mask[or][oc];});
const ng=grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.grid[or][oc];});
const nf=this.gravityField?grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.gravityField[or][oc];}):null;
// Markers are substrate-anchored — same frame as the mask under D4 rotates and lane collapses
// (board-frame; reorient with the board). Scroll is the exception and stays untouched there;
// see scroll() for that side. The `collected` state rides with the cell, naturally.
const nMk=this.markers?grid(nR,nC,(r,c)=>{const[or,oc]=src(r,c);return this.markers[or][oc];}):null;
this.rows=nR;this.cols=nC;this.mask=nm;this.grid=ng;this.gravityField=nf;
if(nMk)this.markers=nMk;
}
_snapshotMoves(){const m=[];for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){const id=this.grid[r][c];if(id!=null)m.push({id,toR:r,toC:c});}return m;}
maskCopy(){return this.mask.map(row=>row.slice());}
// Snapshot of the markers grid — emitted on steps that reshape the lattice (rotate, collapse,
// markersCollected) so the timeline carries the post-step marker state and replay/scrub work.
markersCopy(){return this.markers?this.markers.map(row=>row.slice()):null;}
// a collapse step also carries the post-collapse mask, since collapse reshapes the wall pattern
_collapseStep(){const cm=this.collapse();return cm?{kind:'collapse',dims:{rows:this.rows,cols:this.cols},moves:cm,mask:this.maskCopy(),markers:this.markersCopy()}:null;}
// ── Board-wide match detection (freshdesign §16.1 — the cascade re-scan). Returns a flat,
// de-duplicated list of {r,c} cells belonging to any clearable group, scanning the WHOLE
// board because cascades form matches wherever pieces fall. Reuses findGroup's flood-fill
// through a shared `seen` set, so every cell is touched once (O(cells)). matchAdjacency and
// wrapMatch come along for free via findGroup. `line` matchShape (collinear runs) is Phase 5.
findAllMatches(){
if(this.matchShape==='line')return this._findLineMatches(); // P5: scan rows/cols for runs
const min=this.def.rules.minGroupSize,seen=new Set(),out=[];
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
if(this.grid[r][c]==null)continue;
if(seen.has(r+','+c))continue;
const grp=this.findGroup(r,c);
for(const cell of grp)seen.add(cell.r+','+cell.c); // whole group visited, size aside
if(grp.length>=min)for(const cell of grp)out.push(cell);
}
return out;
}
// P5: collinear-run detection (classic match-3 lines).
_findLineMatches(){
const min = this.def.rules.minGroupSize || 3;
const matchedCells = new Set();
const dirs = [[0, 1], [1, 0]]; // horizontal and vertical
if (this.def.rules.matchAdjacency === 'moore') {
dirs.push([1, 1], [1, -1]); // diagonals
}
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const id = this.grid[r][c];
if (id == null) continue;
const pal = this.palAt(r, c);
for (const [dr, dc] of dirs) {
let prevR = r - dr;
let prevC = c - dc;
let hasPrevMatch = false;
if (this.def.rules.wrapMatch) {
prevR = (prevR + this.rows) % this.rows;
prevC = (prevC + this.cols) % this.cols;
}
if (this.inBounds(prevR, prevC) && this.mask[prevR][prevC]) {
if (this.palAt(prevR, prevC) === pal) {
hasPrevMatch = true;
}
}
if (hasPrevMatch && !this.def.rules.wrapMatch) {
continue; // Already processed as part of a larger run
}
const run = [{r, c}];
let currR = r, currC = c;
const limit = Math.max(this.rows, this.cols);
for (let step = 1; step < limit; step++) {
let nr = currR + dr;
let nc = currC + dc;
if (this.def.rules.wrapMatch) {
nr = (nr + this.rows) % this.rows;
nc = (nc + this.cols) % this.cols;
}
if (!this.inBounds(nr, nc) || !this.mask[nr][nc]) break;
if (this.palAt(nr, nc) !== pal) break;
if (nr === r && nc === c) break;
run.push({r: nr, c: nc});
currR = nr;
currC = nc;
}
if (run.length >= min) {
for (const cell of run) {
matchedCells.add(cell.r + ',' + cell.c);
}
}
}
}
}
const out = [];
for (const k of matchedCells) {
const [sr, sc] = k.split(',').map(Number);
out.push({r: sr, c: sc});
}
return out;
}
// ── Combo multiplier for cascade scoring (freshdesign §16.1, Decision 7).
// Phase 7: escalate per cascade tick (linear ramp), capped by comboMax.
_combo(tick){
if (this.comboMode === 'linear') {
return Math.min(this.comboMax || Infinity, tick);
}
return 1;
}
// ── Refill (freshdesign §16.1, refill:fromSource — locked Decisions 4 & 5). Scalar gravity
// only. For each lane parallel to gravity, fill the SOURCE-CONNECTED segment (the run of
// playable cells open to the source edge, up to the first wall) to full: after a settle the
// holes sit contiguously at the source end, and a piece entering from outside falls until it
// hits a wall or a resting piece — so those source-end holes are exactly the fillable cells.
// Pieces are placed DIRECTLY at their settled cells (engine never holds off-board state), and
// each carries an off-board {fromR,fromC} origin = cell − k·gravity (k = holes in the lane) so
// the View animates a rigid stack dropping in from beyond the source edge (model i). Palettes
// are drawn from this.rng (same as reset), so refill replays deterministically. Returns a
// {kind:'spawn',spawned:[…]} step, or null when there's nothing to do or the config is out of
// scope (per-cell-gravity field, or gravity:none — both have no single source edge).
// Effective refill mode. ENDLESS modes always refill: a non-refilling endless game just shrinks
// to nothing, so "endless" with refill:none would be a contradiction. Read live from the def so
// it stays correct however the definition was assembled or restored (reset, JSON, frame-restore).
_refillMode(){
if(this.def.win && this.def.win.type==='endless') return 'fromSource';
return this.def.rules.refill || 'none';
}
_refill(){
if(this._refillMode()!=='fromSource')return null;
if(this.gravityField)return null; // per-cell-gravity refill deferred (scalar only)
const g=GRAVITY[this.gravityDir];
if(g.dr===0&&g.dc===0)return null; // gravity:none has no source edge
const vertical=g.dc===0; // down/up → lanes are columns; left/right → rows
const nLanes=vertical?this.cols:this.rows;
const laneLen=vertical?this.rows:this.cols;
const spawned=[];
for(let L=0;L<nLanes;L++){
const empties=[];
for(let s=0;s<laneLen;s++){ // s walks source→sink along +gravity
const r=vertical?(g.dr>0?s:laneLen-1-s):L;
const c=vertical?L:(g.dc>0?s:laneLen-1-s);
if(!this.mask[r][c])break; // first wall closes the source-connected segment
if(this.grid[r][c]==null)empties.push({r,c});
}
const k=empties.length;
if(!k)continue;
for(const cell of empties){
const pal=Math.floor(this.rng.next()*this.def.palette.length);
const id=this._spawn(pal,false);
this.grid[cell.r][cell.c]=id;
spawned.push({id,r:cell.r,c:cell.c,palette:pal,prize:false,
fromR:cell.r-k*g.dr,fromC:cell.c-k*g.dc}); // off-board origin for the drop-in
}
}
return spawned.length?{kind:'spawn',spawned}:null;
}
// ── Shared post-match resolution. Given the localized match the action already found
// (`initial`: a list of {r,c}), clear it, settle, [refill — P3], and if resolveMode
// is 'cascade' re-scan the whole board and repeat until no matches remain; then collapse.
// tapAt and the future swapAt both funnel through here; the trailing {kind:'state'} is
// appended by the caller. With resolveMode 'single' the loop runs exactly once, which is
// byte-identical to tapAt's old inline tail. `center` is preserved on the first clear step.
// Guards: a hard tick cap (mirrors settle's) and a break if a tick clears nothing, so a
// cascade can never spin — important once P3 refill stops the board strictly shrinking.
_resolve(initial,center){
const steps=[];
let matched=initial,tick=0;
const maxTicks=this.rows*this.cols+5;
while(matched&&matched.length&&tick<maxTicks){
tick++;
const cleared=[];
for(const{r:gr,c:gc}of matched){const id=this.grid[gr][gc];if(id==null)continue;
const pd=this.pieceData.get(id);
if(pd.prize){this.collected++;this.score+=50;}
cleared.push({id,r:gr,c:gc,palette:pd.palette,prize:pd.prize});
this.grid[gr][gc]=null;this.pieceData.delete(id);}
if(!cleared.length)break; // nothing actually cleared → stop
this.score+=cleared.length*10*this._combo(tick); // P7: ×combo; P1/P2: ×1 ⇒ flat, as before
const clearStep={kind:'clear',cells:cleared};
if(tick===1&&center)clearStep.center=center; // keep tap's center metadata intact
steps.push(clearStep);
const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm});
if(this._refillMode()==='fromSource'){const sp=this._refill();if(sp)steps.push(sp);} // top up to full
if(this.resolveMode!=='cascade')break;
matched=this.findAllMatches(); // re-scan board-wide for chained matches
}
// ── Marker collection (marked.md §2): scan at the END of _resolve, after the cascade loop has
// fully terminated and any final refill has settled. Mid-cascade exposure does NOT count —
// a marker only collects on a *resting* board. Runs BEFORE _collapseStep so a marker on a
// lane that's about to vanish gets collected (and then the now-marker-free lane collapses
// normally). Step is omitted when nothing was collected, to keep timelines uncluttered.
const mc=this._collectMarkers(); if(mc) steps.push(mc);
const cs=this._collapseStep();if(cs)steps.push(cs);
// Auto-shuffle deadlock resolution (Decision 8)
if (this.interaction === 'swapAdjacent' && !this.canMove()) {
const isWon = (this.def.win.type==='collectAllPrizes'&&this.totalPrizes>0&&this.collected>=this.totalPrizes) ||
(this.def.win.type==='collectAllMarkers'&&this.totalMarkers>0&&this.collectedMarkers>=this.totalMarkers) ||
(this.def.win.type==='clearAll'&&this.pieceData.size===0);
if (!isWon) {
const shuffleMoves = this.shufflePieces();
if (shuffleMoves) {
steps.push({kind: 'shuffle', moves: shuffleMoves});
}
}
}
return steps;
}
// Marker collection scan (called at end of _resolve and after every other settle-producing
// path — setGravity, rotateBoard, scrollBoard). For each uncollected marker whose cell is
// currently empty, flip it to collected, bump the counter, and emit a {kind:'markersCollected'}
// step carrying the cells (for view animation) and a fresh markers snapshot (for replay/scrub).
_collectMarkers(){
if(!this.markers) return null;
const cells=[];
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
if(this.markers[r][c]===false && this.grid[r][c]==null && this.mask[r][c]){
this.markers[r][c]=true; this.collectedMarkers++;
cells.push({r,c});
this.score+=50; // mirror prize-collection score boost
}
}
if(!cells.length) return null;
return {kind:'markersCollected', cells, markers:this.markersCopy()};
}
shufflePieces(){
const cells = [];
const pieces = [];
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (this.mask[r][c] && this.grid[r][c] != null) {
cells.push({r, c});
pieces.push(this.grid[r][c]);
}
}
}
if (cells.length === 0) return null;
let attempts = 0;
const maxAttempts = 200;
let ok = false;
while (!ok && attempts++ < maxAttempts) {
// Fisher-Yates shuffle
for (let i = pieces.length - 1; i > 0; i--) {
const j = Math.floor(this.rng.next() * (i + 1));
const tmp = pieces[i];
pieces[i] = pieces[j];
pieces[j] = tmp;
}
for (let i = 0; i < cells.length; i++) {
const {r, c} = cells[i];
this.grid[r][c] = pieces[i];
}
if (this.findAllMatches().length === 0 && this.canMove()) {
ok = true;
}
}
if (!ok) return null;
const moves = [];
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const id = this.grid[r][c];
if (id != null) {
moves.push({id, toR: r, toC: c});
}
}
}
return moves;
}
tapAt(r,c){
const group=this.findGroup(r,c);
if(group.length<this.def.rules.minGroupSize)return null;
const steps=this._resolve(group,{r,c});
steps.push({kind:'state',...this.checkState()});
return steps;
}
// ── Swap two adjacent cells (freshdesign §16.1, interaction:swapAdjacent — Decisions 2 & 3).
// Commit-only with swapMustMatch always on for v1: the swap is applied, then kept ONLY if it
// forms a match touching either swapped cell; otherwise it's reverted and a 'swapReject' bounce
// is returned (board + score unchanged). On commit the 'swap' step animates the two pieces
// crossing, then _resolve runs clear→settle→refill→cascade exactly as a tap does. Adjacency is
// enforced by the caller via isSwapAdjacent; this method still no-ops (null) on out-of-range,
// masked, or empty cells so it can never corrupt state.
//
// swapToEmpty extension: when this.swapToEmpty is on, ONE side may be empty (mask=true,
// grid=null). That's a slide-into-empty move: the lone piece relocates, the source becomes
// empty, and the move is kept only if the relocated piece forms a match at its destination.
// The step shapes use b.id=null to signal "no second piece" so the renderer can degrade
// cleanly (no arc-around for swap, single-piece bounce for swapReject).
swapAt(r1,c1,r2,c2){
if(!this.inBounds(r1,c1)||!this.inBounds(r2,c2))return null;
if(!this.mask[r1][c1]||!this.mask[r2][c2])return null;
const id1=this.grid[r1][c1],id2=this.grid[r2][c2];
// Both empty is never meaningful. Both filled is the classic swap. One-filled-one-empty is the
// slide-into-empty case, only permitted when swapToEmpty is on.
if(id1==null && id2==null) return null;
const slide = (id1==null || id2==null);
if(slide && !this.swapToEmpty) return null;
this.grid[r1][c1]=id2;this.grid[r2][c2]=id1; // tentatively swap (works for slide too: null↔piece)
const min=this.def.rules.minGroupSize;
// For the match test, only count cells that hold a piece after the (tentative) move. A slide
// leaves one of the two cells empty — that cell can't be in a group, so we just skip it.
const checkCells=[];
if(this.grid[r1][c1]!=null) checkCells.push({r:r1,c:c1});
if(this.grid[r2][c2]!=null) checkCells.push({r:r2,c:c2});
let ok1=false, ok2=false;
let g1=[], g2=[];
if (this.matchShape === 'line') {
const allMatches = this.findAllMatches();
const matchSet = new Set(allMatches.map(cell => cell.r + ',' + cell.c));
if(this.grid[r1][c1]!=null) ok1 = matchSet.has(r1 + ',' + c1);
if(this.grid[r2][c2]!=null) ok2 = matchSet.has(r2 + ',' + c2);
if (ok1 || ok2) {
if (ok1) g1 = allMatches; // seed cascade with the whole current match set
if (ok2) g2 = allMatches; // (seen-set below dedupes overlap; cascade re-scan handles later ticks)
}
} else {
if(this.grid[r1][c1]!=null){ g1=this.findGroup(r1,c1); ok1=g1.length>=min; }
if(this.grid[r2][c2]!=null){ g2=this.findGroup(r2,c2); ok2=g2.length>=min; }
}
if(ok1||ok2){ // forms a match → commit
// For a slide, the "empty side" of the step carries id:null so the View can recognize the
// one-piece variant and skip the two-piece arc-around. The position interpolation falls out
// of frame A→B placements automatically (the relocated piece appears at its new cell in B).
const swapStep={kind:'swap',
a:{id:id1,toR:r2,toC:c2},
b:{id:id2,toR:r1,toC:c1}};
const initial=[],seen=new Set();
for(const g of [ok1?g1:[],ok2?g2:[]])for(const cell of g){
const k=cell.r+','+cell.c;if(!seen.has(k)){seen.add(k);initial.push(cell);}}
const steps=[swapStep,...this._resolve(initial,null)];
steps.push({kind:'state',...this.checkState()});
return steps;
}
this.grid[r1][c1]=id1;this.grid[r2][c2]=id2; // no match → revert, bounce only
return [{kind:'swapReject',a:{id:id1,r:r1,c:c1},b:{id:id2,r:r2,c:c2}},
{kind:'state',...this.checkState()}];
}
setGravity(dir){this.gravityDir=dir;const steps=[];
const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm});
const mc=this._collectMarkers();if(mc)steps.push(mc);
const cs=this._collapseStep();if(cs)steps.push(cs);
steps.push({kind:'state',...this.checkState()});return steps;}
rotateBoard(dir){const rm=this.rotate(dir);
const steps=[{kind:'rotate',dir,dims:{rows:this.rows,cols:this.cols},moves:rm,mask:this.maskCopy(),markers:this.markersCopy()}];
const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm});
const mc=this._collectMarkers();if(mc)steps.push(mc);
const cs=this._collapseStep();if(cs)steps.push(cs);
steps.push({kind:'state',...this.checkState()});return steps;}
scroll(spec){
let dr=0,dc=0;
if(spec==='scrollC+')dc=1; else if(spec==='scrollC-')dc=-1;
else if(spec==='scrollR+')dr=1; else if(spec==='scrollR-')dr=-1; else return null;
const ng=grid(this.rows,this.cols,()=>null);
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){const id=this.grid[r][c];if(id==null)continue;
ng[(r+dr+this.rows)%this.rows][(c+dc+this.cols)%this.cols]=id;}
this.grid=ng; return this._snapshotMoves();
// Note: scroll deliberately does NOT touch this.markers — markers are world-frame under scroll
// (marked.md §3, freshdesign §5.1). Pieces scroll past stationary markers, which is the whole
// point: empty cells can be walked under markers to expose them.
}
scrollBoard(spec){
const sm0=this.scroll(spec); if(!sm0)return[{kind:'state',...this.checkState()}];
const steps=[{kind:'scroll',moves:sm0}];
const sm=this.settle();if(sm.length)steps.push({kind:'settle',moves:sm});
const mc=this._collectMarkers();if(mc)steps.push(mc);
const cs=this._collapseStep();if(cs)steps.push(cs);
steps.push({kind:'state',...this.checkState()});return steps;}
hasValidMoves(){for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++)
if(this.grid[r][c]!=null&&this.findGroup(r,c).length>=this.def.rules.minGroupSize)return true;return false;}
// Adjacency test for swap input (uses swapAdjacency, independent of matchAdjacency — Decision 3).
isSwapAdjacent(r1,c1,r2,c2){
const offs=ADJACENCY[this.swapAdjacency]||ADJACENCY.vonNeumann;
return offs.some(([dr,dc])=>r1+dr===r2&&c1+dc===c2);
}
// Swap-game liveness: does ANY adjacent swap produce a match? This — not "a group already exists"
// — is the correct "valid move" test for swap games, whose resting board is deliberately match-free.
// When swapToEmpty is on, slide-into-empty moves (piece ↔ adjacent empty playable cell) also count
// as candidate moves, so a board with empty cells stays alive as long as one slide forms a match.
hasValidSwap(){
const offs=ADJACENCY[this.swapAdjacency]||ADJACENCY.vonNeumann;
const min=this.def.rules.minGroupSize;
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
if(this.grid[r][c]==null||!this.mask[r][c])continue; // we iterate from each *piece*
for(const [dr,dc] of offs){
const nr=r+dr,nc=c+dc;
if(!this.inBounds(nr,nc)||!this.mask[nr][nc])continue;
const neighborPiece = this.grid[nr][nc]!=null;
// Piece↔piece pair: test each unordered pair once (the (nr<r||(nr===r&&nc<c)) guard).
// Piece↔empty (slide): NEVER symmetric — the empty side has no piece to seed iteration from
// — so we test it from the piece side regardless of (r,c) ordering.
if(neighborPiece){
if(nr<r||(nr===r&&nc<c))continue;
}else{
if(!this.swapToEmpty)continue;
}
const a=this.grid[r][c],b=this.grid[nr][nc];
this.grid[r][c]=b;this.grid[nr][nc]=a;
let ok;
if (this.matchShape === 'line') {
ok = this.findAllMatches().length > 0;
} else {
// For a slide, one side is empty after the swap; only test the filled side. findGroup on
// an empty cell would return [] and skew nothing, but skipping is cheaper and clearer.
const okR = (this.grid[r][c] !=null) && this.findGroup(r,c).length>=min;
const okNR = (this.grid[nr][nc] !=null) && this.findGroup(nr,nc).length>=min;
ok = okR || okNR;
}
this.grid[r][c]=a;this.grid[nr][nc]=b;
if(ok)return true;
}
}
return false;
}
// Unified liveness used by checkState + the game-start retry. Tap games keep exact old semantics.
canMove(){return this.interaction==='swapAdjacent'?this.hasValidSwap():this.hasValidMoves();}
checkState(){
const w=this.def.win.type;
if(w==='collectAllPrizes'&&this.totalPrizes>0&&this.collected>=this.totalPrizes)return{status:'won'};
if(w==='collectAllMarkers'&&this.totalMarkers>0&&this.collectedMarkers>=this.totalMarkers)return{status:'won'};
if(w==='clearAll'&&this.pieceData.size===0)return{status:'won'};
if(w==='endless')return{status:'playing'}; // never wins; placed before the no-moves check so it never auto-loses → truly endless
if(!this.canMove())return{status:'lost'};
return{status:'playing'};
}
restoreFromFrame(frame,score,collected,rngState){
this.rows=frame.dims.rows;
this.cols=frame.dims.cols;
this.grid=grid(this.rows,this.cols,()=>null);
this.pieceData.clear();
let maxId=0;
for(const p of frame.placements){
this.grid[p.r][p.c]=p.id;
this.pieceData.set(p.id,{palette:p.palette,prize:p.prize});
if(p.id>maxId)maxId=p.id;
}
this._nextId=maxId+1;
this.score=score;
this.collected=collected;
if(rngState !== null) this.rng.setState(rngState);
if(frame.mask){
this.mask=frame.mask.map(row=>row.slice());
}else{
this.mask=grid(this.rows,this.cols,(r,c)=>{
if(this.def.layers.mask.grid[r]!==undefined&&this.def.layers.mask.grid[r][c]!==undefined){
return this.def.layers.mask.grid[r][c];
}
return true;
});
}
if(frame.gravityField){
this.gravityField=frame.gravityField.map(row=>row.slice());
}else if(this.def.layers.gravityZones){
this.gravityField=grid(this.rows,this.cols,(r,c)=>{
if(this.def.layers.gravityZones.grid[r]!==undefined&&this.def.layers.gravityZones.grid[r][c]!==undefined){
return this.def.layers.gravityZones.grid[r][c];
}
return null;
});
}else{this.gravityField=null;}
this.gravityField=this._normGravity(this.gravityField); // all-null field ⇒ scalar gravity (see _normGravity)
// Restore markers. Replay/scrub frames carry the full grid (collected flags and all); fresh
// restores (e.g. Test Board) fall back to building from def.layers.markers.cells. The
// collected counter is derived rather than stored so we don't drift if a frame is hand-edited.
if(frame.markers){
this.markers=frame.markers.map(row=>row.slice());
}else if(this.def.layers.markers){
const mc=new Set((this.def.layers.markers.cells||[]).map(([r,c])=>r+','+c));
this.markers=grid(this.rows,this.cols,(r,c)=>mc.has(r+','+c)?false:null);
}else{
this.markers=grid(this.rows,this.cols,()=>null);
}
this.totalMarkers=0; this.collectedMarkers=0;
for(let r=0;r<this.rows;r++)for(let c=0;c<this.cols;c++){
if(this.markers[r][c]===false){ this.totalMarkers++; }
else if(this.markers[r][c]===true){ this.totalMarkers++; this.collectedMarkers++; }
}
}
}
// ████ DEFINITIONS — rules config mapping to rules engine ████
const PALETTE=[{id:'sphere',color:0xff0000},{id:'cube',color:0x00ff00},{id:'tetra',color:0x0080ff},
{id:'octa',color:0xffff00},{id:'icosa',color:0xff00ff},{id:'dodeca',color:0xffffff}];
function baseDef(name,rows,cols,prizeCells,over){
const d={
name, format:'lattice-game/1',
lattice:{rows,cols,topology:'plane'},
palette:PALETTE,
layers:{
mask:{binding:'substrate',grid:grid(rows,cols,()=>true)},
pieces:{binding:'mobile',grid:grid(rows,cols,()=>null)},
randomFill:{binding:'field',grid:grid(rows,cols,()=>true)},
prizes:{binding:'rider',cells:prizeCells||[]},
markers:{binding:'marker',cells:[]}
},
rules:{matchAdjacency:'moore',matchBy:'type',minGroupSize:2,gravity:'down',laneCollapse:true,
transforms:['rotateCCW','rotateCW']},
win:{type:'collectAllPrizes'}, lose:{type:'noValidMoves'}
};
return over?over(d):d;
}
const GAMES=[
{key:'eight', label:'Eight Neighbors',
build:(r,c,pz)=>baseDef('Eight Neighbors',r,c,pz)},
{key:'same', label:'SameGame · orthogonal',
build:(r,c,pz)=>baseDef('SameGame',r,c,pz,d=>{d.rules.matchAdjacency='vonNeumann';d.rules.transforms=[];return d;})},
{key:'sideways', label:'Sideways collapse',
build:(r,c,pz)=>baseDef('Sideways collapse',r,c,pz,d=>{d.rules.gravity='left';return d;})},
{key:'carousel', label:'Carousel · scroll & match',
build:(r,c,pz)=>baseDef('Carousel',r,c,pz,d=>{d.rules.laneCollapse=false;
d.rules.transforms=['scrollC-','scrollC+','scrollR-','scrollR+'];return d;})},
{key:'twozone', label:'Two-zone gravity',
build:(r,c,pz)=>baseDef('Two-zone gravity',r,c,pz,d=>{
d.layers.gravityZones={binding:'field',anchored:true,
grid:grid(r,c,(rr,cc)=> cc>=Math.floor(c/2)?'right':null)};
return d;})},
{key:'cascade', label:'Cascade SameGame · chains', // P2: resolve:cascade — clears chain as pieces fall
build:(r,c,pz)=>baseDef('Cascade SameGame',r,c,pz,d=>{
d.rules.matchAdjacency='vonNeumann';d.rules.transforms=[];d.rules.resolve='cascade';return d;})},
{key:'refill', label:'Refill cascade · tap', // P3: cascade + refill:fromSource — board tops up from the top
build:(r,c,pz)=>baseDef('Refill cascade',r,c,pz,d=>{
d.rules.matchAdjacency='vonNeumann';d.rules.transforms=[];d.rules.minGroupSize=3;
d.rules.resolve='cascade';d.rules.refill='fromSource';d.rules.laneCollapse=false;return d;})},
{key:'bejeweled', label:'Bejeweled-lite · swap', // P4: swapAdjacent — the playable match-3 (swap → cascade → refill)
build:(r,c,pz)=>baseDef('Bejeweled-lite',r,c,pz,d=>{
d.rules.interaction='swapAdjacent';d.rules.swapAdjacency='vonNeumann';
d.rules.matchAdjacency='vonNeumann';d.rules.minGroupSize=3;
d.rules.resolve='cascade';d.rules.refill='fromSource';
d.rules.transforms=[];d.rules.laneCollapse=false;return d;})},
{key:'endlessA', label:'Endless Collapse · swap', // swap + connected match-N; win:endless ⇒ truly endless (no prizes, never wins/loses, auto-shuffle keeps it playable)
build:(r,c,pz)=>baseDef('Endless Collapse',r,c,pz,d=>{
d.rules.interaction='swapAdjacent';d.rules.swapAdjacency='vonNeumann';
d.rules.matchAdjacency='vonNeumann';d.rules.minGroupSize=3;
d.rules.matchShape='connected';d.rules.resolve='cascade';d.rules.refill='fromSource';
d.rules.transforms=[];d.rules.laneCollapse=false;
d.rules.comboMultiplier='linear';d.rules.comboMax=8;
d.win={type:'endless'};return d;})},
{key:'endlessB', label:'Endless Lines · swap', // swap + line detection (P5); win:endless ⇒ truly endless; also exercises the swapAt line-branch clear fix
build:(r,c,pz)=>baseDef('Endless Lines',r,c,pz,d=>{
d.rules.interaction='swapAdjacent';d.rules.swapAdjacency='vonNeumann';
d.rules.matchAdjacency='vonNeumann';d.rules.minGroupSize=3;
d.rules.matchShape='line';d.rules.resolve='cascade';d.rules.refill='fromSource';
d.rules.transforms=[];d.rules.laneCollapse=false;
d.rules.comboMultiplier='linear';d.rules.comboMax=8;
d.win={type:'endless'};return d;})},
];
const TEMPLATES={
rectangle:(r,c)=>grid(r,c,()=>true),
diamond:(r,c)=>{const mr=(r-1)/2,mc=(c-1)/2,maxD=Math.min(mr,mc);
return grid(r,c,(i,j)=>Math.abs(i-mr)+Math.abs(j-mc)<=maxD+1e-9);},
cross:(r,c)=>{const mr=(r-1)/2,mc=(c-1)/2,arm=Math.max(0,Math.floor(Math.min(r,c)/5));
return grid(r,c,(i,j)=>Math.abs(i-mr)<=arm+0.5||Math.abs(j-mc)<=arm+0.5);},
diagonal:(r,c)=>grid(r,c,(i,j)=> j/Math.max(1,c-1) <= i/Math.max(1,r-1)+1e-9),
};
const TEMPLATE_LABELS={rectangle:'Rectangle',diamond:'Diamond',cross:'Cross',diagonal:'Diagonal'};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment