Hunt the Wumpus, in two skins

A browser recreation of Gregory Yob's 1973 game. The rules are his and are
free to use; the sentences he wrote to explain them are not, and none of them
are here. Every line this game prints was written for it. NOTICE.md sets out
what is ours, what is not, and why -- the same argument the lemonade stand
makes about its own text, made before shipping rather than after.

Two skins over one game, and the period one is a Teletype rather than a
television. Wumpus is six years older than the Atari the lemonade stand runs
on. It lived on a timesharing service you telephoned and it printed on paper,
which is why the game asks you to remember things: there is no screen to look
back at. So 1973 is fanfold and black ink and a print head hammering out one
character at a time, and the remaster is the same cave with the lamp on. The
rules never differ, and neither does a single word.

The cave is proved rather than trusted. The adjacency table is typed out by
hand, because a generated dodecahedron comes out with an arbitrary numbering
and this is the numbering every listing has used since 1973 -- so a transcript
from a magazine still makes sense against it. Typed out means it can be typed
wrong, and a wrong table would not crash: it would quietly stop being a
dodecahedron while the game still played. cave.test.ts checks degree three,
symmetry, connectivity, a diameter of exactly five, and three pentagons
through every room.

The map is not allowed to know more than the paper. CaveMap draws only from
the rooms the hunter has stood in and the warnings those rooms gave, and it
marks suspects rather than answers. That guarantee is asserted in the engine
rather than in the component, because it is a property of the engine and would
still have to hold if the map were thrown away.

Drawing a solid flat took two attempts. Rooms 6 to 15 are not two pentagons at
different depths, they are one ten-room ring alternating inward and outward
wiring, and getting that wrong still draws and still looks cave-shaped. It is
much harder to read. The test checks the property that actually matters --
that no tunnel crosses another -- and was confirmed to fail against the bad
layout before it was believed.

The dial-in is a real Bell 103 call rather than a noise: dial tone at 350 and
440 Hz, Touch-Tone dialling, ringback at 440 and 480, the far modem's 2225 Hz
mark tone, then both ends keying at once through a telephone band. The screech
everybody remembers is a V.34 handshake from the 1990s and could not have
existed here.

