
Battisi is our Android version of Sholo Guti, the 32-bead jump-capture game also called Bead 16 or 16 Guti. It started as a web game we built in JavaScript. For Android we ported the rules engine and the computer opponent line for line to Kotlin, kept both free of Android code so they're tested on a plain JVM, and added something no web page can do: two phones playing on the same Wi-Fi with no internet. This devlog covers how each part works, what the ₹200 unlock pays for, and the one crash that only a real phone found. Battisi is in closed testing on Google Play now, and you can join the test from the Battisi page.
Key takeaways
- The board is 37 points stored in a 45-cell array (9 rows × 5 columns). Each side starts with 16 beads, and the middle row of 5 points starts empty.
- The engine and the AI have no Android imports. They're a line-for-line port of the web code, checked by 16 engine and 9 AI test cases on the JVM.
- The AI, the Guru, is a classic game-tree search (negamax with alpha-beta), not a language model. Easy, Medium and Hard differ in depth, time and random noise.
- Same Wi-Fi play uses Android NSD to find the game, a QR code or typed address as a fallback, and one JSON message per line over TCP. The host's engine checks every move.
- The crash: a bouncy press spring went past 1, so a padding of
5.dp * (1 - drop)went negative. The fix is a clamp, and the rule is: never feed a spring value straight intopadding.
Here's the 33-second preview. It has no voice, just the game:
Sholo Guti in two minutes
Sholo Guti is a traditional strategy game played across India, Bangladesh and Nepal. "Sholo guti" means sixteen pieces, and Battisi means thirty-two, for the beads on the board at the start. The rules we use are the ones from our web version:
- The board. A 5 × 5 square of points with a triangle "wing" of 6 points at the top and bottom: 37 points in all, joined by lines. Diagonals run through every other point, and the wing sides continue the square's diagonals.
- The start. One side fills the top four rows with 16 beads, the other fills the bottom four with 16. Only the middle row of 5 points is empty, so the first player has just 9 possible moves.
- A step moves a bead along a line to the next empty point, in any direction, backwards too.
- A capture jumps over an enemy bead in a straight line onto the empty point behind it. Capturing is optional by default.
- A chain. After a capture, the same bead may jump again in the same turn, and you may stop the chain at any point.
- Winning. Take every enemy bead, or leave the other side with no legal move. 50 turns in a row without a capture (25 each) is a draw.
Rules differ from village to village, so Settings can make capturing compulsory, make chains continue, or toss for who moves first. The web rules stay the default.
Where it started: the web version
We first built the game in HTML, CSS and JavaScript, and wrote it up on this blog in 2022: the web version, 16 Beads (Battisi) Game in HTML, CSS and JavaScript. That post covers drawing the board and moving beads in the browser, so we won't repeat it here.
The code lives on in the 32si repo, now a React client with a Node.js server. By the time we started the Android app it had a rules engine (engine.js, about 330 lines of pure functions on a 45-cell array), an AI, 7 lessons, hints, replays and online play, with tests for each. That made the port a translation job, not a redesign.
A rules engine with no Android code
The first decision was where the rules live. In Battisi, the engine and ai packages don't import anything from Android. They're plain Kotlin working on an IntArray(45), with points numbered row * 5 + col. The 8 cells beside the wings (0, 4, 5, 9, 35, 39, 40 and 44) aren't on the board, which leaves the 37 points.
That buys us three things:
- Fast tests. The engine runs in an ordinary JVM unit test, with no emulator.
EngineTesthas 16 cases: the web'sengine.test.jscase for case, plus the new rule options. The first one checks the numbers above: 37 points, 16 beads a side, 5 empty points and 9 opening moves. - One source of truth. The host phone in a Wi-Fi game, the lessons, the replays and the Guru all call the same
Engine.apply. It never changes a board in place: it copies the 45 ints and returns a new state, so undo is just keeping the old one. - A path to sharing. Because nothing ties it to Android, the engine can later move into a Kotlin Multiplatform module shared with an online server.
The neatest part of the port is that jump lines aren't typed in by hand. The adjacency table is copied from the web version, and every jump is derived from it: for neighbours a → b → c, the jump exists if the two steps point the same way. From engine/Board.kt:
val cross = v1x * v2y - v1y * v2x
val dot = v1x * v2x + v1y * v2y
if (cross == 0 && dot > 0) {
over[a] += b
to[a] += c
}
A cross product of 0 means the two steps are on one line; a positive dot product means they don't double back. This also handles the odd spots, like a line that runs from a wing through its apex into the square, where the two steps have different lengths. Only the direction is checked, which is how the game is traditionally played.

