Minesweeper, played by whoever replies. New board post every 30 minutes. Reply “D4” to open that cell, “flag D4” to mark a mine. A mine takes you out for 4 turns. Flags are bets: +1 right, -1 wrong. B
翻译准备中
游戏GitHub @adamsmithkipnis在线运营者未确认
试用过了吗? 点一下就好,无需登录。
尚未收集仓库信息。
尚未检测到更新。GitHub 发布版本后会显示在这里。
Social Minesweeper Game
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗A game of Minesweeper played on one Bluesky account, https://bsky.app/profile/playminesweeper.bsky.social, where followers vote in the replies for the next cell to open.
Every hour the bot posts the board and asks for a coordinate. Replies are tallied, the winning cell is opened, and the follower who called it is named in the next post and replied to. Standard rules: numbers count adjacent mines, opening a blank floods its whole region, and one mine ends the run.
It fixes the two things that were wrong with my earlier Battleship bot.
Hidden information stays hidden. Battleship published each team's fleet as
an image from a public account, so a voter for one side could open the other
account and read the ship positions. Minesweeper is solitaire: there is one
board, one account, and nothing published that anybody could read the answers
from. In this repo that is a structural property, not a habit — the solver
only ever sees a Position built from what a follower can see, and the
renderer's display_grid is the single place the hidden layer can surface.
A vote can be right or wrong. Nine people picking between three arbitrary squares of empty ocean is a coin flip. A Minesweeper position has provably safe cells, provably mined cells, and the occasional genuine 50/50 — so a reply is a claim, and it can be demonstrably correct.
tests/simulate.py plays full boards against a modelled crowd. The numbers
that set the defaults:
python3 tests/simulate.py --games 300 # the default board
python3 tests/simulate.py --quorum 0 --voters 8 # try it without the quorum
Read the replies to the last post.
Tally them. One vote per account; that account's latest valid reply counts. Tied cells go to whichever was called first. Votes for cells that are already open are dropped.
Open the winning cell — unless three or more people voted and no two of them agreed, in which case nothing has actually been decided and the bot breaks the tie with its own safest cell, saying so in the post.
The quorum only applies once there are more voters than the quorum itself. With one or two people voting there is nothing to split, so whoever turned up decides. Applied strictly it looks fine on paper — 87% of boards finish with a single voter — but the bot is playing 100% of the turns and the one human is a spectator.
Post the new board, and reply to the follower whose call was played.
If the post fails, nothing is saved: the next turn replays against the same post rather than losing a turn to a network blip.
Reply with a coordinate — D4, d4, A-1, vote C7. Replies argue, so the
parser reads them properly rather than grabbing the first coordinate it sees:
| Reply | Counts as |
|---|---|
D4 | D4 |
C3 is a mine, so D4 must be safe — D4 | D4 |
don't open C3, try D4 | D4 |
flag C3 / C3 is a mine | flags C3, no vote |
I vote A1, actually I pick B2 | B2 |
Cancel F7. Meant F8 / F8 instead of F7 | F8 |
Cancel F7 | withdraws your F7, no vote |
a good move / I agree | no vote |
Your latest move is the one that counts. A reply that only cancels takes
your move back, but only if it names that move: Cancel F9 after voting
F7 leaves F7 in place.
Replies count whether they are made to the board post or under your own earlier reply, so correcting yourself in place works. Replies under somebody else's move are discussion and are not read as moves.
Flagging costs no turn, so one reply can do both jobs — C3 is a mine, so D4 is safe — D4 flags C3 and votes to open D4. flag C3, C3 = mine, mine at C3, 🚩 C3, C3 and D4 are mines and unflag C3 all work, because the
syntax has to be whatever people already write rather than a command they are
expected to memorise.
One player may flag several cells in one reply, up to FLAG_CAP new ones a
turn (2 by default). Flags are only claims: they do not open cells, trigger
mines, or prevent the crowd from opening a flagged cell later.
A flag is a bet. It pays +1 when the cell turns out to hold a mine and -1 when it does not, settled the moment the cell is opened or against the real layout when the board ends. Flag points are kept apart from move points on purpose: they are a different skill, and in simulation a good flagger earns two to four times as many of them as they do from opening cells, so folding the two together would turn the leaderboard into a flag count.
The penalty is what makes the bet honest. Simulated against four players on a 9x9, somebody flagging random cells nets +13 a board with no penalty and -27 at a penalty of 1, while the players flagging what they can prove lose about a point a board to the odd slip. The per-turn cap is a backstop on top of that; it never binds for honest play.
Taking a flag back is free on the turn you placed it and costs 1 on any later turn — the same as being wrong. Without that, flagging on a hunch and withdrawing once the neighbours disproved it would be free money. Bets are personal: anyone may clear the pennant from the board, as before, but that only hides the mark, and the claims behind it stand.
An assist pays +1 when somebody opens a cell that your flags proved
safe — a revealed number next to it whose remaining mines are exactly your
flags — paid at board end and only once every flag behind it was right.
The attribution runs on the public position alone (solver.flag_assists),
so a wrong flag earns the credit as readily as a right one and nothing leaks.
A board's flag score is floored at zero, and all time is the sum of the floored boards: recognition, never a losing record anybody has to shake.
check D4, or a tick or ❓ next to the cell (✅ D4, D4 ✔️, ❓ D4), marks it
for a second look: probably a mine, not sure enough to bet a flag on. It is
a note to the other players and nothing more. It costs no turn and scores
nothing, it is never read as a move, and a flag on the same cell is the one
drawn. One shared mark per cell; anyone can clear it with uncheck D4, and
it disappears when the cell opens. The board shows a black ?, classic
Minesweeper's "not sure" mark; the alt text uses C, because ? already
means a hidden cell there. Each player gets one reply the first time, which
also says how to open the cell, because before this check D4 was read as
a move to D4.
Off by default (KNOCKOUT=1 to enable). Instead of the crowd voting on one
cell, everyone who replies opens a cell of their own — nobody's move is
discarded. A mine takes that player out of the board rather than ending it
for everybody, and the spent mine stays visible as information for whoever is
left. The board itself fails once more than tier + 1 mines have gone off.
Eliminated players can still flag. Flagging costs no turn and carries no risk, so being knocked out is a change of role rather than an exit — you keep scoring and keep helping the survivors.
An elimination expires after ELIMINATION_TURNS (4 by default). This is
what makes knockout safe with a small crowd. Benching someone for the whole
board only works when enough people are left to finish it: measured with two
active players on a tier-3 board, a permanent elimination costs a median of
40 turns of spectating with a tail near 500, and the bot ends up playing 102
turns by itself because the board cannot finish without the people it knocked
out. At four turns those numbers are 3, 6 and 0.4 — and the board finishes
faster too, 27 turns against 38.
Letting everyone act is only survivable because of the knockout rule. With
N players each independently risking a mine, survival per turn is skill^N.
At the 87.5% accuracy this game actually shows, five players acting under
sudden death clear 52% of boards in five turns — and it gets worse as the
audience grows, dropping to 44% at eight players. Making a mine cost the
player instead of the board inverts that: eight players clear 98%.
| board | cleared | median turns | at 60-min turns |
|---|---|---|---|
| 9x9, 13 mines, 1 spare | 81% | 7 | 7h |
| 10x10, 18 mines, 2 spares | 87% | 10 | 10h |
| 11x11, 24 mines, 3 spares | 83% | 15 | 15h |
A spare per tier loses roughly a fifth of boards on every rung, which is the right weight when being knocked out yourself is the main event; two spares leaves boards clearing 93-98% and the collective stake stops meaning much.
Knockout moves a board roughly five times faster than voting does, so it
pairs with TURN_MINUTES=60. Voting pairs with 30.
A win where the crowd called at least 75% of the turns promotes the next board a tier. A board where they called fewer than 50% demotes it. Losing holds the tier — losing a hard board is the game working; a board the bot had to play itself is one nobody is playing. The gap between the two thresholds stops participation near the boundary from seesawing the board every game.
| tier | board | density | cleared | length at 30-min turns | points |
|---|---|---|---|---|---|
| 1 | 9x9, 13 mines | 16% | 84% | 12h | 1x |
| 2 | 10x10, 18 mines | 18% | 79% | 17h | 2x |
| 3 | 11x11, 24 mines | 20% | 51% | 18h | 3x |
Tiers raise size and density together, and that pairing is the point. Growing dimensions alone makes a board longer and shallower: at a fixed density a larger board is proportionally more open interior and less frontier, so more of its turns are trivially decidable. Simulated over 150 boards, a 12x12 at the starting 16% density runs 43 hours and contains 0.8 turns needing real deduction — fewer than the 9x9 it grew from, which has 1.5.
The ladder stops at three rungs deliberately. A fourth (12x12 at 22%) clears only 38%: a full day invested and two boards in three end in a bang.
Cells are worth the tier number in points, so the reward rises with the stakes. Scaling points rather than handing out extra cells per turn matters: harder boards run longer, so the extra reward is spread over more turns rather than concentrated into fewer.
Flags appear on the board as the classic pennant and tick the mine counter down. A flag is a claim, never a verdict: a wrong flag is drawn exactly like a right one, and a test pins that. Nor can a flag block a cell from being opened — one bad flag would otherwise let a single person lock a safe cell and stall the board forever.
Claims are scored when the cell is opened, or against the real layout when the run ends. The game-over post names the top flaggers by points, and the weekly standings carry a flag table when it fits.
Passing a point total on the MILESTONES ladder — 100, 500, 1,000, 2,500,
5,000 and up — gets a post of its own naming the player. The rungs widen
because the distribution is steep: the top player gained 651 points in a
week while most of the crowd is under 100, so an even ladder would either
ignore newcomers or congratulate the regulars every other day. On the real
history this fires about five times a week, most of them somebody's first
hundred.
Two things make it safe to run unattended. Rungs are claimed in the database before the post goes out, under a unique index, so no replay or retry can congratulate the same person twice; if the post then fails the claim is released and the next turn tries again. And the first time a player is seen, every rung already behind them is marked silently — switching this on does not tell a 942-point regular they have passed 100.
Saturdays at 17:00 LEADERBOARD_TZ (Pacific by default), the bot posts the
top three of the week beside the top three of all time. The week goes first:
the all-time table barely moves, and a table nobody can enter is not a reason
to come back.
Handles appear as their first label — @coil, not @coil.bsky.social —
because a mention facet carries the DID rather than the text, so the short
form still notifies the right account and six names fit in 300 characters
where six full handles do not. If two players' handles share a first label,
everybody in that post keeps their full handle instead.
APScheduler recomputes a cron job's next run at startup, so a deploy that
restarts the bot across 17:00 would skip the week in silence. leaderboard_due
closes that: on startup the bot compares the last standings post against the
most recent scheduled slot and posts a missed one late. The first one ever
waits for its real slot rather than firing the moment this ships.
.venv/bin/python main.py --leaderboard # post this week's table by hand
#Minesweeper is on every post so the game stays findable under one name.
Five more are sampled fresh per post from hashtags.txt —
233 tags, because the same block of six tags 24 times a day reads as a bot
padding for reach. Tags are appended only while they still fit under 300
characters — content is built first, so reach can never be the reason a post
gets clamped, and a tag too long for the room left is skipped rather than
ending the run. Replies to individual followers carry one tag, not six.
The pool lives in a file rather than in .env so a new tag ships with a git
push, and lines are #tag [weight] [day].
Weights, because a wider pool is not automatically more reach. The tags
were chosen by reading the public posts of the 63 people who have actually
played: 18 use #gamedev and 12 use #indiedev, while most of the pool is
used by one or two. Sampling five uniformly from 233 would put #gamedev on
one post in 55 instead of one in four — ten times the variety and twelve
times less reach on the best tag. Weighting the proven tags at roughly three
times their measured player count costs almost nothing in variety:
| pool | #gamedev appears on | a tag repeats after |
|---|---|---|
| 23 tags, uniform (before) | 22.8% of posts | 3 posts |
| 233 tags, uniform | 1.8% | 31 posts |
| 233 tags, weighted (now) | 18.2% | 22 posts |
The puzzle family is the one deliberate exception, weighted above what the
measurement supports: only one or two players use #logicpuzzles, but that
sample is biased — those are the people who already found the bot, and they
found it through the gamedev tags.
Nothing in the pool may be a claim the project cannot make. The
audience's favourite tags include #godot, #blender and #madewithgodot;
this is Python with Pillow, so they are not there. #a11y and
#screenreader are, because the alt text really does carry the whole board.
Day-locked tags carry a weekday and are only eligible on it.
#screenshotsaturday is the strongest tag in this crowd and the easiest to
get wrong — on a Tuesday it is worse than no tag at all.
HASHTAG_POOL in .env still works, but it now adds to the shipped pool
instead of replacing it. That is deliberate: the Mini's .env pins the older
23-tag list, and an override would have quietly kept the bot on 23 tags,
while ignoring the variable would have thrown away a tag added by hand.
| File | Purpose |
|---|---|
main.py | Orchestrator and APScheduler loop |
game.py | Rules, board generation, flood fill, win/loss (pure logic) |
solver.py | Deduction: single-cell rule, subset rule, exact enumeration |
votes.py | Coordinate parsing, vote tallying, flag claims (no network dependency) |
renderer.py | Board PNG and full-position alt text |
tools/tutorial_image.py | Rebuild the annotated pinned-tutorial image |
bluesky.py | AT Protocol wrapper, richtext facets, post deletion, dry run |
db.py | SQLite: board state, history, moves, post log |
dashboard.py | Read-only local dashboard |
reset.py | Wipe posts and/or local data for a clean slate |
deploy.sh | Pull, test, restart — the whole deployment |
status.sh | Read-only report: deployed commit, services, errors, big board |
tests/ | Unit tests, the pacing simulator, the dry run, board previews |
/usr/bin/python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # then fill in the handle and app password
.venv/bin/python main.py # foreground; Ctrl+C to stop
Use a Bluesky app password (Settings → Privacy and Security → App
Passwords), never the account's real password. .env is gitignored and must
stay that way.
./setup.sh # builds the venv, installs the launchd jobs, starts them
./setup.sh --check # what is installed and running right now
./setup.sh --dry-run # show what it would do, change nothing
setup.sh is idempotent and fills the real paths into the launchd plists, so
nothing has to be hand-edited. It will not create or edit .env: the app
password is yours and should not pass through anything that logs it. On the
first run it writes .env from the example with everything filled in except
the password, and stops so you can add that one line.
git push is the deploy. On the Mini, deploy.sh pulls, installs, runs the
test suite, and only restarts the service if the tests pass — so a bad push
aborts the deploy and leaves the running board untouched.
./deploy.sh # pull and restart now
./deploy.sh --if-changed # no-op unless origin moved (used by the watcher)
Install com.minesweeper.deploy.plist and the Mini polls origin every five
minutes, which means nothing has to be typed anywhere to ship a change.
To check on it, ./status.sh reports the deployed commit against origin,
whether the bot and the watcher are running, the latest deploy, errors in
the last 24 hours (--hours N to widen), board starts, and the big-board
schedule, ending in OK or ATTENTION.
Everything runs headlessly, with no network and no credentials:
.venv/bin/python -m unittest discover -s tests -t tests # rules, solver, parsing, rendering
.venv/bin/python tests/simulate.py --games 300 # pacing
.venv/bin/python tests/dryrun.py # a full board, posts written to disk
.venv/bin/python tests/preview.py # sample board images
MILESTONES="5 20" .venv/bin/python tests/dryrun.py # milestone posts in a real turn
.venv/bin/python tools/tutorial_image.py # pinned tutorial image
tests/dryrun.py is the last check before going live. It runs the real turn
loop with POST_MODE=dry and a synthetic crowd, writing every post with its
character count, facet byte offsets, alt text and image, so the copy can be
read in order exactly as a follower would see it.
.venv/bin/python dashboard.py # http://127.0.0.1:8766
Read-only: the database is opened with PRAGMA query_only and votes are read
from Bluesky's public AppView with no credentials.
Do not expose it. The Battleship dashboard's warning was that it showed both fleets; this one is worse, because it shows the solver's answers to the board that is currently being played.
The bot records the account's follower count (with follows and post count)
into the follower_counts table every FOLLOWER_SNAPSHOT_MINUTES (default 60,
0 disables), including once at startup. It is never posted, and reset.py
leaves it alone. Export it as CSV, timestamps in UTC:
.venv/bin/python main.py --followers > followers.csv
config.py calls load_dotenv() at import time. Modules read
os.environ while importing, which happens before main() runs, so loading
.env inside main() would silently do nothing.LOG_PATH, so the stdout log handler is
only added when stdout is a TTY. Otherwise every line is written twice.file:...?mode=ro connection fails against WAL; PRAGMA query_only=1 is
the way to get a read-only connection..env.a, b or I is a word,
not a vote. On this board I is also a row letter, so it only votes with a
digit attached.