← All projects
Solo engineer — product, Kotlin backend, Android client, CI/CD2026

Ttokttakmal (똑딱말)

A Korean word-chain (끝말잇기) duel played on a chess clock: Android client, authoritative Ktor WebSocket server on Azure, and a build-time pipeline that turns 1.7M National Institute of Korean Language entries into a 429,137-word playable dictionary. Shipped to Google Play internal testing as a signed AAB, with 1,233 tests and 12 architecture invariants enforced by the build.

74,425lines of Kotlin across 6 modules
1,233automated tests
429,137playable dictionary words (from 1.7M entries)
signed AAB in internal testingPlay Store

The problem

Korean 끝말잇기 is a game everyone in Korea already knows, but a competitive online version has to solve three hard problems at once. First, correctness: the client is an attacker-controlled process, so word validity, clock state, match outcome and rating all have to be decided server-side without adding latency to a game whose entire identity is a ticking clock. Second, the dictionary: the authoritative Korean corpora ship as 386MB LMF XML and binary Excel 97-2003 exports containing control bytes that XML parsers reject outright, and Korean text arrives in both composed and decomposed Unicode forms that look identical and compare unequal. Third, fairness: a chain game has 한방단어 — dead-end words that end the game — and those have to be computed from the graph rather than guessed at, or the format is broken from day one.

The solution

The rules live exactly once, in a pure `shared/` module with no I/O, no Android, no Ktor and no clock access — called by both the phone (instant feedback) and the server (authority), so "client says valid, server says invalid" is structurally impossible. A separate `arch/` module runs Konsist tests that fail the build when a boundary is crossed: shared/ purity, the money-versus-rating wall, single-owner clock arithmetic, no balance literals outside GameConfig, no storage on the turn path, no solver reachable from ranked code. A build-time pipeline ingests 표준국어대사전, 우리말샘 and 한국어기초사전, normalizes every entry through one NFC gate, builds the chain graph, derives the 한방단어 blocklist from that graph, and emits both the server's in-memory index and the phone's prebuilt SQLite asset from the same run — byte-identical across runs, checksummed, with the handshake rejecting a version mismatch. The Ktor server owns the clock, matchmaking, Elo, friend rooms, the coin economy and rewarded-ad verification; a Redis-backed shared lobby lets a second instance see the same queue. Every green CI gate on main deploys itself to the beta box over SSH with a health probe and automatic rollback to the previous tree.

Overview

**똑딱말 (Ttokttakmal)** — 똑딱 is the tick of a clock, 말 is a word — is a Korean word-chain duel where the pressure comes from a chess clock rather than a per-turn timer. Each player holds one clock that gains 2s for a correct word, loses 3s for a mistake and never exceeds 90s. When a clock hits zero, that player loses; there are no draws. Android-native first, targeting South Korea, with iOS reserved via Kotlin Multiplatform.

The engineering decision that shapes everything else was ordering: rules, clock, dictionary and server authority came before content and polish, because in a game decided by a ticking clock and a dictionary, correctness is the part you cannot retrofit. The rule engine exists exactly once in a pure `shared/` module — no I/O, no Android, no Ktor, no `now()` — and both the client and the server call it. A dedicated `arch/` module holds Konsist tests that fail the build when someone crosses a boundary: coins and ads may never reach MMR or the ranked ladder by any path, the 90s ceiling is applied in one place, dictionary lookups never touch a database at turn time, and the bot solver is unreachable from ranked code. Twelve such invariants are written down with the symptom of each violation, and the ones that can be mechanically checked are checked by CI rather than by review.

The dictionary is its own subsystem. Three 국립국어원 corpora — 1,703,238 raw entries across LMF XML and genuine binary Excel 97-2003 exports — are filtered to nouns, NFC-normalized once at the boundary, deduplicated by surface form, and compiled into a chain graph from which the 한방단어 dead-end lists are derived rather than hand-written. Output: 429,137 playable words, 212,929 of them flagged for the stricter Ranked tier, emitted as both a server index and a prebuilt SQLite asset for the phone, byte-identical across runs and checksummed so the handshake can refuse a client whose dictionary differs from the server's. The source XML turned out to contain control bytes that XML 1.0 forbids, which made the parser reject the whole document — the fix replaces them and *counts* them rather than swallowing them silently.

On the operations side, every green CI gate on `main` builds the server tree, ships it over SSH to an Azure Japan West box and switches with a health probe, rolling back to the previous tree if the new build never answers `/health`. Load work moved the connection quota from a broken reverse-DNS lookup (which put the entire player base in one bucket and admitted 30 of 1,000 arrivals) to real address attribution with a CGNAT-sized limit; the beta box now measures 128 accepted of 140 concurrent sockets from one address, and 120 of 120 clients paired at a 1.50s median. A Redis-backed lobby lets a second instance share the same queue, so a deploy no longer cuts every live match. The Android client is 42 of 48 designed frames built in Compose, 22 of them wired to real server state, with Google and Kakao sign-in, rewarded ads, an Elo ladder, friend rooms and hand-drawn mascot art — shipped as a signed 0.1.1 AAB to Play internal testing.

Key features

  • Server-authoritative WebSocket duels — Ktor + Netty owns word validity, clock, outcome and rating; the client is treated as an attacker-controlled process
  • Single rule engine in a pure Kotlin module called by both phone and server, so client and server can never disagree on a word
  • Build-time dictionary pipeline: 1,703,238 raw entries from three 국립국어원 corpora → 429,137 playable words (212,929 Ranked tier), deterministic and checksummed
  • 한방단어 dead-end blocklists derived from the chain graph rather than hand-authored (5,406 strict / 845 dueum)
  • 12 architecture invariants enforced by Konsist tests that fail the build — including a hard wall between the coin/ads economy and the ranked ladder
  • Retrograde-analysis bot (backward induction over the chain graph) that runs server-side and in local practice only, never reachable from ranked code
  • Redis-backed shared lobby so a second server instance sees the same queue, proposals and socket directory
  • Push-to-deploy: every green CI gate on main ships to the Azure beta box with a health probe and automatic rollback
  • Google and Kakao sign-in, Elo rating with tiers, friend rooms with codes, rewarded-ad coin economy, all-time XP ladder
  • Jetpack Compose client with 42 of 48 designed frames built, four load states per screen, Lottie animations and hand-drawn mascot art

Tech stack

Kotlin 2.2.0Ktor 3.5.2 + Netty (WebSocket)Jetpack ComposeAndroid (AGP 8.13.2, minSdk 26, compileSdk 35)MongoDB (coroutine driver 5.6)Redis / Lettuce 6.5Room + SQLite (prebuilt dictionary asset)kotlinx.serializationGradle 8.14 multi-moduleJDK 17Konsist (architecture tests)ktlint + detektGitHub Actions CI/CDAzure VM + systemd + CaddyApache POI + sqlite-jdbc (build-time pipeline)Google Credential Manager / Kakao SDKAdMob (play-services-ads)Lottie, CoilDocker Compose (local Redis/Mongo)

Gallery