feat: initial high score functionality b922101f
Steve · 2026-03-27 09:20 11 file(s) · +749 −27
.gitignore +1 −0
26 26
.eslintcache
27 27
.cache
28 28
*.tsbuildinfo
29 +
.wrangler
29 30
30 31
# IntelliJ based IDEs
31 32
.idea
functions/api/scores.js (added) +205 −0
1 +
import { GAME_CONFIG } from '../../src/config.js'
2 +
3 +
// Helper functions for consistent JSON responses
4 +
function jsonResponse(data, status = 200) {
5 +
  return new Response(JSON.stringify(data), {
6 +
    status,
7 +
    headers: { 'Content-Type': 'application/json' },
8 +
  })
9 +
}
10 +
11 +
function errorResponse(message, status = 400) {
12 +
  return jsonResponse({ error: message }, status)
13 +
}
14 +
15 +
export const onRequestGet = async ({ env }) => {
16 +
  try {
17 +
    const results = await env.DB
18 +
      .prepare('SELECT player_name, score FROM scores ORDER BY score DESC LIMIT 10')
19 +
      .all()
20 +
21 +
    return jsonResponse({ scores: results.results })
22 +
  } catch (err) {
23 +
    console.error('Failed to fetch scores:', err)
24 +
    return errorResponse('Failed to fetch scores', 500)
25 +
  }
26 +
}
27 +
28 +
export const onRequestPost = async ({ request, env }) => {
29 +
  let body
30 +
  try {
31 +
    body = await request.json()
32 +
  } catch {
33 +
    return errorResponse('Invalid JSON')
34 +
  }
35 +
36 +
  const { playerName, score, eventLog } = body
37 +
38 +
  // Basic type validation
39 +
  if (
40 +
    typeof playerName !== 'string' ||
41 +
    typeof score !== 'number' ||
42 +
    !Array.isArray(eventLog)
43 +
  ) {
44 +
    return errorResponse('Invalid request body')
45 +
  }
46 +
47 +
  // Validate and get server-computed score
48 +
  const validation = validateEventLog(eventLog, score, playerName)
49 +
  if (!validation.ok) {
50 +
    // Log validation failure for debugging (visible in Cloudflare logs)
51 +
    console.warn('Score validation failed:', {
52 +
      reason: validation.reason,
53 +
      playerName,
54 +
      claimedScore: score,
55 +
      eventLogLength: eventLog.length,
56 +
      details: validation.details || {},
57 +
    })
58 +
    return errorResponse('Score validation failed')
59 +
  }
60 +
61 +
  try {
62 +
    const endEvent = eventLog[eventLog.length - 1]
63 +
    const startEvent = eventLog[0]
64 +
65 +
    await env.DB
66 +
      .prepare(
67 +
        `INSERT INTO scores (player_name, score, end_frame, duration_ms) VALUES (?, ?, ?, ?)`
68 +
      )
69 +
      .bind(
70 +
        playerName.slice(0, GAME_CONFIG.MAX_PLAYER_NAME_LENGTH),
71 +
        score,
72 +
        endEvent.frame,
73 +
        endEvent.ts - startEvent.ts
74 +
      )
75 +
      .run()
76 +
77 +
    return jsonResponse({ ok: true, score: score })
78 +
  } catch (err) {
79 +
    console.error('Database error:', err)
80 +
    return errorResponse('Database error', 500)
81 +
  }
82 +
}
83 +
84 +
function validateEventLog(eventLog, claimedScore, playerName) {
85 +
  // Name validation
86 +
  if (playerName.length > GAME_CONFIG.MAX_PLAYER_NAME_LENGTH) {
87 +
    return { ok: false, reason: 'INVALID_NAME_LENGTH' }
88 +
  }
89 +
90 +
  // Score bounds validation
91 +
  if (claimedScore > GAME_CONFIG.MAX_SCORE || claimedScore < 0) {
92 +
    return { ok: false, reason: 'INVALID_SCORE_RANGE' }
93 +
  }
94 +
95 +
  // Event log structure validation
96 +
  if (!eventLog || eventLog.length < 2) {
97 +
    return { ok: false, reason: 'INSUFFICIENT_EVENTS' }
98 +
  }
99 +
100 +
  // Validate event structure before processing
101 +
  for (const event of eventLog) {
102 +
    if (
103 +
      !event ||
104 +
      typeof event.frame !== 'number' ||
105 +
      typeof event.ts !== 'number' ||
106 +
      typeof event.type !== 'string'
107 +
    ) {
108 +
      return { ok: false, reason: 'MALFORMED_EVENT' }
109 +
    }
110 +
  }
111 +
112 +
  // Validate bookends
113 +
  if (eventLog[0].type !== 'START' || eventLog[eventLog.length - 1].type !== 'END') {
114 +
    return { ok: false, reason: 'INVALID_BOOKENDS' }
115 +
  }
116 +
117 +
  let lastFrame = -1
118 +
  let lastTs = -1
119 +
  let lastJumpFrame = -Infinity
120 +
  let prevLevel = 0
121 +
122 +
  for (let i = 0; i < eventLog.length; i++) {
123 +
    const event = eventLog[i]
124 +
125 +
    // Monotonic frame validation
126 +
    if (event.frame < lastFrame) {
127 +
      return { ok: false, reason: 'FRAME_NOT_MONOTONIC' }
128 +
    }
129 +
130 +
    // Monotonic timestamp validation
131 +
    if (event.ts < lastTs) {
132 +
      return { ok: false, reason: 'TIMESTAMP_NOT_MONOTONIC' }
133 +
    }
134 +
135 +
    lastFrame = event.frame
136 +
    lastTs = event.ts
137 +
138 +
    // Jump physics validation
139 +
    if (event.type === 'JUMP') {
140 +
      if (lastFrame - lastJumpFrame < GAME_CONFIG.JUMP_AIRBORNE_FRAMES) {
141 +
        return { ok: false, reason: 'INVALID_JUMP_TIMING' }
142 +
      }
143 +
      lastJumpFrame = lastFrame
144 +
    }
145 +
146 +
    // Level timing validation
147 +
    if (event.type === 'LEVEL') {
148 +
      const expectedFrame = event.value * GAME_CONFIG.LEVEL_SCORE_THRESHOLD * GAME_CONFIG.SCORE_INCREASE_RATE
149 +
      const tolerance = expectedFrame * GAME_CONFIG.LEVEL_FRAME_TOLERANCE
150 +
      if (Math.abs(event.frame - expectedFrame) > tolerance) {
151 +
        return { ok: false, reason: 'INVALID_LEVEL_TIMING' }
152 +
      }
153 +
154 +
      // Level progression validation (no skipping levels)
155 +
      if (event.value > prevLevel + 1) {
156 +
        return { ok: false, reason: 'LEVEL_SKIP_DETECTED' }
157 +
      }
158 +
      prevLevel = event.value
159 +
    }
160 +
  }
161 +
162 +
  const endEvent = eventLog[eventLog.length - 1]
163 +
  const endFrame = endEvent.frame
164 +
  const endTs = endEvent.ts
165 +
  const startEvent = eventLog[0]
166 +
  const startTs = startEvent.ts
167 +
168 +
  // Score calculation validation
169 +
  const expectedScore = Math.floor(endFrame / GAME_CONFIG.SCORE_INCREASE_RATE)
170 +
  if (Math.abs(expectedScore - claimedScore) > 1) {
171 +
    return { ok: false, reason: 'SCORE_MISMATCH' }
172 +
  }
173 +
174 +
  // Wall clock timing validation
175 +
  const minDuration = endFrame * (1000 / 60) * GAME_CONFIG.WALL_CLOCK_TOLERANCE
176 +
  if (endTs - startTs < minDuration) {
177 +
    return { ok: false, reason: 'WALL_CLOCK_TOO_FAST' }
178 +
  }
179 +
180 +
  // Timestamp sanity checks
181 +
  const now = Date.now()
182 +
  const oldestAllowed = now - GAME_CONFIG.MAX_SESSION_AGE_MS
183 +
  if (startTs < oldestAllowed) {
184 +
    return { ok: false, reason: 'TIMESTAMP_TOO_OLD', details: { age: now - startTs } }
185 +
  }
186 +
  if (startTs > now + GAME_CONFIG.CLOCK_DRIFT_ALLOWANCE_MS) {
187 +
    return { 
188 +
      ok: false, 
189 +
      reason: 'TIMESTAMP_IN_FUTURE', 
190 +
      details: { 
191 +
        clientStartTs: startTs, 
192 +
        serverNow: now, 
193 +
        driftMs: startTs - now 
194 +
      } 
195 +
    }
196 +
  }
197 +
198 +
  // Level cap validation
199 +
  const maxLevel = Math.floor(expectedScore / GAME_CONFIG.LEVEL_SCORE_THRESHOLD)
200 +
  if (prevLevel > maxLevel) {
201 +
    return { ok: false, reason: 'LEVEL_TOO_HIGH' }
202 +
  }
203 +
204 +
  return { ok: true, score: expectedScore }
205 +
}
migrations/0001_create_scores.sql (added) +9 −0
1 +
CREATE TABLE IF NOT EXISTS scores (
2 +
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
3 +
  player_name TEXT    NOT NULL,
4 +
  score       INTEGER NOT NULL,
5 +
  end_frame   INTEGER NOT NULL,
6 +
  duration_ms INTEGER NOT NULL,
7 +
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
8 +
);
9 +
CREATE INDEX IF NOT EXISTS idx_scores_score ON scores (score DESC);
migrations/0002_add_indexes.sql (added) +7 −0
1 +
-- Add composite index for tie-breaking and efficient sorting
2 +
CREATE INDEX IF NOT EXISTS idx_scores_score_created 
3 +
  ON scores (score DESC, created_at DESC);