How the Guru chooses a move
The computer opponent is called the Guru. It's a port of the web game's ai.js, and it's a classic search, the kind chess programs use:
- Negamax with alpha-beta pruning. It looks ahead through moves and replies, and skips branches that can't beat what it has already found.
- Iterative deepening. It searches 1 move deep, then 2, then 3, until its time runs out, and keeps the best answer from the deepest search that finished.
- A transposition table. Positions are hashed with 64-bit Zobrist keys, so a position reached by two different move orders is scored once.
- Quiescence search. At the end of the normal search it keeps following captures (up to 8 more), so it doesn't stop in the middle of an exchange and misjudge it.
- Whole turns. A capture chain becomes one turn for every point where the chain could stop, so the Guru sees a triple jump as one move.
To score a position, it counts material first. From ai/Guru.kt:
val diff = mine - theirs
var score = diff * 100 + diff * (32 - mine - theirs) * 4
A bead is worth 100, and a lead counts for more as the board empties: one bead up with 30 beads left scores 108, one bead up with 4 left scores 212. On top of that come smaller terms: points with more lines are worth more, having more moves than the other side helps, and the side that's ahead gets a bonus for closing in, so it hunts down the last beads instead of shuffling.
The three levels are one enum, with the numbers taken from the web version:
EASY(1, 300, false, 120),
MEDIUM(3, 800, true, 25),
HARD(30, 2000, true, 0),
| Level | Search depth | Time | Quiescence | Noise |
|---|---|---|---|---|
| Easy | 1 | 300 ms | No | ±120 |
| Medium | 3 | 800 ms | Yes | ±25 |
| Hard | up to 30 | 2 s | Yes | 0 |
The noise is what makes Easy feel human. After the search, every move's score gets a random nudge of up to ±120, and the Guru plays the best nudged score. A move that's a bead worse can win the draw, so Easy blunders now and then. Hard adds no noise. When a forced win or loss is found, there's no noise at any level.
The search runs on Dispatchers.Default, off the main thread, and the Guru always takes at least 450 ms, because an instant reply feels like a machine. Then it lifts its bead, shows a dashed arrow to where it's going, and plays one hop at a time so you can follow its chains. GuruTest has 9 cases, including self-play: Easy beat a random player 4 games out of 4, and Hard beat Easy 2 out of 2.
Two phones, one board, no internet
The feature we most wanted was two people in one room, each on their own phone. When we checked the Sholo Guti apps in the Google Play India store on 8 October 2026, none of them advertised play over the same Wi-Fi. We wanted it to work on a train or at a chai stall, with no account and no server.
We ruled out Bluetooth, Wi-Fi Direct and Nearby Connections early: each needs a runtime permission Android treats as dangerous (Bluetooth scanning, nearby Wi-Fi devices, or location). Battisi asks for none. Here's what it uses instead:
- Finding the game. The host registers an NSD (mDNS) service called
_battisi._tcp, named after the player ("Riya's game"), on a free port. The joining phone lists the games it finds. - A fallback. Service discovery may be flaky on some phone brands, so the host also shows a QR code and the address to type in. The QR is scanned with Google Code Scanner, which, in Google's words, scans "without requiring your app to request camera permission".
- A PIN. The host shows a 4-digit PIN, and joining needs it, so a stranger on the same café Wi-Fi can't walk in. Five wrong tries lock the game.
- Moves. Plain TCP sockets carry UTF-8 JSON, one message per line, each tagged with protocol version 1. Lines are capped at 4 KB and 20 messages a second. A ping goes every 5 seconds, 3 missed pings mean the other phone is gone, and it gets 60 seconds to come back.
The QR code holds everything the joiner needs. From local/JoinCode.kt:
fun format() = "$PREFIX?h=$host&p=$port&k=$pin&v=$PROTOCOL_VERSION"
When it's scanned, parse refuses anything that isn't a private LAN address (10.x, 172.16–31.x or 192.168.x), a valid port and a 4-digit PIN.
The host is the referee. It runs the only engine, the joiner sends intents, and the host sends back the whole board after every change. Every move from the joiner goes through one check in local/HostSession.kt:
if (m.seq != s.ply || s.turn != peerSide || Engine.validate(s, action) != null) {
The move must be for the current position (seq equals the host's move count), it must be the joiner's turn, and the engine must accept it. Anything else gets an error and a fresh copy of the board, so a late or repeated tap can't put the phones out of step.
The ₹200 unlock
Everything except hosting a Wi-Fi game is free, with no ads and no sign-in: the Guru at all three levels, pass and play on one phone, the 7 lessons, replays of the last 20 games, and all 12 bead designs, 12 colour pairs, 10 boards and 8 line styles.
Same Wi-Fi play is a one-time in-app purchase on Google Play: product local_play, a non-consumable item named "Same Wi-Fi play", at ₹200. Its description on Play reads "Host two-phone games on the same Wi-Fi or hotspot. The other phone joins free." Only the hosting phone needs the unlock, so one purchase lets two people play.
The billing code handles the Indian cases: UPI payments can sit as pending ("Waiting for payment…"), purchases are acknowledged on the phone, and the unlock is re-checked with Play at every launch. There's no server to verify purchases, which is a deliberate trade-off for a ₹200 game.
The crash only a real phone found
Version 0.2.1 exists mostly because of one bug. Every screen had been checked on the emulator and in screen renders on the JVM. The first install on a real phone, an OPPO CPH2613, crashed the moment we tapped Start, in both modes:
IllegalArgumentException: Padding must be non-negative
The culprit was GameButton, our chunky game-style button. Its coloured face sits on a darker 5 dp lip, and pressing it animates a value called drop from 0 to 1 with a bouncy spring. The face moves down by lip * drop, and the lip under it shrinks to lip * (1 - drop).
A bouncy spring doesn't stop at its target. It overshoots and swings back, like a door on a spring hinge. With a damping ratio of 0.5, drop peaks at about 1.163 before settling at 1. At that moment, 1 - drop is about -0.163, the bottom padding is about -0.815 dp, and Compose throws, because padding can't be negative. Our design doc records the crash as found on the phone, not the emulator. We didn't dig into why the emulator runs missed it: the maths below says a full press overshoots every time.

We reproduced it in a few lines of Python, simulating the same spring:
import math
zeta, k = 0.5, 1500.0 # GameButton's damping ratio; the stiffness doesn't change the peak
c = 2 * zeta * math.sqrt(k) # damping for a unit mass
x, v, dt, peak = 0.0, 0.0, 1e-5, 0.0
while v >= 0: # the press pulls drop from 0 towards 1
v += (-k * (x - 1) - c * v) * dt
x += v * dt
peak = max(peak, x)
lip = 5.0 # dp
print(round(peak, 3)) # 1.163
print(round(lip * (1 - peak), 3)) # -0.815: "Padding must be non-negative"
print(lip * (1 - min(max(peak, 0), 1))) # 0.0 after the clamp
The peak doesn't depend on how stiff the spring is, only on the damping ratio: the overshoot is e−ζπ/√(1−ζ²), which is 16.3% for ζ = 0.5. A stiffer spring just gets there sooner. The fix keeps the bounce for things that can take it and clamps the value where it feeds layout. From ui/Components.kt:
// The spring overshoots past 1 on a real press; padding can't go negative, so clamp it.
.offset(y = lipH * drop.coerceIn(0f, 1f))
.padding(bottom = lipH * (1f - drop.coerceIn(0f, 1f)))
padding. Springs are allowed to overshoot. Scale, rotation and offset don't mind a value of 1.16, but padding, sizes and anything with a "must be non-negative" check do. Clamp with coerceIn at the point of use.The same phone found a second, quieter problem: at 360 × 804 dp and 0.9 font scale, the Home screen didn't fit. Home now switches to a compact header on screens shorter than 880 dp, so every button fits without scrolling.
What's still open
We'd rather list the gaps than have testers find them:
- In a Wi-Fi game, the joiner's saved replay can miss moves that arrived as a full board sync.
- A resign sent while the connection is down is lost.
- Each phone runs its own 60-second "player left" timer, so the two can disagree by a moment.
- When Play requires apps to target Android 17 (API 37), local-network traffic will need a new "Nearby devices" permission. We plan to ask for it only when someone taps Host a game.
Two quick ones. In the score formula above, how much is a lead of 2 beads worth when 10 beads are left on the board? And if GameButton's spring had a damping ratio of 1.0 (no bounce), would it still have crashed?
Show the answers
2 × 100 + 2 × (32 − 10) × 4 = 376.
No. A critically damped spring (ζ = 1) never passes its target, so drop stays at or below 1 and the padding never goes negative. We kept the bounce and added the clamp instead.
Questions people ask
Is Battisi the same game as Sholo Guti, 16 Guti or Bead 16?
Yes. They're names for the same game: two sides of 16 beads on a 37-point board, capturing by jumping. Battisi means thirty-two, for all the beads together.
Is Battisi on Google Play?
It's in closed testing on Google Play now, at version 0.2.1. You can join the test from the Battisi page on letsbug.in.
Can two people play without internet?
Yes, two ways: pass one phone back and forth, or use two phones on the same Wi-Fi or one phone's hotspot. Nothing goes over the internet, and only the hosting phone needs the ₹200 unlock.
Does the Guru use AI like ChatGPT?
No. It's a classic game-tree search (negamax with alpha-beta pruning) that runs entirely on the phone. It looks ahead through moves and scores positions, mostly by counting beads.
Why not Bluetooth for two-phone play?
Bluetooth, Wi-Fi Direct and Nearby Connections all need permissions Android treats as dangerous. NSD and plain sockets over Wi-Fi need only normal permissions, so Battisi doesn't show a permission prompt today.
Which phones does it run on?
Android 8.0 and newer (minimum SDK 26). It's built with Kotlin and Jetpack Compose.
Keep going
- See where the game began: the web version, 16 Beads (Battisi) Game in HTML, CSS and JavaScript.
- Alpha-beta drops branches it can prove can't matter, the same "throw away what can't hold the answer" idea behind binary search, explained step by step.
- Screens, the full feature list and the closed test are on the Battisi app page.
Work with letsBug
We build Android apps the way this devlog describes: logic you can test without a device, offline first, no data collected unless it has to be, and real-phone testing before anything ships. If you have an app or a game you want built or rescued, see what we do or get in touch.
Sources
- letsBug: Battisi app page (status, version, features)
- GitHub: Aniket-git-hub/32si, the web version this app is ported from
- Android Developers: Use network service discovery
- Android Developers: Local network permission (the Android 17 change)
- Google for Developers: Google code scanner (scanning without the camera permission)
- Android Developers: Spring constants in Compose (damping ratios)
- Wikipedia: Sixteen soldiers (sholo guti and its variants)
Every number in this post was checked against the Battisi source and design docs, and the Python spring simulation was run before publishing.