Skip to content
This repository was archived by the owner on Oct 25, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion brightway-park/ART-CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

The seaside fairground models, visitor figures, entrance sign and UI in this game
are original artwork authored for Brightway Park. `scripts/author-models.mjs`
reproduces the twenty-three GLB meshes in `public/art/models/` using native geometry.
reproduces the eighty GLB meshes in `public/art/models/` (rides, moving ride parts, coaster track pieces,
shops, facilities, scenery, paths and people) and `motion.json` using native geometry; the kit is in `scripts/models/`.
No third-party models are embedded in those meshes.

The existing published asset catalogs remain configured for editor compatibility.
Expand Down
78 changes: 45 additions & 33 deletions brightway-park/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,47 +9,59 @@ rating climbing, and keep the crew on the breakdowns. Runs on published `@jgengi

Read from `src/game/{park,catalog,objects,sim,scenarios,milestones,rides}`; the park is one declaration in `park.ts`. Numbers are what the build does now.

- **Core loop.** $6,000 and a 150-second day starting at 09:00. Guests arrive through the gate while
it is open, pay the entry fee (default $18), then pick a ride or a stall by need, mood, the wait they
can see and the price. Upkeep and staff wages run by the second through the day; each midnight
restocks the stalls (paid per item sold) and takes the loan instalment.
- **Core loop.** $10,000 in the till and $10,000 owed to the bank at 0.8% a day, a 150-second day
starting at 09:00. Guests arrive through the gate while it is open, pay the entry fee (default $8;
the more the park is worth, the more they will pay), then pick a ride or a stall by need, mood, the
wait they can see and the price. Upkeep and wages run by the second; each midnight restocks the
stalls (paid per item sold) and takes the loan's interest.
- **Progression — four systems.**
- [x] Rides that rate. Every placed ride runs the shared ride sim: its own load, ride and unload cycle,
its own line whose wait comes from measured throughput, its own wear and breakdown curve. A
breakdown takes the ride offline, sours the crowd's mood, drops the ride's appeal out of the rating
and queues a repair for the two-lane crew (22 s and $90 at the carousel); a mechanic bay cuts each
repair by a third, and servicing clears wear before it breaks. The status card flags a ride that is down; the park books show the queue.
- [x] Money that binds. Entry fee plus a per-ride ticket the park sets; guests weigh the ticket
against the wait. Staff are a wage line separate from facility upkeep. A $6,000 loan is repaid $703 a
night for ten nights ($7,030 in all), or an instalment early on demand; what is owed shows in the park books (Books, or L). Rating still gates the crowd: 34 visitors at zero stars, plus 0.14 per star.
- [x] Milestones that open real content. Six milestones on the park's best-ever rating; each
locked blueprint's world condition reads that rating, and each milestone names the unlock it grants (flower beds and lamps, the drinks kiosk and fountain, the ferris wheel
and souvenir shop, the mechanic bay, the drop tower, the grand topiary), and the game refuses to
load if a milestone opens nothing, so a dead milestone fails at startup instead of in play.
- [x] Three scenarios. Opening Weekend ($2,000 at the gate over two days), Star Attraction (420 stars
by the end of day three) and Full House (80 visitors in one day), each a timed objective on the
shared mission runtime with its own fail states, reward and fresh-park replay. Free play is the
sandbox: the blue ribbon needs three rides, rating 220, happiness above 50, litter under 40, a
profitable day, and three days in debt still closes the park for good.
- **Failure states.** A day ends in the red and the loan instalment still comes out of the till; an
unpaid bill becomes debt, and three days in debt closes the park for good. A breakdown takes its ride
offline until its queued repair lands — the crew is sent at once when the till can pay 90 at the carousel, 140 at the coaster station, 180 at the
drop tower, and waits for a click when it cannot — so a park that skips servicing loses its queue and its rating with it. A scenario that
misses its clock fails in its own named state rather than ending free play.
- **Minute 5 → hour 5.** A day is 150 seconds and the gate is open from 09:36 to 22:04, so 5:00 is two
park days in: the first bill settled, one milestone unlocked, and the first instalment of the $6,000
loan taken. Hour five is around day 24 at 1× (day 6 at 4×): the ferris-wheel milestone is passed, a
full-house day is the next reachable goal, and the loan is either repaid or the park is gone.
- [x] Twenty-eight rides in five families (gentle 8, thrill 8, water 4, transport 3, coasters 5),
each with its own excitement, intensity, nausea, capacity, cycle and ticket on the shared ride
sim. Wear builds every cycle until the ride breaks; the crew's repair is a full overhaul, a
mechanic bay cuts it by a third, and servicing clears wear before it breaks.
- [x] Money that binds. Every stall and ride has a price the player sets; a guest refuses one more
than a quarter over what it is worth ("too expensive" counts on the dashboard). Ten shops and
facilities: burgers, drinks, toilets and the information kiosk at the start, six more in the lab.
Borrow or repay in $1,000 steps up to $25,000 in the park books (Books, or L), which show what is
owed and the night's interest.
- [x] A research lab. Four rides, four shops and nine scenery pieces are open at the start; every
other ride, shop, theme piece and the mechanic bay is a lab topic with its own fee and time, each
after the one before it in its family.
- [x] Theming. Fourteen scenery pieces (library trees, bushes, rocks, benches, lamps and bins, plus
the park's own flower beds, hedges, fountain, topiary, statue, arch and flagpole) add charm to
every ride in reach, which lifts the ride's appeal and excitement, up to 6 points a ride.
- [x] Awards. Six rating awards (150 to 900 stars) pay $500 to $6,000 once each.
- [x] Guests say what they think. Each guest keeps its last few thoughts (hungry, needs the toilet,
feels sick, the queue is too long, too expensive, great ride); the dashboard shows what the crowd
is saying most, and a park full of complaints loses rating.
- [x] Staff with patrol areas. Handymen sweep, mechanics inspect and service worn rides, entertainers
cheer guests and ease long queues, security guards stop unhappy guests making a mess; each works
a ring the player widens or narrows.
- [x] Building like a park sim. Height levels with land raise and lower (drag an area, $25 a cell);
rides need flat ground. Paths and queue lines drag out as a line. A ride opens only with an
entrance and an exit against it joined to the paths from the gate, and a shop only with a path
beside it; a shut one says why and offers "Connect it". Guests queue on the queue line, which
makes a long wait easier to bear. U undoes the last build, demolish or land change.
- [x] Three scenarios. Opening Weekend, Star Attraction and Full House, each a timed objective on the
shared mission runtime with its own fail states, reward and fresh-park replay.
- **Failure states.** A day that ends in the red counts toward bankruptcy; three days in the red
closes the park for good. A breakdown takes its ride offline until its queued repair lands, so a
park that skips servicing loses its queue and its rating with it. A scenario that misses its clock
fails in its own named state rather than ending free play.
- **Minute 5 → hour 5.** The 30-minute balance bot (`bun scripts/balance.ts 30`) spends the opening
$10,000 on rides and shops (with their entrances, exits and paths) in four minutes, then earns
about $275 a minute operating ($610 taking) while the rating climbs from 160 to 370, so each new
$2,000-$5,000 ride is ten minutes of good play. Hour five is the top of the lab and the 600-900 star awards.
- **References it is chasing.**
- *RollerCoaster Tycoon 2* — feel: guests as individuals with opinions you can click; density: a park
full of walkers at every zoom; UI: build palette beside a guest window, price control in the stall.
- *Parkitect* — feel: upkeep and restocking that actually bite; density: a compact grid where every
square is accounted for; UI: rating and finance panels that explain the number rather than show it.
- *Planet Coaster* — feel: milestone celebration worth pausing for; density: crowd scale at the gate;
UI: dashboard-first management with the park still visible behind it.
- **Gap to target.** Past the scenarios there is no campaign ladder and no second park, and the
catalogue ends at 16 objects. A long queue's mood cost is the park's main soft failure today; a paid
queue-skip or a second gate would take the pressure off.
- **Gap to target.** Next: a piece-by-piece coaster builder (slopes, turns, loops, lifts) with a
test run, scenario goals like 800 guests and a 600 rating by year 2, and guests that walk the
paths rather than straight to the door.

Loop proof: to-open-park

Expand Down
39 changes: 39 additions & 0 deletions brightway-park/proofs/flows/build-staff-thoughts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{ "steps": [
{ "assert": "/gates open/i.test(document.body.innerText)",
"before": { "is": "/gates open/i.test(document.body.innerText)", "timeoutMs": 30000 },
"note": "arrival: the park day is running and the gate is open" },

{ "key": "Digit1", "ms": 80, "note": "the palette's carousel hotkey" },
{ "clickAt": [1120, 420], "note": "an empty plot right of the main path, clear of the palette" },
{ "assert": "document.body.innerText.includes('8 rides · 3/8')",
"before": { "is": "document.body.innerText.includes('8 rides · 3/8')", "timeoutMs": 5000 },
"note": "arrival: the starter park's two rides plus the placed carousel" },

{ "clickAt": [1120, 400], "note": "click the new carousel to inspect it" },
{ "assert": "document.querySelector('.bw-side')?.innerText.includes('Needs an entrance') === true",
"before": { "is": "document.querySelector('.bw-side')?.innerText.includes('Connect it') === true", "timeoutMs": 5000 },
"note": "arrival: a new ride is shut until it has an entrance, an exit and a path, and says so" },
{ "click": "Connect it" },
{ "assert": "document.body.innerText.includes('Connected')",
"before": { "is": "document.body.innerText.includes('Connected')", "timeoutMs": 5000 },
"note": "arrival: Connect it laid the entrance, exit and paths, so the ride opens" },
{ "shot": "brightway-connected" },

{ "click": "Staff" },
{ "click": "Handyman" },
{ "clickAt": [1300, 470], "note": "grass right of the carousel, inside the fence" },
{ "assert": "document.body.innerText.includes('Handyman built')",
"before": { "is": "document.body.innerText.includes('Handyman built')", "timeoutMs": 5000 },
"note": "arrival: a handyman is hired, the park's own notice confirming it" },

{ "assert": "document.querySelectorAll('.bw-said li').length > 0",
"before": { "is": "document.querySelectorAll('.bw-said li').length > 0", "timeoutMs": 30000 },
"note": "arrival: the status card lists what the crowd is saying, from the live guests' thoughts" },
{ "assert": "window.__jgengineAgent.handle({ method: 'run_command', name: 'park.pointer', input: { point: [0, 0, 0], entity: window.__brightway.read().thinkingGuests[0], object: null } }).status",
"is": "applied",
"note": "pick a live guest the way a click on one does: the pointer command with that guest as the hit entity" },
{ "assert": "document.querySelector('.bw-thoughts li') !== null",
"before": { "is": "document.querySelector('.bw-thoughts li') !== null", "timeoutMs": 10000 },
"note": "arrival: the guest window shows that guest's own thoughts" },
{ "shot": "brightway-guest-thoughts" }
] }
8 changes: 4 additions & 4 deletions brightway-park/proofs/flows/to-open-park.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
"note": "arrival: the park day is running and the gate is open, not a title card" },

{ "key": "Digit1", "ms": 80, "note": "the palette's carousel hotkey" },
{ "clickAt": [400, 700] },
{ "assert": "document.body.innerText.includes('Three rides · 3/3')",
"before": { "is": "document.body.innerText.includes('Three rides · 3/3')", "timeoutMs": 5000 },
{ "clickAt": [1120, 420], "note": "an empty plot right of the main path, clear of the palette" },
{ "assert": "document.body.innerText.includes('8 rides · 3/8')",
"before": { "is": "document.body.innerText.includes('8 rides · 3/8')", "timeoutMs": 5000 },
"note": "arrival: the starter park's two rides plus one placed carousel are online, so the ride exists in the park and not just as a selected blueprint" },

{ "key": "Digit6", "ms": 80, "note": "the palette's path hotkey" },
{ "clickAt": [300, 700] },
{ "clickAt": [1300, 470] },
{ "assert": "document.body.innerText.includes('Path built')",
"before": { "is": "document.body.innerText.includes('Path built')", "timeoutMs": 5000 },
"note": "arrival: a guest path is on the ground, the park's own notice confirming the placement" },
Expand Down
2 changes: 1 addition & 1 deletion brightway-park/public/art/models/motion.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
0
],
"speed": 0.12,
"amplitude": 0.7
"amplitude": 0.75
},
"ride_topspin": {
"model": "ride_topspin_motion",
Expand Down
Binary file modified brightway-park/public/art/models/ride_boats.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/ride_inverted.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/ride_looping.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/ride_pirate.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/ride_pirate_motion.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/ride_rapids.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/ride_splash.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/staff_entertainer.glb
Binary file not shown.
Binary file modified brightway-park/public/art/models/track_loop.glb
Binary file not shown.
94 changes: 94 additions & 0 deletions brightway-park/scripts/balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* The first 30 minutes of play at 1x, headless: a steady player who opens the park and, every half
* minute, builds the next ride or shop it can afford while keeping a cash reserve, and keeps the lab
* busy when the till allows. Prices stay at their defaults.
* Prints the till, takings and running costs minute by minute and the cash per minute.
*
* bun scripts/balance.ts [minutes=30]
*/
import { createHeadlessRunner } from "@jgengine/core/runtime/headlessRunner";
import { game } from "../src/game.config";
import { content } from "../src/game/content";
import { BUILDABLES, BUILD_IDS } from "../src/game/objects/catalog";
import { LOT, park } from "../src/game/park";
import { session } from "../src/game/session";
import { connectPark } from "../src/game/build/links";

const records = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: (key: string) => records.get(key) ?? null,
setItem: (key: string, value: string) => records.set(key, value),
removeItem: (key: string) => records.delete(key),
} });

const minutes = Number(process.argv[2] ?? 30);
const runner = createHeadlessRunner({ definition: game.game, content, loop: game.game.loop, maxStepSeconds: 1 });
runner.ui.invoke("park.start");
const w = park.of(runner.ctx);
const buildable = BUILD_IDS.filter((id) => BUILDABLES[id]!.category === "ride" || BUILDABLES[id]!.category === "stall");
let built = 0;
let spentOnBuilds = 0;
const RESERVE = Number(process.env.RESERVE ?? 1500);
const tryResearch = () => {
if (w.orders("lab").length > 0 || w.num("park.cash") < 4000) return;
for (const id of BUILD_IDS) {
if (BUILDABLES[id]!.research === undefined) continue;
if (w.canAct(`research_${id}`) === null && runner.ctx.game.commands.run("brightway.act", { action: `research_${id}` }).status === "applied") return;
}
};
const tryBuild = () => {
const cash = w.num("park.cash") - RESERVE;
// A steady player hires a handyman once the paths start to look dirty.
const wanted = w.num("park.litter") > 15 && w.num("n.staff_janitor") < 1 + Math.floor(session.guests.size / 80) ? ["staff_janitor"] : [];
for (const kind of wanted) for (let row = 4; row < LOT.rows - 4; row += 2) for (let col = 4; col < LOT.cols - 4; col += 2) {
if (wanted.length === 0 || w.canPlace(kind, { cell: [col, row] }) !== null) continue;
if (runner.ctx.game.commands.run("brightway.place", { kind, cell: [col, row] }).status === "applied") { built += 1; spentOnBuilds += BUILDABLES[kind]!.cost; wanted.length = 0; }
}
const order = [...buildable].sort((a, b) => (w.num(`n.${a}`) - w.num(`n.${b}`)) || BUILDABLES[a]!.cost - BUILDABLES[b]!.cost);
for (const kind of order) {
if (BUILDABLES[kind]!.cost > cash) continue;
if (w.canPlace(kind)?.reason === "locked") continue;
for (let row = 2; row < LOT.rows - 2; row += 1) for (let col = 2; col < LOT.cols - 2; col += 1) {
if (w.canPlace(kind, { cell: [col, row] }) !== null) continue;
const before = new Set(w.instances().map((i) => i.id));
if (runner.ctx.game.commands.run("brightway.place", { kind, cell: [col, row] }).status === "applied") {
// A steady player lays the entrance, exit and paths to it straight away, or takes it back down.
const id = w.instances().find((i) => !before.has(i.id))!.id;
const cashBefore = w.num("park.cash");
connectPark(runner.ctx, { only: id });
if (w.fact(`${id}.closed`) === true) { runner.ctx.game.commands.run("brightway.remove", { id }); continue; }
built += 1; spentOnBuilds += BUILDABLES[kind]!.cost + (cashBefore - w.num("park.cash")); return;
}
}
}
};
const start = w.num("park.cash");
let lastCash = start;
let takings = 0;
let lastRevenue = w.num("park.revenue_today");
let lastSpent = w.num("park.spent_today");
let lastAdmits = w.num("park.guests_today");
console.log("min cash d.cash takings/min running/min admits guests rating rides built");
for (let m = 1; m <= minutes; m += 1) {
let takingsThisMinute = 0;
let spentThisMinute = 0;
let admitsThisMinute = 0;
for (let s = 0; s < 60; s += 1) {
if (s % 30 === 0) { tryBuild(); tryResearch(); }
runner.step(1);
const rev = w.num("park.revenue_today");
const delta = rev >= lastRevenue ? rev - lastRevenue : rev;
takingsThisMinute += delta; lastRevenue = rev;
const sp = w.num("park.spent_today"); spentThisMinute += sp >= lastSpent ? sp - lastSpent : sp; lastSpent = sp;
const ad = w.num("park.guests_today"); admitsThisMinute += ad >= lastAdmits ? ad - lastAdmits : ad; lastAdmits = ad;
}
takings += takingsThisMinute;
const cash = w.num("park.cash");
console.log(`${String(m).padStart(3)} ${String(Math.round(cash)).padStart(6)} ${String(Math.round(cash - lastCash)).padStart(7)} ${String(Math.round(takingsThisMinute)).padStart(11)} ${String(Math.round(spentThisMinute)).padStart(11)} ${String(admitsThisMinute).padStart(6)} ${String(session.guests.size).padStart(7)} ${String(Math.round(w.num("park.rating"))).padStart(6)} ${String(w.num("n.ride_carousel") >= 0 ? Object.values(w.instances()).filter((i) => BUILDABLES[i.kind]?.ride).length : 0).padStart(5)} ${String(built).padStart(5)}`);
lastCash = cash;
}
const end = w.num("park.cash");
console.log(`\nstart $${Math.round(start)} end $${Math.round(end)} built ${built} ($${spentOnBuilds})`);
console.log(`till change per minute: $${Math.round((end - start) / minutes)}`);
console.log(`operating cash per minute (till change + build spend): $${Math.round((end - start + spentOnBuilds) / minutes)}`);
console.log(`takings per minute: $${Math.round(takings / minutes)}`);
4 changes: 2 additions & 2 deletions brightway-park/scripts/models/coasters.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Coasters and coaster-builder track pieces.
import { T, P, V, Y, box, cyl, ball, ring, beam, group, mesh, segBox, deckRect, bulbLine, pennant, bunting, railing, signBoard, stairs, tube, frames, pathLengths, onPath, buildTrack, Turtle, lampPost, shrub, tree } from './core.mjs';
import { T, P, V, Y, loopShift, box, cyl, ball, ring, beam, group, mesh, segBox, deckRect, bulbLine, pennant, bunting, railing, signBoard, stairs, tube, frames, pathLengths, onPath, buildTrack, Turtle, lampPost, shrub, tree } from './core.mjs';

const PI = Math.PI;
function steelCar(c, color, front) {
Expand Down Expand Up @@ -139,7 +139,7 @@ export function trackLoop() {
const g = new T.Group(), pts = [], ups = [], R = 3, c = 4 / (PI * 2), a = 1.6, n = 64;
for (let i = 0; i <= n; i++) {
const th = i / n * PI * 2;
pts.push(V(1.0 * Math.sin(th), 1.1 + R * (1 - Math.cos(th)), -2 + c * th + a * Math.sin(th)));
pts.push(V(4.4 * loopShift(th), 1.1 + R * (1 - Math.cos(th)), -2 + c * th + a * Math.sin(th)));
ups.push(V(0, c + a * Math.cos(th), -R * Math.sin(th)).normalize());
}
const F = piece(g, pts, ups, [1, n - 1]);
Expand Down
Loading
Loading