{"slug": "build-a-dinosaur-runner-game-with-deno-pt-4", "title": "Build a dinosaur runner game with Deno, pt. 4", "summary": "Based on the article, this is the fourth installment in a series about building a browser-based dinosaur runner game using Deno. The post focuses on integrating a PostgreSQL database to persist player scores, creating API endpoints for submitting and ranking scores, and building a dedicated leaderboard page that auto-refreshes with live data. The tutorial includes step-by-step instructions for setting up the database, creating the HTML leaderboard page with CSS styling, and wiring up the necessary server-side routing in the main application file.", "body_md": "# Build a dinosaur runner game with Deno, pt. 4\n\nThis series of blog posts will guide you through building a simple browser-based dinosaur runner game using Deno.\n\n[Setup a basic project][Game loop, canvas, and controls][Obstacles and collision detection][Databases and global leaderboards]- Player profiles, customization, and live tuning\n- Observability, metrics, and alerting\n\nDatabase Integration & Global Leaderboards\n\nNow that we have a fully playable endless runner, it is time to persist scores. In this post, we will add a database, expose a leaderboard API, and render those rankings inside the UI. Later we can build on this foundation to add player profiles and make the leaderboard global across multiple sessions and devices.\n\nWhat you’ll learn\n\nBy the end of this stage you will have:\n\n- Set up a PostgreSQL database (locally and in Deno Deploy).\n- Created API endpoints to submit, fetch, and rank player scores.\n- Built a dedicated leaderboard page that auto-refreshes with live data.\n\n1. Build the leaderboard page\n\nWe’ll start by creating a new HTML page to display the leaderboard. For now this will only display your top score, but in later stages we will enhance it with global rankings once we introduce player profile settings.\n\nCreate a new file called `public/leaderboard.html`\n\nand add the following:\n\n```\n<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <link rel=\"stylesheet\" href=\"css/styles.css\" />\n    <link rel=\"icon\" href=\"favicon.ico\" type=\"image/x-icon\" />\n    <title>Dino Runner - Global Leaderboard</title>\n  </head>\n  <body>\n    <main class=\"leaderboard-container\">\n      <header>\n        <h1>🏆 Global Leaderboard</h1>\n        <p>Top players from around the world</p>\n      </header>\n\n      <nav>\n        <a href=\"/\" class=\"btn\">🎮 Play Game</a>\n        <button\n          onclick=\"refreshLeaderboard()\"\n          class=\"btn btn-secondary refresh-btn\"\n        >\n          🔄 Refresh\n        </button>\n      </nav>\n\n      <section class=\"container\" id=\"leaderboard-content\">\n        <div class=\"loading\">\n          <div>🦕 Loading leaderboard...</div>\n        </div>\n      </section>\n\n      <div class=\"last-updated\" id=\"last-updated\"></div>\n    </main>\n\n    <script src=\"js/leaderboard.js\"></script>\n  </body>\n</html>\n```\n\nWe can also add some basic styling in `public/css/styles.css`\n\nto make the\nleaderboard look nice:\n\n```\n/* Stage 4: Leaderboard CSS */\n\n.leaderboard-list {\n  padding: 0;\n  border: 1px solid var(--secondary);\n}\n\n.leaderboard-entry {\n  display: flex;\n  padding: 0.5rem 1.5rem;\n  justify-content: space-between;\n  border-bottom: 1px solid var(--secondary);\n}\n\n.leaderboard-entry:last-child {\n  border-bottom: none;\n}\n\n.leaderboard-rank {\n  font-weight: bold;\n  color: var(--dark);\n  width: 30px;\n}\n\n.leaderboard-rank.gold {\n  color: #ffd700;\n}\n\n.leaderboard-rank.silver {\n  color: #c0c0c0;\n}\n\n.leaderboard-rank.bronze {\n  color: #cd7f32;\n}\n\n.leaderboard-name {\n  flex-grow: 1;\n  margin-left: 15px;\n  font-weight: 500;\n}\n\n.leaderboard-score {\n  font-weight: bold;\n  color: var(--primary-dark);\n}\n\n.avatar {\n  display: inline-block;\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  background-color: var(--secondary);\n  color: var(--dark);\n  text-align: center;\n  line-height: 40px;\n  font-weight: bold;\n}\n\n.loading {\n  text-align: center;\n  padding: 20px;\n  color: var(--dark);\n  font-style: italic;\n}\n\n/* Leaderboard grid */\n\n.leaderboard-grid {\n  display: grid;\n  grid-template-columns: repeat(5, auto);\n  gap: 1rem;\n  margin-top: 1rem;\n}\n\n.player {\n  font-weight: bold;\n}\n\n.tl {\n  text-align: left;\n}\n```\n\nAnd finally, we’ll add a link to the leaderboard page into the index page. Open\n`public/index.html`\n\nand add a link under the controls section:\n\n```\n<a href=\"/leaderboard\" class=\"btn btn-primary btn-block\"\n>View the Leaderboard</a>\n```\n\nServe the leaderboard page by short-circuiting Oak before the regular static\nmiddleware takes over. In your `main.ts`\n\nfile, add the following before the\nstatic file serving middleware:\n\n``` js\napp.use(async (context, next) => {\n  if (context.request.url.pathname === \"/leaderboard\") {\n    await context.send({\n      root: `${Deno.cwd()}/public`,\n      path: \"leaderboard.html\",\n    });\n    return;\n  }\n\n  await next();\n});\n```\n\n2. Wire up the leaderboard script\n\nCreate `public/js/leaderboard.js`\n\nto fetch and render leaderboard rows every 30\nseconds. For now we’ll only see one row, but later we will expand this to show\nglobal rankings.\n\n``` js\nlet leaderboardData = [];\n\nasync function loadLeaderboard() {\n  try {\n    const response = await fetch(\"/api/leaderboard?limit=50\");\n    const data = await response.json();\n\n    if (data.success && data.leaderboard) {\n      leaderboardData = data.leaderboard;\n      renderLeaderboard();\n      updateLastUpdated();\n      return;\n    }\n\n    throw new Error(data.error || \"Failed to load leaderboard\");\n  } catch (error) {\n    console.error(\"Error loading leaderboard\", error);\n    renderError();\n  }\n}\n\nfunction renderLeaderboard() {\n  const content = document.getElementById(\"leaderboard-content\");\n\n  if (leaderboardData.length === 0) {\n    content.innerHTML = `\n      <div class=\"empty-state\">\n        <h3>No scores yet!</h3>\n        <p>Be the first to set a high score!</p>\n        <a href=\"/\" class=\"btn\">Start Playing</a>\n      </div>`;\n    return;\n  }\n\n  const tableHTML = `\n    <div class=\"leaderboard-grid\">\n      <h4 class=\"tl\">Rank</h4>\n      <h4 class=\"tl\">Player</h4>\n      <h4>Score</h4>\n      <h4>Obstacles</h4>\n      <h4>Date</h4>\n      ${\n    leaderboardData\n      .map(\n        (entry) => `\n          <span class=\"tl rank-${\n          entry.rank <= 3 ? entry.rank : \"\"\n        }\">#${entry.rank}</span>\n          <div class=\"player tl\">\n            <span class=\"avatar\" style=\"background-color: ${\n          playerInitialsToColor(entry.playerName)\n        };\">\n              ${getPlayerInitials(entry.playerName)}\n            </span>\n            <span>${escapeHtml(entry.playerName)}</span>\n          </div>\n          <span>${entry.score.toLocaleString()}</span>\n          <span>${entry.obstaclesAvoided || 0}</span>\n          <span>${formatDate(entry.date)}</span>\n        `,\n      )\n      .join(\"\")\n  }\n    </div>`;\n\n  content.innerHTML = tableHTML;\n}\n\nfunction renderError() {\n  const content = document.getElementById(\"leaderboard-content\");\n  content.innerHTML = `\n    <div class=\"error\">\n      <h3>Unable to load leaderboard</h3>\n      <p>Please check your connection and try again.</p>\n      <button onclick=\"refreshLeaderboard()\" class=\"btn refresh-btn\">Try Again</button>\n    </div>`;\n}\n\nfunction getPlayerInitials(name) {\n  return name.slice(0, 2).toUpperCase();\n}\n\nfunction playerInitialsToColor(name) {\n  const firstChar = name.charCodeAt(0);\n  const hue = (firstChar * 7) % 360;\n  return `hsl(${hue}, 70%, 50%)`;\n}\n\nfunction escapeHtml(text) {\n  const div = document.createElement(\"div\");\n  div.textContent = text;\n  return div.innerHTML;\n}\n\nfunction formatDate(dateString) {\n  const date = new Date(dateString);\n  return date.toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n  });\n}\n\nfunction updateLastUpdated() {\n  const now = new Date();\n  document.getElementById(\"last-updated\").textContent =\n    `Last updated: ${now.toLocaleTimeString()}`;\n}\n\nasync function refreshLeaderboard() {\n  const content = document.getElementById(\"leaderboard-content\");\n  content.innerHTML = `\n    <div class=\"loading\">\n      <div>Refreshing leaderboard...</div>\n    </div>`;\n  await loadLeaderboard();\n}\n\nsetInterval(loadLeaderboard, 30000);\ndocument.addEventListener(\"DOMContentLoaded\", loadLeaderboard);\n```\n\nThe helper wraps everything needed to keep the leaderboard fresh client-side:\n`refreshLeaderboard()`\n\nswaps in a loading state, `loadLeaderboard()`\n\nfetches the\nlatest scores, and the rendering helpers sanitize incoming values, format dates,\nand hydrate the DOM so you never risk injection issues. A `setInterval`\n\nand a\n`DOMContentLoaded`\n\nhook ensure the scoreboard initializes when the page loads\nand keeps polling every 30 seconds without user interaction.\n\n3. Provision a managed PostgreSQL\n\nHead to the [Deno Deploy dashboard](https://console.deno.com/) and create a new\nmanaged PostgreSQL database:\n\n- Navigate to\n**Databases → + Provision Database**. - Choose\n**Provision New Database → Prisma Postgres**. - Provide a slug such as\n`dino-runner-db`\n\n, pick a region close to your players, and click**Provision Database**. - After the database is ready, click\n**Assign** and attach it to your Dino Runner project so Deploy automatically injects credentials.\n\n4. Configure local development\n\nWith the\n[deno –tunnel`]([https://docs.deno.com/runtime/reference/cli/run/#options-tunnel](https://docs.deno.com/runtime/reference/cli/run/#options-tunnel))\ncommand, you can connect to your managed database from your local machine\nwithout needing to set up environment variables or other configuration.\n\nSimply use the run command with the `--tunnel`\n\nflag and Deno will do the rest.\n\n```\ndeno run --tunnel dev\n```\n\nUsing a Database url\n\nIf you prefer, you can also connect to your managed database using a standard\n`DATABASE_URL`\n\nconnection string (for example if you’re using a local Postgres\ninstance for development). Set up a `DATABASE_URL`\n\nenvironment variable in your\n`.env`\n\nfile. To get the connection string for your managed database, go to the\nDeno Deploy dashboard, navigate to your database, and copy the URL from the\n**Databases** tab:\n\nThen, paste it into your `.env`\n\nfile. Be sure to append `?sslmode=require`\n\nto\nthe end of your connection string, which ensures the connection is encrypted via\nSSL/TLS:\n\n```\nDATABASE_URL=postgresql://your_db_url@db.prisma.io:port?sslmode=require\n```\n\n5. Create the database connection helper\n\nPlace the following file at `src/database/connection.ts`\n\n. It lazily creates a\nconnection pool and gracefully degrades through three credential sources.\n\n``` js\nimport { Pool } from \"npm:pg\";\n\nlet pool: Pool | null = null;\n\nexport function getDatabase(): Pool {\n  if (pool) {\n    return pool;\n  }\n\n  const databaseUrl = Deno.env.get(\"DATABASE_URL\");\n\n  if (databaseUrl) {\n    console.log(\"🔧 Using DATABASE_URL for connection pool\");\n    pool = new Pool({ connectionString: databaseUrl, max: 10 });\n    return pool;\n  }\n\n  const pgHost = Deno.env.get(\"PGHOST\");\n  const pgUser = Deno.env.get(\"PGUSER\");\n\n  if (pgHost && pgUser) {\n    console.log(\"🔧 Using Deno Deploy PostgreSQL environment variables\");\n    pool = new Pool({\n      host: pgHost,\n      user: pgUser,\n      password: Deno.env.get(\"PGPASSWORD\") || undefined,\n      database: Deno.env.get(\"PGDATABASE\") || \"postgres\",\n      port: parseInt(Deno.env.get(\"PGPORT\") || \"5432\"),\n      max: 10,\n    });\n    return pool;\n  }\n\n  console.log(\"🔧 Using custom DB environment variables (local development)\");\n  pool = new Pool({\n    host: Deno.env.get(\"DB_HOST\") || \"localhost\",\n    user: Deno.env.get(\"DB_USER\") || \"postgres\",\n    password: Deno.env.get(\"DB_PASSWORD\") || undefined,\n    database: Deno.env.get(\"DB_NAME\") || \"dino_runner\",\n    port: parseInt(Deno.env.get(\"DB_PORT\") || \"5432\"),\n    max: 10,\n  });\n\n  console.log(\"Database pool created successfully\");\n  return pool;\n}\n\nexport async function closeDatabase(): Promise<void> {\n  if (!pool) return;\n  await pool.end();\n  pool = null;\n  console.log(\"Database pool closed\");\n}\n```\n\nAt the bottom of the same file, lets release clients back to the pool:\n\n``` js\nconst db = getDatabase();\nconst client = await db.connect();\ntry {\n  const result = await client.query(\"SELECT 1\");\n  console.log(result.rows);\n} finally {\n  client.release();\n}\n```\n\n6. Define the schema\n\nCreate `src/database/schema.sql`\n\n(or a migration) to store scores and the\nmetadata we collect from the game client.\n\n```\nCREATE TABLE IF NOT EXISTS scores (\n  id BIGSERIAL PRIMARY KEY,\n  player_name TEXT NOT NULL,\n  score INTEGER NOT NULL CHECK (score >= 0),\n  obstacles_avoided INTEGER DEFAULT 0,\n  game_duration_ms INTEGER DEFAULT 0,\n  max_speed NUMERIC(6, 2) DEFAULT 0,\n  created_at TIMESTAMPTZ DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS scores_score_idx\n  ON scores (score DESC, created_at DESC);\n```\n\n7. Migrate the database\n\nMake a simple migration script to run the schema against the database. This\nscript, which will create the necessary tables and columns for the global\nleaderboard, must be executed before the game starts. Create\n`src/database/migration.ts`\n\n:\n\n``` js\nimport { getDatabase } from \"./connection.ts\";\n\nexport async function initializeDatabase(): Promise<void> {\n  const pool = getDatabase();\n\n  console.log(\"🚀 Initializing database schema...\");\n\n  const schema = await Deno.readTextFile(\"./src/database/schema.sql\");\n\n  // Use pool.query() for schema initialization with npm:pg\n  const client = await pool.connect();\n  try {\n    await client.query(schema);\n    console.log(\"✅ Database schema initialized successfully\");\n  } finally {\n    client.release(); // Release client back to pool\n  }\n}\n\nexport function runMigrations(): void {\n  // Future migrations can be added here\n  console.log(\"📦 No pending migrations\");\n}\n```\n\nThen, call it from your `main.ts`\n\nbefore starting the server:\n\n``` js\nimport { initializeDatabase } from \"./database/migration.ts\";\nawait initializeDatabase();\n```\n\n8. Implement leaderboard API routes\n\nWe will have to add a router to handle API routes for `/api/leaderboard`\n\nand\n`/api/scores`\n\nfor returning the right data for the leaderboard and scores. Let’s\ncreate the file `/src/routes/leaderboard.routes.ts`\n\nand fill it with the below:\n\n``` js\nimport { Router } from \"jsr:@oak/oak/router\";\nimport { getDatabase } from \"../database/connection.ts\";\n\nconst leaderboardRouter = new Router();\n\nleaderboardRouter\n  .get(\"/api/leaderboard\", async (context) => {\n    const limit = Number(context.request.url.searchParams.get(\"limit\")) || 10;\n    const client = await getDatabase().connect();\n\n    try {\n      const result = await client.query(\n        \"SELECT player_name, score, obstacles_avoided, created_at FROM scores ORDER BY score DESC, created_at ASC LIMIT $1\",\n        [limit],\n      );\n\n      const leaderboard = result.rows.map((row, index) => ({\n        rank: index + 1,\n        playerName: row.player_name,\n        score: Number(row.score),\n        obstaclesAvoided: Number(row.obstacles_avoided || 0),\n        date: row.created_at,\n      }));\n\n      context.response.body = { success: true, leaderboard };\n    } finally {\n      client.release();\n    }\n  })\n  .post(\"/api/scores\", async (context) => {\n    const body = await context.request.body({ type: \"json\" }).value;\n    const { playerName, score, obstaclesAvoided = 0, gameDuration, maxSpeed } =\n      body;\n\n    if (!playerName || typeof score !== \"number\") {\n      context.response.status = 400;\n      context.response.body = { success: false, error: \"Invalid payload\" };\n      return;\n    }\n\n    const client = await getDatabase().connect();\n    try {\n      const insertResult = await client.query(\n        `INSERT INTO scores (player_name, score, obstacles_avoided, game_duration_ms, max_speed)\n         VALUES ($1, $2, $3, $4, $5)\n         RETURNING id`,\n        [playerName, score, obstaclesAvoided, gameDuration ?? 0, maxSpeed ?? 0],\n      );\n\n      const rankResult = await client.query(\n        `SELECT COUNT(*) + 1 AS rank\n         FROM scores\n         WHERE score > $1 OR (score = $1 AND created_at < NOW())`,\n        [score],\n      );\n\n      context.response.body = {\n        success: true,\n        id: Number(insertResult.rows[0].id),\n        globalRank: Number(rankResult.rows[0].rank) || 1,\n        isNewRecord: Number(rankResult.rows[0].rank) === 1,\n      };\n    } finally {\n      client.release();\n    }\n  });\n\nexport default leaderboardRouter;\n```\n\n**BigInt note:** PostgreSQL numeric types can bubble up as JavaScript `bigint`\n\n.\nConvert them to `Number`\n\n(as shown above) before serializing to JSON to avoid\nruntime errors.\n\nAfter you have created `leaderboardRouter`\n\n, we’ll need to import that into\n`main.ts`\n\nand include those routes in our `app`\n\n:\n\n``` python\n// Other logic...\n\nimport leaderboardRouter from \"./routes/leaderboard.routes.ts\";\n\n// ...\n\napp.use(leaderboardRouter.routes());\napp.use(leaderboardRouter.allowedMethods());\n\n// ...\n```\n\n9. Push scores from the game client\n\nExtend `public/js/game.js`\n\nwith a helper function `submitScoreToDatabase`\n\nthat\nposts the score after each run.\n\nNote that for now, you can hard code your `playerName`\n\n, but in the next lesson,\nwe will show you how you can create player profiles that allow users to update\nit themselves.\n\n```\n async submitScoreToDatabase(gameDuration) {\n    // if (!this.playerName) return;\n    this.playerName = \"Jo\";\n\n    try {\n      const response = await fetch(\"/api/scores\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          playerName: this.playerName,\n          score: Math.floor(this.score),\n          obstaclesAvoided: this.obstaclesAvoided,\n          gameDuration\n        })\n      });\n\n      if (!response.ok) throw new Error(\"Failed to submit score\");\n\n      const data = await response.json();\n      if (data.isNewRecord) {\n        console.log(\"🏆 NEW GLOBAL RECORD!\");\n        if (typeof this.showNewRecordMessage === \"function\") {\n          this.showNewRecordMessage();\n        }\n      }\n\n      console.log(`Score submitted! Global rank: #${data.globalRank}`);\n      if (typeof this.loadGlobalLeaderboard === \"function\") {\n        this.loadGlobalLeaderboard();\n      }\n    } catch (error) {\n      console.error(\"Failed to submit score\", error);\n    }\n  }\n```\n\nThen update the game over logic to call this function:\n\nWe will have to make a few other updates to `public/js/game.js`\n\nso that we can\npass the right information in a function call in `submitScoreToDatabase`\n\n. First,\nlet’s update `startGame`\n\nto instantiate a new private variable, `gameStartTime`\n\n,\nwhich will be used to calculate the duration the player played in a game.\n\n```\n// ...\nstartGame() {\n  // Instantiating other variables...\n  this.gameStartTime = Date.now();\n}\n```\n\nNext, we’ll have to update our `gameOver`\n\nlogic to calculate `gameDuration`\n\nand\nto call `submitScoreToDatabase`\n\n:\n\n``` js\ngameOver() {\n  // existing game over logic...\n\n  const gameDuration = Math.floor((Date.now() - this.gameStartTime) / 1000);\n  this.submitScoreToDatabase(gameDuration);\n}\n```\n\nOnce you have done that, we can start the server with `deno task dev`\n\n, play a\nround, and then see our score on the global leaderboard:\n\nProject structure recap\n\n```\nRunner Game/\n├── src/\n│   ├── database/\n│   │   ├── connection.ts\n│   │   └── schema.sql\n│   ├── middleware/\n│   ├── routes/\n│   │   └── leaderboard.routes.ts\n│   └── main.ts\n├── public/\n│   ├── index.html\n│   ├── leaderboard.html\n│   └── js/\n│       ├── game.js\n│       └── leaderboard.js\n├── deno.json\n└── README.md\n```\n\nStage 4 accomplishments\n\n✅ Persistent PostgreSQL storage for scores and analytics metrics.\n\n✅ Global leaderboard API (GET `/api/leaderboard`\n\n, POST `/api/scores`\n\n) with rank\ncalculation.\n\n✅ Dedicated leaderboard page plus in-game auto-refresh to keep data in sync.\n\n✅ Robust connection pooling, environment-variable fallbacks, and BigInt-safe serialization.\n\nWhat’s next?\n\nIn Stage 5, we will lean into personalization: player profiles, cosmetic customization. Once we have player profiles, we can make the leaderboard truly global by associating scores with user accounts. We’ll update our database to store user preferences and scores.", "url": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-4", "canonical_source": "https://deno.com/blog/build-a-game-with-deno-4", "published_at": "2026-01-26 15:00:00+00:00", "updated_at": "2026-05-22 12:20:39.424448+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "data"], "entities": ["Deno", "Deno Deploy", "PostgreSQL"], "alternates": {"html": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-4", "markdown": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-4.md", "text": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-4.txt", "jsonld": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-4.jsonld"}}