38 tests. Typecheck clean.
This commit is contained in:
2026-09-08 22:32:31 -07:00
commit 6485f9b161
37 changed files with 8618 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
import { EDGES, LAYOUT, ROOM_COUNT, tunnelsFrom } from '../game/cave'
import type { Cave, Hazard } from '../game/types'
/**
* The remaster: the cave, drawn.
*
* Every mark here is generated from `LAYOUT` and `EDGES` -- there is no
* artwork file in this repository and there is not going to be one. Cel
* shading in practice means flat fills, one heavy outline, and a highlight
* that is a second flat fill rather than a gradient, which is a style that
* happens to suit a thing made entirely of `<circle>` and `<path>`.
*
* The hard rule is that this component may not show the player anything the
* teletype has not already printed. It takes `reveal` for the two screens
* that are allowed to lift that -- the death and the standings -- and
* everywhere else the wumpus, the pits and the bats are simply not in the
* output. A map that quietly knows more than the transcript would make the
* remaster an easier game than the original, and they are meant to be the
* same game.
*/
const R = 210 // Unit radius; the viewBox is a little larger to hold labels.
const ROOM_R = 15
type Known = { hazards: Hazard[]; visited: boolean; adjacent: boolean }
export function CaveMap({
cave,
reveal = false,
aim = [],
}: {
cave: Cave
/** Show where everything actually was. Only the end of a hunt earns this. */
reveal?: boolean
/** Rooms in the arrow path being typed, so a shot can be seen before it flies. */
aim?: readonly number[]
}) {
const at = (room: number): [number, number] => {
const [x, y] = LAYOUT[room] ?? [0, 0]
return [x * R, y * R]
}
const near = tunnelsFrom(cave.you)
const known = new Map<number, Known>()
for (let room = 1; room <= ROOM_COUNT; room++) {
known.set(room, {
hazards: cave.sensed[room] ? [...cave.sensed[room]] : [],
visited: cave.visited.includes(room),
adjacent: near.includes(room),
})
}
/**
* What a room is worth worrying about, from what has been printed only.
*
* A room the hunter has stood in is safe and is drawn so. A room next to a
* warning is suspect, and the map says which warning -- never which room
* the hazard is actually in, because nothing has told the hunter that.
*/
const suspicion = (room: number): Hazard[] => {
if (known.get(room)!.visited) return []
const out = new Set<Hazard>()
for (const from of cave.visited) {
if (!tunnelsFrom(from).includes(room)) continue
for (const h of cave.sensed[from] ?? []) out.add(h)
}
return [...out]
}
const aimPath = aim.length ? [cave.you, ...aim] : []
return (
<svg
className="cave-map"
viewBox={`${-R - 34} ${-R - 34} ${(R + 34) * 2} ${(R + 34) * 2}`}
role="img"
aria-label={`Cave map. You are in room ${cave.you}.`}
>
<defs>
{/* The lamp. A radial fill is the one gradient the style allows,
because it is light rather than shading. */}
<radialGradient id="lamp" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#ffd97a" stopOpacity="0.30" />
<stop offset="60%" stopColor="#ffb347" stopOpacity="0.08" />
<stop offset="100%" stopColor="#ffb347" stopOpacity="0" />
</radialGradient>
</defs>
{/* Tunnels first, so the rooms sit on top of them. */}
<g className="tunnels">
{EDGES.map(([a, b]) => {
const [ax, ay] = at(a)
const [bx, by] = at(b)
const walked = cave.visited.includes(a) && cave.visited.includes(b)
const lit = a === cave.you || b === cave.you
return (
<line
key={`${a}-${b}`}
x1={ax}
y1={ay}
x2={bx}
y2={by}
className={`tunnel${lit ? ' tunnel-lit' : walked ? ' tunnel-walked' : ''}`}
/>
)
})}
</g>
{/* The arrow being aimed, drawn over the tunnels it is going down. */}
{aimPath.length > 1 && (
<polyline
className="aim"
points={aimPath.map((r) => at(r).join(',')).join(' ')}
fill="none"
/>
)}
<circle cx={at(cave.you)[0]} cy={at(cave.you)[1]} r={96} fill="url(#lamp)" />
<g className="rooms">
{Array.from({ length: ROOM_COUNT }, (_, i) => i + 1).map((room) => {
const k = known.get(room)!
const worry = suspicion(room)
const here = room === cave.you
const shown = reveal
? [
...(cave.wumpus === room ? (['wumpus'] as Hazard[]) : []),
...(cave.pits.includes(room) ? (['pit'] as Hazard[]) : []),
...(cave.bats.includes(room) ? (['bats'] as Hazard[]) : []),
]
: []
const [x, y] = at(room)
const cls = [
'room',
here && 'room-here',
k.visited && !here && 'room-visited',
k.adjacent && !here && 'room-open',
!reveal && worry.length > 0 && 'room-suspect',
reveal && shown.length > 0 && 'room-revealed',
]
.filter(Boolean)
.join(' ')
return (
<g key={room} className={cls} transform={`translate(${x} ${y})`}>
<circle className="room-disc" r={ROOM_R} />
<text className="room-label" y={4}>
{room}
</text>
{/* Warnings sit above the room as marks, never as the answer. */}
{!reveal && worry.length > 0 && (
<g className="marks" transform={`translate(0 ${-ROOM_R - 9})`}>
{worry.map((h, i) => (
<Mark key={h} hazard={h} x={(i - (worry.length - 1) / 2) * 15} />
))}
</g>
)}
{reveal &&
shown.map((h, i) => (
<g key={h} transform={`translate(${(i - (shown.length - 1) / 2) * 18} ${-ROOM_R - 12})`}>
<Truth hazard={h} />
</g>
))}
{here && <Hunter />}
</g>
)
})}
</g>
</svg>
)
}
/** A suspicion: the shape of a warning, not the thing itself. */
function Mark({ hazard, x }: { hazard: Hazard; x: number }) {
if (hazard === 'wumpus') {
// A nose. It is the smell that has been reported, so it is a nostril.
return (
<g transform={`translate(${x} 0)`} className="mark mark-wumpus">
<path d="M-5 3 Q0 -6 5 3 Q0 6 -5 3 Z" />
</g>
)
}
if (hazard === 'pit') {
// A draft. Three lines leaning the way the air is going.
return (
<g transform={`translate(${x} 0)`} className="mark mark-pit">
<path d="M-6 -3 H4 M-6 0 H6 M-6 3 H3" />
</g>
)
}
return (
<g transform={`translate(${x} 0)`} className="mark mark-bats">
<path d="M-6 2 Q-3 -4 0 1 Q3 -4 6 2" />
</g>
)
}
/** What was actually in the room, once the hunt is over and it can be told. */
function Truth({ hazard }: { hazard: Hazard }) {
if (hazard === 'wumpus') {
return (
<g className="truth truth-wumpus">
<path d="M-9 6 Q-11 -6 0 -8 Q11 -6 9 6 Z" />
<circle cx={-3.5} cy={-2} r={1.7} className="eye" />
<circle cx={3.5} cy={-2} r={1.7} className="eye" />
<path d="M-4 3 L-2 6 L0 3 L2 6 L4 3" className="teeth" />
</g>
)
}
if (hazard === 'pit') {
return (
<g className="truth truth-pit">
<ellipse rx={9} ry={5} />
<ellipse rx={5} ry={2.6} className="deeper" />
</g>
)
}
return (
<g className="truth truth-bats">
<path d="M-10 3 Q-6 -5 -2 1 Q0 -3 2 1 Q6 -5 10 3 Q5 0 0 4 Q-5 0 -10 3 Z" />
</g>
)
}
/**
* The hunter.
*
* Drawn as a ring around the room rather than a figure standing on it. A
* figure covered the room number, and the number is what the whole game is
* played in -- "TUNNELS LEAD TO 13 16 19" is useless if you cannot see which
* one you are standing in.
*/
function Hunter() {
return (
<g className="hunter">
<circle r={ROOM_R + 7} className="hunter-ring" />
<circle r={ROOM_R + 11} className="hunter-ring hunter-ring-outer" />
</g>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { useSkin } from '../skin'
/**
* The thing the game is happening inside.
*
* Both skins get a frame, and the frames are not the same object: 1973 is a
* Teletype, so it is a sheet of fanfold paper coming out of a machine, with
* the sprocket holes down both edges and a platen at the top. The remaster is
* a cave, so it is a lit chamber with rock around it. Everything inside is
* laid out identically -- only the box changes.
*/
export function Frame({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const { skin } = useSkin()
return (
<div className="cabinet">
<div className="bezel">
{skin === 'teletype' && <div className="platen" aria-hidden />}
<div className="screen">
<div className="screen-inner">{children}</div>
{skin === 'teletype' ? (
<>
<div className="sprockets sprockets-left" aria-hidden />
<div className="sprockets sprockets-right" aria-hidden />
<div className="paper-grain" aria-hidden />
</>
) : (
<>
<div className="lamp" aria-hidden />
<div className="vignette" aria-hidden />
</>
)}
</div>
</div>
{footer}
</div>
)
}
/**
* A sheet of paper does not scroll and a cave mouth does not either. If the
* page comes out taller than the frame, shrink it rather than clipping it.
* Lifted from the lemonade stand, where the argument was about televisions.
*/
export function Fit({ children }: { children: ReactNode }) {
const box = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
useLayoutEffect(() => {
const measure = () => {
const el = box.current
const page = el?.firstElementChild as HTMLElement | null
if (!el || !page) return
// offsetHeight is the laid-out size and ignores the transform, so
// measuring here cannot feed back into itself.
const h = page.offsetHeight
const avail = el.clientHeight
setScale(h > 0 && avail > 0 ? Math.min(1, avail / h) : 1)
}
measure()
const ro = new ResizeObserver(measure)
if (box.current) ro.observe(box.current)
if (box.current?.firstElementChild) ro.observe(box.current.firstElementChild)
return () => ro.disconnect()
})
return (
<div className="screen-body" ref={box} style={{ '--fit': scale } as React.CSSProperties}>
{children}
</div>
)
}
export function Line({ children, className = '' }: { children?: ReactNode; className?: string }) {
return <div className={`line ${className}`}>{children ?? ' '}</div>
}
export function Btn({
children,
onClick,
kind = 'normal',
disabled,
title,
}: {
children: ReactNode
onClick: () => void
kind?: 'normal' | 'primary' | 'ghost'
disabled?: boolean
title?: string
}) {
return (
<button className={`btn btn-${kind}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useState } from 'react'
import { MAX_ARROW_PATH } from '../game/constants'
import { areConnected, tunnelsFrom } from '../game/cave'
import type { Cave, Command, Line } from '../game/types'
import { useSkin } from '../skin'
import { CaveMap } from './CaveMap'
import { Btn } from './Frame'
import { Teletype } from './Teletype'
/**
* The screen where the game is played, and the only one either skin shows
* differently in any way that matters.
*
* 1973 gets the transcript and a prompt. The remaster gets the map, the same
* transcript underneath it, and buttons for the rooms you can reach -- which
* is a convenience over typing a number, not information. The three tunnels
* out of a room are printed in both skins, because the listing printed them.
*/
export function HuntScreen({
cave,
transcript,
hunterName,
busy,
onCommand,
onBlip,
onReject,
}: {
cave: Cave
transcript: Line[]
hunterName: string
/** True while the teletype is still printing; a prompt mid-line is a lie. */
busy: boolean
onCommand: (c: Command) => void
onBlip: () => void
onReject: () => void
}) {
const { skin } = useSkin()
const [mode, setMode] = useState<'move' | 'shoot'>('move')
const [path, setPath] = useState<number[]>([])
const exits = tunnelsFrom(cave.you)
// A new room is a fresh decision; never carry a half-typed shot into it.
useEffect(() => {
setMode('move')
setPath([])
}, [cave.you, cave.depth])
/** Where the arrow is now, so the next room can be offered from there. */
const arrowAt = path.length ? path[path.length - 1]! : cave.you
const arrowExits = tunnelsFrom(arrowAt)
const canFire = path.length > 0
const canExtend = path.length < MAX_ARROW_PATH
const shoot = () => {
if (!canFire) return onReject()
onCommand({ type: 'shoot', path })
setPath([])
setMode('move')
}
const move = (to: number) => {
if (!areConnected(cave.you, to)) return onReject()
onCommand({ type: 'move', to })
}
// Typing a room number works in both skins; it is how the game was played.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (busy) return
if (e.key === 'Escape') {
setPath([])
setMode('move')
return
}
if (e.key === 'Enter' && mode === 'shoot') {
shoot()
return
}
const n = Number(e.key)
if (!Number.isInteger(n)) return
// Room numbers run to 20, so a digit alone is ambiguous; the buttons
// and the number field are the honest ways in. This is a shortcut for
// the common case only: a single-digit room you can actually reach.
const target = mode === 'move' ? exits : arrowExits
const hit = target.find((r) => r === n)
if (hit === undefined) return
if (mode === 'move') move(hit)
else setPath((p) => (p.length < MAX_ARROW_PATH ? [...p, hit] : p))
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
})
const senses = useMemo(
() => transcript.filter((l) => l.kind === 'sense').slice(-3),
[transcript],
)
return (
<div className="stack hunt">
{skin === 'modern' && (
<div className="map-wrap">
<CaveMap cave={cave} aim={path} />
</div>
)}
<Teletype lines={transcript} />
{!busy && !cave.outcome && (
<div className="prompt">
{mode === 'move' ? (
<>
<div className="prompt-q">WALK, OR SHOOT?</div>
<div className="prompt-row">
<Btn
kind="primary"
onClick={() => {
onBlip()
setMode('shoot')
}}
title="Fire a crooked arrow"
>
SHOOT
</Btn>
<span className="prompt-sep">WHICH TUNNEL?</span>
{exits.map((r) => (
<Btn key={r} onClick={() => move(r)} title={`Walk into room ${r}`}>
{r}
</Btn>
))}
</div>
</>
) : (
<>
<div className="prompt-q">
AIM IT THROUGH HOW MANY ROOMS, UP TO {MAX_ARROW_PATH}?{' '}
{path.length ? path.join(' - ') : '--'}
</div>
<div className="prompt-row">
{canExtend ? (
arrowExits.map((r) => (
<Btn
key={r}
onClick={() => {
onBlip()
setPath((p) => [...p, r])
}}
title={`Send the arrow on to room ${r}`}
>
{r}
</Btn>
))
) : (
<span className="prompt-sep">THE ARROW WILL GO NO FURTHER</span>
)}
<Btn kind="primary" onClick={shoot} disabled={!canFire} title="Loose the arrow">
FIRE
</Btn>
<Btn
kind="ghost"
onClick={() => {
onBlip()
setPath([])
setMode('move')
}}
>
BACK
</Btn>
</div>
{/* The rule that makes long shots frightening, said out loud. */}
<div className="prompt-note">
NAME A ROOM THE ARROW CANNOT REACH AND IT PICKS ITS OWN TUNNEL.
</div>
</>
)}
</div>
)}
<div className="statusbar">
<span>{hunterName}</span>
<span>CAVE {cave.depth}</span>
<span>ARROWS {cave.arrows}</span>
<span className="senses">{senses.map((s) => s.text).join(' ') || ' '}</span>
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react'
import { synth } from '../audio/synth'
import type { Line } from '../game/types'
/**
* The 1973 skin: everything the machine has said, printed.
*
* A Teletype prints at ten characters a second and cannot take any of it
* back, so this types rather than appears, and nothing already printed ever
* changes. That is not decoration -- it is the reason the game feels the way
* it does. Reading "I SMELL A WUMPUS" arrive one letter at a time is a
* different experience from finding it already on screen, and the whole game
* is three sentences arriving in the dark.
*
* The print head is played per character. At ten a second that is a lot of
* one-shot oscillators, which is why `synth.print()` is as small as it is.
*/
const CPS = 42 // Faster than a real ASR-33. A real one is 10, and it is a lot.
export function Teletype({ lines, onIdle }: { lines: Line[]; onIdle?: () => void }) {
const [printed, setPrinted] = useState<string[]>([])
const [partial, setPartial] = useState('')
const done = useRef(0)
const paper = useRef<HTMLDivElement>(null)
// A new expedition throws the old transcript away rather than scrolling it.
useEffect(() => {
if (lines.length < done.current) {
done.current = 0
setPrinted([])
setPartial('')
}
}, [lines.length])
useEffect(() => {
if (done.current >= lines.length) {
onIdle?.()
return
}
const text = lines[done.current]!.text
let at = 0
setPartial('')
const timer = window.setInterval(() => {
at += 1
setPartial(text.slice(0, at))
if (synth.sfxOn) synth.print()
if (at >= text.length) {
window.clearInterval(timer)
synth.carriage()
done.current += 1
setPartial('')
setPrinted((p) => [...p, text])
}
}, 1000 / CPS)
return () => window.clearInterval(timer)
// `printed` is the trigger: finishing one line starts the next.
}, [lines, printed.length, onIdle])
// The paper feeds up; you always read at the bottom.
useEffect(() => {
paper.current?.scrollTo({ top: paper.current.scrollHeight })
}, [printed.length, partial])
return (
<div className="paper" ref={paper}>
{printed.map((text, i) => (
<div className="printed" key={i}>
{text}
</div>
))}
{partial && (
<div className="printed">
{partial}
<span className="head" aria-hidden />
</div>
)}
</div>
)
}
+365
View File
@@ -0,0 +1,365 @@
import { useEffect, useRef, useState } from 'react'
import { DIAL_UP, synth } from '../audio/synth'
import { MAX_HUNTERS, MAX_NAME, STARTING_ARROWS } from '../game/constants'
import { ROOM_COUNT } from '../game/cave'
import { compareHunts, outcomeText } from '../game/engine'
import type { Score } from '../game/highscores'
import type { Cave, Hunter } from '../game/types'
import { isWin } from '../game/types'
import { CaveMap } from './CaveMap'
import { Btn, Line } from './Frame'
/**
* Everything that is not the hunt itself. These are small enough that keeping
* them in one file makes the shape of the game easier to see than eight files
* would, and none of them holds a rule.
*/
// ------------------------------------------------------------------- boot
/**
* Dialling in.
*
* The lemonade stand loads from cassette because that is how an Atari got its
* programs. This game is six years older than that machine and did not live on
* a machine you owned at all -- it lived on a timesharing service you
* telephoned. So the boot is a real Bell 103 call, and pushing the handset
* into the coupler is the gesture that unlocks the audio.
*
* The stages are printed as they happen, from the same schedule the audio is
* built on, so what is on the paper is what is on the line. See DIAL_UP.
*/
const STAGES: [number, string][] = [
[0, 'DIAL TONE'],
[DIAL_UP.dialTone, 'DIALLING 555-1973'],
[DIAL_UP.ringAt, 'RINGING'],
[DIAL_UP.answerAt, 'ANSWER TONE 2225 HZ'],
[DIAL_UP.answerAt + DIAL_UP.answer, 'CARRIER -- 110 BAUD, FULL DUPLEX'],
]
export function BootScreen({ onLoaded }: { onLoaded: () => void }) {
const [dialling, setDialling] = useState(false)
const [stage, setStage] = useState(-1)
const stop = useRef<(() => void) | null>(null)
useEffect(() => {
if (!dialling) return
stop.current = synth.dialUp()
const timers = STAGES.map(([at], i) =>
window.setTimeout(() => setStage(i), at * 1000),
)
const done = window.setTimeout(() => {
stop.current?.()
onLoaded()
}, DIAL_UP.total * 1000)
return () => {
for (const t of timers) window.clearTimeout(t)
window.clearTimeout(done)
stop.current?.()
}
}, [dialling, onLoaded])
/** Nobody should have to sit through a handshake twice. */
const skip = () => {
stop.current?.()
onLoaded()
}
return (
<div className="stack boot">
<Line className="accent">PEOPLE&apos;S COMPUTER COMPANY</Line>
<Line> </Line>
{dialling ? (
<>
{STAGES.slice(0, stage + 1).map(([, label]) => (
<Line key={label}>{label}</Line>
))}
<Line> </Line>
<Btn kind="ghost" onClick={skip} title="Skip the handshake">
SKIP
</Btn>
</>
) : (
<>
<Line>TELEPHONE THE MACHINE TO BEGIN.</Line>
<Line> </Line>
<Btn
kind="primary"
onClick={() => setDialling(true)}
title="Dial in. This is also what lets the browser make a sound."
>
DIAL
</Btn>
<Line> </Line>
<Line className="dim">SOUND STARTS HERE, AS BROWSERS INSIST.</Line>
</>
)}
</div>
)
}
// ------------------------------------------------------------------ title
export function TitleScreen({
onStart,
onInstructions,
onScores,
}: {
onStart: () => void
onInstructions: () => void
onScores: () => void
}) {
return (
<div className="stack title">
<pre className="banner" aria-label="Hunt the Wumpus">{`
HUNT THE WUMPUS
`}</pre>
<Line className="dim">TWENTY ROOMS. THREE TUNNELS EACH. ONE OF IT.</Line>
<Line> </Line>
<div className="prompt-row">
<Btn kind="primary" onClick={onStart}>
HUNT
</Btn>
<Btn onClick={onInstructions}>INSTRUCTIONS</Btn>
<Btn onClick={onScores}>BOARD</Btn>
</div>
</div>
)
}
// ----------------------------------------------------------- instructions
/**
* The rules, on request, the way the machine offered them in 1973 -- but in
* our words rather than Yob's. See NOTICE.md: the rules themselves are ideas
* and free to use, the sentences he wrote to explain them are not.
*/
export function InstructionsScreen({ onDone }: { onDone: () => void }) {
return (
<div className="stack rules">
<Line className="accent">THE CAVE</Line>
<Line>
{ROOM_COUNT} ROOMS, EACH WITH EXACTLY THREE TUNNELS OUT OF IT. THE SHAPE NEVER CHANGES.
WHAT IS IN IT DOES.
</Line>
<Line> </Line>
<Line className="accent">WHAT IS DOWN THERE WITH YOU</Line>
<Line>ONE WUMPUS. IT SLEEPS. IT IS TOO HEAVY TO FALL DOWN A PIT AND TOO HEAVY FOR THE
BATS TO LIFT, SO NOTHING DOWN HERE TROUBLES IT BUT YOU.</Line>
<Line>TWO PITS. WALK INTO ONE AND YOU KEEP GOING.</Line>
<Line>TWO ROOSTS OF BATS. THEY WILL NOT HURT YOU. THEY WILL PICK YOU UP AND PUT YOU DOWN
SOMEWHERE ELSE, AND THEY DO NOT CARE WHAT IS ALREADY THERE.</Line>
<Line> </Line>
<Line className="accent">WHAT THE CAVE TELLS YOU</Line>
<Line>STANDING ONE TUNNEL AWAY FROM ANY OF THEM, YOU WILL KNOW SOMETHING IS CLOSE. YOU
WILL NEVER BE TOLD WHICH ROOM. THREE TUNNELS AND ONE WARNING IS THE WHOLE PUZZLE.</Line>
<Line> </Line>
<Line className="accent">YOUR TURN</Line>
<Line>WALK, OR SHOOT. YOU CARRY {STARTING_ARROWS} ARROWS AND THEY ARE CROOKED: EACH ONE
CAN BE AIMED THROUGH AS MANY AS FIVE ROOMS IN A ROW.</Line>
<Line>NAME A ROOM THE ARROW CANNOT REACH FROM WHERE IT IS AND IT WILL NOT STOP. IT WILL
TAKE A TUNNEL OF ITS OWN CHOOSING, AND ONE OF THOSE LEADS BACK TO YOU.</Line>
<Line>A SHOT THAT MISSES USUALLY WAKES THE WUMPUS. A WOKEN WUMPUS MOVES ONE ROOM, OR
STAYS WHERE IT IS. IF IT ARRIVES WHERE YOU ARE, THAT IS THE END OF IT.</Line>
<Line> </Line>
<Line className="accent">AFTERWARDS</Line>
<Line>KILL IT AND YOU GO DEEPER, KEEPING WHAT IS LEFT IN THE QUIVER. DIE AND YOU ARE
FINISHED. STOP WHENEVER YOU LIKE -- THAT IS WHAT CLIMB OUT IS FOR.</Line>
<Line> </Line>
<Btn kind="primary" onClick={onDone}>
GOT IT
</Btn>
</div>
)
}
// ------------------------------------------------------------------ setup
export function SetupScreen({
onStart,
onBlip,
}: {
onStart: (names: string[]) => void
onBlip: () => void
}) {
const [names, setNames] = useState<string[]>([''])
const set = (i: number, v: string) =>
setNames((n) => n.map((old, j) => (j === i ? v.slice(0, MAX_NAME) : old)))
return (
<div className="stack setup">
<Line className="accent">WHO IS GOING IN?</Line>
<Line className="dim">EACH HUNTER GETS THEIR OWN CAVE. YOU TAKE IT IN TURNS.</Line>
<Line> </Line>
{names.map((name, i) => (
<div className="field" key={i}>
<label htmlFor={`hunter-${i}`}>HUNTER {i + 1}</label>
<input
id={`hunter-${i}`}
className="field-input"
value={name}
maxLength={MAX_NAME}
placeholder={`HUNTER ${i + 1}`}
onChange={(e) => set(i, e.target.value)}
/>
</div>
))}
<Line> </Line>
<div className="prompt-row">
{names.length < MAX_HUNTERS && (
<Btn
onClick={() => {
onBlip()
setNames((n) => [...n, ''])
}}
>
ONE MORE
</Btn>
)}
<Btn kind="primary" onClick={() => onStart(names)}>
GO IN
</Btn>
</div>
</div>
)
}
// ----------------------------------------------------------------- report
/**
* The end of one hunt. This is the only screen other than the standings
* allowed to show where things actually were -- the hunt is over, so knowing
* costs nothing, and not showing it would waste the one moment the map is
* genuinely interesting.
*/
export function ReportScreen({
cave,
hunter,
moreToCome,
onNext,
onClimbOut,
}: {
cave: Cave
hunter: Hunter
/** True when somebody is still alive to take another turn. */
moreToCome: boolean
onNext: () => void
onClimbOut: () => void
}) {
const won = isWin(cave.outcome)
return (
<div className="stack report">
<Line className={won ? 'accent' : 'warn'}>{cave.outcome ? outcomeText(cave.outcome) : ''}</Line>
<Line> </Line>
<div className="map-wrap map-reveal">
<CaveMap cave={cave} reveal />
</div>
<Line> </Line>
<Line>
{hunter.name} {hunter.bagged} BAGGED, {hunter.arrows} ARROW
{hunter.arrows === 1 ? '' : 'S'} LEFT, {hunter.roomsWalked} ROOMS WALKED
</Line>
<Line className="dim">
THE WUMPUS WAS IN {cave.wumpus}. PITS: {cave.pits.join(' AND ')}. BATS:{' '}
{cave.bats.join(' AND ')}.
</Line>
<Line> </Line>
<div className="prompt-row">
<Btn kind="primary" onClick={onNext}>
{moreToCome ? 'NEXT HUNTER' : won ? 'GO DEEPER' : 'THAT IS THAT'}
</Btn>
<Btn onClick={onClimbOut} title="End the expedition and post the board">
CLIMB OUT
</Btn>
</div>
</div>
)
}
// --------------------------------------------------------------- standings
export function GameOverScreen({
hunters,
climbedOut,
onRestart,
onScores,
}: {
hunters: Hunter[]
climbedOut: boolean
onRestart: () => void
onScores: () => void
}) {
const ranked = [...hunters].sort(compareHunts)
return (
<div className="stack over">
<Line className="accent">{climbedOut ? 'YOU CLIMBED OUT.' : 'THE CAVE KEPT EVERYBODY.'}</Line>
<Line> </Line>
{ranked.map((h, i) => (
<Line key={h.id}>
{String(i + 1).padStart(2)}. {h.name.padEnd(MAX_NAME)} {String(h.bagged).padStart(2)}{' '}
BAGGED {String(h.arrows).padStart(2)} ARROWS {String(h.roomsWalked).padStart(3)} ROOMS
{h.died ? ` ${outcomeText(h.died)}` : ' WALKED OUT'}
</Line>
))}
<Line> </Line>
<div className="prompt-row">
<Btn kind="primary" onClick={onRestart}>
AGAIN
</Btn>
<Btn onClick={onScores}>BOARD</Btn>
</div>
</div>
)
}
// ------------------------------------------------------------------- board
export type BoardState = 'loading' | 'ready' | 'error'
export function HighScoreScreen({
scores,
state,
highlight,
onBack,
onRetry,
}: {
scores: Score[]
state: BoardState
highlight: string[]
onBack: () => void
onRetry: () => void
}) {
return (
<div className="stack board">
<Line className="accent">THE BOARD</Line>
<Line className="dim">RANKED ON WUMPODES, THEN ARROWS LEFT, THEN ROOMS WALKED.</Line>
<Line> </Line>
{state === 'loading' && <Line>ASKING THE MACHINE...</Line>}
{state === 'error' && (
<>
{/* The game is entirely playable without the board, and says so
rather than pretending the run did not happen. */}
<Line className="warn">THE BOARD IS NOT ANSWERING. YOUR HUNT STILL COUNTED.</Line>
<Line> </Line>
<Btn onClick={onRetry}>TRY AGAIN</Btn>
</>
)}
{state === 'ready' && scores.length === 0 && <Line>NOBODY HAS COME BACK OUT YET.</Line>}
{state === 'ready' &&
scores.map((s, i) => (
<Line key={s.id} className={highlight.includes(s.id) ? 'accent' : ''}>
{String(i + 1).padStart(2)}. {s.name.padEnd(MAX_NAME)} {String(s.bagged).padStart(2)}{' '}
{String(s.arrows).padStart(2)} {String(s.roomsWalked).padStart(3)}
{s.died ? '' : ' *'}
</Line>
))}
<Line> </Line>
<Btn kind="primary" onClick={onBack}>
BACK
</Btn>
</div>
)
}