惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

MyScale Blog
MyScale Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
人人都是产品经理
人人都是产品经理
V
Visual Studio Blog
博客园 - 叶小钗
A
About on SuperTechFans
Last Week in AI
Last Week in AI
量子位
博客园 - 三生石上(FineUI控件)
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
MongoDB | Blog
MongoDB | Blog
T
The Blog of Author Tim Ferriss
Vercel News
Vercel News
博客园 - 司徒正美
博客园 - Franky
博客园 - 【当耐特】
月光博客
月光博客
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Apple Machine Learning Research
Apple Machine Learning Research
Hugging Face - Blog
Hugging Face - Blog
S
SegmentFault 最新的问题
大猫的无限游戏
大猫的无限游戏
博客园 - 聂微东
J
Java Code Geeks

Deno

Deno 2.8 | Deno Claw Patrol: an open-source security firewall for agents | Deno Fresh 2.3: Zero JS by default, View Transitions, and Temporal support | Deno Deno 2.7: Temporal API, Windows ARM, and npm overrides | Deno Build a dinosaur runner game with Deno, pt. 6 | Deno Build a dinosaur runner game with Deno, pt. 5 | Deno Deno Deploy is Generally Available | Deno Introducing Deno Sandbox | Deno Build a dinosaur runner game with Deno, pt. 3 | Deno Build a dinosaur runner game with Deno, pt. 2 | Deno React / Next.js Denial-of-Service Vulnerability: Deno Deploy users protected | Deno Deno 2.6: dx is the new npx | Deno Build a dinosaur runner game with Deno, pt. 1 | Deno React Server Functions / Next.js Vulnerability: Deno Deploy users protected | Deno My highlights from the new Deno Deploy | Deno Deno's Other Open Source Projects | Deno How Deno protects against npm exploits | Deno Help Us Raise $200k to Free JavaScript from Oracle | Deno Deno 2.5: Permissions in the config file | Deno Fresh 2.0 Graduates to Beta, Adds Vite Support | Deno Deno 2.4: deno bundle is back | Deno JavaScript™ Trademark Update | Deno What's coming to JavaScript | Deno A brief history of JavaScript | Deno Reports of Deno's Demise Have Been Greatly Exaggerated | Deno An Update on Fresh | Deno How Plaid migrated 100 services to a new database platform 5x faster with Deno | Deno Deno 2.3: Improved deno compile, local npm packages, and more | Deno Add JSR packages with pnpm and Yarn | Deno Zero-config Debugging with Deno and OpenTelemetry | Deno
Build a dinosaur runner game with Deno, pt. 4 | Deno
Jo Franchett · 2026-01-26 · via Deno

This series of blog posts will guide you through building a simple browser-based dinosaur runner game using Deno.

  1. Setup a basic project
  2. Game loop, canvas, and controls
  3. Obstacles and collision detection
  4. Databases and global leaderboards
  5. Player profiles, customization, and live tuning
  6. Observability, metrics, and alerting

Database Integration & Global Leaderboards

Now 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.

What you’ll learn

By the end of this stage you will have:

  • Set up a PostgreSQL database (locally and in Deno Deploy).
  • Created API endpoints to submit, fetch, and rank player scores.
  • Built a dedicated leaderboard page that auto-refreshes with live data.

1. Build the leaderboard page

We’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.

Create a new file called public/leaderboard.html and add the following:

public/leaderboard.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="css/styles.css" />
    <link rel="icon" href="favicon.ico" type="image/x-icon" />
    <title>Dino Runner - Global Leaderboard</title>
  </head>
  <body>
    <main class="leaderboard-container">
      <header>
        <h1>🏆 Global Leaderboard</h1>
        <p>Top players from around the world</p>
      </header>

      <nav>
        <a href="/" class="btn">🎮 Play Game</a>
        <button
          onclick="refreshLeaderboard()"
          class="btn btn-secondary refresh-btn"
        >
          🔄 Refresh
        </button>
      </nav>

      <section class="container" id="leaderboard-content">
        <div class="loading">
          <div>🦕 Loading leaderboard...</div>
        </div>
      </section>

      <div class="last-updated" id="last-updated"></div>
    </main>

    <script src="js/leaderboard.js"></script>
  </body>