4 +
5 +
-- Add index for player lookups (for potential future features)
6 +
CREATE INDEX IF NOT EXISTS idx_scores_player 
7 +
  ON scores (player_name);
package.json +2 −1
5 5
  "private": true,
6 6
  "scripts": {
7 7
    "dev": "bun --hot index.ts",
8 -
    "build": "bun build ./index.html --outdir ./dist && cp -r ./public/assets ./dist/assets && cp ./public/*.png ./public/*.ico ./public/*.webmanifest ./dist/"
8 +
    "build": "bun run scripts/build.js",
9 +
    "deploy": "bun run build && wrangler pages deploy ./dist"
9 10
  },
10 11
  "devDependencies": {
11 12
    "@types/bun": "latest"
scripts/build.js (added) +40 −0
1 +
#!/usr/bin/env bun
2 +
import { $ } from "bun"
3 +
import { existsSync, rmSync, mkdirSync } from "fs"
4 +
5 +
const DIST_DIR = "./dist"
6 +
const PUBLIC_DIR = "./public"
7 +
8 +
console.log("🦕 Building Dino Game...\n")
9 +
10 +
// Clean and create dist
11 +
if (existsSync(DIST_DIR)) {
12 +
  console.log("🧹 Cleaning dist directory...")
13 +
  rmSync(DIST_DIR, { recursive: true, force: true })
14 +
}
15 +
mkdirSync(DIST_DIR, { recursive: true })
16 +
17 +
// Build with Bun
18 +
console.log("📦 Building bundle...")
19 +
await $`bun build ./index.html --outdir ${DIST_DIR}`
20 +
21 +
// Copy assets
22 +
console.log("📁 Copying assets...")
23 +
await $`cp -r ${PUBLIC_DIR}/assets ${DIST_DIR}/assets`
24 +
25 +
// Copy public files (icons, manifest, etc)
26 +
console.log("🖼️  Copying icons and manifest...")
27 +
await $`cp ${PUBLIC_DIR}/*.png ${DIST_DIR}/`.quiet()
28 +
await $`cp ${PUBLIC_DIR}/*.ico ${DIST_DIR}/`.quiet()
29 +
await $`cp ${PUBLIC_DIR}/*.webmanifest ${DIST_DIR}/`.quiet()
30 +
31 +
// Copy functions
32 +
console.log("⚡ Copying API functions...")
33 +
await $`cp -r ./functions ${DIST_DIR}/functions`
34 +
35 +
// Copy config for API functions
36 +
console.log("⚙️  Copying config for API...")
37 +
mkdirSync(`${DIST_DIR}/src`, { recursive: true })
38 +
await $`cp ./src/config.js ${DIST_DIR}/src/config.js`
39 +
40 +
console.log("\n✅ Build complete! Output in ./dist")
src/config.js (added) +15 −0
1 +
// Game physics and scoring constants
2 +
// Used by both client (DinoGame) and server (validation)
3 +
export const GAME_CONFIG = {
4 +
  SCORE_INCREASE_RATE: 6,        // frames per score point
5 +
  JUMP_AIRBORNE_FRAMES: 40,      // minimum frames between jumps
6 +
  WALL_CLOCK_TOLERANCE: 0.75,    // 75% of expected real-time duration
7 +
  LEVEL_SCORE_THRESHOLD: 100,    // score points per level
8 +
  LEVEL_FRAME_TOLERANCE: 0.05,   // 5% tolerance for level-up timing
9 +
  MAX_PLAYER_NAME_LENGTH: 3,     // enforced on both client and server
10 +
  MAX_SCORE: 99999,              // reasonable upper bound
11 +
  MAX_SESSION_AGE_MS: 24 * 60 * 60 * 1000, // 24 hours
12 +
  CLOCK_DRIFT_ALLOWANCE_MS: 60000, // 60 seconds future tolerance (handles clock drift)
13 +
}
14 +
15 +
export const FRAME_RATE = 60
src/game/DinoGame.js +22 −1
13 13
  randInteger,
14 14
} from '../utils.js'
15 15
import GameRunner from './GameRunner.js'
16 +
import { GAME_CONFIG } from '../config.js'
16 17
17 18
export default class DinoGame extends GameRunner {
18 19
  constructor(width, height, container) {
45 46
      dinoLegsRate: 6, // fpa
46 47
      dinoLift: 10, // ppf
47 48
      scoreBlinkRate: 20, // fpa
48 -
      scoreIncreaseRate: 6, // fpa
49 +
      scoreIncreaseRate: GAME_CONFIG.SCORE_INCREASE_RATE, // fpa
49 50
    }
50 51
51 52
    this.state = {
66 67
        value: 0,
67 68
      },
68 69
    }
