0
GAMES#3a58fbc7
Oregon Trail Game - Classic 1990
@GigaChad·deposited 3w ago·updated 2w ago·44 views
GAMES#3a58fbc7
Oregon Trail Game - Classic 1990
GI
@GigaChad
44Views
0Comments
0Forks
0Saves
SHARE · REMIX
Oregon Trail Game - Classic 1990 — a JSX Games widget by @GigaChad.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widgetNo Vibes SDK features detected yet
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "Oregon Trail Game - Classic 1990" by @GigaChad]
Source: https://itjustvibes.com/GigaChad/oregon-trail-game-classic-1990
Type: React/JSX
--- SOURCE CODE ---
```jsx
import { useState, useEffect, useCallback, useRef } from "react";
const MONTHS = ["March","April","May","June","July","August","September","October","November"];
const WEATHER = ["clear","rainy","snowy","foggy","hot"];
const HEALTH_LABELS = ["good","fair","poor","very poor","dead"];
const PACE_OPTIONS = ["steady","strenuous","grueling"];
const RATION_OPTIONS = ["filling","meager","bare bones"];
const LANDMARKS = [
{ name: "Independence, MO", mile: 0, type: "town" },
{ name: "Kansas River Crossing", mile: 102, type: "river", depth: 3, width: 620 },
{ name: "Big Blue River Crossing", mile: 185, type: "river", depth: 4, width: 300 },
{ name: "Fort Kearney", mile: 304, type: "fort" },
{ name: "Chimney Rock", mile: 554, type: "landmark" },
{ name: "Fort Laramie", mile: 640, type: "fort" },
{ name: "Independence Rock", mile: 830, type: "landmark" },
{ name: "South Pass", mile: 914, type: "landmark" },
{ name: "Green River Crossing", mile: 988, type: "river", depth: 12, width: 400 },
{ name: "Fort Bridger", mile: 1025, type: "fort" },
{ name: "Soda Springs", mile: 1160, type: "landmark" },
{ name: "Fort Hall", mile: 1210, type: "fort" },
{ name: "Snake River Crossing", mile: 1330, type: "river", depth: 8, width: 1000 },
{ name: "Fort Boise", mile: 1490, type: "fort" },
{ name: "Blue Mountains", mile: 1600, type: "landmark" },
{ name: "The Dalles", mile: 1740, type: "landmark" },
{ name: "Willamette Valley, OR", mile: 2040, type: "town" },
];
const STORE_ITEMS = {
oxen: { price: 40, unit: "yoke", desc: "Need at least 1 yoke" },
food: { price: 0.20, unit: "lb", desc: "Per person eats ~1-3 lbs/day" },
clothing: { price: 10, unit: "set", desc: "2 sets per person recommended" },
ammunition: { price: 2, unit: "box (20)", desc: "For hunting" },
wheels: { price: 10, unit: "each", desc: "Spare wagon wheels" },
axles: { price: 10, unit: "each", desc: "Spare wagon axles" },
tongues: { price: 10, unit: "each", desc: "Spare wagon tongues" },
};
const OCCUPATIONS = [
{ name: "Banker", money: 1600, scoreMultiplier: 1 },
{ name: "Carpenter", money: 800, scoreMultiplier: 2 },
{ name: "Farmer", money: 400, scoreMultiplier: 3 },
];
const TRAIL_EVENTS = [
{ text: "found wild fruit along the trail", effect: "food", amount: 20 },
{ text: "lost the trail for a day", effect: "miles", amount: -10 },
{ text: "discovered an abandoned wagon with supplies", effect: "food", amount: 40 },
{ text: "a thief stole supplies during the night", effect: "food", amount: -25 },
{ text: "heavy rain caused flooding", effect: "health", amount: -5 },
{ text: "found a shortcut through the hills", effect: "miles", amount: 15 },
{ text: "one of your oxen is injured", effect: "oxen", amount: -1 },
{ text: "met friendly Native Americans who traded food", effect: "food", amount: 30 },
{ text: "wagon wheel broke", effect: "wheels", amount: -1 },
{ text: "axle broke on the rough trail", effect: "axles", amount: -1 },
{ text: "wagon tongue snapped", effect: "tongues", amount: -1 },
{ text: "dust storm slows progress", effect: "miles", amount: -8 },
];
const DISEASES = [
"dysentery", "typhoid", "cholera", "measles", "snakebite",
"broken leg", "broken arm", "exhaustion", "fever"
];
const ANIMALS = [
{ name: "squirrel", weight: 3, difficulty: 0.8 },
{ name: "rabbit", weight: 5, difficulty: 0.7 },
{ name: "deer", weight: 55, difficulty: 0.4 },
{ name: "elk", weight: 120, difficulty: 0.3 },
{ name: "bear", weight: 200, difficulty: 0.2 },
{ name: "bison", weight: 500, difficulty: 0.15 },
];
function rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
const pixel = (size = 2) => `${size}px`;
// ─── Pixel Art Components ────────────────────────────────
function Wagon({ x = 50, animate = false }) {
return (
<svg viewBox="0 0 120 60" width="120" height="60" style={{ position: "absolute", bottom: 18, left: `${x}%`, transition: animate ? "left 2s linear" : "none" }}>
<rect x="20" y="10" width="70" height="30" fill="#8B4513" stroke="#5a2d0c" strokeWidth="2" rx="2"/>
<path d="M 15 10 Q 55 -15 95 10" fill="none" stroke="#f5f5dc" strokeWidth="3"/>
<line x1="20" y1="10" x2="30" y2="-2" stroke="#f5f5dc" strokeWidth="1.5"/>
<line x1="55" y1="10" x2="55" y2="-10" stroke="#f5f5dc" strokeWidth="1.5"/>
<line x1="90" y1="10" x2="80" y2="-2" stroke="#f5f5dc" strokeWidth="1.5"/>
<circle cx="28" cy="45" r="10" fill="none" stroke="#8B4513" strokeWidth="3"/>
<circle cx="28" cy="45" r="2" fill="#8B4513"/>
<line x1="28" y1="35" x2="28" y2="55" stroke="#8B4513" strokeWidth="1"/>
<line x1="18" y1="45" x2="38" y2="45" stroke="#8B4513" strokeWidth="1"/>
<circle cx="82" cy="45" r="10" fill="none" stroke="#8B4513" strokeWidth="3"/>
<circle cx="82" cy="45" r="2" fill="#8B4513"/>
<line x1="82" y1="35" x2="82" y2="55" stroke="#8B4513" strokeWidth="1"/>
<line x1="72" y1="45" x2="92" y2="45" stroke="#8B4513" strokeWidth="1"/>
<rect x="5" y="25" width="18" height="6" fill="#8B4513" rx="1"/>
</svg>
);
}
function OxSprite() {
return (
<svg viewBox="0 0 50 40" width="50" height="40" style={{ position: "absolute", bottom: 22, left: "calc(50% - 80px)" }}>
<ellipse cx="25" cy="22" rx="18" ry="12" fill="#a0522d"/>
<circle cx="10" cy="14" r="6" fill="#a0522d"/>
<line x1="6" y1="10" x2="2" y2="4" stroke="#555" strokeWidth="2" strokeLinecap="round"/>
<line x1="14" y1="10" x2="18" y2="4" stroke="#555" strokeWidth="2" strokeLinecap="round"/>
<circle cx="8" cy="13" r="1.5" fill="#fff"/>
<circle cx="8" cy="13" r="0.8" fill="#000"/>
<rect x="12" y="30" width="3" height="8" fill="#6b3a1f" rx="1"/>
<rect x="22" y="30" width="3" height="8" fill="#6b3a1f" rx="1"/>
<rect x="30" y="30" width="3" height="8" fill="#6b3a1f" rx="1"/>
<rect x="37" y="30" width="3" height="8" fill="#6b3a1f" rx="1"/>
</svg>
);
}
function TrailScene({ weather, mile, totalMiles }) {
const progress = (mile / totalMiles) * 100;
const skyColors = {
clear: ["#1a0533","#2d1b69","#87CEEB"],
rainy: ["#2c2c3a","#4a4a5e","#708090"],
snowy: ["#c0c8d0","#d0d8e0","#e8ecf0"],
foggy: ["#8a8a7a","#a0a090","#bbb8a8"],
hot: ["#ff6b00","#ff8c42","#ffcc02"],
};
const sky = skyColors[weather] || skyColors.clear;
const groundColor = weather === "snowy" ? "#e8e8e8" : "#5a7247";
const trailColor = weather === "snowy" ? "#c8b89a" : "#a08050";
return (
<div style={{ position: "relative", width: "100%", height: 200, overflow: "hidden", borderRadius: 8, border: "3px solid #2a1a0a" }}>
<div style={{ position: "absolute", inset: 0, background: `linear-gradient(180deg, ${sky[0]} 0%, ${sky[1]} 40%, ${sky[2]} 70%, ${groundColor} 70%, ${groundColor} 100%)` }}/>
{weather === "clear" && <>
<div style={{ position: "absolute", top: 20, right: 40, width: 30, height: 30, borderRadius: "50%", background: "#ffe066", boxShadow: "0 0 20px #ffe066" }}/>
</>}
{weather === "rainy" && Array.from({length:30}).map((_,i)=>(
<div key={i} style={{ position:"absolute", top: rand(0,120), left: `${rand(0,100)}%`, width:2, height:10, background:"rgba(150,180,255,0.5)", animation:`rain ${0.5+Math.random()*0.5}s linear infinite`, animationDelay:`${Math.random()*0.5}s` }}/>
))}
{weather === "snowy" && Array.from({length:25}).map((_,i)=>(
<div key={i} style={{ position:"absolute", top: rand(0,140), left: `${rand(0,100)}%`, width:4, height:4, borderRadius:"50%", background:"rgba(255,255,255,0.8)", animation:`snow ${2+Math.random()*3}s linear infinite`, animationDelay:`${Math.random()*2}s` }}/>
))}
{/* Mountains */}
<svg style={{ position: "absolute", bottom: 60, left: 0, width: "100%", height: 100 }} viewBox="0 0 800 100" preserveAspectRatio="none">
<polygon points="0,100 100,30 200,100" fill="#4a5e3a" opacity="0.5"/>
<polygon points="150,100 300,10 450,100" fill="#3d5030" opacity="0.6"/>
<polygon points="400,100 550,25 700,100" fill="#4a5e3a" opacity="0.5"/>
<polygon points="600,100 750,40 800,100" fill="#3d5030" opacity="0.4"/>
</svg>
{/* Trail */}
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 30, background: trailColor, borderTop: `2px dashed ${weather === "snowy" ? "#999" : "#806030"}` }}/>
<OxSprite/>
<Wagon x={50} />
{/* Mile marker */}
<div style={{ position: "absolute", bottom: 4, right: 8, fontFamily: "'Press Start 2P', monospace", fontSize: 9, color: "#fff", textShadow: "1px 1px 0 #000" }}>
Mile {mile} / {totalMiles}
</div>
</div>
);
}
// ─── SCREENS ─────────────────────────────────────────────
function TitleScreen({ onStart }) {
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div style={{ fontSize: 11, color: "#4a7a2e", marginBottom: 10, letterSpacing: 4 }}>═══════════════════════</div>
<h1 style={{ fontFamily: "'Press Start 2P', monospace", fontSize: 22, color: "#4a7a2e", margin: "10px 0", lineHeight: 1.5, textShadow: "2px 2px 0 rgba(0,0,0,0.3)" }}>
THE OREGON<br/>TRAIL
</h1>
<div style={{ fontSize: 11, color: "#4a7a2e", marginBottom: 20, letterSpacing: 4 }}>═══════════════════════</div>
<p style={{ color: "#c8b89a", fontSize: 11, marginBottom: 8, lineHeight: 1.8 }}>
The year is 1848. You have decided<br/>
to travel the 2,040 mile Oregon Trail<br/>
from Independence, Missouri to<br/>
Oregon's Willamette Valley.
</p>
<div style={{ margin: "30px 0" }}>
<Wagon x={35} />
<div style={{ height: 80 }}/>
</div>
<button onClick={onStart} style={btnStyle("#4a7a2e")}>
▶ PRESS TO START
</button>
<p style={{ color: "#666", fontSize: 9, marginTop: 30 }}>A faithful remake of the 1990 classic</p>
</div>
);
}
function OccupationScreen({ onSelect }) {
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>Choose Your Occupation</h2>
<p style={subTextStyle}>Your occupation determines your starting money and final score multiplier.</p>
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 20 }}>
{OCCUPATIONS.map(o => (
<button key={o.name} onClick={() => onSelect(o)} style={{...btnStyle("#3a2a1a"), textAlign: "left", padding: "12px 16px"}}>
<div style={{ fontSize: 12 }}>{o.name}</div>
<div style={{ fontSize: 9, color: "#a08060", marginTop: 4 }}>
Starting money: ${o.money} · Score: *{o.scoreMultiplier}
</div>
</button>
))}
</div>
</div>
);
}
function NamingScreen({ onDone }) {
const [names, setNames] = useState(["", "", "", "", ""]);
const labels = ["Party Leader", "Member 2", "Member 3", "Member 4", "Member 5"];
const defaults = ["Captain", "Sarah", "James", "Emily", "Ben"];
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>Name Your Party</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginTop: 16 }}>
{labels.map((label, i) => (
<div key={i}>
<label style={{ fontSize: 10, color: "#a08060" }}>{label}</label>
<input
value={names[i]}
onChange={e => { const n = [...names]; n[i] = e.target.value; setNames(n); }}
placeholder={defaults[i]}
style={inputStyle}
/>
</div>
))}
</div>
<button onClick={() => onDone(names.map((n, i) => n.trim() || defaults[i]))} style={{ ...btnStyle("#4a7a2e"), marginTop: 20, width: "100%" }}>
Continue →
</button>
</div>
);
}
function MonthScreen({ onSelect }) {
const months = ["March","April","May","June","July"];
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>Choose Departure Month</h2>
<p style={subTextStyle}>Leaving too early means cold weather. Too late risks snow in the mountains.</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 16 }}>
{months.map((m, i) => (
<button key={m} onClick={() => onSelect(i)} style={btnStyle("#3a2a1a")}>
{m} 1848
</button>
))}
</div>
</div>
);
}
function StoreScreen({ money, supplies, onBuy, onLeave, landmark, error }) {
const [cart, setCart] = useState({});
const total = Object.entries(cart).reduce((s, [k, v]) => s + (k === "food" ? v * STORE_ITEMS[k].price : v * STORE_ITEMS[k].price), 0);
const updateCart = (item, delta) => {
setCart(c => {
const newVal = Math.max(0, (c[item] || 0) + delta);
return { ...c, [item]: newVal };
});
};
const purchase = () => {
if (total <= money) {
onBuy(cart, total);
}
};
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>General Store{landmark ? ` -- ${landmark}` : ""}</h2>
<p style={{ ...subTextStyle, color: "#4a7a2e" }}>Cash: ${money.toFixed(2)}</p>
{error && <p style={{ fontSize: 9, color: "#c0392b", marginTop: 4, marginBottom: 4 }}>{error}</p>}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 12 }}>
{Object.entries(STORE_ITEMS).map(([key, item]) => (
<div key={key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", background: "#1a1208", padding: "8px 12px", borderRadius: 6, border: "1px solid #3a2a1a" }}>
<div>
<div style={{ fontSize: 11, color: "#e8dcc8" }}>{key}</div>
<div style={{ fontSize: 8, color: "#806a50" }}>${item.price}/{item.unit} -- {item.desc}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => updateCart(key, key === "food" ? -10 : -1)} style={smallBtn}>-</button>
<span style={{ fontSize: 11, color: "#e8dcc8", minWidth: 24, textAlign: "center" }}>{cart[key] || 0}</span>
<button onClick={() => updateCart(key, key === "food" ? 10 : 1)} style={smallBtn}>+</button>
</div>
</div>
))}
</div>
<div style={{ marginTop: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 11, color: total > money ? "#c0392b" : "#4a7a2e" }}>
Total: ${total.toFixed(2)}
</span>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button onClick={purchase} disabled={total > money || total === 0} style={{ ...btnStyle(total > money ? "#555" : "#4a7a2e"), flex: 1, opacity: total > money || total === 0 ? 0.5 : 1 }}>
Buy
</button>
<button onClick={onLeave} style={{ ...btnStyle("#8a6a3a"), flex: 1 }}>
Leave Store
</button>
</div>
</div>
);
}
function HuntingScreen({ ammo, onFinish }) {
const [results, setResults] = useState([]);
const initialShots = Math.min(ammo, 20);
const [shotsLeft, setShotsLeft] = useState(initialShots);
const [animals, setAnimals] = useState([]);
const [message, setMessage] = useState("Animals appear on the prairie...");
const [done, setDone] = useState(false);
const totalFood = results.reduce((s, r) => s + r.weight, 0);
useEffect(() => {
const count = rand(2, 5);
const a = Array.from({ length: count }, () => ({ ...pick(ANIMALS), id: Math.random(), x: rand(10, 85), y: rand(10, 70) }));
setAnimals(a);
}, []);
const shoot = (animal) => {
if (shotsLeft <= 0 || done) return;
setShotsLeft(s => s - 1);
if (Math.random() < animal.difficulty) {
setResults(r => [...r, animal]);
setAnimals(a => a.filter(x => x.id !== animal.id));
setMessage(`You shot a ${animal.name}! (${animal.weight} lbs)`);
} else {
setMessage(`You missed the ${animal.name}!`);
}
if (shotsLeft <= 1) setDone(true);
};
const finish = () => {
const carry = Math.min(totalFood, 200);
onFinish(carry, initialShots - shotsLeft);
};
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>Hunting</h2>
<p style={subTextStyle}>{message}</p>
<div style={{ position: "relative", width: "100%", height: 180, background: "linear-gradient(180deg, #87CEEB 0%, #5a7247 60%, #4a6237 100%)", borderRadius: 8, border: "2px solid #3a2a1a", marginTop: 10, cursor: "crosshair", overflow: "hidden" }}>
{animals.map(a => (
<button key={a.id} onClick={() => shoot(a)} style={{ position: "absolute", left: `${a.x}%`, top: `${a.y}%`, background: "#8B4513", border: "2px solid #5a2d0c", borderRadius: 4, color: "#fff", fontSize: 8, padding: "4px 6px", cursor: "crosshair", fontFamily: "'Press Start 2P', monospace" }}>
{a.name}
</button>
))}
{animals.length === 0 && <p style={{ textAlign: "center", paddingTop: 70, color: "#fff", fontSize: 10 }}>No more animals</p>}
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 12, fontSize: 10, color: "#c8b89a" }}>
<span>Shots: {shotsLeft}</span>
<span>Meat: {totalFood} lbs (carry max 200)</span>
</div>
<button onClick={finish} style={{ ...btnStyle("#4a7a2e"), width: "100%", marginTop: 12 }}>
Done Hunting
</button>
</div>
);
}
function RiverScreen({ river, supplies, onCross }) {
const canCaulk = true;
const canFerry = supplies.money >= 5;
const canFord = river.depth <= 3;
const attempt = (method) => {
let success = true;
let message = "";
let loss = {};
if (method === "ford") {
if (river.depth > 3) { success = false; message = "The river is too deep to ford!"; }
else if (Math.random() < 0.15) { loss = { food: rand(10, 30) }; message = "Some food got wet while fording."; }
else { message = "You safely forded the river."; }
} else if (method === "caulk") {
if (Math.random() < 0.3) { loss = { food: rand(10, 40) }; message = "Water leaked into the wagon!"; }
else { message = "You floated across safely!"; }
} else if (method === "ferry") {
loss = { money: 5 };
if (Math.random() < 0.05) { loss.food = rand(5, 15); message = "Minor incident on the ferry, but you made it."; }
else { message = "The ferry took you across safely."; }
} else {
message = "You waited a day for conditions to improve.";
loss = { days: 1 };
}
onCross(success, message, loss, method);
};
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>{river.name}</h2>
<div style={{ background: "#0a2a4a", borderRadius: 8, padding: 16, border: "2px solid #1a4a7a", marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "#6ab0e8" }}>
<span>Depth: {river.depth} ft</span>
<span>Width: {river.width} ft</span>
</div>
<div style={{ width: "100%", height: 40, background: "linear-gradient(180deg, #1a5a9a, #0a3a6a)", borderRadius: 4, marginTop: 10, position: "relative", overflow: "hidden" }}>
{Array.from({length: 8}).map((_, i) => (
<div key={i} style={{ position: "absolute", top: rand(5, 30), left: `${i * 13}%`, width: 30, height: 2, background: "rgba(150,200,255,0.3)", borderRadius: 2 }}/>
))}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{canFord && <button onClick={() => attempt("ford")} style={btnStyle("#3a6a2a")}>Ford the river</button>}
<button onClick={() => attempt("caulk")} style={btnStyle("#2a4a6a")}>Caulk and float</button>
{canFerry && <button onClick={() => attempt("ferry")} style={btnStyle("#6a5a2a")}>Take the ferry ($5)</button>}
<button onClick={() => attempt("wait")} style={btnStyle("#3a2a1a")}>Wait a day</button>
</div>
</div>
);
}
function TradeScreen({ onTrade, onDecline }) {
const items = ["food","clothing","ammunition","wheels","axles","tongues"];
const offer = pick(items);
const want = pick(items.filter(i => i !== offer));
const offerAmt = offer === "food" ? rand(20, 60) : rand(1, 3);
const wantAmt = want === "food" ? rand(10, 40) : rand(1, 2);
return (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>A Traveler Wants to Trade</h2>
<div style={{ background: "#1a1208", borderRadius: 8, padding: 16, border: "1px solid #3a2a1a", marginTop: 16, textAlign: "center" }}>
<p style={{ color: "#c8b89a", fontSize: 11 }}>
They offer: <span style={{ color: "#4a7a2e" }}>{offerAmt} {offer}</span>
</p>
<p style={{ color: "#c8b89a", fontSize: 11, marginTop: 8 }}>
They want: <span style={{ color: "#c0392b" }}>{wantAmt} {want}</span>
</p>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button onClick={() => onTrade(offer, offerAmt, want, wantAmt)} style={{ ...btnStyle("#4a7a2e"), flex: 1 }}>Trade</button>
<button onClick={onDecline} style={{ ...btnStyle("#8a3a2a"), flex: 1 }}>Decline</button>
</div>
</div>
);
}
function StatusBar({ game }) {
return (
<div style={{ background: "#0d0a06", padding: "8px 16px", borderTop: "2px solid #3a2a1a", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 4, fontSize: 9, color: "#a08060" }}>
<div>Date: {MONTHS[game.month]} {game.day}, 1848</div>
<div>Weather: {game.weather}</div>
<div>Health: {HEALTH_LABELS[Math.min(4, Math.floor((100 - game.health) / 25))]}</div>
<div>Food: {game.food} lbs</div>
<div>Pace: {game.pace}</div>
<div>Next: {game.nextLandmark?.name || "?"}</div>
</div>
);
}
function GameOverScreen({ won, game, onRestart }) {
const score = won ? Math.floor((game.health * 2 + game.food + game.money) * game.scoreMultiplier) : 0;
return (
<div style={{ padding: 30, textAlign: "center" }}>
<h2 style={{ ...headerStyle, color: won ? "#4a7a2e" : "#c0392b" }}>
{won ? "CONGRATULATIONS!" : "GAME OVER"}
</h2>
{won ? (
<>
<p style={{ ...subTextStyle, lineHeight: 2 }}>
Your party has arrived in<br/>Oregon's Willamette Valley!
</p>
<div style={{ background: "#1a1208", borderRadius: 8, padding: 16, margin: "20px 0", border: "1px solid #3a2a1a" }}>
<p style={{ color: "#4a7a2e", fontSize: 14, fontFamily: "'Press Start 2P', monospace" }}>Score: {score}</p>
</div>
<p style={{ fontSize: 9, color: "#806a50" }}>
Survivors: {game.party.filter(p => p.alive).map(p => p.name).join(", ")}
</p>
</>
) : (
<p style={{ ...subTextStyle, lineHeight: 2, marginTop: 10 }}>
{game.food <= 0 ? "Your party starved on the trail." :
game.party[0] && !game.party[0].alive ? `${game.party[0].name} has died.` :
game.oxen <= 0 ? "You have no oxen to pull the wagon." :
"Your journey has ended."}
</p>
)}
<button onClick={onRestart} style={{ ...btnStyle("#4a7a2e"), marginTop: 24 }}>
Play Again
</button>
</div>
);
}
// ─── MAIN GAME ────────────────────────────────────────────
const INITIAL_GAME = {
mile: 0, totalMiles: 2040, day: 1, month: 0,
health: 100, food: 0, money: 0,
oxen: 0, clothing: 0, ammunition: 0,
wheels: 0, axles: 0, tongues: 0,
pace: "steady", rations: "filling",
weather: "clear", party: [], scoreMultiplier: 1,
nextLandmark: LANDMARKS[1], landmarkIndex: 0,
visitedLandmarks: {}, // track which landmarks we've already handled
log: [],
};
export default function OregonTrail() {
const [screen, setScreen] = useState("title");
const [game, setGame] = useState({ ...INITIAL_GAME });
const [menuOpen, setMenuOpen] = useState(false);
const [eventText, setEventText] = useState(null);
const [riverData, setRiverData] = useState(null);
const scrollRef = useRef(null);
const addLog = useCallback((msg) => {
setGame(g => ({ ...g, log: [...g.log.slice(-50), msg] }));
}, []);
const startGame = () => setScreen("occupation");
const selectOccupation = (occ) => {
setGame(g => ({ ...g, money: occ.money, scoreMultiplier: occ.scoreMultiplier }));
setScreen("naming");
};
const setNames = (names) => {
setGame(g => ({ ...g, party: names.map(n => ({ name: n, alive: true, sick: null })) }));
setScreen("month");
};
const selectMonth = (m) => {
setGame(g => ({ ...g, month: m }));
setScreen("store");
};
const buyItems = (cart, total) => {
setGame(g => ({
...g,
money: g.money - total,
oxen: g.oxen + (cart.oxen || 0) * 2,
food: g.food + (cart.food || 0),
clothing: g.clothing + (cart.clothing || 0),
ammunition: g.ammunition + (cart.ammunition || 0) * 20,
wheels: g.wheels + (cart.wheels || 0),
axles: g.axles + (cart.axles || 0),
tongues: g.tongues + (cart.tongues || 0),
}));
};
const leaveStore = () => {
if (game.oxen < 2) {
setEventText("You need at least 1 yoke of oxen (buy oxen in the store)!");
return;
}
setScreen("trail");
setEventText(null);
};
const advanceDay = useCallback(() => {
setGame(g => {
let ng = { ...g };
// Pace affects miles
const paceMiles = g.pace === "steady" ? rand(12, 18) : g.pace === "strenuous" ? rand(16, 24) : rand(20, 30);
// Rations affect food
const partySize = g.party.filter(p => p.alive).length;
const rationMulti = g.rations === "filling" ? 3 : g.rations === "meager" ? 2 : 1;
const foodUsed = partySize * rationMulti;
ng.mile = Math.min(g.totalMiles, g.mile + paceMiles);
ng.food = Math.max(0, g.food - foodUsed);
// Health
let healthDelta = 0;
if (g.rations === "bare bones") healthDelta -= 2;
if (g.rations === "meager") healthDelta -= 1;
if (g.pace === "grueling") healthDelta -= 2;
if (g.pace === "strenuous") healthDelta -= 1;
if (g.food <= 0) healthDelta -= 8;
if (g.weather === "rainy" || g.weather === "snowy") healthDelta -= 1;
if (g.clothing >= partySize * 2) healthDelta += 1;
healthDelta += 1; // natural recovery
ng.health = Math.max(0, Math.min(100, g.health + healthDelta));
// Advance date
ng.day = g.day + 1;
if (ng.day > 30) { ng.day = 1; ng.month = g.month + 1; }
// Weather
if (Math.random() < 0.2) {
const mo = ng.month;
if (mo >= 6) ng.weather = pick(["clear","clear","rainy","snowy","foggy"]);
else if (mo >= 4) ng.weather = pick(["clear","clear","clear","hot","rainy"]);
else ng.weather = pick(["clear","clear","rainy","foggy","snowy"]);
}
// Check landmark -- only advance the index, don't trigger screens here
const nextLM = LANDMARKS[g.landmarkIndex + 1];
if (nextLM && ng.mile >= nextLM.mile) {
ng.landmarkIndex = g.landmarkIndex + 1;
ng.nextLandmark = LANDMARKS[ng.landmarkIndex + 1] || null;
// Don't mark as visited here -- the useEffect will handle showing the screen
}
// Random events (15% chance)
if (Math.random() < 0.15) {
const evt = pick(TRAIL_EVENTS);
let canApply = true;
if (evt.effect === "wheels" && ng.wheels <= 0) canApply = false;
if (evt.effect === "axles" && ng.axles <= 0) canApply = false;
if (evt.effect === "tongues" && ng.tongues <= 0) canApply = false;
if (canApply) {
if (evt.effect === "food") ng.food = Math.max(0, ng.food + evt.amount);
if (evt.effect === "health") ng.health = Math.max(0, Math.min(100, ng.health + evt.amount));
if (evt.effect === "miles") ng.mile = Math.max(0, ng.mile + evt.amount);
if (evt.effect === "oxen") ng.oxen = Math.max(0, ng.oxen + evt.amount);
if (evt.effect === "wheels") ng.wheels = Math.max(0, ng.wheels + evt.amount);
if (evt.effect === "axles") ng.axles = Math.max(0, ng.axles + evt.amount);
if (evt.effect === "tongues") ng.tongues = Math.max(0, ng.tongues + evt.amount);
ng.log = [...g.log.slice(-50), evt.text];
}
}
// Disease (5% per person)
ng.party = ng.party.map(p => {
if (!p.alive) return p;
if (p.sick) {
if (Math.random() < 0.15) return { ...p, alive: false };
if (Math.random() < 0.3) return { ...p, sick: null };
return p;
}
if (Math.random() < 0.05 * (1 - ng.health / 150)) {
const disease = pick(DISEASES);
ng.log = [...ng.log.slice(-50), `${p.name} has ${disease}`];
return { ...p, sick: disease };
}
return p;
});
return ng;
});
}, []);
// Check for game end states
useEffect(() => {
if (screen !== "trail") return;
if (game.mile >= game.totalMiles) {
setScreen("won");
} else if (game.party[0] && !game.party[0].alive) {
setScreen("gameover");
} else if (game.oxen <= 0) {
setScreen("gameover");
} else if (game.health <= 0) {
// Everyone is too sick to continue
setGame(g => ({ ...g, party: g.party.map((p, i) => i === 0 ? { ...p, alive: false } : p) }));
setScreen("gameover");
}
}, [game.mile, game.party, game.oxen, game.health, screen]);
// Check for landmark arrival -- only trigger once per landmark
useEffect(() => {
if (screen !== "trail") return;
const lm = LANDMARKS[game.landmarkIndex];
if (!lm || lm.mile === 0) return;
if (game.mile < lm.mile) return;
// Already visited this landmark? Skip.
if (game.visitedLandmarks[game.landmarkIndex]) return;
// Mark as visited FIRST to prevent re-triggering
setGame(g => ({ ...g, visitedLandmarks: { ...g.visitedLandmarks, [g.landmarkIndex]: true } }));
if (lm.type === "river") {
setRiverData(lm);
setScreen("river");
} else if (lm.type === "fort") {
setEventText(`You have arrived at ${lm.name}. You can buy supplies here.`);
setScreen("fort_store");
} else {
setEventText(`You have reached ${lm.name}!`);
}
}, [game.landmarkIndex, game.mile, screen]);
const handleRiverCross = (success, message, loss) => {
setGame(g => {
let ng = { ...g };
if (loss.food) ng.food = Math.max(0, ng.food - loss.food);
if (loss.money) ng.money = Math.max(0, ng.money - loss.money);
if (loss.days) { ng.day += loss.days; }
return ng;
});
setRiverData(null);
setEventText(message);
setScreen("trail");
};
const handleTrade = (offer, offerAmt, want, wantAmt) => {
setGame(g => {
let ng = { ...g };
ng[offer] = (ng[offer] || 0) + offerAmt;
ng[want] = Math.max(0, (ng[want] || 0) - wantAmt);
return ng;
});
setEventText("You made a trade!");
setScreen("trail");
};
const handleHuntFinish = (food, ammoUsed) => {
setGame(g => ({ ...g, food: g.food + food, ammunition: g.ammunition - ammoUsed }));
setEventText(`You brought back ${food} lbs of food.`);
setScreen("trail");
};
const restart = () => {
setGame({ ...INITIAL_GAME });
setScreen("title");
setEventText(null);
setRiverData(null);
setMenuOpen(false);
};
// Trail screen
const TrailScreen = () => (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<TrailScene weather={game.weather} mile={game.mile} totalMiles={game.totalMiles} />
<StatusBar game={game} />
{/* Progress bar */}
<div style={{ background: "#1a1208", padding: "6px 16px" }}>
<div style={{ background: "#0d0a06", borderRadius: 4, height: 10, overflow: "hidden", border: "1px solid #3a2a1a" }}>
<div style={{ width: `${(game.mile / game.totalMiles) * 100}%`, height: "100%", background: "linear-gradient(90deg, #4a7a2e, #6aaa3e)", transition: "width 0.5s" }}/>
</div>
</div>
{/* Event text */}
{eventText && (
<div style={{ background: "#2a1a0a", padding: "8px 16px", borderTop: "1px solid #3a2a1a", fontSize: 10, color: "#e8dcc8" }}>
{eventText}
<button onClick={() => setEventText(null)} style={{ marginLeft: 8, background: "none", border: "none", color: "#4a7a2e", cursor: "pointer", fontSize: 10, fontFamily: "'Press Start 2P', monospace" }}>OK</button>
</div>
)}
{/* Log */}
<div ref={scrollRef} style={{ flex: 1, overflow: "auto", padding: "8px 16px", minHeight: 60 }}>
{game.log.slice(-8).map((msg, i) => (
<div key={i} style={{ fontSize: 9, color: "#806a50", marginBottom: 2 }}>▸ {msg}</div>
))}
</div>
{/* Party */}
<div style={{ padding: "4px 16px", display: "flex", gap: 6, flexWrap: "wrap" }}>
{game.party.map((p, i) => (
<span key={i} style={{ fontSize: 8, padding: "2px 6px", borderRadius: 3, background: !p.alive ? "#3a1a1a" : p.sick ? "#3a3a1a" : "#1a2a1a", color: !p.alive ? "#c0392b" : p.sick ? "#e8c83a" : "#4a7a2e", border: `1px solid ${!p.alive ? "#5a2a2a" : p.sick ? "#5a5a2a" : "#2a4a2a"}` }}>
{p.name}{p.sick ? ` (${p.sick})` : ""}{!p.alive ? " ✝" : ""}
</span>
))}
</div>
{/* Actions */}
<div style={{ padding: "8px 16px 12px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
<button onClick={advanceDay} style={btnStyle("#4a7a2e")}>Continue ▶</button>
<button onClick={() => setMenuOpen(!menuOpen)} style={btnStyle("#3a2a1a")}>Menu ☰</button>
{menuOpen && <>
<button onClick={() => { setGame(g => ({ ...g, pace: PACE_OPTIONS[(PACE_OPTIONS.indexOf(g.pace) + 1) % 3] })); }} style={btnStyle("#2a3a4a")}>
Pace: {game.pace}
</button>
<button onClick={() => { setGame(g => ({ ...g, rations: RATION_OPTIONS[(RATION_OPTIONS.indexOf(g.rations) + 1) % 3] })); }} style={btnStyle("#2a3a4a")}>
Rations: {game.rations}
</button>
{game.ammunition > 0 && <button onClick={() => setScreen("hunting")} style={btnStyle("#5a4a2a")}>Hunt 🦌</button>}
<button onClick={() => { if (Math.random() < 0.4) setScreen("trade"); else setEventText("No one wants to trade right now."); }} style={btnStyle("#4a3a2a")}>
Trade
</button>
<button onClick={() => { setGame(g => ({ ...g, health: Math.min(100, g.health + 5), day: g.day + 1 })); addLog("You rested for a day."); }} style={btnStyle("#2a4a2a")}>
Rest
</button>
<button onClick={() => { setGame(g => ({ ...g })); setScreen("supplies"); }} style={btnStyle("#3a3a3a")}>
Supplies
</button>
</>}
</div>
</div>
);
const SuppliesScreen = () => (
<div style={{ padding: 20 }}>
<h2 style={headerStyle}>Supplies</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 12 }}>
{[
["Money", `$${game.money.toFixed(2)}`],
["Oxen", game.oxen],
["Food", `${game.food} lbs`],
["Clothing", `${game.clothing} sets`],
["Ammunition", `${game.ammunition} rounds`],
["Spare wheels", game.wheels],
["Spare axles", game.axles],
["Spare tongues", game.tongues],
].map(([label, val]) => (
<div key={label} style={{ display: "flex", justifyContent: "space-between", padding: "6px 12px", background: "#1a1208", borderRadius: 4, border: "1px solid #2a1a0a" }}>
<span style={{ fontSize: 10, color: "#a08060" }}>{label}</span>
<span style={{ fontSize: 10, color: "#e8dcc8" }}>{val}</span>
</div>
))}
</div>
<button onClick={() => setScreen("trail")} style={{ ...btnStyle("#4a7a2e"), width: "100%", marginTop: 16 }}>Back</button>
</div>
);
return (
<div style={{ maxWidth: 480, margin: "0 auto", minHeight: "100vh", background: "#120e08", fontFamily: "'Press Start 2P', monospace", color: "#e8dcc8", display: "flex", flexDirection: "column" }}>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"/>
<style>{`
@keyframes rain { from { transform: translateY(-10px); } to { transform: translateY(200px); } }
@keyframes snow { from { transform: translateY(-10px) rotate(0deg); } to { transform: translateY(200px) rotate(360deg); } }
* { box-sizing: border-box; margin: 0; padding: 0; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0d0a06; }
::-webkit-scrollbar-thumb { background: #3a2a1a; border-radius: 3px; }
button { font-family: 'Press Start 2P', monospace; }
input { font-family: 'Press Start 2P', monospace; }
`}</style>
{screen === "title" && <TitleScreen onStart={startGame} />}
{screen === "occupation" && <OccupationScreen onSelect={selectOccupation} />}
{screen === "naming" && <NamingScreen onDone={setNames} />}
{screen === "month" && <MonthScreen onSelect={selectMonth} />}
{screen === "store" && <StoreScreen money={game.money} supplies={game} onBuy={buyItems} onLeave={leaveStore} error={eventText} />}
{screen === "fort_store" && <StoreScreen money={game.money} supplies={game} onBuy={buyItems} onLeave={() => { setScreen("trail"); setEventText(null); }} landmark={LANDMARKS[game.landmarkIndex]?.name} />}
{screen === "trail" && <TrailScreen />}
{screen === "supplies" && <SuppliesScreen />}
{screen === "hunting" && <HuntingScreen ammo={game.ammunition} onFinish={handleHuntFinish} />}
{screen === "river" && riverData && <RiverScreen river={riverData} supplies={game} onCross={handleRiverCross} />}
{screen === "trade" && <TradeScreen onTrade={handleTrade} onDecline={() => { setEventText("You declined the trade."); setScreen("trail"); }} />}
{screen === "won" && <GameOverScreen won={true} game={game} onRestart={restart} />}
{screen === "gameover" && <GameOverScreen won={false} game={game} onRestart={restart} />}
</div>
);
}
const btnStyle = (bg) => ({
background: bg, color: "#e8dcc8", border: `2px solid ${bg === "#4a7a2e" ? "#6aaa3e" : "#5a4a3a"}`,
padding: "10px 16px", borderRadius: 6, cursor: "pointer", fontSize: 10,
transition: "all 0.15s", textAlign: "center",
});
const smallBtn = {
background: "#2a1a0a", color: "#e8dcc8", border: "1px solid #5a4a3a",
width: 24, height: 24, borderRadius: 4, cursor: "pointer", fontSize: 12,
display: "flex", alignItems: "center", justifyContent: "center",
};
const headerStyle = {
fontFamily: "'Press Start 2P', monospace", fontSize: 14, color: "#4a7a2e",
marginBottom: 8, textShadow: "1px 1px 0 rgba(0,0,0,0.5)",
};
const subTextStyle = { fontSize: 10, color: "#a08060", lineHeight: 1.6 };
const inputStyle = {
width: "100%", padding: "8px 10px", background: "#1a1208", border: "2px solid #3a2a1a",
borderRadius: 4, color: "#e8dcc8", fontSize: 10, marginTop: 4, outline: "none",
};
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **Do NOT import external JS libraries via script tags** — they will be blocked. Include library code inline or use a CDN link in a `<link>` tag for CSS only.
6. **Keep total code under 80KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained JSX file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/GigaChad/oregon-trail-game-classic-1990 */