</html>

We can also add some basic styling in public/css/styles.css to make the leaderboard look nice:



.leaderboard-list {
  padding: 0;
  border: 1px solid var(--secondary);
}

.leaderboard-entry {
  display: flex;
  padding: 0.5rem 1.5rem;
  justify-content: space-between;
  border-bottom: 1px solid var(--secondary);
}

.leaderboard-entry:last-child {
  border-bottom: none;
}

.leaderboard-rank {
  font-weight: bold;
  color: var(--dark);
  width: 30px;
}

.leaderboard-rank.gold {
  color: #ffd700;
}

.leaderboard-rank.silver {
  color: #c0c0c0;
}

.leaderboard-rank.bronze {
  color: #cd7f32;
}

.leaderboard-name {
  flex-grow: 1;
  margin-left: 15px;
  font-weight: 500;
}

.leaderboard-score {
  font-weight: bold;
  color: var(--primary-dark);
}

.avatar {
  display: inline-block;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: var(--secondary);
  color: var(--dark);
  text-align: center;
  line-height: 40px;
  font-weight: bold;
}

.loading {
  text-align: center;
  padding: 20px;
  color: var(--dark);
  font-style: italic;
}



.leaderboard-grid {
  display: grid;
  grid-template-columns: repeat(5, auto);
  gap: 1rem;
  margin-top: 1rem;
}

.player {
  font-weight: bold;
}

.tl {
  text-align: left;
}

And finally, we’ll add a link to the leaderboard page into the index page. Open public/index.html and add a link under the controls section:

<a href="/leaderboard" class="btn btn-primary btn-block"
>View the Leaderboard</a>

Serve the leaderboard page by short-circuiting Oak before the regular static middleware takes over. In your main.ts file, add the following before the static file serving middleware:

app.use(async (context, next) => {
  if (context.request.url.pathname === "/leaderboard") {
    await context.send({
      root: `${Deno.cwd()}/public`,
      path: "leaderboard.html",
    });
    return;
  }

  await next();
});

2. Wire up the leaderboard script

Create public/js/leaderboard.js to fetch and render leaderboard rows every 30 seconds. For now we’ll only see one row, but later we will expand this to show global rankings.

public/js/leaderboard.js

let leaderboardData = [];

async function loadLeaderboard() {
  try {
    const response = await fetch("/api/leaderboard?limit=50");
    const data = await response.json();

    if (data.success && data.leaderboard) {
      leaderboardData = data.leaderboard;
      renderLeaderboard();
      updateLastUpdated();
      return;
    }

    throw new Error(data.error || "Failed to load leaderboard");
  } catch (error) {
    console.error("Error loading leaderboard", error);
    renderError();
  }
}

function renderLeaderboard() {
  const content = document.getElementById("leaderboard-content");

  if (leaderboardData.length === 0) {
    content.innerHTML = `
      <div class="empty-state">
        <h3>No scores yet!</h3>
        <p>Be the first to set a high score!</p>
        <a href="/" class="btn">Start Playing</a>
      </div>`;
    return;
  }

  const tableHTML = `
    <div class="leaderboard-grid">
      <h4 class="tl">Rank</h4>
      <h4 class="tl">Player</h4>
      <h4>Score</h4>
      <h4>Obstacles</h4>
      <h4>Date</h4>
      ${
    leaderboardData
      .map(
        (entry) => `
          <span class="tl rank-${
          entry.rank <= 3 ? entry.rank : ""
        }">#${entry.rank}</span>
          <div class="player tl">
            <span class="avatar" style="background-color: ${
          playerInitialsToColor(entry.playerName)
        };">
              ${getPlayerInitials(entry.playerName)}
            </span>
            <span>${escapeHtml(entry.playerName)}</span>
          </div>
          <span>${entry.score.toLocaleString()}</span>
          <span>${entry.obstaclesAvoided || 0}</span>
          <span>${formatDate(entry.date)}</span>
        `,
      )
      .join("")
  }
    </div>`;

  content.innerHTML = tableHTML;
}