70 +
71 +
    this.eventLog = []
72 +
    this.onGameOver = null
73 +
  }
74 +
75 +
  _recordEvent(data) {
76 +
    this.eventLog.push({ ...data, frame: this.frameCount, ts: Date.now() })
69 77
  }
70 78
71 79
  createCanvas(width, height) {
138 146
      case 'jump': {
139 147
        if (state.isRunning) {
140 148
          if (state.dino.jump()) {
149 +
            this._recordEvent({ type: 'JUMP' })
141 150
            playSound('jump')
142 151
          }
143 152
        } else {
151 160
      case 'duck': {
152 161
        if (state.isRunning) {
153 162
          state.dino.duck(true)
163 +
          this._recordEvent({ type: 'DUCK' })
154 164
        }
155 165
        break
156 166
      }
158 168
      case 'stop-duck': {
159 169
        if (state.isRunning) {
160 170
          state.dino.duck(false)
171 +
          this._recordEvent({ type: 'STAND' })
161 172
        }
162 173
        break
163 174
      }
165 176
  }
166 177
167 178
  resetGame() {
179 +
    this.frameCount = 0
180 +
    this.eventLog = []
181 +
    this._recordEvent({ type: 'START' })
182 +
168 183
    this.state.dino.reset()
169 184
    Object.assign(this.state, {
170 185
      settings: { ...this.defaultSettings },
185 200
  }
186 201
187 202
  endGame() {
203 +
    this._recordEvent({ type: 'END', score: this.state.score.value })
204 +
    if (typeof this.onGameOver === 'function') {
205 +
      this.onGameOver(this.state.score.value, this.eventLog)
206 +
    }
207 +
188 208
    const iconSprite = sprites.replayIcon
189 209
    const padding = 15
190 210
255 275
      state.level = Math.floor(state.score.value / 100)
256 276
257 277
      if (state.level !== oldLevel) {
278 +
        this._recordEvent({ type: 'LEVEL', value: state.level })
258 279
        playSound('level-up')
259 280
        this.increaseDifficulty()
260 281
        state.score.isBlinking = true
src/index.js +291 −25
2 2
3 3
const game = new DinoGame(600, 150, document.getElementById('game-container'))
4 4
5 -
// Touch controls
6 -
document.addEventListener(
7 -
  'touchstart',
8 -
  (e) => {
9 -
    e.preventDefault()
10 -
    if (e.touches.length === 1) {
11 -
      game.onInput('jump')
12 -
    } else if (e.touches.length === 2) {
13 -
      game.onInput('duck')
14 -
    }
15 -
  },
16 -
  { passive: false }
17 -
)
18 -
19 -
document.addEventListener('touchend', (e) => {
20 -
  game.onInput('stop-duck')
21 -
})
22 -
23 -
// Keyboard controls
24 5
const keycodes = {
25 6
  JUMP: { 38: 1, 32: 1 },
26 7
  DUCK: { 40: 1 },
27 8
}
28 9
29 -
document.addEventListener('keydown', ({ keyCode }) => {
30 -
  if (keycodes.JUMP[keyCode]) {
10 +
const container = document.getElementById('game-container')
11 +
const overlay = document.createElement('div')
12 +
overlay.id = 'score-overlay'
13 +
overlay.style.display = 'none'
14 +
container.appendChild(overlay)
15 +
16 +
overlay.innerHTML = `
17 +
  <div id="score-modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
18 +
    <h2 id="modal-title">GAME OVER</h2>
19 +
    <p id="final-score-display" aria-live="polite"></p>
20 +
    <div id="submit-section">
21 +
      <input 
22 +
        type="text" 
23 +
        id="player-initials" 
24 +
        placeholder="AAA" 
25 +
        maxlength="3" 
26 +
        autocomplete="off"
27 +
        autocapitalize="characters"
28 +
        aria-label="Enter your initials (3 letters)"
29 +
      />
30 +
      <button id="submit-btn" aria-label="Submit your score">SUBMIT</button>
31 +
    </div>
32 +
    <div id="leaderboard-section" aria-live="polite">
33 +
      <div id="leaderboard-list"></div>
34 +
    </div>
35 +
    <div id="modal-footer">
36 +
      <button id="action-btn" aria-label="Play again">PLAY AGAIN</button>
37 +
    </div>
38 +
  </div>
39 +
`
40 +
41 +
const modalTitle = document.getElementById('modal-title')
42 +
const finalScoreDisplay = document.getElementById('final-score-display')
43 +
const initialsInput = document.getElementById('player-initials')
44 +
const submitBtn = document.getElementById('submit-btn')
45 +
const submitSection = document.getElementById('submit-section')
46 +
const leaderboardSection = document.getElementById('leaderboard-section')
47 +
const leaderboardList = document.getElementById('leaderboard-list')
48 +
const actionBtn = document.getElementById('action-btn')
49 +
50 +
// Focus trap elements
51 +
let focusableElements
52 +
let firstFocusable
53 +
let lastFocusable
54 +
55 +
function updateFocusTrap() {
56 +
  focusableElements = overlay.querySelectorAll('button:not(:disabled), input:not(:disabled)')
57 +
  firstFocusable = focusableElements[0]
58 +
  lastFocusable = focusableElements[focusableElements.length - 1]
59 +
}
60 +
61 +
// Handle Tab key for focus trap
62 +
overlay.addEventListener('keydown', (e) => {
63 +
  if (e.key !== 'Tab' || !overlayState.visible) return
64 +
  
65 +
  if (e.shiftKey) {
66 +
    if (document.activeElement === firstFocusable) {
67 +
      e.preventDefault()
68 +
      lastFocusable.focus()
69 +
    }
70 +
  } else {
71 +
    if (document.activeElement === lastFocusable) {
72 +
      e.preventDefault()
73 +
      firstFocusable.focus()
74 +
    }
75 +
  }
76 +
})
77 +
78 +
// Touch to dismiss overlay (tap background)
79 +
overlay.addEventListener('click', (e) => {
80 +
  if (e.target === overlay && overlayState.visible) {
81 +
    hideOverlay()
31 82
    game.onInput('jump')
32 -
  } else if (keycodes.DUCK[keyCode]) {
33 -
    game.onInput('duck')
34 83
  }
35 84
})
36 85
37 -
document.addEventListener('keyup', ({ keyCode }) => {
38 -
  if (keycodes.DUCK[keyCode]) {
86 +
// Overlay state management
87 +
const overlayState = {
88 +
  visible: false,
89 +
  submitted: false,
90 +
  submitting: false,
91 +
  submittedInitials: '',
92 +
  currentScore: 0,
93 +
  currentEventLog: [],
94 +
}
95 +
96 +
function escapeHtml(text) {
97 +
  const div = document.createElement('div')
98 +
  div.textContent = text
99 +
  return div.innerHTML
100 +
}
101 +
102 +
function showOverlay(score, eventLog) {
103 +
  overlayState.visible = true
104 +
  overlayState.submitted = false
105 +
  overlayState.submitting = false
106 +
  overlayState.submittedInitials = ''
107 +
  overlayState.currentScore = score
108 +
  overlayState.currentEventLog = eventLog
109 +
110 +
  modalTitle.textContent = 'GAME OVER'
111 +
  finalScoreDisplay.textContent = `SCORE: ${score}`
112 +
  initialsInput.value = ''
113 +
  submitSection.style.display = ''
114 +
  initialsInput.disabled = false
115 +
  submitBtn.disabled = false
116 +
  submitBtn.textContent = 'SUBMIT'
117 +
  overlay.style.display = ''
118 +
  updateFocusTrap()
119 +
  initialsInput.focus()
120 +
  loadLeaderboard(false) // Use cache if available
121 +
}
122 +
123 +
function hideOverlay() {
124 +
  overlayState.visible = false
125 +
  overlayState.currentScore = 0
126 +
  overlayState.currentEventLog = []
127 +
  overlay.style.display = 'none'
128 +
}
129 +
130 +
function formatInitials(value) {
131 +
  return value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3)
132 +
}
133 +
134 +
initialsInput.addEventListener('input', (e) => {
135 +
  e.target.value = formatInitials(e.target.value)
136 +
})
137 +
138 +
async function submitScore() {
139 +
  if (overlayState.submitted || overlayState.submitting) return
140 +
141 +
  const initials = initialsInput.value.trim().toUpperCase() || 'AAA'
142 +
  overlayState.submitting = true
143 +
  submitBtn.disabled = true
144 +
145 +
  try {
146 +
    const res = await fetch('/api/scores', {
147 +
      method: 'POST',
148 +
      headers: { 'Content-Type': 'application/json' },
149 +
      body: JSON.stringify({
150 +
        playerName: initials,
151 +
        score: overlayState.currentScore,
152 +
        eventLog: overlayState.currentEventLog,
153 +
      }),
154 +
    })
155 +
156 +
    if (res.ok) {
157 +
      overlayState.submitted = true
158 +
      overlayState.submitting = false
159 +
      overlayState.submittedInitials = initials
160 +
      initialsInput.disabled = true
161 +
      submitSection.style.display = 'none'
162 +
      updateFocusTrap() // Update after hiding submit section
163 +
      loadLeaderboard(true) // Force refresh after submit
164 +
    } else {
165 +
      const data = await res.json()
166 +
      modalTitle.textContent = data.error || 'FAILED'
167 +
      overlayState.submitting = false
168 +
      submitBtn.disabled = false
169 +
      submitBtn.textContent = 'SUBMIT'
170 +
    }
171 +
  } catch {
172 +
    modalTitle.textContent = 'NETWORK ERROR'
173 +
    overlayState.submitting = false
174 +
    submitBtn.disabled = false
175 +
    submitBtn.textContent = 'SUBMIT'
176 +
  }
177 +
}
178 +
179 +
submitBtn.addEventListener('click', submitScore)
180 +
actionBtn.addEventListener('click', () => {
181 +
  hideOverlay()
182 +
  game.onInput('jump')
183 +
})
184 +
initialsInput.addEventListener('keydown', (e) => {
185 +
  if (e.key === 'Enter') {
186 +
    submitScore()
187 +
  }
188 +
})
189 +
190 +
// Leaderboard caching
191 +
const leaderboardCache = {
192 +
  data: null,
193 +
  timestamp: 0,
194 +
  TTL: 10000, // 10 seconds
195 +
}
196 +
197 +
async function loadLeaderboard(forceRefresh = false) {
198 +
  const now = Date.now()
199 +
200 +
  // Use cache if valid and not forcing refresh
201 +
  if (
202 +
    !forceRefresh &&
203 +
    leaderboardCache.data &&
204 +
    now - leaderboardCache.timestamp < leaderboardCache.TTL
205 +
  ) {
206 +
    renderLeaderboard(leaderboardCache.data)
207 +
    return
208 +
  }
209 +
210 +
  leaderboardList.innerHTML = '<p class="loading">LOADING...</p>'
211 +
212 +
  try {
213 +
    const res = await fetch('/api/scores')
214 +
    const data = await res.json()
215 +
216 +
    leaderboardCache.data = data
217 +
    leaderboardCache.timestamp = now
218 +
219 +
    renderLeaderboard(data)
220 +
  } catch {
221 +
    leaderboardList.innerHTML = '<p class="error">FAILED TO LOAD</p>'
222 +
  }
223 +
}
224 +
225 +
function renderLeaderboard(data) {
226 +
  if (data.scores && data.scores.length > 0) {
227 +
    leaderboardList.innerHTML = `
228 +
      <table class="leaderboard-table">
229 +
        <thead>
230 +
          <tr>
231 +
            <th>#</th>
232 +
            <th>NAME</th>
233 +
            <th>SCORE</th>
234 +
          </tr>
235 +
        </thead>
236 +
        <tbody>
237 +
          ${data.scores
238 +
            .map(
239 +
              (s, i) => `
240 +
            <tr class="${s.score === overlayState.currentScore && s.player_name === overlayState.submittedInitials ? 'highlight' : ''}">
241 +
              <td>${i + 1}</td>
242 +
              <td>${escapeHtml(s.player_name)}</td>
243 +
              <td>${s.score}</td>
244 +
            </tr>
245 +
          `
246 +
            )
247 +
            .join('')}
248 +
        </tbody>
249 +
      </table>
250 +
    `
251 +
  } else {
252 +
    leaderboardList.innerHTML = '<p class="empty">NO SCORES YET</p>'
253 +
  }
254 +
}
255 +
256 +
game.onGameOver = (score, eventLog) => {
257 +
  setTimeout(() => showOverlay(score, eventLog), 300)
258 +
}
259 +
260 +
document.addEventListener('keydown', (e) => {
261 +
  if (!overlayState.visible) {
262 +
    if (keycodes.JUMP[e.keyCode]) {
263 +
      game.onInput('jump')
264 +
    } else if (keycodes.DUCK[e.keyCode]) {
265 +
      game.onInput('duck')
266 +
    }
267 +
  } else {
268 +
    // Escape key closes modal
269 +
    if (e.key === 'Escape') {
270 +
      hideOverlay()
271 +
      game.onInput('jump')
272 +
      return
273 +
    }
274 +
    // Space/Enter closes only if not in input field
275 +
    if (document.activeElement !== initialsInput && keycodes.JUMP[e.keyCode]) {
276 +
      hideOverlay()
277 +
      game.onInput('jump')
278 +
    }
279 +
  }
280 +
})
281 +
282 +
document.addEventListener('keyup', (e) => {
283 +
  if (!overlayState.visible && keycodes.DUCK[e.keyCode]) {
284 +
    game.onInput('stop-duck')
285 +
  }
286 +
})
287 +
288 +
document.addEventListener(
289 +
  'touchstart',
290 +
  (e) => {
291 +
    if (!overlayState.visible) {
292 +
      e.preventDefault()
293 +
      if (e.touches.length === 1) {
294 +
        game.onInput('jump')
295 +
      } else if (e.touches.length === 2) {
296 +
        game.onInput('duck')
297 +
      }
298 +
    }
299 +
  },
300 +
  { passive: false }
301 +
)
302 +
303 +
document.addEventListener('touchend', (e) => {
304 +
  if (!overlayState.visible) {
39 305
    game.onInput('stop-duck')
40 306
  }
41 307
})
src/style.css +149 −0
24 24
  padding: 0 16px;
25 25
  display: flex;
26 26
  justify-content: center;
27 +
  position: relative;
27 28
}
28 29
29 30
#game-container canvas {
33 34
  image-rendering: pixelated;
34 35
  image-rendering: crisp-edges;
35 36
}
37 +
38 +
#score-overlay {
39 +
  position: absolute;
40 +
  inset: 0;
41 +
  display: flex;
42 +
  align-items: center;
43 +
  justify-content: center;
44 +
  background: rgba(18, 17, 19, 0.88);
45 +
  z-index: 10;
46 +
}
47 +
48 +
#score-modal {
49 +
  display: flex;
50 +
  flex-direction: column;
51 +
  align-items: center;
52 +
  gap: 16px;
53 +
  background: #1a1a1b;
54 +
  border: 2px solid #3a3a3b;
55 +
  border-radius: 4px;
56 +
  padding: 32px;
57 +
  font-family: 'PressStart2P', monospace;
58 +
  color: #e8e8e8;
59 +
  text-align: center;
60 +
  min-width: 400px;
61 +
  max-width: 90vw;
62 +
}
63 +
64 +
@media (max-width: 500px) {
65 +
  #score-modal {
66 +
    min-width: auto;
67 +
    width: 90vw;
68 +
  }
69 +
}
70 +
71 +
/* Ensure modal is visible above mobile keyboard */
72 +
@media (max-height: 600px) {
73 +
  #score-modal {
74 +
    max-height: 90vh;
75 +
    overflow-y: auto;
76 +
    padding: 20px;
77 +
    gap: 12px;
78 +
  }
79 +
}
80 +
81 +
#score-modal h2 {
82 +
  font-size: 14px;
83 +
  margin-bottom: 8px;
84 +
  letter-spacing: 1px;
85 +
}
86 +
87 +
#final-score-display {
88 +
  font-size: 12px;
89 +
  color: #a8a8a8;
90 +
}
91 +
92 +
#submit-section {
93 +
  display: flex;
94 +
  flex-direction: column;
95 +
  align-items: center;
96 +
  gap: 12px;
97 +
}
98 +
99 +
#player-initials {
100 +
  width: 100px;
101 +
  padding: 12px 8px;
102 +
  font-family: 'PressStart2P', monospace;
103 +
  font-size: 16px;
104 +
  background: #121113;
105 +
  border: 2px solid #3a3a3b;
106 +
  border-radius: 2px;
107 +
  color: #e8e8e8;
108 +
  text-transform: uppercase;
109 +
  text-align: center;
110 +
  letter-spacing: 4px;
111 +
  outline: none;
112 +
}
113 +
114 +
#player-initials:focus {
115 +
  border-color: #5a5a5b;
116 +
}
117 +
118 +
#player-initials::placeholder {
119 +
  color: #5a5a5b;
