A walkable Mandalay Bay that mounts the terminal’s own screens

Published

July 25, 2026

Abstract

The pixel RPG is the third playable surface of The Mandalay Bay: a Phaser 3 top-down overworld of twenty-eight authored rooms, laid over the exact same chip wallet, save slot, and rule set as the Python CLI and the web terminal. This page is generated from the live world data in docs/rpg/js/data/ — every table below is read from the JSON the game itself loads at boot.

Keywords

Phaser, pixel RPG, overworld, quest design, Mandalay Bay

1 What it is

Build a guest — archetype, skin tone, hair, outfit — arrive on Las Vegas Blvd, and walk in through the gold doors. From there the resort is a place rather than a menu tree: you cross the casino floor to reach the sports book, ride the tower up to a room that charges you nightly, and get stopped by staff who spotted you from across the carpet.

The reference feel is Pokémon — a top-down overworld, a START menu that holds your whole life, line-of-sight challengers, a collection dex, and secrets that pay in flavor rather than chips.

Tiles, props, and guest sprites are drawn at boot by TextureFactory.js (no vendored assets/tiles/ loader on the live path). Characters are 32×44 tuxedo-style pixel grids with a three-frame stride; the wardrobe recolors skin, hair, and outfit.

Code
pd.DataFrame(
    [
        {"Content": "Rooms", "Count": len(MAPS)},
        {"Content": "NPCs", "Count": sum(len(v) for v in NPCS.values())},
        {"Content": "Doors", "Count": sum(len(m.get("doors", [])) for m in MAPS.values())},
        {"Content": "Dialogue nodes", "Count": len(DIALOGUES)},
        {"Content": "Quests", "Count": len(QUESTS)},
        {"Content": "Easter eggs", "Count": len(EGGS)},
        {"Content": "Zone triggers", "Count": len(TRIGGERS)},
    ]
)
Table 1. World size at render time, read from the authored JSON
Content Count
0 Rooms 33
1 NPCs 78
2 Doors 76
3 Dialogue nodes 316
4 Quests 10
5 Easter eggs 22
6 Zone triggers 28

2 The delegation rule

The RPG does not reimplement game logic or game screens. Every casino, hotel, pool, and shopping flow lives once in docs/js/ as a buildXRenderers(ctx) factory. The web terminal spreads those factories into its renderer table; the RPG’s TerminalHostOverlay builds the same context and mounts them inside an encounter panel. A feature shipped to the terminal shows up in the RPG with no RPG-side work.

Code
flowchart LR
  LOGIC["docs/js/ — rules, wallet, world clock"] --> UI["docs/js/ui/ — buildXRenderers(ctx)"]
  UI --> APP["app.js — web terminal"]
  UI --> HOST["TerminalHostOverlay — RPG"]
  DATA["docs/rpg/js/data/*.json"] --> OW["OverworldScene"]
  OW -->|encounter id| HOST
  OW -->|Esc / X| MENU["MenuOverlay — START"]
  MENU --> HOST

flowchart LR
  LOGIC["docs/js/ — rules, wallet, world clock"] --> UI["docs/js/ui/ — buildXRenderers(ctx)"]
  UI --> APP["app.js — web terminal"]
  UI --> HOST["TerminalHostOverlay — RPG"]
  DATA["docs/rpg/js/data/*.json"] --> OW["OverworldScene"]
  OW -->|encounter id| HOST
  OW -->|Esc / X| MENU["MenuOverlay — START"]
  MENU --> HOST

The four exceptions are the screens that read better in-world: blackjack, hold’em, roulette, and the House of Blues rhythm minigame. Those are the battle screens, and they still take their bet through the shared stake-tier picker.

3 The property

Rooms are authored as declarative JSON — a base fill, ground rectangles, decor, a deterministic scatter rule for greenery, and explicit clears that keep doorways open — then compiled into tile layers by MapLoader.compileMap() at boot.

Code
rows = []
for mid, m in MAPS.items():
    rows.append(
        {
            "Wing": m.get("wing", "Unsorted"),
            "Room": m.get("label", mid),
            "Map id": mid,
            "NPCs": len(NPCS.get(mid, [])),
            "Exits": len(m.get("doors", [])),
            "Spawn": f"({m['spawn']['x']}, {m['spawn']['y']})",
        }
    )
world = pd.DataFrame(rows)
world["Wing"] = pd.Categorical(world["Wing"], categories=WING_ORDER, ordered=True)
world = world.sort_values(["Wing", "Room"]).reset_index(drop=True)

