Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions EkiDash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# EkiDash (駅ダッシュ)

Turn every train ride into a race. EkiDash is an iOS game you play *while
riding a train in Japan*: pick your boarding and destination station, and
the train's real GPS speed drives an endless-runner mini-game — faster
train, faster/harder game — while the ride's actual duration is the length
of the round. Arrive at your stop and your score posts to a leaderboard
scoped to that exact station pair (e.g. "Tokyo → Shinagawa"), so every
JR/Metro/Shinkansen segment has its own ranking.

## Why this idea

- **Speed as difficulty**: live CoreLocation speed feeds directly into the
game's scroll speed and obstacle frequency (`GameConfig.swift`), so a
Yamanote local and a 285 km/h Tokaido Shinkansen feel completely
different to play.
- **Time as the round length**: there's no artificial timer — the round
*is* the ride. A 3-minute hop and a 90-minute Shinkansen leg are both
valid, self-contained sessions.
- **Perfect Stop**: when the train visibly decelerates into any station,
a timing challenge arms itself; nailing the tap while the train is
stopped scores a bonus, gamifying the actual physical sensation of
arriving.
- **Per-route leaderboards**: every station-to-station pair is a distinct
leaderboard (`RouteID` = origin + destination), crowd-sourcing "who's
the fastest/best player on this exact segment" across all of Japan's
rail network.
- **Works when GPS doesn't**: Japan's subways lose GPS fix underground
constantly. `LocationSpeedManager` detects staleness and falls back to a
synthetic speed curve so the game — and the ride — never just freezes,
and a manual "We've Arrived" button always lets the player end the round.

## Architecture

```
Sources/
App/ App entry point (EkiDashApp)
Models/ Station, TrainRoute, ScoreEntry
Data/ StationDatabase — curated real stations (Yamanote,
Tokaido Shinkansen, Osaka Loop) with coordinates
Services/
LocationSpeedManager CoreLocation speed feed + geofenced
departure/arrival detection
RideManager Ride state machine (select → riding → arrived),
elapsed clock, score handoff
LeaderboardService CloudKit public-database leaderboard, queried
and sorted per routeID
LocalScoreCache UserDefaults fallback when CloudKit/network
is unreachable (common underground)
Game/
RailRunnerScene SpriteKit endless runner; speed-driven scroll/
obstacle pacing + Perfect Stop challenge
GameConfig Tunable difficulty curve constants
Views/ SwiftUI: route selection, live ride + HUD, results,
per-route and browsable leaderboards
```

Score, GPS speed, and CloudKit access all flow one-directionally into
`RideManager`, which is the single `@EnvironmentObject` the views observe —
`RailRunnerScene` never touches CloudKit or CoreLocation directly, it just
reads a `speedProvider` closure and reports score/events back out.

## Building the Xcode project