120 +
  letter-spacing: 4px;
121 +
}
122 +
123 +
#submit-btn,
124 +
#action-btn {
125 +
  width: 100%;
126 +
  max-width: 200px;
127 +
  padding: 12px 16px;
128 +
  font-family: 'PressStart2P', monospace;
129 +
  font-size: 10px;
130 +
  background: #2a2a2b;
131 +
  border: 2px solid #4a4a4b;
132 +
  border-radius: 2px;
133 +
  color: #e8e8e8;
134 +
  cursor: pointer;
135 +
  transition: background 0.15s, border-color 0.15s;
136 +
}
137 +
138 +
#submit-btn:hover:not(:disabled),
139 +
#action-btn:hover {
140 +
  background: #3a3a3b;
141 +
  border-color: #5a5a5b;
142 +
}
143 +
144 +
#submit-btn:disabled {
145 +
  opacity: 0.5;
146 +
  cursor: not-allowed;
147 +
}
148 +
149 +
.leaderboard-table {
150 +
  width: 100%;
151 +
  border-collapse: collapse;
152 +
  font-size: 10px;
153 +
}
154 +
155 +
.leaderboard-table th {
156 +
  padding: 8px 4px;
157 +
  border-bottom: 1px solid #3a3a3b;
158 +
  color: #a8a8a8;
159 +
}
160 +
161 +
.leaderboard-table td {
162 +
  padding: 8px 4px;
163 +
}
164 +
165 +
.leaderboard-table tbody tr:hover {
166 +
  background: #252526;
167 +
}
168 +
169 +
.leaderboard-table tbody tr.highlight {
170 +
  background: #2a3a2a;
171 +
  color: #7fc97f;
172 +
}
173 +
174 +
.loading,
175 +
.empty,
176 +
.error {
177 +
  font-size: 10px;
178 +
  color: #a8a8a8;
179 +
  padding: 20px;
180 +
}
181 +
182 +
.error {
183 +
  color: #e85a5a;
184 +
}
wrangler.toml (added) +8 −0
1 +
name = "dino"
2 +
compatibility_date = "2024-09-23"
3 +
pages_build_output_dir = "./dist"
4 +
5 +
[[d1_databases]]
6 +
binding = "DB"
7 +
database_name = "dino-scores"
8 +
database_id = "fd10c71f-a539-49f6-ad85-39fa943f4d3c"