world.groupby("Wing", observed=True).agg(
    Rooms=("Room", "count"), NPCs=("NPCs", "sum"), Exits=("Exits", "sum")
).reset_index()
Table 2. Rooms, residents, and exits per wing
Wing Rooms NPCs Exits
0 Arrival 2 7 8
1 Casino 5 21 15
2 Retail 3 6 7
3 Bars 2 3 2
4 Hotel 6 13 16
5 Pool 4 6 8
6 Attractions 5 8 8
7 Back of house 1 2 1
Code
summary = world.groupby("Wing", observed=True).agg(
    Rooms=("Room", "count"), NPCs=("NPCs", "sum")
)
fig, ax = plt.subplots(figsize=(8.4, 4.0))
x = range(len(summary))
ax.bar([i - 0.2 for i in x], summary["Rooms"], width=0.4, label="Rooms",
       color="#C5050C", edgecolor="#7a0307")
ax.bar([i + 0.2 for i in x], summary["NPCs"], width=0.4, label="NPCs",
       color="#f0b429", edgecolor="#a8781a")
ax.set_xticks(list(x))
ax.set_xticklabels(summary.index, rotation=20, ha="right")
ax.set_ylabel("Count")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(frameon=False)
fig.tight_layout()
plt.show()
Figure 1. Where the world’s rooms and residents sit
Code
world
Table 3. Every room, its residents, and its exits
Wing Room Map id NPCs Exits Spawn
0 Arrival Registration Lobby registration_lobby 4 6 (15, 26)
1 Arrival Valet & Parking valet_garage 3 2 (26, 15)
2 Casino Casino Floor North main_resort 9 6 (15, 26)
3 Casino Casino Floor South casino_floor_south 4 5 (15, 3)
4 Casino Foundation Room foundation_room 3 1 (15, 26)
5 Casino High Limit Salon high_limit_salon 3 2 (15, 26)
6 Casino Race & Sports Book race_sports_book 2 1 (26, 15)
7 Retail Convention Center convention_center 2 2 (15, 26)
8 Retail Sky Bridge sky_bridge 1 3 (15, 26)
9 Retail The Shoppes at Mandalay Place mandalay_place 3 2 (15, 26)
10 Bars Betty's Bar betty_bar 2 1 (15, 26)
11 Bars Skyfall Lounge skyfall_lounge 1 1 (15, 26)
12 Hotel Bathhouse Spa spa 1 1 (15, 3)
13 Hotel Delano Wing delano_wing 1 3 (26, 15)
14 Hotel Gentleman's Club — Velvet Ledger gentlemans_club 3 1 (15, 26)
15 Hotel Guest Floor Corridor guest_corridor 4 4 (15, 26)
16 Hotel Tower Elevator Lobby hotel_tower 2 6 (15, 26)
17 Hotel Your Room guest_room 2 1 (15, 26)
18 Pool Cabanas & Hot Tubs cabana_row 2 1 (3, 15)
19 Pool Mandalay Beach mandalay_beach 2 4 (15, 3)
20 Pool Moonlight Rave Stage rave_stage 1 1 (15, 26)
21 Pool Moorea Beach Club beach_club 1 2 (15, 3)
22 Attractions HOB Green Room hob_green_room 1 1 (26, 15)
23 Attractions House of Blues house_of_blues 2 2 (15, 26)
24 Attractions Shark Reef Exhibit Hall shark_reef 2 1 (26, 15)
25 Attractions Shark Reef Tunnel reef_tunnel 1 2 (26, 15)
26 Attractions ULTRA Arena Concourse ultra_arena 2 2 (15, 26)
27 Back of house Back of House staff_corridor 2 1 (15, 26)
28 NaN Excalibur Courtyard excalibur_courtyard 2 1 (26, 15)
29 NaN Las Vegas Blvd — Excalibur strip_excalibur 2 2 (15, 3)
30 NaN Las Vegas Blvd — Luxor strip_luxor 3 3 (15, 3)
31 NaN Las Vegas Blvd — Mandalay strip_sidewalk 3 3 (15, 22)
32 NaN Luxor Atrium luxor_atrium 2 2 (26, 15)

Two doors are gated at the rope: the High Limit Salon checks chips and stake tier through docs/js/venues.js, and the Foundation Room wants Noir standing. Your own room door stops working while the folio is unpaid.

Code
gates = []
for mid, m in MAPS.items():
    for d in m.get("doors", []):
        conditions = []
        if d.get("venueGate"):
            conditions.append(f"venue gate: {d['venueGate']}")
        if d.get("requiresChips"):
            conditions.append(f"{d['requiresChips']:,} chips")
        if d.get("requiresFlag"):
            conditions.append(f"flag: {d['requiresFlag']}")
        if d.get("requiresRoomKey"):
            conditions.append("a working room key")
        if conditions:
            gates.append(
                {
                    "From": MAPS[mid].get("label", mid),
                    "To": MAPS.get(d["to"], {}).get("label", d["to"]),
                    "Requires": ", ".join(conditions),
                }
            )
