@@ -0,0 +1,249 @@
/**
* txt-game — number guessing game
* Single-file Node.js HTTP server using only stdlib.
* Listens on port 3000, serves HTML form UI, maintains per-session state via cookie.
*/
import { createServer } from "node:http" ;
import { randomUUID } from "node:crypto" ;
const PORT = 3000 ;
const MAX _ATTEMPTS = 7 ;
/** @type {Map<string, { target: number, attempts: number, won: boolean, lost: boolean, history: number[] }>} */
const sessions = new Map ( ) ;
function newGame ( ) {
return {
target : Math . floor ( Math . random ( ) * 100 ) + 1 ,
attempts : 0 ,
won : false ,
lost : false ,
history : [ ] ,
} ;
}
function getSession ( cookies ) {
const match = ( cookies || "" ) . match ( /sid=([a-f0-9-]{36})/ ) ;
if ( match ) {
const id = match [ 1 ] ;
if ( sessions . has ( id ) ) return { id , session : sessions . get ( id ) } ;
}
const id = randomUUID ( ) ;
const session = newGame ( ) ;
sessions . set ( id , session ) ;
return { id , session } ;
}
function hint ( guess , target ) {
if ( guess < target ) return "Too low ↑" ;
if ( guess > target ) return "Too high ↓" ;
return "Correct!" ;
}
function clue ( guess , target ) {
const diff = Math . abs ( guess - target ) ;
if ( diff === 0 ) return "🎯 Spot on!" ;
if ( diff <= 5 ) return "🔥 Very hot" ;
if ( diff <= 15 ) return "♨️ Warm" ;
if ( diff <= 30 ) return "🌡️ Cool" ;
return "🧊 Cold" ;
}
function renderPage ( session , message , guess ) {
const attemptsLeft = MAX _ATTEMPTS - session . attempts ;
const historyRows = session . history
. map (
( g , i ) =>
` <tr><td> ${ i + 1 } </td><td> ${ g } </td><td> ${ hint ( g , session . won && i === session . history . length - 1 ? g : session . target ) } </td><td> ${ clue ( g , session . target ) } </td></tr> `
)
. join ( "" ) ;
const historySection =
session . history . length > 0
? ` <table>
<thead><tr><th>#</th><th>Guess</th><th>Hint</th><th>Temperature</th></tr></thead>
<tbody> ${ historyRows } </tbody>
</table> `
: "" ;
const playAgainBtn = ` <form method="POST" action="/reset"><button type="submit">Play again</button></form> ` ;
let formSection = "" ;
if ( ! session . won && ! session . lost ) {
formSection = `
<form method="POST" action="/guess">
<label for="guess">Enter a number (1– 100):</label>
<input id="guess" name="guess" type="number" min="1" max="100" required autofocus />
<button type="submit">Guess</button>
</form> ` ;
}
let statusSection = "" ;
if ( session . won ) {
statusSection = ` <p class="success">🎉 You won! The number was <strong> ${ session . target } </strong>. You used ${ session . attempts } attempt ${ session . attempts === 1 ? "" : "s" } .</p> ${ playAgainBtn } ` ;
} else if ( session . lost ) {
statusSection = ` <p class="failure">💀 Game over! The number was <strong> ${ session . target } </strong>.</p> ${ playAgainBtn } ` ;
} else {
statusSection = ` <p class="attempts">Attempts left: <strong> ${ attemptsLeft } </strong></p> ` ;
}
const messageHtml = message
? ` <p class="message"> ${ message } </p> `
: "" ;
return ` <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Number Guessing Game</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier New', Courier, monospace;
background: #0d0d0d;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 2rem;
max-width: 600px;
width: 100%;
}
h1 { color: #7ec8e3; margin-bottom: 0.5rem; font-size: 1.6rem; }
p.subtitle { color: #888; margin-bottom: 1.5rem; font-size: 0.9rem; }
form { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
input[type="number"] {
background: #111;
border: 1px solid #444;
color: #e0e0e0;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
width: 120px;
}
button {
background: #2a5c8a;
color: #e0e0e0;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
cursor: pointer;
}
button:hover { background: #3a7cbd; }
.message { color: #f0c040; margin: 0.75rem 0; }
.success { color: #5dbb63; margin: 0.75rem 0; font-weight: bold; }
.failure { color: #e05252; margin: 0.75rem 0; font-weight: bold; }
.attempts { color: #aaa; margin: 0.75rem 0; }
label { color: #aaa; font-size: 0.9rem; display: block; margin-bottom: 0.5rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; font-size: 0.9rem; }
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2a2a; }
th { color: #7ec8e3; }
td:first-child { color: #666; }
td:nth-child(2) { font-weight: bold; color: #e0e0e0; }
</style>
</head>
<body>
<div class="card">
<h1>🔢 Number Guessing Game</h1>
<p class="subtitle">I'm thinking of a number between 1 and 100. You have ${ MAX _ATTEMPTS } attempts.</p>
${ statusSection }
${ messageHtml }
${ formSection }
${ historySection }
</div>
</body>
</html> ` ;
}
function parseBody ( req ) {
return new Promise ( ( resolve ) => {
let body = "" ;
req . on ( "data" , ( chunk ) => ( body += chunk ) ) ;
req . on ( "end" , ( ) => {
const params = new URLSearchParams ( body ) ;
resolve ( Object . fromEntries ( params . entries ( ) ) ) ;
} ) ;
} ) ;
}
function setCookie ( res , id ) {
res . setHeader (
"Set-Cookie" ,
` sid= ${ id } ; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400 `
) ;
}
const server = createServer ( async ( req , res ) => {
// Forward x-forwarded-host so URLs resolve correctly behind Traefik
const fwdHost = req . headers [ "x-forwarded-host" ] ;
if ( fwdHost ) req . headers . host = fwdHost ;
const { id , session } = getSession ( req . headers [ "cookie" ] ) ;
const url = req . url . split ( "?" ) [ 0 ] ;
if ( req . method === "GET" && url === "/" ) {
setCookie ( res , id ) ;
res . writeHead ( 200 , { "Content-Type" : "text/html; charset=utf-8" } ) ;
res . end ( renderPage ( session , "" , null ) ) ;
return ;
}
if ( req . method === "POST" && url === "/guess" ) {
const body = await parseBody ( req ) ;
const guess = parseInt ( body . guess , 10 ) ;
let message = "" ;
if ( session . won || session . lost ) {
// Ignore guesses after game ends
} else if ( ! Number . isInteger ( guess ) || guess < 1 || guess > 100 ) {
message = "Please enter a number between 1 and 100." ;
} else {
session . attempts ++ ;
session . history . push ( guess ) ;
if ( guess === session . target ) {
session . won = true ;
// Fix last history row hint (show "Correct!")
} else if ( session . attempts >= MAX _ATTEMPTS ) {
session . lost = true ;
message = ` ${ hint ( guess , session . target ) } — ${ clue ( guess , session . target ) } ` ;
} else {
message = ` ${ hint ( guess , session . target ) } — ${ clue ( guess , session . target ) } ` ;
}
}
setCookie ( res , id ) ;
res . writeHead ( 200 , { "Content-Type" : "text/html; charset=utf-8" } ) ;
res . end ( renderPage ( session , message , guess ) ) ;
return ;
}
if ( req . method === "POST" && url === "/reset" ) {
const fresh = newGame ( ) ;
sessions . set ( id , fresh ) ;
setCookie ( res , id ) ;
res . writeHead ( 303 , { Location : "/" } ) ;
res . end ( ) ;
return ;
}
res . writeHead ( 404 , { "Content-Type" : "text/plain" } ) ;
res . end ( "Not found" ) ;
} ) ;
server . listen ( PORT , ( ) => {
console . log ( ` txt-game listening on port ${ PORT } ` ) ;
} ) ;