function renderError() {
  const content = document.getElementById("leaderboard-content");
  content.innerHTML = `
    <div class="error">
      <h3>Unable to load leaderboard</h3>
      <p>Please check your connection and try again.</p>
      <button onclick="refreshLeaderboard()" class="btn refresh-btn">Try Again</button>
    </div>`;
}

function getPlayerInitials(name) {
  return name.slice(0, 2).toUpperCase();
}

function playerInitialsToColor(name) {
  const firstChar = name.charCodeAt(0);
  const hue = (firstChar * 7) % 360;
  return `hsl(${hue}, 70%, 50%)`;
}

function escapeHtml(text) {
  const div = document.createElement("div");
  div.textContent = text;
  return div.innerHTML;
}

function formatDate(dateString) {
  const date = new Date(dateString);
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

function updateLastUpdated() {
  const now = new Date();
  document.getElementById("last-updated").textContent =
    `Last updated: ${now.toLocaleTimeString()}`;
}

async function refreshLeaderboard() {
  const content = document.getElementById("leaderboard-content");
  content.innerHTML = `
    <div class="loading">
      <div>Refreshing leaderboard...</div>
    </div>`;
  await loadLeaderboard();
}

setInterval(loadLeaderboard, 30000);
document.addEventListener("DOMContentLoaded", loadLeaderboard);

The helper wraps everything needed to keep the leaderboard fresh client-side: refreshLeaderboard() swaps in a loading state, loadLeaderboard() fetches the latest scores, and the rendering helpers sanitize incoming values, format dates, and hydrate the DOM so you never risk injection issues. A setInterval and a DOMContentLoaded hook ensure the scoreboard initializes when the page loads and keeps polling every 30 seconds without user interaction.

3. Provision a managed PostgreSQL

Head to the Deno Deploy dashboard and create a new managed PostgreSQL database:

  1. Navigate to Databases → + Provision Database.
  2. Choose Provision New Database → Prisma Postgres.
  3. Provide a slug such as dino-runner-db, pick a region close to your players, and click Provision Database.
  4. After the database is ready, click Assign and attach it to your Dino Runner project so Deploy automatically injects credentials.

4. Configure local development

With the [deno –tunnel`](https://docs.deno.com/runtime/reference/cli/run/#options-tunnel) command, you can connect to your managed database from your local machine without needing to set up environment variables or other configuration.

Simply use the run command with the --tunnel flag and Deno will do the rest.

Using a Database url

If you prefer, you can also connect to your managed database using a standard DATABASE_URL connection string (for example if you’re using a local Postgres instance for development). Set up a DATABASE_URL environment variable in your .env file. To get the connection string for your managed database, go to the Deno Deploy dashboard, navigate to your database, and copy the URL from the Databases tab:

Easily copy the database connection string to your clipboard from the Deno Deploy dashboard.

Then, paste it into your .env file. Be sure to append ?sslmode=require to the end of your connection string, which ensures the connection is encrypted via SSL/TLS:

.env

DATABASE_URL=postgresql://your_db_url@db.prisma.io:port?sslmode=require

5. Create the database connection helper

Place the following file at src/database/connection.ts. It lazily creates a connection pool and gracefully degrades through three credential sources.

src/database/connection.ts

import { Pool } from "npm:pg";

let pool: Pool | null = null;

export function getDatabase(): Pool {
  if (pool) {
    return pool;
  }

  const databaseUrl = Deno.env.get("DATABASE_URL");

  if (databaseUrl) {
    console.log("🔧 Using DATABASE_URL for connection pool");
    pool = new Pool({ connectionString: databaseUrl, max: 10 });
    return pool;
  }

  const pgHost = Deno.env.get("PGHOST");
  const pgUser = Deno.env.get("PGUSER");

  if (pgHost && pgUser) {
    console.log("🔧 Using Deno Deploy PostgreSQL environment variables");
    pool = new Pool({
      host: pgHost,
      user: pgUser,
      password: Deno.env.get("PGPASSWORD") || undefined,
      database: Deno.env.get("PGDATABASE") || "postgres",
      port: parseInt(Deno.env.get("PGPORT") || "5432"),
      max: 10,
    });
    return pool;
  }

  console.log("🔧 Using custom DB environment variables (local development)");
  pool = new Pool({
    host: Deno.env.get("DB_HOST") || "localhost",
    user: Deno.env.get("DB_USER") || "postgres",
    password: Deno.env.get("DB_PASSWORD") || undefined,
    database: Deno.env.get("DB_NAME") || "dino_runner",
    port: parseInt(Deno.env.get("DB_PORT") || "5432"),
    max: 10,
  });

  console.log("Database pool created successfully");
  return pool;
}

export async function closeDatabase(): Promise<void> {
  if (!pool) return;
  await pool.end();
  pool = null;
  console.log("Database pool closed");
}

At the bottom of the same file, lets release clients back to the pool:

const db = getDatabase();
const client = await db.connect();
try {
  const result = await client.query("SELECT 1");
  console.log(result.rows);
} finally {
  client.release();
}

6. Define the schema

Create src/database/schema.sql (or a migration) to store scores and the metadata we collect from the game client.

CREATE TABLE IF NOT EXISTS scores (
  id BIGSERIAL PRIMARY KEY,
  player_name TEXT NOT NULL,
  score INTEGER NOT NULL CHECK (score >= 0),
  obstacles_avoided INTEGER DEFAULT 0,
  game_duration_ms INTEGER DEFAULT 0,
  max_speed NUMERIC(6, 2) DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS scores_score_idx
  ON scores (score DESC, created_at DESC);

7. Migrate the database

Make a simple migration script to run the schema against the database. This script, which will create the necessary tables and columns for the global leaderboard, must be executed before the game starts. Create src/database/migration.ts:

src/database/migration.ts

import { getDatabase } from "./connection.ts";

export async function initializeDatabase(): Promise<void> {
  const pool = getDatabase();

  console.log("🚀 Initializing database schema...");

  const schema = await Deno.readTextFile("./src/database/schema.sql");

  
  const client = await pool.connect();
  try {
    await client.query(schema);
    console.log("✅ Database schema initialized successfully");
  } finally {
    client.release(); 
  }
}

export function runMigrations(): void {
  
  console.log("📦 No pending migrations");
}

Then, call it from your main.ts before starting the server:

import { initializeDatabase } from "./database/migration.ts";
await initializeDatabase();

8. Implement leaderboard API routes

We will have to add a router to handle API routes for /api/leaderboard and /api/scores for returning the right data for the leaderboard and scores. Let’s create the file /src/routes/leaderboard.routes.ts and fill it with the below:

/src/routes/leaderboard.routes.ts

import { Router } from "jsr:@oak/oak/router";
import { getDatabase } from "../database/connection.ts";

const leaderboardRouter = new Router();

leaderboardRouter
  .get("/api/leaderboard", async (context) => {
    const limit = Number(context.request.url.searchParams.get("limit")) || 10;
    const client = await getDatabase().connect();

    try {
      const result = await client.query(
        "SELECT player_name, score, obstacles_avoided, created_at FROM scores ORDER BY score DESC, created_at ASC LIMIT $1",
        [limit],
      );

      const leaderboard = result.rows.map((row, index) => ({
        rank: index + 1,
        playerName: row.player_name,
        score: Number(row.score),
        obstaclesAvoided: Number(row.obstacles_avoided || 0),
        date: row.created_at,
      }));

      context.response.body = { success: true, leaderboard };
    } finally {
      client.release();
    }
  })
  .post("/api/scores", async (context) => {
    const body = await context.request.body({ type: "json" }).value;
    const { playerName, score, obstaclesAvoided = 0, gameDuration, maxSpeed } =
      body;

    if (!playerName || typeof score !== "number") {
      context.response.status = 400;
      context.response.body = { success: false, error: "Invalid payload" };
      return;
    }

    const client = await getDatabase().connect();
    try {
      const insertResult = await client.query(
        `INSERT INTO scores (player_name, score, obstacles_avoided, game_duration_ms, max_speed)
         VALUES ($1, $2, $3, $4, $5)
         RETURNING id`,
        [playerName, score, obstaclesAvoided, gameDuration ?? 0, maxSpeed ?? 0],
      );

      const rankResult = await client.query(
        `SELECT COUNT(*) + 1 AS rank
         FROM scores
         WHERE score > $1 OR (score = $1 AND created_at < NOW())`,
        [score],
      );

      context.response.body = {
        success: true,
        id: Number(insertResult.rows[0].id),
        globalRank: Number(rankResult.rows[0].rank) || 1,
        isNewRecord: Number(rankResult.rows[0].rank) === 1,
      };
    } finally {
      client.release();
    }
  });

export default leaderboardRouter;

BigInt note: PostgreSQL numeric types can bubble up as JavaScript bigint. Convert them to Number (as shown above) before serializing to JSON to avoid runtime errors.

After you have created leaderboardRouter, we’ll need to import that into main.ts and include those routes in our app:

src/main.ts



import leaderboardRouter from "./routes/leaderboard.routes.ts";



app.use(leaderboardRouter.routes());
app.use(leaderboardRouter.allowedMethods());

9. Push scores from the game client

Extend public/js/game.js with a helper function submitScoreToDatabase that posts the score after each run.

Note that for now, you can hard code your playerName, but in the next lesson, we will show you how you can create player profiles that allow users to update it themselves.

 async submitScoreToDatabase(gameDuration) {
    
    this.playerName = "Jo";

    try {
      const response = await fetch("/api/scores", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          playerName: this.playerName,
          score: Math.floor(this.score),
          obstaclesAvoided: this.obstaclesAvoided,
          gameDuration
        })
      });

      if (!response.ok) throw new Error("Failed to submit score");

      const data = await response.json();
      if (data.isNewRecord) {
        console.log("🏆 NEW GLOBAL RECORD!");
        if (typeof this.showNewRecordMessage === "function") {
          this.showNewRecordMessage();
        }
      }

      console.log(`Score submitted! Global rank: #${data.globalRank}`);
      if (typeof this.loadGlobalLeaderboard === "function") {
        this.loadGlobalLeaderboard();
      }
    } catch (error) {
      console.error("Failed to submit score", error);
    }
  }

