ποΈ Darkwaar27: Pigment β A Dice Puzzle Built on Rotation and Color Algebra
on October 11, 2026
Play: https://darkwoodcom.itch.io/darkwaar27
One Die, One Rule
Pigment is a deterministic puzzle on a 5Γ5 board. One die occupies a floor cell. A tip moves it onto an orthogonal neighbor and rotates it ninety degrees so a new face is down. That downward face stains the destination cell. Nothing else moves.
The player never picks a color from a menu. The pigment that lands is whichever face the path has rotated onto the floor. The four directions look like pathfinding; they also rewrite which faces will be available later. That coupling is the game.
Five authored levels, a move limit per board, undo and restart. No randomness and no second actor.
Modeling a Six-Faced Die
The die is six integers keyed by side: D, U, N, S, E, W. Opposites are fixed: nullβblack, redβblue, yellowβwhite. Every level starts from the same orientation:
| Direction | Face |
|---|---|
| Down | Null |
| Up | Black |
| North | Red |
| South | Blue |
| East | Yellow |
| West | White |
A tip is a permutation of those six slots. East and west leave north and south unchanged; north and south leave east and west unchanged. From the start, the first east tip lands yellow, the first south tip lands blue, the first north tip lands red, and the first west tip lands white. Later tips move those faces around.
static func roll(die: Dictionary, action: String) -> Dictionary:
var d := int(die["D"])
var u := int(die["U"])
var n := int(die["N"])
var s := int(die["S"])
var e := int(die["E"])
var w := int(die["W"])
match action:
"E":
return {"D": e, "U": w, "N": n, "S": s, "E": u, "W": d}
"W":
return {"D": w, "U": e, "N": n, "S": s, "E": d, "W": u}
"N":
return {"D": n, "U": s, "N": u, "S": d, "E": e, "W": w}
"S":
return {"D": s, "U": n, "N": d, "S": u, "E": e, "W": w}
_:
return {"D": d, "U": u, "N": n, "S": s, "E": e, "W": w}
This is a state permutation, not a physics simulation. There is no angular velocity, no collision response, no mesh skin. The cube on screen is a drawing of the same six integers after the rules have already accepted the tip. An invariant check keeps opposites honest: if D is red, U must still be blue.
Color Algebra
Stains form a small alphabet: empty, red, yellow, blue, white, black, orange, purple, green. The landing face writes the destination cell through a deterministic function of (existing_stain, landing_face):
static func mix(existing: int, face: int) -> int:
if face == EMPTY:
return existing
if face == WHITE:
return EMPTY
if face == BLACK:
return BLACK
if existing == EMPTY:
return face
if existing == face:
return existing
var low := mini(existing, face)
var high := maxi(existing, face)
if low == RED and high == YELLOW:
return ORANGE
if low == RED and high == BLUE:
return PURPLE
if low == YELLOW and high == BLUE:
return GREEN
return BLACK
Primaries paint empty cells and mix into secondaries. The same primary twice is a no-op. White clears anything, including a sealed cell. Black seals. A primary on a secondary, or on a sealed cell, seals it. Null leaves the cell unchanged β a free rotation that still spends a move.
Mixing beats overwrite because the cell remembers the first primary. Delivering the second half of a pair means spending other cells on the turns that put that face down. The board is both canvas and gearbox.
A tip copies state, refuses illegal directions, rolls, mixes, then classifies the phase:
static func apply(state: Dictionary, action: String) -> Dictionary:
var current := snapshot(state)
if str(current["phase"]) != "play" or not DIRS.has(action):
return {"state": current, "events": {"accepted": false, "action": action}}
var from: Vector2i = current["pos"]
var to: Vector2i = from + DIRS[action]
if not _floor(current, to):
return {"state": current, "events": {"accepted": false, "action": action, "from": from}}
var die: Dictionary = roll(current["die"], action)
var stains: Array = current["stains"]
var at := _index(to)
var before := int(stains[at])
var after := mix(before, int(die["D"]))
stains[at] = after
current["die"] = die
current["stains"] = stains
current["pos"] = to
current["spent"] = int(current["spent"]) + 1
# phase becomes "won" or "lost" from marks, limit, and legal tips
Off-board tips are refused without spending a move. A sealed mark is not an instant hard lock: white can still clear it inside the limit, and undo restores the previous orientation and stain.
Position Γ Orientation Γ Pigment
Pathfinding asks whether a cell is reachable. Pigment asks whether a cell is reachable with a specific face down, after a specific stain history. The full state is the product of:
- Position on the 5Γ5
- Die orientation (six faces, constrained by opposites)
- Twenty-five stain values
- Moves spent against the level limit
Two paths to the same cell diverge when the die arrives with different facing, or when an earlier tip left the wrong pigment on a mark.
Level 2 (Return) makes the product concrete. Start at (0, 0). The only mark is (1, 0) green. Limit 5. Green is not on the die; yellow and blue must meet on that cell.
| Step | Tip | Land | Down face | Effect on (1, 0) |
|---|---|---|---|---|
| 1 | East | (1, 0) |
Yellow | Empty β Yellow |
| 2 | South | (1, 1) |
Blue | unmarked cell becomes Blue |
| 3 | West | (0, 1) |
Null | no paint |
| 4 | North | (0, 0) |
Yellow | start cell becomes Yellow |
| 5 | East | (1, 0) |
Blue | Yellow + Blue β Green |
Steps 2β4 exist mostly to rotate. The blue cell under the mark is evidence the player produced, not a goal. Another length-5 line also greens (1, 0): east, south, east, north, west. Level 2 accepts both. Level 3 adds a second mark β the start cell must be yellow β and the second line fails. Same green, wrong leftovers. The mark list is the entire win condition; unmarked cells may end as anything.
Five Levels as a Constraint Ladder
Each board adds one constraint to the same machinery:
- First Face β land yellow on
(1, 0)in one tip. No mix. - Return β mix green by revisiting. Limit 5.
- What You Leave β green on the mark and yellow on the start. Same five tips as level 2; the alternate green line fails the extra mark.
- The Other Pair β orange at
(1, 1). Red and yellow must meet. Limit 6. Painting the mark once and stopping cannot produce orange. - White β
(2, 0)starts sealed. Marks require yellow on(1, 0)and empty on the seal. White must be rotated onto the sealed cell. Avoiding the seal cannot win.
Move limits equal the shortest known solution lengths, so a detour must replace steps rather than pad them. Victory is checked after every accepted tip: every markβs stain equals its target. Loss is the limit exhausted with a wrong mark, or no legal tip left with a wrong mark.
Replay cases exercise the tables: opposites stay consistent, authored paths reach the expected phase, marks hold on wins, corner refusals do not spend moves, and level 5 starts sealed. That is regression against known lines, not an exhaustive uniqueness proof.
Purple sits in the mix table and is unused by these five boards. The rule stays so the alphabet does not change if a later board needs it.
Rules Separated from View
The runtime splits cleanly:
| Piece | Responsibility |
|---|---|
rules.gd |
Orientation, mix, level data, win/loss, replay checks |
main.gd |
Input, _draw, tween, UI, history stack |
main.tscn |
One Control |
rules.gd is a RefCounted with static functions. It never draws. main.gd holds the current dictionary, pushes a deep snapshot before an accepted tip, then starts a tween. The view never invents the next face.
Transitions are deterministic functions of state and action; the view is a projection. The program is not purely functional: dictionaries and arrays are mutated after a snapshot copy, and undo is an explicit history stack rather than persistent data. The split is enough to keep the permutation table testable without a scene tree of face nodes.
No ECS, no physics bodies, no shaders, no procedural generator. Undo pops history and plays a shorter reverse tween. Restart rebuilds start_level(index) and clears the stack. Both work after a loss, so a sealed mark is recoverable.
Rendering the State
The board is flat pigment on a dark field. Marks are a ring plus a letter (Y, G, O, β¦) so the goal is not carried by hue alone. Sealed cells use a dark fill and a thin edge.
The die is a pseudo-3D cube: six quads, a camera bias, painterβs algorithm by depth, glyphs on the upward face. During a tip, position lerps, the cube lifts on a sine arc, and a Basis rotates by k Β· Ο/2 around the tip axis. The destination stain tweens from the previous color to the new one on the same weight. A mix is one color change. White flashes paper, then settles to floor.
Under the board, a compass of swatches shows the down face and the four sides β the die laid flat. Orientation is the resource; the drawing has to make facing readable, or the mix feels arbitrary.
Input is arrows, WASD, neighbor-cell taps, and an on-screen pad. While a tween runs, tips are ignored so the picture cannot lag the rules.
What the Model Implies
Position Γ orientation Γ stains is a large state space from a small rule set. Null tips spend a move to turn without painting. Deterministic tables remain hard to search by eye: the five-step green line is obvious after you see it and opaque before. Animation that names a rule β tip arc, stain change, compass β is part of the interface, not decoration.
The campaign is finite: five boards, purple unused, no score. The interesting object is still the die: piece and paint, with orientation as memory. Everything else is a table and a tween.