pd.DataFrame(gates)
Table 4. Gated doors and what they ask for
From To Requires
0 Registration Lobby Your Room a working room key
1 Casino Floor North High Limit Salon venue gate: high_limit_salon
2 Casino Floor North Back of House flag: hint_north_wall
3 High Limit Salon Foundation Room venue gate: foundation_room
4 Tower Elevator Lobby Your Room a working room key
5 Tower Elevator Lobby Gentleman's Club — Velvet Ledger venue gate: gentlemans_club
6 Guest Floor Corridor Your Room a working room key
7 House of Blues HOB Green Room flag: hob_backstage

4 Trainers

An NPC with a sight cone notices you walking into it, crosses the room, says its piece, and drops straight into its encounter. Each one challenges you once.

Code
trainers = [
    {
        "Who": npc["name"],
        "Where": MAPS[mid].get("label", mid),
        "Watches": npc["sight"].get("dir", npc.get("direction", "down")),
        "Range": npc["sight"].get("range", 4),
        "Encounter": npc.get("encounter", "conversation"),
    }
    for mid, roster in NPCS.items()
    for npc in roster
    if npc.get("sight")
]
pd.DataFrame(trainers).sort_values("Where").reset_index(drop=True)
Table 5. Line-of-sight challengers
Who Where Watches Range Encounter
0 Blackjack Pit Casino Floor North down 5 blackjack
1 Stickman Stan Casino Floor North left 4 craps
2 Spinster Sal Casino Floor North left 4 slots_fortune
3 Slot Tech Tessa Casino Floor South right 4 slots
4 Whale Warren Foundation Room left 5 slots_high_roller
5 Sasha Gentleman's Club — Velvet Ledger left 3 conversation
6 Housekeeper Hana Guest Floor Corridor down 5 conversation
7 Salon Dealer High Limit Salon down 6 blackjack
8 Jester Jess Las Vegas Blvd — Excalibur left 3 conversation
9 Lifeguard Lou Mandalay Beach down 4 pool_wave
10 Moonlight DJ Moonlight Rave Stage down 5 pool_rave
11 Bookie Blake Race & Sports Book down 5 sportsbook
12 Busker Bo Sky Bridge down 4 conversation
13 Valet Vic Valet & Parking down 4 conversation

Staff also keep schedules. NPCs carrying a schedule entry move with the world clock’s day phase rather than standing in one spot forever:

Code
scheduled = [
    {
        "Who": npc["name"],
        "Where": MAPS[mid].get("label", mid),
        "Phases": ", ".join(sorted(npc["schedule"])),
    }
    for mid, roster in NPCS.items()
    for npc in roster
    if npc.get("schedule")
]
pd.DataFrame(scheduled).sort_values("Where").reset_index(drop=True)
Table 6. NPCs who move with the clock
Who Where Phases
0 Barkeep Betty Betty's Bar dawn
1 Spinster Sal Casino Floor North dawn
2 Security Sam Casino Floor North dawn, dusk, late, midday
3 Cocktail Cora Casino Floor South dawn
4 Housekeeper Hana Guest Floor Corridor late
5 Cab Line Carl Las Vegas Blvd — Mandalay late
6 Lifeguard Lou Mandalay Beach late
7 Bookie Blake Race & Sports Book late
8 Tourist Tina Registration Lobby dawn, dusk, late

5 Quests

Quest progress is derived, never incremented. QuestManager.syncDerived() reads reef photos, bar orders, dex counts, purchases, unlocked vignettes, egg count, and resort completion straight from shared session state, so a quest can never disagree with the system that produced it. Work you did before accepting a quest still counts — the derived value is banked and applied when you take the job.

Code
pd.DataFrame(
    [
        {
            "Quest": q["label"],
            "Given by": q.get("giverName", q.get("giver", "—")),
            "Category": q.get("category", "—"),
            "Goal": q.get("target", 1),
            "Reward": q.get("reward", "—"),
        }
        for q in QUESTS.values()
    ]
)
Table 7. The quest board from js/data/quests.json
Quest Given by Category Goal Reward
0 Shark Reef Photographer Reef Docent collection 5 Reef Photographer badge (cosmetic)
1 Dana's Lucky Hand Blackjack Pit tables 1 Dealer's nod (cosmetic)
2 Mandalay Bar Crawl Barkeep Betty amenities 3 Betty's coaster (cosmetic)
3 Meet the Pit Hold'em Pit collection 7 Pit regular lanyard (cosmetic)
4 Jackpot Hunt Spinster Sal slots 8 Loose-slot map (cosmetic)
5 Suite Stories Clerk Carmen hotel 5 Do-not-disturb hanger (cosmetic)
6 Eleven Acres Lifeguard Lou pool 4 Wave pool wristband (cosmetic)
7 Sky Bridge Haul Shop Clerk amenities 6 Mandalay Place tote (cosmetic)
8 Resort Completionist Golden Statue meta 100 Golden statue wink (cosmetic)
9 The Bay Remembers Janitor Joe secrets 10 Back-of-house key ring (cosmetic)