Then update the game over logic to call this function:

We will have to make a few other updates to public/js/game.js so that we can pass the right information in a function call in submitScoreToDatabase. First, let’s update startGame to instantiate a new private variable, gameStartTime, which will be used to calculate the duration the player played in a game.

public/js/game.js


startGame() {
  
  this.gameStartTime = Date.now();
}

Next, we’ll have to update our gameOver logic to calculate gameDuration and to call submitScoreToDatabase:

public/js/game.js

gameOver() {
  

  const gameDuration = Math.floor((Date.now() - this.gameStartTime) / 1000);
  this.submitScoreToDatabase(gameDuration);
}

Once you have done that, we can start the server with deno task dev, play a round, and then see our score on the global leaderboard:

Screenshot of the global leaderboard

Project structure recap

Runner Game/
├── src/
│   ├── database/
│   │   ├── connection.ts
│   │   └── schema.sql
│   ├── middleware/
│   ├── routes/
│   │   └── leaderboard.routes.ts
│   └── main.ts
├── public/
│   ├── index.html
│   ├── leaderboard.html
│   └── js/
│       ├── game.js
│       └── leaderboard.js
├── deno.json
└── README.md

Stage 4 accomplishments

✅ Persistent PostgreSQL storage for scores and analytics metrics.

✅ Global leaderboard API (GET /api/leaderboard, POST /api/scores) with rank calculation.

✅ Dedicated leaderboard page plus in-game auto-refresh to keep data in sync.

✅ Robust connection pooling, environment-variable fallbacks, and BigInt-safe serialization.

What’s next?

In 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.