This repo ships Swift sources plus an [XcodeGen](https://github.com/yonaskolb/XcodeGen)
spec (`project.yml`) instead of a checked-in `.xcodeproj`, so the project
file itself never rots or produces noisy diffs. On a Mac:

```sh
brew install xcodegen
cd EkiDash
xcodegen generate
open EkiDash.xcodeproj
```

Then in Xcode:

1. **Signing & Capabilities** → set your Team, confirm the bundle ID
(`com.ekidash.app` by default — change it and the iCloud container ID
in `project.yml` if that's taken).
2. **iCloud capability** should already be enabled with a CloudKit
container (`iCloud.com.ekidash.app`) via the generated entitlements.
On first run, open the [CloudKit Dashboard](https://icloud.developer.apple.com/)
for that container and mark `routeID` as **Queryable** and `score` as
**Queryable + Sortable** on the auto-created `RouteScore` record type
(CloudKit requires indexes for fields used in predicates/sorts before
you deploy the schema to Production).
3. Run on a **physical device** — the Simulator doesn't report real GPS
speed, so `LocationSpeedManager` will mostly exercise its simulated
fallback curve. For a quick desk test, use Xcode's
Debug → Simulate Location → a GPX route with varying speed.

## Known limitations / next steps

- `StationDatabase` coordinates are approximate (a few tens of meters) —
fine for the 150m arrival geofence, not survey-grade. Swap in an
authoritative dataset (e.g. an ekidata.jp export) before shipping, and
expand well beyond the ~40 stations included here.
- Player identity is a local display name (`RideManager.playerName`); add
Sign in with Apple or Game Center if you want portable identity/anti-cheat
across devices.
- There's no server-side validation that a submitted score's `rideDurationSeconds`
is physically plausible for its route — worth adding before treating the
leaderboard as competitive/public.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.200",
"green" : "0.550",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
13 changes: 13 additions & 0 deletions EkiDash/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions EkiDash/Resources/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
14 changes: 14 additions & 0 deletions EkiDash/Sources/App/EkiDashApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import SwiftUI

@main
struct EkiDashApp: App {
@StateObject private var rideManager = RideManager()

var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(rideManager)
.preferredColorScheme(.dark)
}
}
}
85 changes: 85 additions & 0 deletions EkiDash/Sources/Data/StationDatabase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Foundation

/// A curated starter set of real Japanese stations.
///
/// Coordinates are approximate (station-entrance precision, a few tens of
/// meters), which is sufficient for the geofenced arrival/departure
/// detection used by `LocationSpeedManager` (150m trigger radius) but is
/// NOT survey-grade. Swap in an authoritative dataset (e.g. an ekidata.jp
/// export) before shipping.
enum StationDatabase {

// MARK: Yamanote Line (JR East) — dense urban loop, low top speed,
// frequent stops. Great for short, high-frequency rounds.
static let yamanote: [Station] = [
Station(id: "JY01", name: "Tokyo", nameJa: "東京", line: "Yamanote", latitude: 35.6812, longitude: 139.7671),
Station(id: "JY02", name: "Kanda", nameJa: "神田", line: "Yamanote", latitude: 35.6918, longitude: 139.7709),
Station(id: "JY03", name: "Akihabara", nameJa: "秋葉原", line: "Yamanote", latitude: 35.6984, longitude: 139.7731),
Station(id: "JY04", name: "Okachimachi", nameJa: "御徒町", line: "Yamanote", latitude: 35.7075, longitude: 139.7746),
Station(id: "JY05", name: "Ueno", nameJa: "上野", line: "Yamanote", latitude: 35.7141, longitude: 139.7774),
Station(id: "JY06", name: "Uguisudani", nameJa: "鶯谷", line: "Yamanote", latitude: 35.7207, longitude: 139.7787),
Station(id: "JY07", name: "Nippori", nameJa: "日暮里", line: "Yamanote", latitude: 35.7281, longitude: 139.7709),
Station(id: "JY08", name: "Nishi-Nippori", nameJa: "西日暮里", line: "Yamanote", latitude: 35.7322, longitude: 139.7668),
Station(id: "JY09", name: "Tabata", nameJa: "田端", line: "Yamanote", latitude: 35.7379, longitude: 139.7607),
Station(id: "JY10", name: "Komagome", nameJa: "駒込", line: "Yamanote", latitude: 35.7364, longitude: 139.7469),
Station(id: "JY11", name: "Sugamo", nameJa: "巣鴨", line: "Yamanote", latitude: 35.7333, longitude: 139.7391),
Station(id: "JY12", name: "Otsuka", nameJa: "大塚", line: "Yamanote", latitude: 35.7317, longitude: 139.7286),
Station(id: "JY13", name: "Ikebukuro", nameJa: "池袋", line: "Yamanote", latitude: 35.7295, longitude: 139.7109),
Station(id: "JY14", name: "Mejiro", nameJa: "目白", line: "Yamanote", latitude: 35.7211, longitude: 139.7061),
Station(id: "JY15", name: "Takadanobaba", nameJa: "高田馬場", line: "Yamanote", latitude: 35.7128, longitude: 139.7036),
Station(id: "JY16", name: "Shin-Okubo", nameJa: "新大久保", line: "Yamanote", latitude: 35.7010, longitude: 139.7003),
Station(id: "JY17", name: "Shinjuku", nameJa: "新宿", line: "Yamanote", latitude: 35.6896, longitude: 139.7006),
Station(id: "JY18", name: "Yoyogi", nameJa: "代々木", line: "Yamanote", latitude: 35.6830, longitude: 139.7020),
Station(id: "JY19", name: "Harajuku", nameJa: "原宿", line: "Yamanote", latitude: 35.6702, longitude: 139.7027),
Station(id: "JY20", name: "Shibuya", nameJa: "渋谷", line: "Yamanote", latitude: 35.6580, longitude: 139.7016),
Station(id: "JY21", name: "Ebisu", nameJa: "恵比寿", line: "Yamanote", latitude: 35.6467, longitude: 139.7101),
Station(id: "JY22", name: "Meguro", nameJa: "目黒", line: "Yamanote", latitude: 35.6339, longitude: 139.7157),
Station(id: "JY23", name: "Gotanda", nameJa: "五反田", line: "Yamanote", latitude: 35.6262, longitude: 139.7237),
Station(id: "JY24", name: "Osaki", nameJa: "大崎", line: "Yamanote", latitude: 35.6197, longitude: 139.7285),
Station(id: "JY25", name: "Shinagawa", nameJa: "品川", line: "Yamanote", latitude: 35.6285, longitude: 139.7388),
Station(id: "JY26", name: "Tamachi", nameJa: "田町", line: "Yamanote", latitude: 35.6455, longitude: 139.7476),
Station(id: "JY27", name: "Hamamatsucho", nameJa: "浜松町", line: "Yamanote", latitude: 35.6553, longitude: 139.7570),
Station(id: "JY28", name: "Shimbashi", nameJa: "新橋", line: "Yamanote", latitude: 35.6663, longitude: 139.7580),
Station(id: "JY29", name: "Yurakucho", nameJa: "有楽町", line: "Yamanote", latitude: 35.6751, longitude: 139.7634),
]

// MARK: Tokaido Shinkansen — long, very high speed segments (up to
// ~285 km/h), perfect for showing off the speed-scaled difficulty curve.
static let tokaidoShinkansen: [Station] = [
Station(id: "SK01", name: "Tokyo", nameJa: "東京", line: "Tokaido Shinkansen", latitude: 35.6812, longitude: 139.7671),
Station(id: "SK02", name: "Shinagawa", nameJa: "品川", line: "Tokaido Shinkansen", latitude: 35.6285, longitude: 139.7388),
Station(id: "SK03", name: "Shin-Yokohama", nameJa: "新横浜", line: "Tokaido Shinkansen", latitude: 35.5079, longitude: 139.6169),
Station(id: "SK04", name: "Nagoya", nameJa: "名古屋", line: "Tokaido Shinkansen", latitude: 35.1709, longitude: 136.8815),
Station(id: "SK05", name: "Kyoto", nameJa: "京都", line: "Tokaido Shinkansen", latitude: 34.9858, longitude: 135.7588),
Station(id: "SK06", name: "Shin-Osaka", nameJa: "新大阪", line: "Tokaido Shinkansen", latitude: 34.7333, longitude: 135.5002),
]

// MARK: Osaka Loop Line
static let osakaLoop: [Station] = [
Station(id: "OL01", name: "Osaka", nameJa: "大阪", line: "Osaka Loop", latitude: 34.7024, longitude: 135.4959),
Station(id: "OL02", name: "Tenma", nameJa: "天満", line: "Osaka Loop", latitude: 34.6969, longitude: 135.5133),
Station(id: "OL03", name: "Kyobashi", nameJa: "京橋", line: "Osaka Loop", latitude: 34.6969, longitude: 135.5342),
Station(id: "OL04", name: "Tsuruhashi", nameJa: "鶴橋", line: "Osaka Loop", latitude: 34.6656, longitude: 135.5292),
Station(id: "OL05", name: "Tennoji", nameJa: "天王寺", line: "Osaka Loop", latitude: 34.6457, longitude: 135.5136),
Station(id: "OL06", name: "Namba (JR)", nameJa: "難波", line: "Osaka Loop", latitude: 34.6598, longitude: 135.4993),
]

static var all: [Station] {
yamanote + tokaidoShinkansen + osakaLoop
}

static func station(withID id: String) -> Station? {
all.first { $0.id == id }
}

/// Ordered stops for a given line, used to suggest the "next station"
/// as a default destination once an origin is picked.
static func line(named line: String) -> [Station] {
switch line {
case "Yamanote": return yamanote
case "Tokaido Shinkansen": return tokaidoShinkansen
case "Osaka Loop": return osakaLoop
default: return []
}
}
}
47 changes: 47 additions & 0 deletions EkiDash/Sources/Game/GameConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import CoreGraphics
import Foundation

/// Tunable constants for RailRunnerScene, isolated here so difficulty can
/// be balanced without touching game logic.
enum GameConfig {
/// Real-world speed range we map into gameplay scroll speed. Below
/// minKmh (e.g. stopped at a platform) the world nearly freezes; above
/// maxKmh (Shinkansen cruising speed) we cap so the game stays playable.
static let minKmh: Double = 3
static let maxKmh: Double = 260

static let minScrollPointsPerSecond: CGFloat = 60
static let maxScrollPointsPerSecond: CGFloat = 620

static let minObstacleInterval: TimeInterval = 0.55
static let maxObstacleInterval: TimeInterval = 2.2

static let coinInterval: TimeInterval = 1.4
static let coinScore = 10
static let survivalScorePerSecond = 4

static let obstaclePenalty = 25

/// A player must have been rolling above this speed, then drop below
/// `stopThresholdKmh`, to arm the Perfect Stop challenge.
static let brakingArmSpeedKmh: Double = 15
static let stopThresholdKmh: Double = 3
static let stopChallengeWindow: TimeInterval = 2.5
static let perfectStopBonus = 150

/// Maps a real speed in km/h to a scroll speed in points/second using
/// a square-root curve so low/medium speeds feel responsive while very
/// high Shinkansen speeds don't blow past what's playable.
static func scrollSpeed(forKmh kmh: Double) -> CGFloat {
let clamped = min(max(kmh, minKmh), maxKmh)
let t = (clamped - minKmh) / (maxKmh - minKmh)
let curved = t.squareRoot()
return minScrollPointsPerSecond + CGFloat(curved) * (maxScrollPointsPerSecond - minScrollPointsPerSecond)
}

static func obstacleInterval(forKmh kmh: Double) -> TimeInterval {
let clamped = min(max(kmh, minKmh), maxKmh)
let t = (clamped - minKmh) / (maxKmh - minKmh)
return maxObstacleInterval - t * (maxObstacleInterval - minObstacleInterval)
}
}
Loading