GRIDLOCK Special Move Deep Dive #11 — PLACEMENT
Final one.
PLACEMENT sounds like it should be the easiest special move in the game. You place a piece, and the cell tells the next player where to go. That's basically Ultimate Tic-Tac-Toe.
Then I added 3-player and 4-player modes.
And somehow “send the next player there” became “start a deterministic multi-player routing relay whose state can be propagated, redirected, consumed or cancelled by other game systems.”
...excellent. 😑
The core idea is still simple. PLACEMENT puts down your marker, then uses the position of that exact cell inside its 3×3 micro-grid to determine the macro-grid the next player is routed toward.
Internally, the routing calculation looks roughly like this:
row = targetIndex ~/ outerBoardWidth
col = targetIndex % outerBoardWidth
zoneRow = row % macroGridWidth
zoneCol = col % macroGridWidth
forcedMacroIndex = zoneRow * macroGridWidth + zoneCol
In other words, the cell isn't just a destination. It's carrying routing information.
That works nicely in 2-player Ultimate Tic-Tac-Toe because there's only one opponent to route. But GRIDLOCK supports 2P, 3P and 4P, so I had to decide what PLACEMENT actually means when there are several players waiting behind you.
The answer became a relay:
relayMovesRemaining = playerCount - 1
So in 2P, the routing survives for 1 opponent move. In 3P, it can propagate through 2 opponent moves. In 4P, it can propagate through 3.
Which means a 4P sequence can effectively become:
A places → routes B → B lands → routes C → C lands → routes D
The original PLACEMENT doesn't independently choose destinations for B, C and D. It starts the relay. Each eligible landing can produce the routing information for whoever comes next.
That's the point where I stopped thinking of PLACEMENT as “force somebody into a macro” and started thinking of it as a tiny routing protocol running through the turn system.
And like any perfectly reasonable routing protocol, other systems immediately started interfering with it.
Only Standard and Push pass the relay forward. That's particularly interesting for Push, because the routing information has to come from the piece's landing cell, not the cell where the Push began.
So if a Push starts at A but the moved state eventually lands at B, the next forced macro is derived from B.
The route follows the result of the move, not merely its intent.
That distinction sounds small until you get it wrong and the UI tells the next player to go somewhere completely different from the board state the engine just produced.
BAIT complicates it differently.
BAIT contact is resolved before the ordinary Placement/Standard/Overwrite mutation. So if a relay-carrying incoming move hits BAIT and gets consumed by the trap logic, you don't pretend the normal move happened and continue routing from a placement that never actually survived.
No successful landing means no imaginary routing event.
Dictator can interfere at an even higher level. It can clear the active relay, while sentence restrictions can constrain the player's available geography. So PLACEMENT can't live in isolation and assume that because it calculated a forcedMacroIndex three transitions ago, that route must remain valid forever.
This is why the relay itself lives in explicit game state:
forcedMacroIndex
relayMovesRemaining
Those two values tell the engine both where the current routing instruction points and how much longer that instruction is supposed to propagate.
I specifically didn't want clients reconstructing the relay from something like:
“Well, PLACEMENT happened two turns ago, we're in 4P, therefore I think there should be one hop left.”
Nope.
Relay duration is state, not a guess derived from history.
The authoritative game state carries forcedMacroIndex and relayMovesRemaining, and the clients render the routing that actually exists.
That becomes especially important once the relay starts interacting with specials. A player can see the same board geometry while the underlying routing state is completely different depending on whether a relay is active, how many hops remain, whether BAIT intercepted something, whether Dictator cleared it, or whether sentence rules are currently affecting the player.
Same board.
Different turn topology.
And that's probably the most interesting thing PLACEMENT ended up doing: it modifies future turns without modifying those future turns yet.
NUKE also affected the future, but through a pending destructive event. PLACEMENT does it through routing state.
One says:
something will happen to this macro later
The other says:
the next part of the turn graph has changed
That distinction forced me to keep “where pieces are” and “where players are currently allowed/required to act” as related but separate pieces of game state.
It also made PLACEMENT a pretty fitting move to finish this series with.
Because after implementing all 11 specials, the recurring problem was almost never the visible effect itself.
ANCHOR wasn't difficult because putting a protected piece on the board is visually complicated.
PUSH wasn't difficult because moving pieces looks complicated.
BAIT wasn't difficult because drawing fake markers is complicated.
MIRROR wasn't difficult because totalCells - 1 - targetIndex is complicated.
NUKE wasn't difficult because deleting pieces is complicated.
GRAVITY wasn't difficult because making things move toward an edge is complicated.
And PLACEMENT isn't difficult because calculating another macro index is complicated.
The difficult part is what happens when all of those rules are allowed to exist in the same game.
A move has to know what state it reads, what state it changes, what state it must preserve, which other systems can intercept it, whether its effects are immediate or persistent, how long those effects survive, and who gets the final say when several rules collide.
That's ultimately why GRIDLOCK ended up with a RuleEngine instead of eleven isolated buttons containing eleven clever tricks.
PLACEMENT is a good miniature example of the whole architecture:
place marker → derive route from landing geometry → store forced macro → initialize playerCount - 1 relay → propagate only through eligible moves → let higher-priority interactions interrupt it → decrement relay → eventually clear it
The player just sees a piece land and the board tell somebody where to go next.
The engine sees a little packet being routed through several turns while BAIT, Push, Dictator, sentence rules and normal legality stand around waiting to ruin its day. 😑
And with that...
all 11 GRIDLOCK special moves are done.
What started as “Ultimate Tic-Tac-Toe, but with a few special moves” somehow became a rules system involving protection, wrapped physics, ownership mutation, line capture, hidden state, symmetry, delayed transitions, state stripping, whole-board resettlement, sentencing and multi-player routing.
Apparently I have a very loose definition of “a few.”
#Shipaton #BuildInPublic #GRIDLOCK
Hey @polmiro! Your nudge sent me back into the docs, and I’ve started mapping GRIDLOCK’s rewarded-ad flow properly.
First architecture question: GRIDLOCK caps rewarded-credit grants at 6 per player/day, and I need that limit reserved server-side before the ad is shown. Otherwise two devices could both see 5/6, show concurrently, and race for the final slot.
I see @RevenueCat generates the reward-verification token pre-show, then uses AdMob SSV to verify and grant the configured currency afterward.
Is there any way to use that pre-show step to atomically authorize/reserve one of GRIDLOCK’s daily reward slots, then associate that reservation with the eventual verified reward?
Or should GRIDLOCK keep the quota/reservation layer in its own backend, while RevenueCat owns the AdMob SSV verification → currency grant?
Trying to keep RevenueCat as the source of truth for value entering the economy without introducing a multi-device race condition.
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #10 — GRAVITY 🌀
At some point I apparently looked at a board that can contain 729 cells and thought: “You know what this needs? A button that moves almost everything.”
So I built GRAVITY.
Not “shift this row.” Not “push these pieces.” Pick one of four cardinal directions and resettle the movable state of the entire board toward that side.
And that's when a special move quietly turned into a deterministic physics problem. 😑
GRAVITY is a Spectacle-tier move with a 2-own-turn cooldown, but unlike NUKE it has no pending phase. Once a legal Gravity move is accepted, the board transformation happens immediately. There is no armed Gravity sitting around waiting for another turn, which is also why EMP has nothing to cancel.
The first problem was figuring out what “everything falls left” actually means in GRIDLOCK.
Because the answer absolutely isn't:
for every piece: move left
The board contains state that must not move. Anchors and Walls remain fixed. Pieces inside claimed or closed macros aren't part of the movable set, and active Dead Zones remain fixed as well. Gravity first has to classify the board into movable state and immovable structure before it can relocate anything.
Conceptually, the operation became:
collect movable markers → clear their origins → build legal destinations → rank destinations toward gravity → resettle markers
That “clear their origins” step is important. Trying to move pieces one at a time directly across the existing board would make the result depend on processing order. Move A could occupy a destination before B is evaluated, which could change B's result, which could change C's result...
Suddenly “gravity points left” means something different depending on which loop happened to run first.
Instead, the movable population is collected first, its original locations are cleared, and settlement operates against the resulting destination space. The transformation can therefore reason about the whole board state, rather than pretending hundreds of independent little Push moves happened sequentially.
Then comes geometry.
Once the direction is known, legal destinations are ranked toward the appropriate hemisphere. LEFT prefers cells toward the left side, RIGHT toward the right, UP toward the top and DOWN toward the bottom. But that ranking operates only over cells that are actually valid destinations; fixed/protected regions aren't magically displaced because physics felt enthusiastic today.
And then GRIDLOCK adds another complication.
Owned pieces prefer destinations that avoid immediately creating a macro win when a safe alternative exists.
Which means GRAVITY isn't simply:
sort(destinationCells, direction)
and fill them.
Direction determines the pull, but game legality still participates in settlement. If an available placement would immediately create a macro win and another suitable destination avoids doing so, the resolver prefers the safer alternative.
So what looks visually like:
“everything fell downward”
is closer internally to:
“everything movable was globally reallocated toward the downward hemisphere while respecting fixed structures and settlement constraints.”
Doesn't fit quite as nicely on the button. 😑
But the nastiest part isn't actually position.
It's identity.
When Gravity moves something, it can't just move ownerId.
GRIDLOCK cells can carry additional gameplay state. BAIT is the obvious example: a real BAIT marker or one of its decoys can be physically moved by Gravity without triggering or revealing it.
So if I did something like:
newCell.ownerId = oldCell.ownerId
I'd successfully move the player's symbol while quietly murdering everything that made that marker special.
Instead, the relevant CellState travels with the marker. Position changes; the semantics attached to that movable marker don't magically reset just because Gravity picked it up.
That gives GRAVITY a very different relationship with BAIT than Standard, Placement or Overwrite. Those moves can trigger BAIT through contact. Gravity simply relocates the hidden trap state along with the piece.
Which creates a wonderfully evil player-side consequence: something you thought you understood spatially can move across the board while remaining exactly as suspicious as it was before.
From the engine's perspective, though, there's nothing mysterious about it. Hidden to the player doesn't mean ambiguous to the state machine — the same principle that came up when I built BAIT itself.
Scaling made all of this more interesting.
GRIDLOCK's micro-grids remain 3×3, but the macro board grows with player count: 3×3 for 2P, 6×6 for 3P and 9×9 for 4P. That gives the largest mode 81 macro-grids and 729 total cells.
Most specials affect one cell, a line, or a macro. GRAVITY potentially has to reason about the entire 729-cell board in one move.
So I didn't want separate “2P Gravity,” “3P Gravity” and “4P Gravity” implementations full of magic dimensions. The same resolver works from the board dimensions and current state; the size changes the amount of work, not the definition of Gravity.
There's another architectural difference from NUKE worth calling out.
NUKE needed persistent pending state because activation and resolution happen on different turns.
GRAVITY doesn't.
Its lifecycle is:
intent → validation → global board transformation → resulting state
rather than:
intent → pending → future resolution
That's why pendingSpectacle is currently a NUKE concept rather than a generic “all Spectacle moves wait here” bucket. Two moves can belong to the same tier and still have completely different state lifecycles.
And that distinction matters for multiplayer too. The client doesn't independently decide where 300+ pieces “probably” landed and then tell everybody else.
Gravity needs one deterministic authoritative result.
Same pre-move state + same legal Gravity direction should lead to the same resulting board state. Otherwise a whole-board special becomes an extremely efficient multiplayer desynchronization generator.
So the useful mental model for GRAVITY ended up being less like animation and more like a pure-ish board transformation:
current BoardState + direction → deterministic next BoardState
The animation can then show pieces being swept, dragged or pulled toward the selected edge, but once again the visual effect is describing a state transition that has already been decided by the rules. It's not running the physics and hoping the game agrees afterward.
And that's probably the main thing GRAVITY taught me.
Moving a piece is easy. Moving state is harder. Moving almost all of the state at once, while some of it is immovable, some of it is hidden, some destinations are strategically constrained, and every client still needs to agree on the exact result... that's GRAVITY.
Player: “Everything falls down.”
Engine: “Except Anchors, Walls, unavailable regions, Dead Zones, and also I need to preserve every movable CellState while deterministically reallocating—”
Player: “Everything eligible falls down.”
Engine: “…thank you.” 😑
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #9 — EMP ⚡
After building NUKE as a pending state machine, I needed something capable of stopping it. Naturally, I made EMP.
Then EMP somehow became responsible for disabling Anchors, defusing BAIT, removing decoys, creating temporary no-special zones and cancelling pending NUKEs... while being very specifically forbidden from deleting the actual pieces underneath any of them.
Because EMP isn't really a destruction mechanic. It's a state-stripping mechanic.
That distinction became the foundation of the move. A GRIDLOCK cell isn't simply empty or occupied; ownership and special properties exist as separate state. A marker can belong to a player while also being Anchored or carrying real BAIT state, while decoys carry their own status and owner metadata.
So EMP isn't cell → empty. It's much closer to:
cell(owner + special state) → cell(owner)
Across the targeted macro, EMP removes Anchor state, real BAIT state, BAIT-decoy state and decoy-owner metadata while preserving normal ownerId. An Anchored marker therefore doesn't disappear — it just loses its protection. An Overwrite-captured marker keeps its owner but loses the Anchor it received from Overwrite. A real BAIT marker stops being a trap without deleting the underlying piece. And decoy metadata is explicitly cleared rather than leaving behind some dormant trap waiting to ruin my week three turns later.
EMP attacks properties, not ownership.
This is one of those implementation details that sounds obvious after the architecture exists, but gets messy very quickly if every combination of ownership + protection + trap state is represented as a completely different “piece type.” EMP ended up being a pretty good stress test for keeping those layers separate.
But stripping state is only half of the move. Once EMP resolves, that macro becomes a temporary Dead Zone.
A Dead Zone doesn't mean the macro stops being playable. Standard moves can still happen there; what gets disabled is the ability to begin new special moves there. So EMP doesn't temporarily remove a section of the board — it temporarily removes the special-move layer from that region.
The expiry is currently represented as:
deadZones[targetMacro] = state.turnNumber + state.playerCount
That matters because GRIDLOCK supports 2P, 3P and 4P. Expressing the restriction relative to player count keeps the timing tied to the match's turn structure instead of pretending that the same number of global turns means the same thing in every mode. The server owns that authoritative expiry; clients render the resulting restriction.
There is one boundary I'm deliberately not declaring magically solved: exactly when turnNumber + playerCount transitions from “still blocked” to “legal again.” The audit specifically calls for a dedicated boundary test there, because expires on turn X and available after turn X are exactly the kind of sentences that produce an off-by-one bug while everyone swears they mean the same thing. 😑
Targeting has its own safeguards too. Like NUKE, EMP carries a canonical target cell and a secondary macro target, and both have to identify the same macro. A stale or malicious secondary target doesn't get to redirect the effect somewhere else. The macro also has to be available rather than claimed/full, and an already-active Dead Zone can't simply be EMP'd again.
And then there's the interaction that made EMP particularly interesting: NUKE.
NUKE is delayed, so while it's armed there is an actual pending object in game state waiting for its resolution boundary. If EMP legally targets the matching macro before that happens, the pending NUKE is removed. EMP isn't “protecting the pieces from the explosion” or cancelling a visual effect — it's removing the future state transition before that transition gets a chance to happen.
So NUKE can go ARMED → DETONATED, while a successful matching EMP can instead send it ARMED → DEFUSED.
That same logic explains why EMP doesn't cancel Gravity. Gravity might also be a Spectacle move, but it resolves immediately. There is no pending Gravity sitting in state waiting for a future turn, so there is nothing for EMP to defuse. Interactions depend on the lifecycle of the move, not merely which tier its button belongs to.
This also makes EMP a nice intersection point for several earlier specials. ANCHOR loses protection but keeps ownership. OVERWRITE's captured-and-Anchored result becomes an ordinary owned marker. BAIT's real trap and decoy state inside the macro are stripped. A matching pending NUKE can be cancelled. And after all of that, the resulting Dead Zone temporarily prevents new specials from originating there while Standard play continues normally.
The cleaner mental model ended up being:
validate target → strip special properties → preserve ownership → establish Dead Zone → cancel matching pending Nuke if present → publish resulting state
rather than accumulating a giant pile of unrelated if Anchor, if Bait, if Nuke exceptions.
And that became the architectural lesson from EMP: removing what a piece can do is not the same operation as removing the piece itself.
Once ownership, protection, traps, regional restrictions and pending effects exist as separate layers of state, you can manipulate one without accidentally destroying the others.
Player: “EMP disables everything weird.”
Engine: “Technically it selectively strips specific state properties, establishes a turn-bounded regional restriction and conditionally cancels a matching pending—”
Player: “Everything weird.”
Engine: “…fine.” 😑
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #8 — NUKE ☢
You'd think NUKE would be one of the easiest special moves to implement: select a macro, delete everything inside it, play an unnecessarily dramatic explosion, done.
Except GRIDLOCK's NUKE doesn't explode when you use it. It detonates at the start of your next turn.
Which means I didn't really build an explosion. I built a small scheduled state machine with a bomb attached to it.
NUKE is a Spectacle-tier move with a 2-own-turn cooldown, and unlike GRIDLOCK's immediate specials, activation and resolution are two separate events. When the move is accepted, nothing in the targeted macro is destroyed yet. The RuleEngine creates a pending Nuke containing the owner, the turn it was armed on, its origin target and the target macro. That pending state is then published and telegraphed to everyone.
So instead of the usual intent → validation → mutation → new state, NUKE behaves more like:
intent → validation → pending state → intervening turns → resolution/cancellation → new state
That distinction ended up being the entire architecture of the move.
A pending NUKE can't just exist inside the animation system. If someone disconnects after arming it and reconnects before it detonates, the bomb doesn't get amnesia. If the UI rebuilds, it doesn't disappear. If an animation drops frames, it doesn't get an extra-long fuse. And if a client decides the explosion animation has finished early... congratulations to the client, I guess. The board still doesn't care.
The turn state is the clock. The animation only visualizes it.
That even applies when a turn times out. The current transition logic still advances pending-Spectacle and cooldown state through the same turn machinery, rather than depending on somebody successfully completing a pretty UI interaction.
Targeting also ended up being stricter than “send me the macro you want deleted.” NUKE carries a canonical target cell plus a secondary macro target, and both have to identify the same macro. If they disagree, the move is rejected. The target macro itself must also be open, unclaimed, not full/closed and outside an active Dead Zone, and another pending Spectacle can't already be occupying the pending slot.
Which means: the client can ask to arm a NUKE. It doesn't get to define an inconsistent target and hope the server accepts whichever half is convenient.
Once accepted, though, that delay becomes actual gameplay rather than just visual anticipation. Everyone gets the telegraph, turns continue, and the pending Nuke can still be interacted with before its resolution boundary.
Specifically: EMP can defuse it.
If EMP legally targets the matching macro while the NUKE is pending, the pending Nuke is removed. EMP isn't cancelling an explosion animation; it's changing authoritative game state so that there is no longer a Nuke waiting to resolve there. Gravity, by comparison, resolves immediately and doesn't create a pending state, so it has no equivalent EMP cancellation window.
That's a subtle distinction I ended up liking:
ARMED → DETONATED
or
ARMED → DEFUSED
but never:
ARMED → DEFUSED → "well one client still had the explosion queued so"
The spectacle follows the state. The spectacle doesn't decide the state.
If the pending Nuke survives until the start of its owner's next turn, then the actual destructive transition happens. But even “clear the macro” isn't quite as simple as setting nine cells to empty.
NUKE clears the eligible unanchored, non-Wall cells in the target macro. Anchors and Walls are supposed to survive. The pending object is then removed and the resulting macro state is recalculated.
So the detonation is really a filtered transformation:
target macro → inspect cell state → preserve protected cells → clear eligible cells → recalculate
That distinction matters because GRIDLOCK cells aren't just occupied or empty. They can carry rule-relevant state, and “destroy this area” doesn't automatically mean “erase every object the loop encounters.”
And this is where NUKE gave me a useful reminder not to confuse a rule contract with a green test suite.
The current contract says Walls survive NUKE. But the d2786dc validation notes still record one pre-existing Wall-preservation test failure. I'm not going to turn a red test green with creative writing — without a fresh dedicated passing test, that regression stays documented.
It's actually a great example of why destructive mechanics need tests in both directions. It's not enough to ask “did everything that should disappear disappear?” You also need to ask “did everything that must survive actually survive?”
An explosion that removes 8 correct things and 1 incorrect thing can look completely convincing on screen while still violating the game rules.
So the useful NUKE test isn't simply:
did macro get cleared?
It's closer to:
expected destruction + expected survivors = valid detonation
The multiplayer side follows the same philosophy. A client can submit the NUKE intent and render the resulting telegraph, but it cannot author an early blast. The backend mirrors the pending state and detonation timing, and online special telegraphs are server-authored.
So from the player's side, NUKE is basically:
arm → wait → maybe get EMP'd → boom
From the engine's side, it's:
validate target → create authoritative pending state → publish telegraph → advance through turn transitions → allow matching cancellation → resolve protected/clearable cells → recalculate board → publish
And that's probably my favourite part of implementing it: the explosion itself is almost the least interesting engineering problem. Waiting correctly is harder.
NUKE started as “delete a macro next turn.” It ended up reinforcing a much more useful rule for the rest of GRIDLOCK:
If something has to survive turns, timeouts, reconnects and counterplay, it isn't an animation. It's game state.
Player: “I dropped a nuke.”
Engine: “You created a pending turn-bound state transition with conditional cancellation and protected-state preservation.”
Player: “…so I dropped a nuke.”
Engine: “Fine.” 😑
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #7 — MIRROR 🪞
MIRROR started with probably the cleanest equation of any special move in GRIDLOCK:
mirrorIndex = (N - 1) - targetIndex
Pick a cell, calculate its point-symmetric opposite, place on both.
Simple.
Until the obvious question appeared:
“What happens when the mirror points back at itself?” 😑
And somehow that one-line equation turned into dual-target validation, two-macro legality, Dead Zones, sentence restrictions, atomic placement, server-side recomputation...
...and something I ended up calling Mirror Ascension.
GRIDLOCK stores the board as a flattened set of cells, so every playable position has an index. For a board containing N cells, the reflection is:
0 ↔ N-1
1 ↔ N-2
2 ↔ N-3
and generally:
mirror(i) = (N - 1) - i
That gives MIRROR point symmetry across the entire board.
The nice part is that the equation doesn't care which GRIDLOCK mode is running. The board can scale with player count; the transformation stays the same.
The geometry was the easy part.
MIRROR isn't really:
place A → place B
It's:
one move producing a two-location state transition.
The player chooses A.
GRIDLOCK derives:
B = mirror(A)
But before either cell changes, both sides have to be legal.
So the actual mental model became:
select A
→ derive B
→ validate A + B
→ validate both affected macro regions
→ apply the transformation atomically
If A is legal but B is blocked, unavailable, anchored, inside an invalid region, or otherwise fails MIRROR's legality rules, the whole move fails.
You don't get half a reflection.
And importantly, GRIDLOCK doesn't place A, discover that B is illegal, and then try to repair the board.
It resolves the entire move first.
Resolve first → mutate second.
Same principle I've ended up enforcing across a lot of the special-move system, but MIRROR makes the reason particularly obvious.
There's another rule here that became surprisingly important:
derived targets aren't free targets.
The player only tapped A, but GRIDLOCK is still going to mutate B.
So B has to obey the rules too.
MIRROR therefore checks both affected macro regions. Dead Zone restrictions apply to both sides, and sentence restrictions have to account for both affected locations as well.
The fact that the player didn't manually choose the second cell doesn't exempt it from legality.
That also changes how I handle MIRROR online.
The client doesn't need to author:
“I selected A, and I promise B is its mirror.”
It sends the selected target.
The authoritative side can calculate:
B = (N - 1) - A
itself.
Then it validates both sides against the current authoritative board before accepting the transformation.
Which means: the client can preview the geometry.
It doesn't define the geometry.
Then there's the fun part.
The centre.
Point symmetry has a fixed point.
For the exact centre cell C:
mirror(C) = C
Mathematically, nothing is wrong.
Gameplay-wise...
MIRROR is supposed to affect two symmetric positions, except now both positions are literally the same cell.
I could've just marked the centre as invalid.
Instead:
MIRROR ASCENSION.
When MIRROR maps the selected cell back onto itself, GRIDLOCK doesn't attempt to place the same marker twice.
It resolves the centre as one owned, anchored cell.
So the transformation becomes:
A ≠ mirror(A)
→ normal mirrored pair
A = mirror(A)
→ single placement + Anchor
Which is probably my favourite edge case in this move because it isn't really fighting the mathematics.
It's acknowledging it.
The centre is its own reflection, so it gets its own consequence.
And this is where MIRROR became a good example of something I've repeatedly run into while building GRIDLOCK:
mathematical correctness ≠ game-rule correctness.
(N - 1) - i
perfectly answers:
“Where is the mirrored cell?”
It does not answer:
Is that cell legal?
Is its macro still available?
Is either side affected by a Dead Zone?
Do sentence restrictions allow both locations?
Can both mutations happen together?
What happens when both coordinates collapse into one?
Who derives the inverse in multiplayer?
What state should actually be published afterward?
The equation answers the geometry.
The RuleEngine answers the game.
So MIRROR ultimately became:
one player-selected point → one deterministic inverse → two-sided validation → fixed-point detection → one atomic state transition.
All from:
mirrorIndex = (N - 1) - targetIndex
Player:
“Nice. Two pieces for one move.”
Engine:
“Yes, except technically when the selected coordinate is the fixed point of the transformation—”
Player:
“I regret asking.” 😑
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #6 — BAIT
BAIT looks like a bluff mechanic.
Underneath, it became a hidden-state synchronization problem.
The player only needs to wonder:
“Which one is real?”
The engine has a slightly worse day.
It needs to know which marker is real, which ones are decoys, whether those decoys count as actual ownership, what happens when another move touches one, what happens when board physics moves them, and how every authoritative system reaches the exact same answer without casually giving the secret away.
BAIT originally started much simpler: a hidden trap/decoy concept where you tempt another player into attacking the wrong cell.
But an earlier implementation exposed a nasty problem:
hidden state + multiplayer disagreement = divergent boards.
The development history actually records an earlier BAIT trigger paradox that could have broken P2P synchronization.
So the mechanic was rebuilt around one principle:
The information can be hidden from the player. It cannot be ambiguous to the engine.
The current BAIT setup scales with the game mode:
2P → 1 real + 2 decoys
3P → 1 real + 6 decoys
4P → 1 real + 9 decoys
But the real marker and the decoys are deliberately not the same thing internally.
The real BAIT marker is an actual owned cell:
ownerId = player
isBait = true
A decoy instead carries decoy metadata and does not create normal ownership, contribute to line wins, or claim a macro.
So visually:
real ≈ decoy
Semantically:
real != decoy
That separation matters.
Otherwise a fake marker could accidentally participate in scoring and the bluff would start rewriting the actual game underneath it.
Then there’s the question that caused considerably more trouble:
where do all the decoys go?
The obvious implementation is randomness.Pick some legal cells. Scatter the decoys. Done.Except if different simulations generate different “random” positions, you’ve now created several equally confident versions of reality.
Not ideal for multiplayer.
So BAIT’s decoy selection is deterministic.
The selection is derived from match state including the match seed, turn, owner, real position and candidate information, with an initial spread across macros before remaining positions are filled.
Conceptually:
same authoritative state + same BAIT intent
↓
same hidden layout
Every time.
Which means: BAIT can feel unpredictable to the opponent while remaining completely reproducible to the rules engine.
That matters for much more than multiplayer.
AI simulation, reconnects, backend validation and replay all need to reconstruct the same board instead of asking some random-number generator what it thinks happened.
But deterministic placement was only half the problem.
The nastier part was contact ordering.
Three incoming move types currently test BAIT contact:
Standard
Placement
Overwrite
And critically, the BAIT check happens before their ordinary effect is applied.
Because this would be very bad:
apply Overwrite
→ change ownership
→ wait... that was BAIT
→ attempt to repair reality
Instead the RuleEngine effectively asks:
“Before I execute this move, what exactly did you touch?”
If it hits a decoy:
the decoy disappears, the incoming move is spent, and there is no snap-back.
If it hits the real BAIT:
the incoming move is cancelled and BAIT’s snap-back resolves instead.
If neither applies:
the original move continues normally.
So conceptually:
incoming intent
→ BAIT contact check
→ decoy? consume + remove decoy
→ real? cancel + snap-back
→ neither? continue normal move
Which means: BAIT is an interceptor.
It doesn’t “undo” a move after the fact.
It decides whether that move is allowed to become its normal board mutation in the first place.
That distinction eliminated an entire class of:
“the move happened locally but the trap happened somewhere else”
problems.
Then board physics made things weird again.
Push and Gravity do NOT trigger BAIT.
They can physically move the real trap or its decoys without revealing or activating them.
Which forced another important modeling decision:
BAIT cannot simply mean:
“coordinate 126 is trapped.”
The hidden property belongs to the cell state being moved, not permanently to its old coordinate.
So when physics moves a BAIT marker, its hidden state moves with it.
Roughly:
CellState(owner, bait, ...)
moves as a unit.
Not:
move owner
→ forget every other property
→ wonder why the trap vanished
This is the same full-state-preservation principle that showed up in PUSH, except now preserving the state also means preserving information the opponent isn't supposed to know.
Then there’s the actual punishment for finding the real one:
snap-back.
Triggering the real BAIT causes its surrounding 3×3 neighborhood to be converted back to the BAIT owner wherever that conversion is legal.
Anchored cells remain protected, and cells inside claimed/full/sealed regions aren't casually rewritten. The attacking Standard / Placement / Overwrite does not resume afterward.
So:
trigger real BAIT
→ cancel incoming effect
→ resolve legal 3×3 snap-back
→ preserve protected state
→ continue from resulting board
Again:
one resulting state.
Not “apply the attack, apply the trap, then figure out which version of the board we like.”
Other specials needed explicit contracts too.
Push and Gravity carry BAIT without triggering it.
Nuke can clear it when applicable.
EMP strips BAIT/decoy status in its target macro, preserving ownership for real markers while clearing decoy metadata.
So one hidden mechanic ended up interacting with:
ownership
scoring
physics
interception
area effects
serialization
Bot
reconnect/replay
server authority
Which brings us to the fun multiplayer part.
The server doesn't ask the client:
“which one was the real BAIT?”
The authoritative state already knows.
And the backend independently derives/validates the deterministic decoy set rather than accepting the opponent-facing representation as truth. Hidden owner metadata is serialized so the systems can remain synchronized without turning the UI into an accidental truth oracle.
That gave me probably my favourite rule from implementing BAIT:
Hidden from the player ≠ hidden from the state machine.
The uncertainty belongs in the player's information model, not in the actual game state.
So the final BAIT contract became:
generate deterministically → store hidden state explicitly → intercept before ordinary mutation → preserve state through movement → resolve exactly one outcome → reveal only when the rules require it.
Player:
“hehe, which one is real?”
Engine:
“I know exactly which one is real. My main concern is making sure the other 14 systems know too without telling you.” 😑
#Shipaton #BuildInPublic #GRIDLOCK
One pass after the mutation — no recursive claim cascade.
OVERWRITE produces the new cell state, then finalisation recalculates the affected macro claim + higher-level scoring from that resulting board. A newly claimed macro can complete a macro-board line in that same transition, but claiming it doesn’t mutate another playable cell, so there’s nothing that needs to re-fire the claim resolver.
Also, a macro line scores rather than ending the match immediately — GRIDLOCK only ends once all macros are claimed/closed.
So basically: cell mutation → claim recalculation → scoring/end-state → publish. No claim-chain reaction
GRIDLOCK Special Move Deep Dive #4 — OVERWRITE
OVERWRITE is probably the most direct tactical move in GRIDLOCK:
“that piece is mine now.”
Which sounds like it should be as simple as:
target.ownerId = currentPlayer
It isn’t.
In a state-heavy board game, ownership is only one property of a cell. The real problem is deciding whether the target is actually stealable, whether something else must intercept the move first, and what the resulting cell should become after a successful capture.
The early version was closer to a straightforward ownership replacement. The problem was that after stealing a piece, the new marker could still be displaced immediately because it wasn’t protected. That didn’t really match the intent of OVERWRITE as a decisive tactical capture.
So the current contract became:
legal enemy target → transfer ownership → anchor the result
On success, the cell becomes owned by the attacking player and immediately gets anchored.
Which means: OVERWRITE is not just an ownership change.
It transforms the target into a new protected state.
But the more interesting engineering problem was ordering.
GRIDLOCK has hidden-state mechanics, so before OVERWRITE is allowed to treat a target as an ordinary enemy piece, the engine has to answer:
“is this actually a normal enemy marker?”
If the target is a decoy, the decoy disappears and the OVERWRITE attempt is consumed.
If it is the real trap, the overwrite is cancelled and the trap resolves instead.
Only if neither case applies does the normal capture path run.
So conceptually:
select target
→ resolve hidden-state contact
→ if intercepted: stop
→ otherwise validate overwrite
→ transfer ownership
→ anchor result
That order matters.
If the engine changed ownership first and checked hidden state afterward, OVERWRITE could bypass the trap system and then try to repair the board after the fact.
Which means: interception rules have to run before the ordinary mutation, not as cleanup afterward.
Validation is also stricter than “is there a marker here?”
The target has to be enemy-owned, available, not anchored, not a wall, and still legal when the authoritative rules evaluate it. Empty cells and your own cells are invalid.
And that matters online.
The client can preview a cell as overwriteable, but by the time the move reaches the server, the board may have changed.
So the client never says:
“I overwrote this cell.”
It says:
“I want to overwrite this cell.”
The server re-checks the current board, resolves any interception, applies the ownership + anchor mutation, and only then broadcasts the resulting state.
So the flow is roughly:
client intent
→ authoritative validation
→ hidden-state interception if needed
→ ownership + anchor mutation
→ synchronized board state
No trusted client-authored final state.
Just intent in, rules out.
There’s an economy boundary here too. A Tactical use is only consumed after the move is accepted. If validation fails, the player should not lose a paid use for a state transition that never happened.
And once the move succeeds, the captured cell behaves like any other anchored piece in the rest of the rules engine.
So the final mental model became:
validate → intercept if necessary → transform ownership → harden the result
The player sees:
“mine now.”
The engine sees a guarded state transition with ordering, protection, multiplayer authority and economy timing attached to it.
Much less catchy, admittedly. 🫡
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #5 — SANDWICH
This move used to be called FLANK.
Then I kept adjusting the mechanic until apparently I had no choice but to eat the FLANK and turn it into a SANDWICH. 😋
Joke aside, the rename actually matched what the move had become much better.
From the player’s side, SANDWICH is simple: place a piece so that one or more contiguous enemy pieces are trapped between the new piece and another one of yours — then flip that whole line.
The obvious implementation sounds like:
place piece → scan outward → find friendly piece → flip everything between
The problem is that once GRIDLOCK has anchors, walls, sealed macros, hidden-state markers and eight possible directions, “just keep scanning” is no longer a safe rule.
The current resolver checks all eight rays from the selected cell — horizontal, vertical and both diagonals. For each direction it collects a contiguous run of enemy cells, but that run is only valid if it eventually reaches a friendly cap without crossing an invalid barrier first.
Which means: seeing one of your pieces farther down the line is not enough.
The path itself has to be valid.
If the resolver encounters an empty gap, anchor, wall, claimed macro or fully closed macro before reaching that friendly cap, that direction dies there. The engine does not look through the obstacle and pretend the pieces on the other side still form one continuous capture.
That turned SANDWICH into a directional-resolution problem rather than a simple ownership flip.
Conceptually, each ray looks more like:
start beside target
→ collect consecutive enemies
→ stop on first non-enemy
→ friendly cap? valid line
→ barrier / empty / boundary? discard line
And this happens independently in all eight directions.
The important part is that none of those scans are allowed to mutate the board while they are still being evaluated.
GRIDLOCK resolves the complete move first.
If there is no valid bracketed line anywhere, SANDWICH does not place the new marker anyway. If there are valid lines, the target placement and every validated flip are applied together.
So once again:
resolve first → mutate second.
That rule keeps appearing in these specials because partial mutation is where things get ugly fast.
Imagine one ray successfully flips three cells, then another ray discovers an anchor in the middle and invalidates part of the action.
If the engine had already started modifying the first line, now you need rollback logic for a board state that never should have existed in the first place.
Much cleaner to prove the entire result from the pre-move state and mutate once.
There’s also a state-cleanup problem hiding inside the flip.
When SANDWICH captures a cell, it does not blindly preserve every property from the previous owner. A flipped real Bait marker loses its trap state and becomes an ordinary owned cell.
Which means: capture is not always:
ownerId = me
Sometimes it also means:
“this old semantic state no longer makes sense under the new owner.”
That cleanup matters because stale flags are exactly how you end up with things like:
“Congratulations, you captured the enemy piece. It is also somehow still their trap.”
Excellent feature. Probably not shipping that one.
The barrier model is another detail I ended up liking.
An anchored cell does not merely resist being flipped. It terminates the ray entirely. Same idea with walls and sealed board regions: they are boundaries in the resolver, not cells the algorithm is allowed to skip over.
That keeps the rule predictable:
continuous enemy run + friendly cap = valid
Anything breaking that continuity kills the line.
Online play follows the same philosophy as the other specials.
The client does not send:
“flip these five cells.”
It sends the move intent.
The backend recomputes every ray from the authoritative pre-move board, builds the valid flip set itself, and only then accepts the mutation.
So the online flow is roughly:
client intent
→ authoritative 8-direction scan
→ validate complete bracket set
→ reject if no legal line
→ apply placement + flips atomically
→ publish resulting board
The UI can preview the sandwich.
It cannot decide what goes inside it.
And then there was the rename.
FLANK had existed long enough that changing one label was nowhere near enough. The old name had leaked into code, assets, localization, UI, replay/state terminology, backend identifiers and AI-facing logic.
So FLANK → SANDWICH became its own parity exercise:
code + serialized state + backend + UI + assets + docs all needed one canonical name.
That was a fun reminder that in a stateful multiplayer game, renaming a mechanic can quietly become a tiny data migration.
So the final SANDWICH contract is:
scan eight directions → prove every bracket → respect every barrier → normalize captured state → mutate everything once.
The player sees:
“nice, I trapped them.”
The engine sees eight directional resolvers, atomic multi-cell mutation, state cleanup and authoritative recomputation.
And somewhere along the way I literally turned FLANK into SANDWICH.
At least the naming pipeline is edible now. 😋
#Shipaton #BuildInPublic #GRIDLOCK
Yep, the ownership flip happens first, then the shared RuleEngine finalisation recalculates macro claims and the overall end-state within the same move transition, before the next authoritative board state is published.
So if OVERWRITE completes a local 3-in-a-row, that macro can be claimed immediately as part of that same transition — it isn’t deferred until some later turn.
I kept that logic centralised rather than inside OVERWRITE itself, so every board-mutating move goes through the same claim/end-state recalculation instead of each special implementing its own version of it.
GRIDLOCK Special Move Deep Dive #3 — DICTATOR
DICTATOR was the point where I stopped being able to think of every special move as:
select target → mutate board → next turn
From the player’s side, the idea is fairly simple: place your marker, then temporarily restrict where your opponents are allowed to play.
Underneath, that single action has to coordinate placement, opponent selection, board-region selection, player-count scaling, duration tracking, existing routing state, UI draft state, AI behavior and server validation as one move.
So DICTATOR became less of a board effect and more of a staged state transition.
The flow is roughly:
choose placement region
→ choose exact cell
→ choose restricted macros for each opponent
→ validate complete selection
→ commit once
Nothing before that final commit is gameplay state.
The UI is allowed to hold a draft, highlight choices and let the player change their mind, but that draft does not place a marker, spend credits, start a cooldown or sentence anybody.
Only the final complete intent gets submitted.
Which means: the UI can be complicated without making the game state complicated.
A half-finished selection screen should never become a half-finished move.
That distinction became especially important because DICTATOR scales with player count.
The current rule is:
2P → up to 1 restricted macro / opponent
3P → up to 2
4P → up to 3
Duration scales too: one affected-player turn in 2P, two in 3P and 4P.
And that duration is not just:
currentTurn + 2
because in a multiplayer game, global turns and that opponent’s own turns are not the same thing.
The sentence decays when the affected player actually takes their turn.
Which means: in 4P, three other players moving should not accidentally burn through somebody else’s restriction timer.
That sounds obvious when written down.
It becomes less obvious when the same state has to survive local play, AI simulation, online rooms, reconnects and turn progression.
The actual committed state therefore has to remember more than “DICTATOR happened.”
It needs to know:
which opponent is restricted
which macros are allowed
how many affected turns remain
and whether any previous routing state should still exist.
In GRIDLOCK’s case, DICTATOR deliberately clears the existing forced-routing state when it commits. Otherwise two control systems could simultaneously disagree about where the next player is supposed to go.
That was one of the more useful design lessons from this move:
when two mechanics can constrain the same future action, you need an explicit precedence rule.
“Both are active, figure it out later” is not a rule.
Validation also gets much more interesting than checking one cell.
The complete DICTATOR intent can contain multiple opponents and multiple board regions, so the server has to independently verify the whole structure:
the placement itself is legal;
every targeted player is actually an opponent;
every macro index exists;
there are no duplicates;
the selection stays within the mode-specific cap;
and the origin is still available when the move is committed.
The client selection screen is convenience.
It is not authority.
So online, the shape is closer to:
client builds draft
→ client sends completed intent
→ server validates entire target map
→ marker + sentence state committed together
→ authoritative state broadcast
If one part of the intent is invalid, the server does not accept the valid half and improvise around the rest.
The whole move is rejected.
That atomicity matters for the economy too.
DICTATOR belongs to the Utility tier and has a one-own-turn cooldown, but the draft UI itself cannot consume the Utility use or charge the wallet. Economy, cooldown, marker placement and sentence state happen only once the complete move has actually been accepted.
There’s also a slightly unusual design decision inside the sentence itself.
DICTATOR does not simply disable every possible action outside the selected regions. The restriction applies only to the move types whose geography is supposed to obey the sentence; other specials can still operate under their own legality rules.
Internally I’ve been calling that the “Prison Riot” exception. 😑
That prevents DICTATOR from becoming “you don’t get to play the game for two turns.”
It controls geography without deleting counterplay.
So the final mental model became:
DICTATOR doesn’t modify one cell. It temporarily modifies another player’s legal-action space.
And once you think about it that way, the implementation makes a lot more sense.
The marker placement is almost the easy part.
The real work is making a temporary rule about somebody else’s future turns survive UI drafting, player-count scaling, timers, multiplayer authority, AI and recovery without any layer inventing its own interpretation.
Quite a lot of machinery for a move whose emotional description is basically:
“No. You play over there now.” 🙂↔️
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #2 — PUSH
PUSH looks simple from the player’s side: select a piece, choose a direction, move the line.
Underneath, it became a wrapped movement resolver with atomic chain mutation — with one rule I cared about a lot:
either the entire Push is valid, or absolutely nothing moves.
GRIDLOCK’s board wraps, so reaching an edge doesn’t necessarily terminate the movement path. A Push can continue from the opposite side of the board. That sounds straightforward until the path is completely occupied.
A naive implementation can keep walking forever, revisit the starting cell, or start moving pieces before discovering that there was never a legal destination in the first place. The current resolver avoids that by walking the chain first, tracking visited cells, and refusing to mutate anything until it has found a real empty destination and proved that every cell along the path is legal.
Conceptually:
selected marker → walk in direction → wrap when needed → collect occupied cells → find first legal empty destination
During that walk, protected cells, walls, claimed/closed board regions and already-visited locations can terminate the attempt. If traversal loops without finding empty space, PUSH fails rather than eventually overwriting its own starting point.
Which means: on a wrapped board, “keep moving until you find an empty cell” isn’t enough. There may be no empty cell at all, and there may be no natural edge to stop you.
Only after the resolver has produced a complete legal chain does the board change.
And the chain is applied backwards.
If the state is:
A → B → C → empty
the mutation effectively happens as:
C → empty, B → old C, A → old B
rather than moving A first and destroying information the rest of the chain still needs.
More importantly, PUSH doesn’t move just a symbol or an ownerId. It moves the complete cell state. Ownership and any metadata attached to that marker travel with it; the vacated origin becomes empty only after the state has been transferred.
That became especially important once GRIDLOCK had hidden-state mechanics. A marker being physically displaced shouldn’t magically lose what it is just because it crossed a cell boundary.
Another thing I changed was the failure model.
Earlier, a blocked Push could effectively reduce down to “invalid move.” The current resolver distinguishes why it failed — empty origin, protected start, protected cell somewhere in the chain, sealed board state, or simply no empty destination in the wrapped path.
Which means: the same logic that protects the board state can also explain the move back to the player. The UI doesn’t need to invent its own version of legality and hope it agrees with the engine.
The other important design choice was separating resolution from mutation.
PUSH first computes the complete result. Only after that result is valid does another step apply it to the board. If the sixth cell in a six-cell chain makes the move illegal, the first five cells are not moved and then rolled back.
Nothing changes.
That makes local state easier to reason about, but it matters even more online.
The client sends the player’s intent — selected cell + direction. It does not get to send a trusted list of destinations.
The server independently resolves the same Push against the authoritative board state, verifies the chain and blockers, applies the result, and then publishes the resulting state.
So the flow is roughly:
client intent → authoritative resolve → validate complete chain → atomic mutation → synchronized board state
The client can preview the physics.
It cannot author the physics.
AI uses the same RuleEngine result too. There isn’t a cheaper “AI version” of PUSH with slightly different rules; it simulates the same legality and resulting state before deciding whether spending a Tactical special is actually worthwhile.
And this is where ANCHOR suddenly matters architecturally.
A protected cell isn’t merely a piece that refuses to move. In a chain-based physics system it becomes a barrier. If PUSH encounters one halfway through the chain, it doesn’t move everything before it and stop.
The whole move is rejected.
So the final PUSH contract became surprisingly clean:
resolve the complete chain first. If every part is legal and there is a real destination, move the whole chain atomically. Otherwise, move nothing.
No partial results. No client-authored destinations. No wraparound infinite loops.
Quite a lot of engineering for a button whose player-facing meaning is basically:
“move that stuff over there.” 😑
#Shipaton #BuildInPublic #GRIDLOCK
GRIDLOCK Special Move Deep Dive #1 — ANCHOR
Anchor is probably the simplest-looking special move in GRIDLOCK:
pick a cell → make it stay there.
The implementation taught me that “make it stay there” is not actually a rule.
It’s an invariant that every other system touching that cell has to understand.
The first version of Anchor was broader. Selecting one piece could propagate protection to adjacent pieces owned by the same player. It worked, but it meant the visible action and the actual mutation didn’t match — one tap could silently change several cells. That also made it much easier for the local engine, backend, Bot and UI to develop slightly different interpretations of the same move.
So I narrowed Anchor down to a much stricter contract:
one intent → one validated cell → one deterministic mutation.
Conceptually, the move now looks roughly like:
validate target
→ confirm macro is playable
→ confirm cell is anchor-compatible
→ assign ownership if needed
→ set anchored = true
→ clear incompatible transient state
→ recalculate resulting board state
The target can be empty or already owned by the current player, but it cannot already be anchored, be a wall, be an enemy hidden-state marker, or belong to a macro that is unavailable. On success, that one cell becomes owned by the player and carries the Anchor flag.
Setting anchored = true is the boring part.
The real engineering problem was making everything else respect it.
Once that flag exists, board-physics operations must treat the cell as immovable. Ownership-changing actions must reject it. Line traversal must stop at it. Area effects need an explicit preservation rule. Hidden-state resolution must not accidentally rewrite it.
Which means: Anchor is less like a power-up and more like adding a constraint to the board graph.
Every system that mutates board state now has to ask some version of:
can this cell move?
can this cell change owner?
can an effect pass through it?
can this effect clear it?
If the answers are scattered across eleven different implementations, eventually one of them disagrees.
So I stopped treating Anchor as something each feature could interpret independently.
The rules layer decides whether the Anchor intent is legal. The mutation layer applies the small state change. Physics and tactical systems read that resulting state. The AI evaluates the same legal board state. The UI only previews what should be possible. And in an online room, the server revalidates the intent before the resulting board state becomes authoritative.
So locally the flow is essentially:
player intent → rules validation → deterministic mutation → next state
Online it becomes:
client intent → server validation → same rule contract → authoritative mutation → synchronized state
The client never gets to say:
“trust me, this cell is anchored now.”
It asks to Anchor a cell. The authoritative rules decide whether that state transition is allowed.
That distinction matters because Anchor also touches the economy.
It belongs to the Utility tier, has a one-own-turn cooldown, and can consume either the player’s available Utility use or the paid-special path. But the economic decision happens around the move — it does not redefine the move.
Which means: having the credits to use Anchor does not make an illegal Anchor legal.
The economy answers:
“can this special use be funded?”
The rules engine answers:
“is this state transition valid?”
Only when both agree does the mutation happen.
There’s also one intentional escape hatch in the design: Anchor is strongly protected, but not metaphysically permanent. GRIDLOCK has a specific counter-system that can remove the Anchor state while preserving ownership. That keeps “permanent” from turning into “nothing in the game can ever interact with this cell again.”
And Bot follows the same contract. It can consider Anchor as a defensive move, but it’s restrained from spending a special simply because one is available — if an ordinary move already produces a better immediate result, Anchor should stay unused.
So the final mental model became:
Anchor does not “freeze a sprite.”
It changes one cell’s legal relationship with the rest of the rules engine.
That one bit of state has to survive local play, server validation, multiplayer synchronization, AI simulation, board physics, tactical effects, cooldowns and the credit economy without any of them inventing their own definition of “anchored.”
All for a move whose player-facing description is basically:
“this one stays.”
And this is the simple special move.
#Shipaton #BuildInPublic #GRIDLOCK
@polmiro — curiosity got the better of me. Dangerous side of participating in #Shipaton
A few hours later, I ended up going back through the @RevenueCat docs and comparing them against GRIDLOCK’s current monetisation/wallet setup. I think I’ve got a much clearer picture now of where the boundary could make sense.
RevenueCat already fits really well around the game economy: store purchases, subscription state, rewarded-ad verification, currency grants, etc.
The reason I haven’t moved the whole wallet over is that GRIDLOCK’s “credits” are more than one balance. The backend separates purchased, subscription-cycle, and earned credits, then applies game-specific rules around expiry, spend priority, match-time costs, daily rewards, ad caps, recovery, refunds, account linking, and so on.
So the split I want to try next is:
RevenueCat verifies the value coming into GRIDLOCK. GRIDLOCK decides how that value behaves once it becomes part of the game economy.
Which means: RevenueCat can verify that a purchase, renewal, or rewarded-ad grant is legitimate, while GRIDLOCK still decides where those credits go, how they expire, what gets spent first, and how they interact with gameplay.
After comparing both sides, I think there’s enough overlap here to make a deeper integration worth trying.
I’m going to experiment with moving purchased-currency grants, subscription grants/expiry, and Ad Rewards further into RevenueCat and see how cleanly the two systems fit together.
If I hit interesting edge cases around multi-bucket wallets, identity, refunds, or custom economy rules, I’ll send them your way too. And once I’ve actually implemented it, I’ll probably have another Engineering Rabbit Hole post to write about what moved, what didn’t, and why.
#Shipaton #BuildInPublic #GRIDLOCK