BeamNG.Earth
BeamNG.drive용 1:1 비율 실시간 지구, 실제 고도·위성 이미지·부동 원점·보조 서버에서 LOD 스트리밍
Vinto 4인 카드 게임, 적게 들고 더 많이 아는 전략
Vinto is a four-player card game where you hold less and know more. Draw, swap, and play action cards to peek, trade and steal — then call Vinto when you think you are lowest. Free, in the browser, no
게임Kotlin · JavaScriptGitHub @Lonli-Lokli온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기A strategic multiplayer card game
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
An unofficial client for the card game VINTO, built with Kotlin Multiplatform,
Compose Multiplatform and a pure reducer. One rule set runs on Android, iOS, the web
and inside a Cloudflare Durable Object — the same GameEngine.reduce, not a client copy of a
server's rules.
VINTO is not ours. It is a card game designed and published by other people, and this
repository is an unofficial app for playing it — written out of admiration, with no
affiliation to or endorsement from its creators.README 전문 보기 ↗An unofficial client for the card game VINTO, built with Kotlin Multiplatform,
Compose Multiplatform and a pure reducer. One rule set runs on Android, iOS, the web
and inside a Cloudflare Durable Object — the same GameEngine.reduce, not a client copy of a
server's rules.
Whose game this is
VINTO is not ours. It is a card game designed and published by other people, and this repository is an unofficial app for playing it — written out of admiration, with no affiliation to or endorsement from its creators.
The original is at vinto.game — the rules, the printed deck, and the people who made it. If you enjoy the game here, that is where to go next.
Every client says so on its own first screen, and links there;
AttributionTestfails if a refactor loses the line.
On your turn, you can either:
The single most important thing on this diagram is that shared/engine appears once. The
same GameEngine.reduce runs on a phone, in a browser and inside a Durable Object; a rule is
not "implemented on the server and mirrored on the client".
graph TB
subgraph Clients
UI[composeApp<br/>one commonMain:<br/>Android, iOS, web]
Session[GameSession<br/>Local or Remote]
end
subgraph Shared["shared/ — one rule set"]
AV[ActionValidator<br/>the seat boundary]
GE[GameEngine.reduce<br/>pure, deterministic]
GS[(GameState<br/>immutable, seeded)]
Bot[BotRunner + MCTS]
end
subgraph Server["worker/ — the authority"]
Room[Room Durable Object<br/>one per room]
end
UI -->|GameAction| Session
Session --> AV
AV --> GE
GE --> GS
GS -->|redacted PlayerView| UI
Bot -->|proposes a GameAction| AV
Session <-->|WebSocket| Room
Room --> AV
Room --> Bot
style GE fill:#90EE90
style GS fill:#87CEEB
style AV fill:#FFE4B5
Every interaction takes the same path, whoever sent it — a bot's move is validated exactly as a stranger's is. There is no second path into the engine.
sequenceDiagram
participant User
participant UI as composeApp
participant S as GameSession
participant V as ActionValidator
participant E as GameEngine
User->>UI: taps "Draw"
UI->>S: dispatch(GameAction.DrawCard(me))
S->>V: validate(state, action)
alt invalid
V-->>UI: Invalid(reason) — nothing changes
else valid
S->>E: reduce(state, action)
E-->>S: a new GameState
S-->>UI: a redacted PlayerView, plus frames to animate
end
The room deals from a seed, validates everything, runs the bots server-side — a client-side bot would need the other seats' hidden cards — and sends each socket its own redacted view.
sequenceDiagram
participant A as Player A
participant B as Player B
participant Room as Room (Durable Object)
A->>Room: GameAction
Room->>Room: validate, reduce, record
Room-->>A: event + the view for A's seat
Room-->>B: the same event + the view for B's seat
Note over Room: bots run here, not on a client
commonMainmaxIssues: 0The Gradle build is the repository root. The rules live once, in Kotlin. They used to live
twice — a Next.js client came first, and the two were held identical by a replay corpus — and
what survives that arrangement is fixtures/: 50 games whose state hashes the TypeScript
engine computed, now frozen, and still replayed on every run. Twice a part of them has been
remade, because both engines shared a defect the corpus then recorded as the rule: what each
seat has been shown, on 2026-09-07; the tails of six games, on 2026-09-15; and four of those
six again on 2026-09-20.
fixtures/recordings/README.md says what moved each time and what provably did not.
shared/
shapes/ # Card, Rank, GameState, GameAction, canonical JSON, SHA-256, Prng
engine/ # GameEngine.reduce, ActionValidator, case handlers, replay
bot/ # MCTS decision service, coalition planner, BotRunner
client/ # GameSession: LocalGameSession (solo) and RemoteGameSession (online)
protocol/ # The wire between a client and a room, declared once
room/ # Room and registry cores, testable off the Worker
composeApp/ # Compose Multiplatform UI — Android, iOS and web from one commonMain
worker/ # Cloudflare Worker + Durable Object per room (Kotlin/JS)
iosApp/ # Xcode project embedding composeApp's framework
fixtures/ # The cross-implementation corpus: 50 recordings + PRNG vectors
docs/kotlin/ # ARCHITECTURE.md (the shape), README.md (the state), protocol, traps
GameSession interface with two lives, so the UI cannot tell a solo
game from an online one.fixtures/recordings/README.md. Frozen, and deliberately not extensible.jvmTarget = 17; a newer JDK fails the build)Full setup, including the Android SDK pointer and the iOS bring-up:
docs/kotlin/README.md.
./gradlew :androidApp:assembleDebug # Android APK
./gradlew :androidApp:installDebug # ...onto a connected phone or emulator
./gradlew :composeApp:wasmJsBrowserDistribution # the Compose web bundle
./gradlew :shared:engine:jvmTest # the parity gate: every recording, every action
./gradlew detekt # static analysis, maxIssues 0
All game state lives in GameState, which is immutable. There is no parallel state in the
UI: UiStore-shaped duplication is what the GameSession seam exists to make unnecessary.
data class GameState(
val players: List<PlayerState>,
val currentPlayerIndex: Int,
val phase: GamePhase,
val drawPile: Pile,
val discardPile: Pile,
val rngState: Long, // the seed, carried in the state — no ambient randomness
// ...
)
GameEngine.reduce is a pure, total, deterministic function. No clock, no ambient randomness,
no I/O. That is not a style preference: it is what lets the same code be the authority inside a
Durable Object and the simulator inside MCTS, and it is what makes a recording a complete
description of a game.
fun reduce(state: GameState, action: GameAction): GameState
The handlers mutate a MutableGameState internally and reduce freezes it on the way out —
deliberately, because the port stayed literal while the parity corpus was the only thing
holding two engines together (docs/kotlin/ARCHITECTURE.md §3).
Every interaction — a tap, a bot's decision, a message off a socket — becomes a serializable
GameAction, and every one goes through the same two steps. There is no second path.
ActionValidator.validate(state, action) // Valid | Invalid(reason)
GameEngine.reduce(state, action) // a new state, or the same one
A bot does not apply its move directly; it proposes an action validated exactly as a stranger's would be. That is what makes the room's anti-cheat boundary one line rather than an audit.
LocalGameSession runs the engine and BotRunner in-process; RemoteGameSession runs them
over a socket. Both hand the screen the same redacted PlayerView, so a screen cannot tell
which it has — and a solo game touches no network at all, which NoNetworkGuardTest proves by
installing a SecurityManager that throws on any connect.
One Cloudflare Durable Object per room — see Online above for what it does and why the bots run there.
GameState | Authoritative, immutable, lives in the engine |
PlayerView | What one seat is allowed to see; the only thing a screen ever gets |
| Compose state | Modals, highlights, the animation queue — presentation only |
shared/shapes/.../GameAction.ktshared/engine/src/commonMain/.../cases/, wired into GameEngine.reduceActionValidator when it is legal — including who may send itIf the change moves any recorded state, note that fixtures/recordings is frozen: it
carries hashes computed by a second implementation that no longer exists.
fixtures/recordings/README.md explains what to do instead — and records the one occasion it
was regenerated anyway, and why that is not a precedent. CorpusIsFrozenTest will stop you
rewriting it quietly.
The engine is pure, so its tests are ordinary unit tests:
@Test
fun drawingACardTakesOneOffThePile() {
val before = initializeGame(seed = 42)
val after = GameEngine.reduce(before, GameAction.DrawCard(PlayerIdPayload("p1")))
assertEquals(before.drawPile.size - 1, after.drawPile.size)
assertNotNull(after.players[0].pendingCard)
}
.github/workflows/kmp.yml runs six checks, split by what each needs rather than by what it
covers: kmp-detekt, kmp-jvm (the parity gate), kmp-web (the same commonTest suites on
Kotlin/JS and Wasm, plus the web client's compile), kmp-android (APK + headless Compose
suites), kmp-worker (the Kotlin/JS bundle, the room gates through workerd, and the size
budget) and kmp-ios. The macOS leg is rationed — pushes, nightly, on demand, and on a pull
request labelled ios — because it bills at ten times the Linux rate.
Beside them sit two workflows that check nothing and publish, both workflow_dispatch only
because deploying is a decision rather than a consequence of merging: deploy-room.yml for the
room Worker and deploy-web.yml for the website.
MIT