6 Collections

The START menu carries three collections, all counting toward resort completion.

Collection Entries Filled by
Dex — Shark Reef 5 species Photographing the reef
Dex — Slot floor 14 machines Playing each machine once
Dex — Staff met Every dealer and resort NPC Talking to people
Bag Quest items, mall purchases, minibar tabs Playing
Secrets 12 easter eggs Poking at things

Easter eggs are cosmetic only — an egg never pays chips. That is a hard design rule, and the world-data checker enforces that every egg flag is actually reachable from something in the world.

Code
pd.DataFrame(
    [{"Secret": e["label"], "Hint": e["hint"]} for e in EGGS.values()]
)
Table 8. The secrets board — hints only, no reveals
Secret Hint
0 Cherry on top Somewhere a machine still pays in fruit.
1 Retro palette Up up down down… you know the rest.
2 Back of house A wall that isn't quite a wall.
3 The talking plant Stand still near the lobby greenery.
4 Statue etiquette Everyone touches the golden statue. Not everyo...
5 Lock of the week Ask the bookie what he actually likes.
6 The floor that isn't Elevators skip a number for a reason.
7 555-0199 Dial the number nobody answers.
8 Do not tap the glass The sign says do not. The sign is a suggestion.
9 The old bell Some jackpots still ring analog.
10 Delano ghost floor The quiet tower keeps quieter guests.
11 Green room list Backstage has a guest list. You are not on it.
12 Velvet guest list Ask the floor hostess who still owes on Row F.
13 Off-menu pour The bottle captain keeps a pour that isn't pri...
14 Velvet back hall Make it rain, then ask security about the serv...
15 Monsoon receipt Make it monsoon more than once.
16 The ledger itself Tip enough that the room starts keeping score.
17 Louis toast Order the bottle that requires a spine.
18 Bar tab mosaic Keep ordering until the tab looks like art.
19 Perfect cascade Stop Tip Cascade dead-center in the green.
20 Triple Ace Nail Bottle Memory when Blair stacks Ace three...
21 Felt ace Win Dante's Felt Flip on an ace.

7 Time and money

docs/js/world-cycle.js is the single clock for all three surfaces. Two real hours make one resort day, split into four phases. In the overworld that clock tints the screen, walks NPCs to their scheduled positions, announces the day’s rotating reservation requirement, posts daily resort charges to your wallet, and — if the folio goes unpaid — evicts you and stops the room door from opening until you settle at the desk or win it back on the floor.

8 Controls

Input Action
WASD / arrows Walk
Tap / click a tile Walk there — the resort is playable on a phone
Shift Run — faster once a host comps the golf cart at Platinum
E / Enter / Space Talk to whoever you are facing, advance dialogue
Esc / X START menu
T Trainer Card and wardrobe
P MGM Rewards phone
↑↑↓↓←→←→BA Retro palette

On a phone the canvas fills the screen and a thumb pad appears in the bottom corners — a d-pad, B to run, A to talk, for the START menu — and it hides itself whenever a conversation or a panel takes over. You can also ignore the pad completely: tap a tile to walk there, tap a person to walk over and talk, and tap anywhere to advance dialogue.

Gold walkways are the wayfinding: they connect the entrance, the pits, the aisles, and every door. Dark trim marks where one floor type ends, and floating signs name the zone you are standing in.

9 Saves

The RPG writes into the same slot as the terminal and the CLI. SAVE_VERSION is 8; rpg carries position, archetype, flags, quests, inventory, dex, eggs, map visits, options, and reputation. A v7 save migrates forward without renaming a key and keeps the map it was saved on. On the Python side, mandalay_bay/saves.py carries the web-only keys through a CLI load/save round trip untouched, so playing in the terminal never erases pixel progress.

10 Extending it

Full detail lives in the Pixel RPG GDD; the short version:

To add… Do this
A room Add a record to scripts/_author_maps.py, regenerate, wire doors both ways
An NPC Add to the NPCS table plus a *_greet node in dialogues.json
A quest Add to quests.json, derive its progress in QuestManager.syncDerived()
A casino screen Build it in docs/js/ui/ and route it from HostedEncounters.js — never twice

Then run the checks:

node scripts/smoke-test-rpg.mjs     # world-data referential integrity
python3 scripts/smoke-test-web.py   # browser walk + end-to-end journey
python3 -m pytest                   # Python rules