Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <main class="container">
  
  <!-- Game board (#board) -->
  <div class="board" id="board">
    <div class="corner space nw" id="corner-free" data-pos="20">
      <div class="container">
        <div class="label">Free</div>
        <div class="symbol parking">
          <i class="fa-solid fa-car"></i>
        </div>
        <div class="label">Parking</div>
      </div>
    </div>
    <div class="row horizontal north">
      <div class="space property" data-group="4" id="prop-kentucky" data-pos="21">
        <div class="container">
          <div class="name">Kentucky Avenue</div>
          <div class="cost money">220</div>
        </div>
      </div>
      <div class="space property" data-group="4" id="prop-indiana" data-pos="22">
        <div class="container">
          <div class="name">Indiana Avenue</div>
          <div class="cost money">220</div>
        </div>
      </div>
      <div class="space deck chance color-1" data-deck="chance" id="chance-2" data-pos="23">
        <div class="container">
          <div class="label">Chance</div>
          <i class="fa-solid fa-question"></i>
        </div>
      </div>
      <div class="space property" data-group="4" id="prop-illinois" data-pos="24">
        <div class="container">
          <div class="name">Illinois Avenue</div>
          <div class="cost money">240</div>
        </div>
      </div>
      <div class="space railroad" data-railroad="2" id="railroad-bando" data-pos="25">
        <div class="container">
          <div class="name">B. &amp; O. Railroad</div>
          <div class="symbol railroad">
            <i class="fa-solid fa-train"></i>
          </div>
          <div class="cost money">200</div>
        </div>
      </div>
      <div class="space property" data-group="5" id="prop-atlantic" data-pos="26">
        <div class="container">
          <div class="name">Atlantic Avenue</div>
          <div class="cost money">260</div>
        </div>
      </div>
      <div class="space property" data-group="5" id="prop-ventnor" data-pos="27">
        <div class="container">
          <div class="name">Ventnor Avenue</div>
          <div class="cost money">260</div>
        </div>
      </div>
      <div class="space utility waterworks" data-util="1" id="util-water" data-pos="28">
        <div class="container">
          <div class="name">Water Works</div>
          <div class="symbol water">
            <i class="fa-solid fa-droplet"></i>
          </div>
          <div class="cost money">150</div>
        </div>
      </div>
      <div class="space property" data-group="5" id="prop-marvin" data-pos="29">
        <div class="container">
          <div class="name">Marvin Gardens</div>
          <div class="cost money">280</div>
        </div>
      </div>
    </div>
    <div class="corner space ne" id="corner-busted" data-pos="30">
      <div class="container">
        <div class="label med go-to">Go to</div>
        <div class="symbol busted">
          <i class="fa-solid fa-gavel"></i>
        </div>
        <div class="label med jail">Jail</div>
      </div>
    </div>
    <div class="row vertical west">
      <div class="space property" data-group="3" id="prop-newyork" data-pos="19">
        <div class="container">
          <div class="name">New York Avenue</div>
          <div class="cost money">200</div>
        </div>
      </div>
      <div class="space property" data-group="3" id="prop-tennessee" data-pos="18">
        <div class="container">
          <div class="name">Tennessee Avenue</div>
          <div class="cost money">180</div>
        </div>
      </div>
      <div class="space deck chest" data-deck="chest" id="chest-2" data-pos="17">
        <div class="container">
          <div class="label">Community Chest</div>
          <i class="fa-solid fa-sack-dollar"></i>
        </div>
      </div>
      <div class="space property" data-group="3" id="prop-stjames" data-pos="16">
        <div class="container">
          <div class="name">St. James Place</div>
          <div class="cost money">180</div>
        </div>
      </div>
      <div class="space railroad" data-railroad="1" id="railroad-pennsylvania" data-pos="15">
        <div class="container">
          <div class="name tighter">Pennsylvania Railroad</div>
          <div class="symbol railroad">
            <i class="fa-solid fa-train"></i>
          </div>
          <div class="cost money">200</div>
        </div>
      </div>
      <div class="space property" data-group="2" id="prop-virginia" data-pos="14">
        <div class="container">
          <div class="name">Virginia Avenue</div>
          <div class="cost money">160</div>
        </div>
      </div>
      <div class="space property" data-group="2" id="prop-states" data-pos="13">
        <div class="container">
          <div class="name">States Avenue</div>
          <div class="cost money">140</div>
        </div>
      </div>
      <div class="space utility electric" data-util="0" id="util-elec" data-pos="12">
        <div class="container">
          <div class="name">Electric Company</div>
          <div class="symbol electric">
            <i class="fa-solid fa-lightbulb"></i>
          </div>
          <div class="cost money">150</div>
        </div>
      </div>
      <div class="space property" data-group="2" id="prop-stcharles" data-pos="11">
        <div class="container">
          <div class="name">St. Charles Place</div>
          <div class="cost money">140</div>
        </div>
      </div>
    </div>
    <div class="center">
      <div class="deck-outline chest"></div>
      <div class="logo">CSS<span class="raised">&ndash;</span>opoly</div>
      <div class="deck-outline chance"></div>
      <div class="player-info hidden">
        <div class="current">
          Current player: <span class="value"></span>
        </div>
        <div class="player-money">
          Money: <span class="money">1500</span>
        </div>
      </div>
      <div class="dice hidden">
        <div class="dice-group">
          <div class="die left" id="die-1" data-value="0">
            <div class="pip a"></div>
            <div class="space"></div>
            <div class="pip e"></div>
            <div class="pip b"></div>
            <div class="pip d"></div>
            <div class="pip f"></div>
            <div class="pip c"></div>
            <div class="space"></div>
            <div class="pip g"></div>
          </div>
          <div class="die right" id="die-2" data-value="0">
            <div class="pip a"></div>
            <div class="space"></div>
            <div class="pip e"></div>
            <div class="pip b"></div>
            <div class="pip d"></div>
            <div class="pip f"></div>
            <div class="pip c"></div>
            <div class="space"></div>
            <div class="pip g"></div>
          </div>
        </div>
        <div class="dice-status hidden">You have rolled:</div>
        <div class="dice-value hidden">0</div>
      </div>
      <div class="main-action">
        <div class="action start hidden">
          <button class="start-game" disabled>Start Game</button>
        </div>
        <div class="action roll hidden">
          <button class="roll-dice">Roll Dice</button>
        </div>
        <div class="action roll-pay hidden">
          <button class="roll-dice try-doubles">Roll Dice</button>
          <span class="or">or</span>
          <button class="pay-bail">Pay <span class="money">50</span></button>
        </div>
        <div class="action pay-bail hidden">
          <button class="pay-bail">Pay <span class="money">50</span> Bail</button>
        </div>
        <div class="action purchase hidden">
          <div class="full-width">
            Cost:
            <strong><span class="money cost-amount"></span></strong>
          </div>
          <button class="purchase-property">
            Purchase
            <span class="name"></span>
          </button>
          <div class="or">or</div>
          <button class="skip-property">Skip</button>
        </div>
        <div class="action end hidden">
          <button class="end-turn">End turn</button>
        </div>
        <div class="action moving hidden">
          Moving...
        </div>
      </div>
    </div>
    <div class="row vertical east">
      <div class="space property" data-group="6" id="prop-pacific" data-pos="31">
        <div class="container">
          <div class="name">Pacific Avenue</div>
          <div class="cost money">300</div>
        </div>
      </div>
      <div class="space property" data-group="6" id="prop-northcarolina" data-pos="32">
        <div class="container">
          <div class="name">North Carolina Avenue</div>
          <div class="cost money">300</div>
        </div>
      </div>
      <div class="space deck chest" data-deck="chest" id="chest-3" data-pos="33">
        <div class="container">
          <div class="label">Community Chest</div>
          <i class="fa-solid fa-sack-dollar"></i>
        </div>
      </div>
      <div class="space property" data-group="6" id="prop-pennsylvania" data-pos="34">
        <div class="container">
          <div class="name tighter">Pennsylvania Avenue</div>
          <div class="cost money">320</div>
        </div>
      </div>
      <div class="space railroad" data-railroad="3" id="railroad-shortline" data-pos="35">
        <div class="container">
          <div class="name">Short Line</div>
          <div class="symbol railroad">
            <i class="fa-solid fa-train"></i>
          </div>
          <div class="cost money">200</div>
        </div>
      </div>
      <div class="space deck chance color-2" data-deck="chance" id="chance-3" data-pos="36">
        <div class="container">
          <div class="label">Chance</div>
          <i class="fa-solid fa-question"></i>
        </div>
      </div>
      <div class="space property" data-group="7" id="prop-parkplace" data-pos="37">
        <div class="container">
          <div class="name">Park Place</div>
          <div class="cost money">350</div>
        </div>
      </div>
      <div class="space tax luxury" data-tax="luxury" id="tax-luxury" data-pos="38">
        <div class="container">
          <div class="label">Luxury Tax</div>
          <div class="symbol ring">
            <i class="fa-regular fa-gem"></i>
          </div>
          <div class="cost">Pay <div class="money">200</div></div>
        </div>
      </div>
      <div class="space property" data-group="7" id="prop-boardwalk" data-pos="39">
        <div class="container">
          <div class="name">Boardwalk</div>
          <div class="cost money">400</div>
        </div>
      </div>
    </div>
    <div class="corner space sw" id="corner-visiting" data-pos="10">
			<div class="subcorner">
				<div class="container">
					<div class="label in">In</div>
					<div class="window">
						<div class="bar"></div>
						<div class="bar"></div>
						<div class="bar"></div>
						<i class="person fa-regular fa-frown"></i>
					</div>
					<div class="label jail">Jail</div>
				</div>
			</div>
      <div class="label just">Just</div>
      <div class="label visiting">Visiting</div>
    </div>
    <div class="row horizontal south">
      <div class="space property" data-group="1" id="prop-connecticut" data-pos="9">
        <div class="container">
          <div class="name">Connecticut Avenue</div>
          <div class="cost money">120</div>
        </div>
      </div>
      <div class="space property" data-group="1" id="prop-vermont" data-pos="8">
        <div class="container">
          <div class="name">Vermont Avenue</div>
          <div class="cost money">100</div>
        </div>
      </div>
      <div class="space deck chance color-0" data-deck="chance" id="chance-1" data-pos="7">
        <div class="container">
          <div class="label">Chance</div>
          <i class="fa-solid fa-question"></i>
        </div>
      </div>
      <div class="space property" data-group="1" id="prop-oriental" data-pos="6">
        <div class="container">
          <div class="name">Oriental Avenue</div>
          <div class="cost money">100</div>
        </div>
      </div>
      <div class="space railroad" data-railroad="0" id="railroad-reading" data-pos="5">
        <div class="container">
          <div class="name">Reading Railroad</div>
          <div class="symbol railroad">
            <i class="fa-solid fa-train"></i>
          </div>
          <div class="cost money">200</div>
        </div>
      </div>
      <div class="space tax income" data-tax="income" id="tax-income" data-pos="4">
        <div class="container">
          <div class="label">Income Tax</div>
          <div class="symbol diamond">
            <div class="square"></div>
          </div>
          <div class="cost">Pay <div class="money">200</div></div>
        </div>
      </div>
      <div class="space property" data-group="0" id="prop-baltic" data-pos="3">
        <div class="container">
          <div class="name">Baltic Avenue</div>
          <div class="cost money">60</div>
        </div>
      </div>
      <div class="space deck chest" data-deck="chest" id="chest-1" data-pos="2">
        <div class="container">
          <div class="label">Community Chest</div>
          <i class="fa-solid fa-sack-dollar"></i>
        </div>
      </div>
      <div class="space property" data-group="0" id="prop-mediterranean" data-pos="1">
        <div class="container">
          <div class="name">Mediter&shy;ranean Avenue</div>
          <div class="cost money">60</div>
        </div>
      </div>
    </div>
    <div class="corner space se" id="corner-go" data-pos="0">
      <div class="container">
        <div class="text">Collect <span class="cost money">200</span> salary as you pass</div>
        <div class="go">GO</div>
      </div>
      <div class="symbol arrow">
        <i class="fa-solid fa-arrow-left"></i>
      </div>
    </div>
    <!-- #modal-overlay -->
    <div id="modal-overlay" class="modal-overlay hide hidden">
      <!-- Types: ok, ok-cancel, yes-no-cancel, prompt, error -->
      <div class="modal-body type-ok">
        <button class="close"><span class="sr-only">Close modal</span></button>
        <div class="modal-header">
          <h5 class="modal-title"></h5>
        </div>
        <div class="modal-content"></div>
        <div class="modal-footer">
          <!-- Button classes: confirm, cancel/dismiss, yes, no, retry, help -->
        </div>
      </div>
      <!-- .modal-body -->
    </div>
    <!-- #card-overlay -->
    <div id="card-overlay" class="modal-overlay in-deck hide hidden">
      <div class="card-body">
        <div class="card-header">
          <h5 class="card-title"></h5>
        </div>
        <div class="card-content"></div>
        <div class="card-footer">
          <button class="card-action">OK</button>
        </div>
      </div>
    </div>
    <!-- #space-overlay -->
    <div id="space-overlay" class="modal-overlay hide hidden"></div>
  </div>
  <!-- end #board -->
  
  <div class="tabletop">
    <h1>Game Setup</h1>
    <div class="players-list">
      <h2>You can have 2 to 12 players</h2>
      <div class="player-selection">
        <div class="player-qty">
          <label for="num-players">How many?</label>
          <input type="number" size="5" id="num-players" value="2" min="2" max="12">
          <button id="set-num-players">OK</button>
        </div>
        <div class="player-names"></div>
        <div class="player-confirm hidden">
          <button id="confirm-players" disabled>Confirm</button>
        </div>
      </div>
    </div>
    <p class="your-money hidden">Your money: <strong class="font-monopoly"><span class="money"></span></strong></p>
    <div class="your-assets hidden">
      <h3><span class="name"></span>'s Assets</h3>
      <p>Click on a deed to view it close-up.</p>
      <div class="cards"></div>
      <div class="card-closeup hidden">
        <h3>
        Full size card
        <button class="close">Close</button>
        </h3>
        <!-- TODO: Convert these 3 DIVs into "templates". -->
        <div class="card deed property hidden">
          <div class="container">
            <div class="color-bar"><!-- add .group-0 etc. -->
              <div class="label">Title Deed</div>
              <h2><span>Name</span></h2>
            </div>
            <table class="rents">
              <tr>
                <td>Rent</td>
                <td><span class="money rent-default">X</span></td>
              </tr>
              <tr>
                <td>Rent with color set</td>
                <td><span class="money rent-colorset">X</span></td>
              </tr>
              <tr>
                <td>Rent with <span class="house">1</span> <span class="visually-hidden">house</span></td>
                <td><span class="money rent-1house">X</span></td>
              </tr>
              <tr>
                <td>Rent with <span class="house">2</span> <span class="visually-hidden">houses</span></td>
                <td><span class="money rent-2house">X</span></td>
              </tr>
              <tr>
                <td>Rent with <span class="house">3</span> <span class="visually-hidden">houses</span></td>
                <td><span class="money rent-3house">X</span></td>
              </tr>
              <tr>
                <td>Rent with <span class="house">4</span> <span class="visually-hidden">houses</span></td>
                <td><span class="money rent-4house">X</span></td>
              </tr>
              <tr>
                <td>Rent with <span class="hotel"></span><span class="visually-hidden">a hotel</span></td>
                <td><span class="money rent-hotel">X</span></td>
              </tr>
            </table>
            <hr>
            <table class="buildings">
              <tr>
                <td>Houses cost</td>
                <td><span class="money cost-building">X</span> each</td>
              </tr>
              <tr>
                <td>Hotels cost</td>
                <td>
                  <span class="money cost-building">X</span> each
                  <small>(plus 4 houses)</small>
                </td>
              </tr>
            </table>
          </div>
        </div>
        <div class="card deed railroad hidden">
          <div class="container">
            <div class="symbol">
              <i class="fa-solid fa-train"></i>
            </div>
            <h2><span>Name</span></h2>
            <table class="rents more-space">
              <tr>
                <td>RENT</td>
                <td><span class="money rent-1owned">25</span></td>
              </tr>
              <tr>
                <td>If 2 Railroads are owned</td>
                <td><span class="money rent-2owned">50</span></td>
              </tr>
              <tr>
                <td>If 3 Railroads are owned</td>
                <td><span class="money rent-3owned">100</span></td>
              </tr>
              <tr>
                <td>If 4 Railroads are owned</td>
                <td><span class="money rent-4owned">200</span></td>
              </tr>
            </table>
          </div>
        </div>
        <div class="card deed utility hidden">
          <div class="container">
            <div class="symbol">
              <i class="fa"></i>
            </div>
            <h2 class="full"><span>Name</span></h2>
            <p class="rents">If one Utility is owned, rent is 4 times amount shown on dice.</p>
            <p class="rents">If both Utilities are owned, rent is 10 times amount shown on dice.</p>
          </div>
        </div>
        <div class="card jailbreak hidden" data-deck="">
          <div class="container">
            <h2></h2>
            <div class="flex">
              <div class="card-text">Get out of Jail Free</div>
              <div class="card-graphic"><i class="fa-solid fa-dove"></i></div>
            </div>
            <div class="bottom">This card may be kept until needed, or sold</div>
          </div>
        </div>
      </div>
    </div>
  </div>
  
</main>

<div id="templates" class="hidden">
  <div class="minicard deed property" data-space-id="">
    <a href="#" class="overlay">Open close-up of title deed</a>
    <div class="container">
      <div class="color-bar name"></div>
      <div class="rents">
        &mdash;&mdash;&mdash;<br>
        &mdash;&mdash;&mdash;<br>
        &mdash;&mdash;&mdash;<br>
        &mdash;&mdash;&mdash;<br>
        &mdash;&mdash;&mdash;<br>
        &mdash;&mdash;&mdash;
      </div>
      <hr>
      <div class="buildings">
        &mdash;&mdash;&mdash;<br>
        &mdash;&mdash;&mdash;
      </div>
    </div>
  </div>
  <div class="minicard deed railroad" data-space-id="">
    <a href="#" class="overlay">Open close-up of title deed</a>
    <div class="container">
      <div class="symbol">
        <i class="fa-solid fa-train"></i>
      </div>
      <div class="name">
        <span class="name1"></span><br><span class="name2"></span>
      </div>
    </div>
  </div>
  <div class="minicard deed utility"  data-space-id="">
    <a href="#" class="overlay">Open close-up of title deed</a>
    <div class="container">
      <div class="symbol">
        <i class="fa"></i>
      </div>
      <div class="name">
        <span class="name1"></span><br><span class="name2"></span>
      </div>
    </div>
  </div>
  <div class="minicard jailbreak" data-deck="">
    <a href="#" class="overlay">Open close-up of title deed</a>
    <div class="container">
      <div class="name">Get out of Jail!</div>
    </div>
  </div>
</div>

              
            
!

CSS

              
                @mixin text-outline($color, $width: 2px, $shadow: 0px) {
  text-shadow: $width    $width  $color, // SE
               $width    0px     $color, // S
               $width  (-$width) $color, // SW
               0px     (-$width) $color, //  W
             (-$width) (-$width) $color, // NW
             (-$width)   0px     $color, // N
             (-$width)   $width  $color, // NE
               0px       $width  $color, //  E
             (-$shadow)  $shadow $black; // opt'l dropshadow
} // @mixin text-outline

/// COLORS ///
// All colors sourced from official game board:
// https://m.media-amazon.com/images/W/MEDIAX_792452-T2/images/I/81oC5pYhh2L.jpg

// Base colors
$black:  #090A0E; // black-ish...
$board:  #CDE6D0; // light seafoam?
$yellow: #FFF000;
$red:    #ED1B24;
$orange: #F7941D;
$brown:  #955436;
$aqua:   #AAE0FA;
$pink:   #D93A96;
$green:  #1FB25A;
$blue:   #0072BB;
$white:  #FFFFFF;
// Property groups
$prop-grp-0: $brown;
$prop-grp-1: $aqua;
$prop-grp-2: $pink;
$prop-grp-3: $orange;
$prop-grp-4: $red;
$prop-grp-5: $yellow;
$prop-grp-6: $green;
$prop-grp-7: $blue;
// Corners
$corner-se: $red;    // red (GO arrow)
$corner-sw: $orange; // orange (jail BG)
$corner-nw: $red;    // red (car)
$corner-ne: $blue;   // blue (finger)
// Card deck groups
$deck-grp-0:  #00B0F0; // light blue
$deck-grp-1a: #DE0987; // magenta
$deck-grp-1b: #00ADEF; // turquoise
$deck-grp-1c: #F68026; // deep orange
// Card deck background colors
$deck-chance-bg: #e73; // orange
$deck-chest-bg:  #fd0; // yellow

/// SIZES ///

// Side lengths
$space-width:  3.75rem; // everything else based on this
$space-height: ($space-width * 5/3); // 6.25rem
$prop-color-w: ($space-height * 1/5); // 1.25rem
$space-gap:    ($space-width / 30); // 0.125rem (2px)
$corner-side:  $space-height; // always same as space height
$row-length:   ($space-width * 9 + $space-gap * 8);
// Borders/gaps
$board-border: ($space-width / 20); // 0.1875rem
// Board dimensions
$board-width:  ($corner-side * 2 + $space-gap * 2 + $row-length); // 47.75rem
$board-height: $board-width;
$ls-tighter:   (-1rem / 16); // -0.0625rem
$ls-tightest:  (-1rem / 14); // -0.0833rem

/// TYPOGRAPHY ///
$board-font-sm: 0.5625rem;
$board-font-md: 0.625rem;
$board-font-lg: 0.75rem;

/// GLOBAL STYLING ///
body {
  margin: 1rem;
}
main.container {
  display: flex;
  gap: 1rem;
}
button:not([disabled]), button:not(:disabled) {
  cursor: pointer;
}
button[disabled], button:disabled {
  cursor: not-allowed;
}
.hidden {
  display: none !important;
}
.visually-hidden:not(:focus):not(:active),
.sr-only:not(:focus):not(:active) {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}
.flex {
  display: flex;
}
.raised {
  position: relative;
  top: -0.625rem;
}
.font-monopoly {
  font-family: "Josefin Sans", sans-serif;
}
.money {
  display: inline-flex;
  justify-content: center;

  &:before {
    bottom: 0.2rem;
    // This symbol is pretty much an upside-down Monopoly $ sign
    content: '\20A9';
    // display: inline-block;
    position: relative;
    transform: rotate(180deg);
  }
}
.tight {
  letter-spacing: $ls-tighter !important;
}
.tighter {
  letter-spacing: $ls-tightest !important;
}

/// GAME BOARD ///
.board {
  background: $black;
  border: $board-border solid $black;
  font-family: "Josefin Sans", sans-serif;
  text-transform: uppercase;
  user-select: none;
  
  display: grid;
  grid-gap: $space-gap;
  grid-template-columns: $corner-side $row-length $corner-side;
  grid-template-rows:    $corner-side $row-length $corner-side;
  
  position: relative;
  height: $board-height;
  width:  $board-width;
  
  // Each CORNER
  .corner {
    background: $board;
    overflow: hidden;
    position: relative;
    text-align: center;
    
    display: flex;
    align-items: center;
    justify-content: center;
    
    // Each corner's CONTAINER
    .container {
      color: $black;
      display: flex;
      flex-flow: column;
      place-content: center;
      position: relative;
      transform-origin: center;
      width: 75%;
      
      // All corner container LABELS
      .label {
        font-size: $board-font-lg;
        font-weight: 600;
        letter-spacing: $ls-tighter;
        line-height: 1;
      }
      
      // All corner SYMBOLS
      .symbol {
        
        // FREE PARKING symbol icon
        &.parking i {
          color: $corner-nw;
          font-size: 3rem;
          margin: 0.25rem 0 0.5rem;
        }
        
        // GO TO JAIL symbol icon
        &.busted i {
          color: $corner-ne;
          font-size: 3rem;
          margin: 0.375rem 0 0.625rem;
          transform: rotate(-12deg);
        }
        
        // Add text-shadow 2px in all 8 directions for outline
        i {
          @include text-outline($black);
        } // i
        
      } // .symbol
      
    } // .container
    
    // SOUTHEAST corner (GO)
    &.se {
      align-items: flex-start;
      
      .container {
        align-items: center;
        justify-content: flex-start;

        height: 100%;
        width:  100%;
        transform: rotate(-45deg);

        font-size: $board-font-sm;
        font-weight: 600;
        line-height: 1;
        
        // "Collect M200 salary..." TEXT
        .text {
          font-size: $board-font-md;
          letter-spacing: $ls-tighter;
          padding-top: 0.25rem;
          width: 65%;
        }
        
        .money:before {
          bottom: 0.2rem; // TODO: variablize this?
          margin-right: -0.0625rem;
        }
        
        // "GO" text
        .go {
          font-family: Oxanium, sans-serif;
          font-size: 3rem;
          font-weight: 800;
          letter-spacing: $ls-tighter;
          margin-top: -0.25rem;
        }
        
      } // .container
      
      // Arrow SYMBOL
      .symbol {
        position: absolute;
        bottom: 0.25rem;
        right: 0.625rem;
        
        color: $red;
        font-size: 2rem;
        line-height: 1;
        
        i {
          @include text-outline($black);
        }
        
      } // .symbol

    } // .corner.se
    
    // SOUTHWEST corner (Jail / Just Visiting)
    &.sw {
      font-size: $board-font-lg;
      font-weight: 600;
      position: relative;
      
      // Orange sub-corner
      .subcorner {
        background: $orange;
        border-color: $black;
        border-style: solid;
        border-width: 0 0 $space-gap $space-gap;
        
        display: flex;
        align-items: center;
        justify-content: center;
        
        position: absolute;
        right: 0;
        top: 0;
        height: 66.6667%;
        width:  66.6667%;
        
        // Inner wrapper
        .container {
          height: 100%;
          width:  100%;
          align-items: center;
          justify-content: space-around;
          transform: rotate(45deg);
          
          // The word "JAIL" is a bit too high
          .label.jail {
            position: relative;
            top: 0.25rem;
          }
          
          // Prison window
          .window {
            background: $white;
            border: $space-gap solid $black;
            
            display: flex;
            align-items: center;
            justify-content: space-around;
            
            position: relative;
            height: 2.5rem;
            width:  2.5rem;
            
            // Each prison bar
            .bar {
              background: $black;
              height: 100%;
              width: $space-gap;
            }
            
            // Little guy in jail
            .person {
              position: absolute;
              transform: translate(-50%, -50%);
              top: 50%;
              left: 50%;
              font-size: 2rem;
            }
            
          } // .window
          
        } // .container
        
      } // .subcorner
      
      .just {
        position: absolute;
        left: 0;
        top: 30%;
        transform: rotate(90deg);
      }
      
      .visiting {
        position: absolute;
        bottom: 5%;
        right: 7.5%;
      }
      
    } // .corner.sw (Jail/Visiting)
    
    // NORTHWEST corner (Free Parking)
    &.nw .container {
      // This translation fixes the apparent offset caused by rotation
      transform: translate(5%, 5%) rotate(135deg);
    }
    
    // NORTHEAST corner (Go to Jail)
    &.ne .container {
      transform: rotate(-135deg);
    }
    
  } // .corner
  
  // Each ROW
  .row {
    display: grid;
    grid-gap: $space-gap;
    position: relative;
    
    // All SPACES along each side
    .space {
      background: $board;
      line-height: 1;
      text-align: center;
      
      &:not(.property) .container .label,
      &:not(.property) .container .name {
        padding-top: 0.75rem;
      }
      
      // All space CONTAINERS
      .container {
        display: flex;
        flex-direction: column;
        hyphens: manual;
        justify-content: space-between;
        position: relative;
        transform-origin: center;
        height: $space-height;
        width:  $space-width;
        
        // All space NAMES and LABELS
        .name, .label {
          font-size: $board-font-sm;
          font-weight: 600;
          letter-spacing: $ls-tighter;
        }
        
        // All space COSTS
        .cost {
          font-size: $board-font-sm;
          font-weight: 600;
          padding-bottom: 0.25rem;
        }
        
        .money:before {
          bottom: 0.1rem; // TODO: variablize this?
        }
          
      } // .container
      
      // All PROPERTY spaces
      &.property {
        
        /// EXCEPTIONS ///
        // MEDITERRANEAN Ave & NORTH CAROLINA Ave (3 lines, too high)
        &#prop-mediterranean .container .name,
        &#prop-northcarolina .container .name {
          padding-top: 0.625rem;
        }
        
        // BALTIC AVENUE and PARK PLACE (must be 2 lines)
        &#prop-baltic    .container .name,
        &#prop-parkplace .container .name {
          width: 75%;
        }
        
        // BOARDWALK (must align to top of other names)
        &#prop-boardwalk .container .name {
          padding-bottom: 0.5rem;
        }
        
        // All property space CONTAINERS
        .container {
          align-items: center;
          
          // Add color bar along top of property spaces
          &:before {
            content: '';
            display: block;
            border-bottom: $space-gap solid $black;
            height: $prop-color-w;
            width:  100%;
            // For best results use this "magical" formula:
            margin-bottom: (0 - $prop-color-w - 3 * $space-gap);
          }
          
        } // .container
        
      } // .property
      
      /// EACH PROPERTY GROUP COLOR ///
      &.property[data-group="0"] .container:before {
        background: $prop-grp-0;
      }
      &.property[data-group="1"] .container:before {
        background: $prop-grp-1;
      }
      &.property[data-group="2"] .container:before {
        background: $prop-grp-2;
      }
      &.property[data-group="3"] .container:before {
        background: $prop-grp-3;
      }
      &.property[data-group="4"] .container:before {
        background: $prop-grp-4;
      }
      &.property[data-group="5"] .container:before {
        background: $prop-grp-5;
      }
      &.property[data-group="6"] .container:before {
        background: $prop-grp-6;
      }
      &.property[data-group="7"] .container:before {
        background: $prop-grp-7;
      }
      
      // All UTILITY, RAILROAD and CC/Chance DECK spaces
      &.utility,
      &.railroad,
      &.deck {
        
        .container {
          align-items: center;
          justify-content: center;
          
          .label, .name {
            padding-bottom: 0.5rem;
          }
          
          i {
            padding-bottom: 0.375rem;
          }
          
        } // .container
        
        &#railroad-shortline .container .name {
          width: 75%;
        }
        
        // RAILROAD symbol icons
        .symbol.railroad i {
          font-size: 2.5rem;
        }
        
        // COMMUNITY CHEST card deck spaces
        &.chest {
          
          .container i {
            @include text-outline($black);
            color: $deck-grp-0;
            font-size: 2.5rem;
            margin-bottom: 1rem;
          }
          
        } // &.deck.chest
        
        // CHANCE card deck spaces
        &.chance {
          
          .container i {
            @include text-outline($black);
            font-size: 4rem;
          }
          &.color-0 .container i {
            color: $deck-grp-1a;
          }
          &.color-1 .container i {
            color: $deck-grp-1b;
          }
          &.color-2 .container i {
            color: $deck-grp-1c;
          }
          
        } // .deck.chance
        
      } // .deck
      
      // All UTILITY spaces (Electric Company, Water Works)
      &.utility {
        
        // Utility NAME labels
        .container .name {
          padding-bottom: 0.25rem;
        }
        
        // Utility SYMBOLS
        .symbol {
          font-size: 2.875rem;
          
          i {
            @include text-outline($black);
          }
          
        } // .symbol
        
        // ELECTRIC Company utility space
        &.electric {
          
          .symbol {
            color: $yellow;
          }

        } // .space.utility.electric
        
        // WATER WORKS utility space
        &.waterworks {
          
          .container .name {
            width: 75%;
          }
          
          .symbol {
            color: $white;
            font-size: 2.25rem;
            margin: 0.25rem 0 0.375rem;
          }
          
        } // .space.utility.waterworks
        
      } // .space.utility
      
      // All TAX spaces (Income, Luxury)
      &.tax {
        
        .container .label {
          font-size: $board-font-lg;
          font-weight: 500;
        }
        
        // INCOME Tax space
        &.income {
          
          .symbol {
            display: flex;
            font-size: 2.875rem;
            justify-content: center;
            
            .square {
              background: $black;
              border: 0.0625rem solid $board;
              outline: 0.0625rem solid $black;
              display: block;
              height: 0.25rem;
              width:  0.25rem;
              transform: rotate(45deg);
            }
            
          } // .symbol
          
        } // .space.tax.income
        
        // LUXURY Tax space
        &.luxury {
          
          .symbol {
            color: $yellow;
            font-size: 2rem;
            
            i {
              @include text-outline($black);
            }
            
          } // .symbol
          
        } // .space.tax.luxury
        
      } // .space.tax
      
    } // .space
    
    // HORIZONTAL rows
    &.horizontal {
      grid-template-columns: repeat(9, $space-width);
      grid-template-rows:    $space-height;
      
      // Horizontal SPACES
      .space {}
      
    } // .row.horizontal
    
    // VERTICAL rows
    &.vertical {
      grid-template-columns: $space-height;
      grid-template-rows:    repeat(9, $space-width);
      
      // Vertical SPACES
      .space .container {
        top: 50%;
        left: 50%;
      }
      
    } // .row.vertical
    
    // NORTH row
    &.north {
      
      // North row SPACE
      .space .container {
        transform: rotate(180deg);
      }
      
    } // .row.north
    
    // WEST row
    &.west {
      
      // West row SPACE
      .space .container {
    		transform: translate(-50%, -50%) rotate(90deg);
      }
      
    } // .row.west
    
    // EAST row
    &.east {
      
      // East row SPACE
      .space .container {
        transform: translate(-50%, -50%) rotate(-90deg);
      }
      
    } // .row.east
    
    // SOUTH row
    &.south {
      
      // South row SPACE
      .space .container {}
      
    } // .row.south
    
  } // .row
  
  // CENTER AREA
  .center {
    background: $board;
    display: flex;
    align-items: center; // vertically center
    justify-content: center; // horizontally center
    position: relative;
    
    // "Monopoly" LOGO
    .logo {
      background: $red; // TODO: make gradient
      background: linear-gradient(
        lighten($red, 25%) 0,
        $red 10%, $red 90%,
        darken($red,  25%)
      );
      border: 0.125rem solid $red;
      outline: 0.25rem solid $black;
      color: $white;
      font-size: 4.5rem;
      font-weight: 600;
      letter-spacing: -0.125rem;
      line-height: 1;
      padding: 1rem 1rem 0;
      @include text-outline(#999, 2px, 5px);
      // transform: rotate(-45deg);
    }
    
    // All absolutely-positioned contents
    .deck-outline,
    .player-info, .dice {
      position: absolute;
    }
    
    // Card DECK OUTLINE boxes
    .deck-outline {
      border: 0.125rem dashed $black;
      width: 9rem;
      height: 6rem;
      
      // Community CHEST deck
      &.chest {
        top: 3.5rem;
        left: 2rem;
        transform: rotate(135deg);
      }
      
      // CHANCE deck
      &.chance {
        right: 2rem;
        bottom: 3.5rem;
        transform: rotate(-45deg);
      }
      
    } // .deck-outline
    
    // Player info
    .player-info {
      top: 3rem;
      right: 3rem;
      text-align: right;
      
      .current .value {
        font-size: 150%;
        font-weight: 800;
      }
      
      .player-money .money {
        font-size: 1.25rem;
        font-weight: 800;
        
        &:before {
          bottom: 0.3333rem;
        }
        
      } // .player-money .money
      
    } // .player
    
    // The dice
    .dice {
      display: flex;
      flex-flow: column;
      align-items: center;
      gap: 0.25rem;
      
      top: 62.5%;
      left: 50%;
      transform: translate(-50%, 0%);
      
      .dice-group {
        display: flex;
        flex-flow: row;
        gap: 0.75rem;
        
        // Each die
        .die {
          background: #f5f5f5;
          border: 0.0625rem solid #6666;
          border-radius: 0.5rem;
          box-shadow:
            inset 0 0.125rem $white,
            inset 0.25rem 0 #ddd,
            inset -0.25rem 0 #ddd,
            inset 0 -0.25rem #ccc,
                  0 0 1rem #3333;
          width: 3rem;
          height: 3rem;
          padding: 0.5rem;

          display: grid;
          grid-template-columns: 1fr 1fr 1fr;
          grid-template-rows: 1fr 1fr 1fr;
          align-items: center;

          // Each pip within a die
          .pip {
            background: $black;
            border-radius: 100%;
            visibility: hidden; // by default
            margin: auto;
            height: 0.75rem;
            width:  0.75rem;
          }

          // 1, 3, and 5 all have middle dot
          &[data-value="1"],
          &[data-value="3"],
          &[data-value="5"] {
            .pip.d {
              visibility: visible;
            }
          }

          // 2 and 3 have two diagonal corner dots
          &[data-value="2"],
          &[data-value="3"] {
            .pip.a, .pip.g {
              visibility: visible;
            }
          }

          // 4, 5, and 6 have all four corner dots
          &[data-value="4"],
          &[data-value="5"],
          &[data-value="6"] {
            .pip.a, .pip.c, .pip.e, .pip.g {
              visibility: visible;
            }
          }

          // only 6 has the two side dots
          &[data-value="6"] {
            .pip.b, .pip.f {
              visibility: visible;
            }
          }

        } // .die
      
      } // .dice-group
      
      .dice-status {
        padding-top: 0.25rem;
        line-height: 1.5;
      }
      
      .dice-value {
        text-shadow:
          0 0 0       $white,
          0 0 0.25rem $white,
          0 0 0.5rem  $white,
          0 0 0.75rem $white;
        font-size: 2rem;
        font-weight: bold;
        line-height: 1;
      }
      
    } // .dice
    
    // Main action buttons
    .main-action {
      display: flex;
      justify-content: center;
      transform: translate(-50%, -50%);
      position: absolute;
      top: 33.3333%;
      left: 50%;
      width: 100%;
      
      .action {
        display: flex;
        flex-flow: row wrap;
        align-items: center;
        justify-content: center;
        
        .full-width {
          flex-basis: 100%;
          font-size: 1.25rem;
          margin-bottom: 0.25rem;
          text-align: center;
        }
        
        .or {
          padding: 0.5rem 0.5rem 0 0.6667rem;
        }
        
        .turn {
          flex-basis: 100%;
          font-weight: bold;
          line-height: 1;
          margin-bottom: 0.5rem;
          text-align: center;
        }
        
        .money:before {
          bottom: 0.3333rem;
        }
        
      } // .action
      
      button {
        appearance: none;
        background: $blue;
        border: 1px solid $black;
        border-radius: 0.25rem;
        box-shadow: 0.25rem 0.25rem 0 $black;
        color: $white;
        font-family: inherit;
        font-size: 1.5rem;
        line-height: 1;
        padding: 0.5rem 0.75rem 0.25rem;
        position: relative;
        left: 0;
        top:  0;
        
        &[disabled] {
          background: #ccc;
          color: #999;
          cursor: not-allowed;
        }
        
        &:not([disabled]):hover {
          background: $aqua;
          color: $black;
        }
        
        &:not([disabled]):active {
          box-shadow: none;
          left: 0.25rem;
          top:  0.25rem;
        }
        
        .money:before {
          bottom: 0.3333rem;
        }
        
      } // button
      
    } // .main-action
    
  } // .center
  
} // .board

$token-size: 1.25rem;

@keyframes glow {
  from {
    box-shadow:
      0.125rem 0.125rem 0.125rem #666,
      inset 0.125rem 0.125rem #fff9,
      inset -0.125rem -0.125rem #0006,
      0 0 0.5rem 0 $white;
  }
  to {
    box-shadow:
      0.125rem 0.125rem 0.125rem #666,
      inset 0.125rem 0.125rem #fff9,
      inset -0.125rem -0.125rem #0006,
      0 0 0.5rem 0.75rem $white;
  }
}

.token {
  border: 1px solid $black;
  border-radius: $token-size;
  box-shadow:
    0.125rem 0.125rem 0.125rem #666,
    inset 0.125rem 0.125rem #fff9,
    inset -0.125rem -0.125rem #0006;
  height: $token-size;
  min-width: $token-size;
  
  &.active {
    animation: glow 0.75s ease-in-out infinite alternate;
    z-index: 100;
  }
  
  // Tabletop tokens
  .tabletop & {
    color: $white;
    font-size: 1rem;
    font-weight: bold;
    line-height: 1.25;
    text-align: center;
  }
  
  // Board tokens
  #board & {
    position: absolute;
    // default values allow animation:
    left: 93.8%; top: 93.8%;
    transform: translate(-50%, -50%);
    transition-duration: 0.4s;
    transition-property: left, top;
    transition-timing-function: ease-in-out;
  }

  &.color-1 {
    background: crimson;
  }
  &.color-2 {
    background: gold;
    color: $black;
  }
  &.color-3 {
    background: dodgerblue;
  }
  &.color-4 {
    background: orange;
    color: $black;
  }
  &.color-5 {
    background: darkviolet;
  }
  &.color-6 {
    background: limegreen;
    color: $black;
  }
  &.color-7 {
    background: hotpink;
    color: $black;
  }
  &.color-8 {
    background: turquoise;
    color: $black;
  }
  &.color-9 {
    background: sienna;
  }
  &.color-10 {
    background: teal;
  }
  &.color-11 {
    background: yellowgreen;
    color: $black;
  }
  &.color-12 {
    background: wheat;
    color: $black;
  }

} // .token

/// TABLETOP ///
.tabletop {
  background: lighten($board, 5%);
  border-radius: 0.5rem;
  border: 0.0625rem solid darken($board, 60%);
  box-shadow: 0.25rem 0.125rem 0.5rem #6666;
  font-family: 'Jost', sans-serif;
  outline: 0.0625rem solid darken($board, 20%);
  padding: 1rem;
  min-width: 21rem;
  width: 21rem;
  
  h1 {
    margin-top: 0;
  }
  
  .players-list {
    
    .player-line {
      align-items: center;
      display: flex;
      gap: 0.5rem;
      margin-bottom: 0.25rem;
      
      .name {
        font-weight: bold;
      }
      
      .indicator {
        color: $pink;
        
        &:before {
          content: '\25C0';
          margin-right: 0.5rem;
        }
        
      } // .indicator
      
    } // .player-line
    
    .player-selection {

      .player-names {
        display: flex;
        flex-flow: column nowrap;
        gap: 0.5rem;

        .line {
          display: flex;
          align-items: center;
          flex-flow: row nowrap;
          justify-content: flex-end;
          gap: 0.5rem;
          
          .player-idx {
            font-weight: bold;
          }
          
        } // .line

      } // .player-names
      
      .player-confirm {
        display: flex;
        justify-content: center;
        
        button {
          margin-top: 1rem;
        }
        
      } // .player-confirm

    } // .player-selection

  } // .players-list
  
  // Player's assets (CARDS).
  .cards {
    display: flex;
    flex-flow: row wrap;
    gap: 0.5rem;
    
    // Each STACK of cards.
    .stack {
      
      // Overlap them so they appear as a stack.
      .minicard + .minicard {
        margin-top: -2.25rem;
      }
      
    } // .stack
    
  } // .cards
  
} // .tabletop

// Full-size CARD graphic
.card {
  font-family: "Jost", sans-serif;
  font-size: 1.25rem;
  background: $white;
  border: 1px solid #999;
  box-shadow: 0.125rem 0.125rem 0.5rem #6666;
  padding: 1rem;
  height: 9rem;
  width: 16rem;
  user-select: none;

  &.deed {
    // portrait
    height: 24rem;
  }
  
  &.jailbreak {
    padding: 0.75rem;
    
    &[data-deck="chance"] {
      background: $deck-chance-bg;
    }
    
    &[data-deck="chest"] {
      background: $deck-chest-bg;
    }
    
    .container {
      border: none;
      
      h2 {
        display: inline-block;
        font-family: 'Satisfy', cursive;
        font-size: 1.5rem;
        font-weight: normal;
        height: auto;
        line-height: 1;
        margin: 0;
        padding: 0;
        text-transform: none;
        width: 62%;
      }
      
    } // .container
    
    .flex {
      align-items: flex-end;
    }
    
    .card-text {
      font-family: 'Bebas Neue', sans-serif;
      font-size: 1.625rem;
      letter-spacing: 0.125rem;
      margin: 0 1.5rem;
      text-align: center;
    }
    
    .card-graphic {
      padding-right: 1rem;
      
      i {
        font-size: 5rem;
      }
    }
    
    .bottom {
      font-family: 'Bebas Neue', sans-serif;
      font-size: 1.0625rem;
      margin-top: 1.25rem;
      text-align: center;
      text-transform: uppercase;
    }
    
  } // .card.jailbreak
  
  &.railroad {
    
    .symbol {
      padding: 2rem 0;
    }
    
  } // .card.railroad
  
  &.utility {
    
    .symbol {
      padding: 1.5rem 0 1rem;
      
      .fa-solid,
      .fa-regular {
        @include text-outline($black, 4px);
      }
      
      .fa-lightbulb {
        color: $yellow;
      }
      
      .fa-tint {
        color: $white;
      }
      
    } // .symbol
    
  } // .card.utility

  .container {
    border: 2px solid $black;
    height: calc(100% - 0.25rem);

    h2 {
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 1.25rem;
      font-weight: 900;
      margin: 0 auto;
      line-height: 1.1;
      padding: 0 0 0.5rem;
      height: 2.5rem;
      width: 80%;
      text-align: center;
      text-transform: uppercase;
      
      &.full {
        width: 100%;
      }
      
    } // h2
    
    p.rents {
      font-size: 1.3333rem;
      letter-spacing: -0.0625rem;
      line-height: 1;
      margin: 0.5rem auto 1.5rem;
      text-align: center;
      width: 79%;
    }

    .color-bar {
      background: #999;
      border: 2px solid $black;
      color: $black;
      font-family: "Josefin Sans", sans-serif;
      letter-spacing: -0.0625rem;
      margin: 0.5rem 0.5rem 0;
      text-align: center;
      text-transform: uppercase;
      font-weight: 800;

      .label {
        font-size: 0.875rem;
        padding: 0.5rem 0;
      }
      
      h2 {
        font-weight: bold;
        line-height: 1;
      }
      
    } // .color-bar
    
    // For Railroads & Utilities
    .symbol {
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 7.5rem;
    }

    table {
      margin: 0.5rem auto;
      width: calc(100% - 1rem);

      tr {

        td {
          font-size: 1.125rem;
          letter-spacing: -0.0625rem;
          line-height: 1.2;
          vertical-align: text-top;

          &:last-child {
            line-height: 1;
            text-align: right;
          }

          small {
            display: block;
            font-size: 0.875rem;
          }
          
          .money:before {
            bottom: 0.0625rem;
          }

        } // td

      } // tr

      // HOUSE and HOTEL icons
      .house, .hotel {
        background: transparent none no-repeat center center;
        background-size: 1.25rem 1.25rem;
        color: $white;
        display: inline-block;
        font-size: 0.75rem;
        font-weight: 800;
        line-height: 1.5rem;
        height: 1.25rem;
        width: 1.25rem;
        text-align: center;
        vertical-align: top;
      }

      .house {
        background-image: url("https://xylot.com/files/house.svg");
      }

      .hotel {
        background-image: url("https://xylot.com/files/hotel.svg")
      }
      
      &.rents.more-space tr td:first-child {
        line-height: 1.5;
      }

    } // table

    hr {
      border-color: lighten($black, 75%);
      border-width: 1px;
      width: calc(100% - 1rem);
    }

  } // .container

} // .card

// MINICARD graphics
.minicard {
  background: $white;
  border: 1px solid $black;
  width: 3.125rem;
  height: 2rem;
  padding: 0.125rem;
  position: relative;
  user-select: none;
  box-shadow: 0 0 1rem transparent;
  transition:
    border-color 0.25s ease-in-out,
    box-shadow   0.25s ease-in-out;
  
  &.deed {
    width: 2rem;
    height: 3.125rem;
  }
  
  &.jailbreak {
    
    &[data-deck="chance"] {
      background: $deck-chance-bg;
    }
    
    &[data-deck="chest"] {
      background: $deck-chest-bg;
    }
    
    .container {
      border-color: #000;
      font-size: 0.6667rem;
      font-weight: 600;
    }
    
  } // .minicard.jailbreak
  
  $minicard-active-color: #096;
  
  a.overlay {
    border: 1px solid transparent;
    cursor: pointer;
    display: block;
    // opacity: 0;
    position: absolute;
    top: 0; left: 0; bottom: 0; right: 0;
    text-indent: -9999px;
    
    &:hover,
    &:active {
      border-color: $minicard-active-color;
    }
    
  } // a.overlay
  
  &.active {
    border-color: $minicard-active-color;
    box-shadow: 0 0 1rem $minicard-active-color;
  }
  
  .container {
    border: 1px solid #999;
    font-size: 0.48rem;
    line-height: 1.2;
    height: calc(100% - 0.375rem);
    padding: 0.125rem;
    text-align: center;
    
    .symbol {
      
      .fa-solid,
      .fa-regular {
        font-size: 1.25rem;
      }
      
      .fa-train {
        padding: 0.125rem 0 0.25rem;
      }
      
      .fa-lightbulb {
        color: $yellow;
        @include text-outline($black, 1px);
        padding: 0.125rem 0;
      }
      
      .fa-tint {
        color: $white;
        @include text-outline($black, 1px);
        padding: 0.125rem 0;
      }
      
    } // .symbol
    
    .color-bar {
      border: 1px solid $black;
      background: #999;
      font-weight: 600;
      height: 0.5rem;
      overflow: hidden;
    }
    
    hr {
      border-top: 1px solid #999;
      margin: 0.0625rem 0;
    }
    
    .rents, .buildings {
      line-height: 0.5;
    }
    
  } // .container
  
} // .minicard

// Color bar groups.
.card .container .color-bar,
.minicard .container .color-bar {
  
  &.group-0 {
    background: $brown;
    color: $white;
  }
  &.group-1 {
    background: $aqua;
  }
  &.group-2 {
    background: $pink;
    color: $white;
  }
  &.group-3 {
    background: $orange;
  }
  &.group-4 {
    background: $red;
    color: $white;
  }
  &.group-5 {
    background: $yellow;
  }
  &.group-6 {
    background: $green;
  }
  &.group-7 {
    background: $blue;
    color: $white;
  }
  
}

// Modal and card overlays.
.modal-overlay {
  background: transparent;
  backdrop-filter: blur(5px);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.25s ease-in-out;
  
  position: absolute;
  left: 0; top: 0;
  height: 100%;
  width: 100%;
  z-index: 1000;
  
  // By default.
  &, &.hide {
    opacity: 0;
  }
  
  // Must explicitly show.
  &.show {
    opacity: 1;
  }
  
  // The "from" locations (each deck on the board).
  &.from-chest.in-deck .card-body {
    background: $orange;
    transform: rotate(135deg) scale(0.38, -0.38) translate(0%, -294%);
  }
  &.from-chance.in-deck .card-body {
    background: $aqua;
    transform: rotate(135deg) scale(0.38, -0.38) translate(0%, 294%);
  }
  
  &.from-chest,
  &.from-chance {
    
    &.in-deck {
    
      .card-body {
        opacity: 1;

        &:before,
        .card-header,
        .card-content,
        .card-footer {
          opacity: 0;
        }
        
      } // .card-body
      
    } // .modal-overlay.from-chest.in-deck
    
  } // .modal-overlay.from-chest
  
  // Common card/modal dialog body properties.
  .card-body,
  .modal-body {
    border: 1px solid #999;
    box-shadow: 0.125rem 0.125rem 0.75rem #0006;
    display: flex;
    flex-flow: column nowrap;
    opacity: 1;
    position: relative;
  }
  
  // Main body of card modal dialog.
  .card-body {
    background: $white;
    font-family: 'Jost', sans-serif;
    padding: 1rem;
    position: relative;
    height: 11.5rem;
    width: 22rem;
    transition: all 1s ease-in;
    
    &:before,
    .card-header,
    .card-content,
    .card-footer {
      opacity: 1;
      transition: all 1s ease-in;
      z-index: 10;
    }
    
    // Simulate inner border of card.
    &:before {
      position: absolute;
      border: 2px solid $black;
      content: '';
      opacity: 1;
      top: 0.5rem; bottom: 0.5rem;
      left: 0.5rem; right: 0.5rem;
      z-index: 1;
    }
    
    .card-header {
      padding: 0.5rem;
      
      .card-title {
        font-size: 1.25rem;
        font-weight: 800;
        letter-spacing: -0.0625rem;
        line-height: 1.5;
        margin: 0;
        text-align: center;
      }
      
    } // .card-header
    
    .card-content {
      display: flex;
      flex-flow: column nowrap;
      align-items: center;
      justify-content: flex-end;
      gap: 0.5rem;
      flex-grow: 1;
      padding-bottom: 1rem;
      text-transform: none;
      
      p {
        letter-spacing: -0.03125rem; // 0.5px
        margin: 0;
      }
      
    } // .card-content
    
    .card-footer {
      display: flex;
      justify-content: flex-end;
      align-items: center;
      
      .card-action {
        appearance: none;
        background: #ccc;
        border: 2px solid #ccc;
        border-radius: 1rem;
        font-family: inherit;
        font-weight: 600;
        padding: 0.25rem 0.5rem;
        
        &:hover {
          background: $aqua;
          border-color: $black;
        }
        
        &:active {
          background: $black;
          border-color: $black;
          color: $white;
        }
        
      } // .card-action
      
    } // .card-footer
    
  } // .card-body
  
  // Main body of normal modal dialog.
  .modal-body {
    background: $white;
    border-radius: 0.75rem;
    font-family: 'Roboto', sans-serif;
    padding: 0.25rem;
    text-transform: none;
    
    min-width: 20rem;
    max-width: 24rem;
    
    // Close button.
    .close {
      appearance: none;
      background: transparent;
      border: none;
      border-radius: 1rem;
      color: $black;
      font-size: 1rem;
      
      display: flex;
      align-items: center;
      justify-content: center;
      
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      height: 1.5rem;
      width: 1.5rem;
      
      &:hover, &:active {
        background: $black;
        color: $white;
      }
      
      &:before {
        content: '\2716';
        font-weight: bold;
        line-height: 1;
      }
      
    } // .close

    // Header.
    .modal-header {

      // Title of modal.
      .modal-title {
        background: #ccc;
        border-radius: 0.625rem 0.625rem 0 0;
        font-size: 1rem;
        line-height: 2;
        margin: 0;
        padding: 0 2rem;
        text-align: center;
        
        max-width: 20rem;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

    } // .modal-header

    // Content of modal.
    .modal-content {
      background: #eee;
      cursor: auto;
      display: flex;
      flex-flow: column nowrap;
      gap: 0.5rem;
      padding: 0.5rem 0.5rem 0;
      user-select: text;
      
      p {
        line-height: 1;
        margin: 0;
      }
      
    } // .modal-content

    // Footer of modal.
    .modal-footer {
      background: #eee;
      border-radius: 0 0 0.625rem 0.625rem;
      display: flex;
      align-items: center; // vertically
      justify-content: flex-end;  // horizontally
      padding: 0.5rem;

      // Each action button.
      .modal-action {
        appearance: none;
        background: #ccc;
        border: 2px solid $black;
        line-height: 1;
        border-radius: 1rem;
        padding: 0.25rem 0.5rem;
        font-family: inherit;
        font-weight: bold;
        // font-size: 1.25rem;
        
        &:hover {
          background: $white;
          border-color: $blue;
          color: $blue;
        }
        
        &:active {
          background: $black;
          border-color: $black;
          color: $white;
        }
        
        // OK/Confirm button.
        &.ok, &.confirm {}

        // Cancel button.
        &.cancel {}

      } // .modal-action

    } // .modal-footer

  } // .modal-body

} // #modal-overlay

              
            
!

JS

              
                // PseudoEnum of space types.
const SpaceType = Object.freeze({
  Property: 'Property',
  Railroad: 'Railroad',
  Utility:  'Utility',
  Tax:      'Tax',
  Busted:   'Busted',
  Free:     'Free',
  Visiting: 'Visiting',
  Deck:     'Deck',
  Go:       'Go',
}); // end enum SpaceTypes

/**
 * Space classes
 *
 * These represent any of the 40 spaces of the board on which a player can land.
 */

// Base class for all spaces.
class Space {
  // Each child class will populate this value with a SpaceType.
  _type = false;

  // On GameState.init() this will be set [0..39].
  _id  = false;

  constructor(type, name, group) {
    this._type   = type;
    this.name    = name;
    this.groupId = group;
  }

  setId(spaceId) {
    this._id = spaceId;
  }

  getId() {
    return this._id;
  }

  getPlayer() {
    return GameState.getCurrentPlayerObject();
  }

  // Common logic for when player lands on any space.
  onLand() {}

  // Common logic when a player passes any space.
  onPass() {}

  getType() {
    return this._type;
  }

  getName() {
    return this.name;
  }

  // This must be overridden if property, railroad, or utility.
  hasTitleDeed() {
    return false;
  }

  getGroupId() {
    return this.groupId;
  }

} // end class Space

// Property space class for all grouped, purchasable properties.
class PropertySpace extends Space {
  owningPlayerId = false; // unowned

  constructor(name, group, miniName, cost, rents = [], bldgCost) {
    super(SpaceType.Property, name, group);
    this.name2 = miniName;
    this.cost  = cost;
    this.rent  = {
      default:  rents[0],
      colorSet: rents[1],
      houses: {
        1: rents[2],
        2: rents[3],
        3: rents[4],
        4: rents[5],
      },
      hotel: rents[6],
    };
    this.bldgCost = bldgCost;
  }

  // Logic for when player lands on any grouped property.
  onLand() {
    let ownerId = this.owningPlayerId;

    // Check if player owns this property.
    if (ownerId === GameState.getCurrentPlayerIndex()) {
      // Player owns this property; do nothing.
      GameState.showModal({
        text:  'Since you own this property, you pay nothing',
        title: 'Your Property',
        onClosed: _ => {
          // Nothing to do but end the turn.
          GameState.showEndTurn();
        },
      });
      return;
    }

    let space = BoardSpaces[this.getId()];

    // Pay rent if owned, allow purchase if not.
    if (ownerId !== false) {
      // Owned. Look up rents.
      let rent  = space.rent.default;
      let owner = GameState.players[ownerId];
      let currPlayer = this.getPlayer();
      let numHouses = owner.propertiesOwned[this.getId()] || 0;
      if (numHouses == 5) {
        // Get rent with hotel.
        rent = space.rent.hotel;
      }
      else if (numHouses > 0) {
        // Get rents with houses (1-4).
        rent = space.rent.houses[numHouses];
      }
      // Check if player owns entire color set.

      // Pay rent.
      GameState.showModal({
        text:  `You pay rent: $${rent}.`,
        title: 'Pay Rent: Property',
        onClosed: _ => {
          // Subtract rent from player who landed here...
          currPlayer.alterMoney(0 - rent); // must be negative to pay vs. receive
          // ...and give it to the owner.
          owner.alterMoney(rent);
          // Show option to end turn (nothing left to do).
          GameState.showEndTurn();
        },
      });
    }
    else {
      // Unowned. Give player option to purchase.
      // @TODO: Make HTML selector a config option.
      $('.action.purchase .name').text(space.getName());
      $('.action.purchase .cost-amount').text(this.cost);
      GameState.showActionState('purchase');
      // Don't end turn, because player must still respond.
    }
  }

  doesPlayerOwnAllInGroup(player) {
    // Loop through each property in this group.
    let properties = GameState.getAllPropertiesInGroup(this.getGroupId());
    for (let i = 0; i < properties.length; i++) {
      // If player doesn't own it, cancel and report false.
      if (player.checkIfOwnsProperty(properties[i]) !== true) {
        return false;
      }
    }
    // If all checks passed, player owns all in gorup.
    return true;
  }

  hasTitleDeed() {
    return true;
  }

  getMiniName() {
    return this.name2;
  }

} // end class Property

// Railroad space class for all railroads.
class RailroadSpace extends Space {
  owningPlayerId = false; // unowned
  // If [idx] number of R/Rs are owned, rent is $[val].
  rents = [0, 25, 50, 100, 200];

  constructor(name, rrIndex, miniName1, miniName2, cost = 200) {
    super(SpaceType.Railroad, name, rrIndex);
    this.name1 = miniName1;
    this.name2 = miniName2;
    this.cost  = cost;
  }

  // Logic for when player lands on any railroad.
  onLand() {
    let ownerId = this.owningPlayerId;

    // Pay rent if owned, allow purchase if not.
    if (ownerId !== false) {
      // Owned. Determine rent based on # owned by same player as this one.
      let owner = GameState.players[ownerId];
      let numOwned = GameState.getNumSpacesOfTypeOwned(this.getType(), ownerId);
      let rent = this.rents[numOwned];
      let currPlayer = this.getPlayer();
      // Subtract rent from player who landed here...
      GameState.showModal({
        text:  `You pay rent: $${rent}.`,
        title: 'Pay Rent: Railroad',
        onClosed: _ => {
          currPlayer.alterMoney(0 - rent); // must be negative to pay vs. receive
          // ...and give it to the owner.
          owner.alterMoney(rent);
          // Show option to end turn (nothing left to do).
          GameState.showEndTurn();
        },
      });
    }
    else {
      // Unowned. Give player option to purchase.
      let space = BoardSpaces[this.getId()];
      $('button.purchase-property .name').text(space.getName());
      $('.action.purchase .cost-amount').text(this.cost);
      GameState.showActionState('purchase');
      // Don't end turn, because player must still respond.
    }
  }

  getNames() {
    return [
      this.name1,
      this.name2,
    ];
  }

  hasTitleDeed() {
    return true;
  }

} // end class Railroad

// Utility space class for all (both) utilities.
class UtilitySpace extends Space {
  owningPlayerId = false; // unowned
  // If [idx] number of utilities are owned, rent is [val] times dice value.
  rents = [0, 4, 10];

  constructor(name, utilGroup, miniName1, miniName2, icon, cost = 150) {
    super(SpaceType.Utility, name, utilGroup);
    this.name1 = miniName1;
    this.name2 = miniName2;
    this.icon  = icon; // e.g. "lightbulb"
    this.cost  = cost;
  }

  // Logic for when player lands on any utility.
  onLand() {
    let ownerId = this.owningPlayerId;

    // Pay rent if owned, allow purchase if not.
    if (ownerId !== false) {
      // Owned. Determine rent based on # owned by same player as this one.
      let owner = GameState.players[ownerId];
      let numOwned = GameState.getNumSpacesOfTypeOwned(this.getType(), ownerId);
      let rentMultiplier = this.rents[numOwned];
      // Rent is [2 or 10] times total shown on dice.
      let rent = GameState.getDiceTotal() * rentMultiplier;
      let currPlayer = this.getPlayer();
      GameState.showModal({
        text:  `You pay rent: $${rent} (${rentMultiplier} × dice)`,
        title: 'Pay Utility Rent',
        onClosed: _ => {
          // Subtract rent from player who landed here...
          currPlayer.alterMoney(0 - rent);
          // ...and give it to the owner.
          owner.alterMoney(rent);
          // Show option to end turn (nothing left to do).
          GameState.showEndTurn();
        },
      });
    }
    else {
      // Unowned. Give player option to purchase.
      let space = BoardSpaces[this.getId()];
      // @TODO: Make HTML selector a config option (same as above).
      $('button.purchase-property .name').text(space.getName());
      $('.action.purchase .cost-amount').text(this.cost);
      GameState.showActionState('purchase');
      // Don't end turn, because player must still respond.
    }
  }

  getIcon() {
    return `fa-${this.icon}`;
  }

  getNames() {
    return [
      this.name1,
      this.name2,
    ];
  }

  hasTitleDeed() {
    return true;
  }

} // end class Utility

// Tax space class for both Income and Luxury taxes.
class TaxSpace extends Space {

  constructor(name, taxGroup, cost = 200) {
    super(SpaceType.Tax, name, taxGroup);
    this.cost = cost;
  }

  // Logic for when player lands on any Tax space.
  onLand() {
    let currPlayer = this.getPlayer();
    let cost = this.cost;
    GameState.showModal({
      text:  `You landed on ${this.name} and must pay: $${cost}.`,
      title: `Pay ${this.name}`,
      onClosed: _ => {
        // Pay the bank.
        currPlayer.alterMoney(0 - cost);
        // End player turn.
        currPlayer.showEndTurn();
      },
    });
  }

} // end class Tax

// Busted space class for "Go to Jail" space.
class BustedSpace extends Space {

  constructor(name) {
    super(SpaceType.Busted, name, false);
  }

  // Logic for when player lands on "Go to Jail".
  onLand() {
    GameState.showModal({
      text:  'Go directly to Jail!',
      title: 'Busted!',
      onClosed: _ => {
        const player = GameState.getCurrentPlayerObject();
        player.sendToJail();
      }
    });
  }

} // end class Busted

// Free space class for "Free Parking" space.
class FreeSpace extends Space {

  constructor(name) {
    super(SpaceType.Free, name, false);
  }

  // Logic for when player lands on Free Parking (usually nothing).
  onLand() {
    GameState.showEndTurn();
  }

} // end class Free

// Visiting space class for "Just Visiting" Jail space.
class VisitingSpace extends Space {

  constructor(name) {
    super(SpaceType.Visiting, name, false);
  }

  // Logic for when player lands on Just Visiting Jail (usually nothing).
  onLand() {
    GameState.showEndTurn();
  }

} // end space Visiting

// Deck space class for "Community Chest" and "Chance" spaces.
class DeckSpace extends Space {

  constructor(name, deckGroup) {
    super(SpaceType.Deck, name, deckGroup);
  }

  // Logic for when player lands on a "draw card" space (Chest/Chance).
  onLand() {
    // First we need the deck we're drawing from.
    const deck = CardDeck[this.getGroupId()];
    const rIdx = Math.floor(Math.random() * deck.length);
    const card = deck[rIdx];
    // Show card to player.
    card.showToPlayer();
    // Do nothing & let the card's Effect handle ending this turn.
  }

} // end class Deck

// Go space class for "Go" space.
class GoSpace extends Space {
  salary = 200;

  constructor(name) {
    super(SpaceType.Go, name, false);
  }

  // Logic for when player LANDS on GO.
  onLand() {
    let currPlayer = this.getPlayer();
    let salary = this.salary;
    GameState.showModal({
      text:  `For landing on GO, you get $${salary}.`,
      title: 'Landed on GO',
      onClosed: _ => {
        // Award salary.
        currPlayer.alterMoney(salary);
        // End player turn.
        currPlayer.showEndTurn();
      },
    });
  }

  // Logic for when player PASSES GO.
  onPass(fToRunAfter = false) {
    fToRunAfter = (typeof fToRunAfter === 'function') ? fToRunAfter : false;
    let currPlayer = this.getPlayer();
    let salary = this.salary;
    GameState.showModal({
      text:  `For passing GO, you collect $${salary}.`,
      title: 'You Passed GO!',
      onClosed: _ => {
        // Award salary.
        currPlayer.alterMoney(salary);
        // DO NOT end turn, as we merely PASSED this space.
        if (fToRunAfter) {
          // Short delay to avoid breaking dismissal functionality of 2nd modal.
          window.setTimeout(_ => {
            fToRunAfter();
          }, 50);
        }
      },
    });
  }

} // end class Go

// Base class for all card effects that can be applied to players.
class Effect {

  constructor() {
    // this.message = message;
  }

  applyEffect(playerObj) {
    // Placeholder.
  }

} // end class Effect

// Effect for gaining or losing money.
class MoneyEffect extends Effect {

  constructor(moneyAmt, toPlayers = false) {
    super();
    this.money     = moneyAmt;
    this.toPlayers = toPlayers;
  }

  applyEffect(playerObj) {
    playerObj.alterMoney(this.money);
    GameState.showEndTurn();
  }

} // end class MoneyEffect

// Effect for moving player token a certain number of spaces back or forward.
class MoveSpacesEffect extends Effect {

  constructor(numSpaces) {
    super();
    this.numSpaces = numSpaces;
  }

  applyEffect(playerObj) {
    Modal.activate('TODO: Move token ' + this.numSpaces + ' spaces.', 'TO-DO');
    GameState.showEndTurn();
    // Something like: playerObj.moveTokenBy(this.numSpaces);
  }

} // end class MoveSpacesEffect

// Effect for moving player token forward to nearest space of a certain type.
class MoveToTypeEffect extends Effect {

  constructor(spaceType, willRollDice, rentMultiplier) {
    super();
    this.spaceType = spaceType;
  }

  applyEffect(playerObj) {
    Modal.activate('TODO: Move token to nearest ' + this.spaceType + ' space.', 'TO-DO');
    GameState.showEndTurn();
    // Something like: playerObj.moveTokenToType(this.spaceType);
  }

} // end class MoveToTypeEffect

// Effect for moving player token forward to a specific space index.
class MoveToSpaceEffect extends Effect {

  constructor(spaceIndex) {
    super();
    this.spaceIndex = spaceIndex;
  }

  applyEffect(playerObj) {
    Modal.activate('TODO: Move token to space index ' + this.spaceIndex + '.', 'TO-DO');
    GameState.showEndTurn();
    // Something like: playerObj.moveTokenToSpace(this.spaceIndex);
  }

} // end class MoveToSpaceEffect

// Effect for adding a "Get Out of Jail Free" card to player's assets.
class JailbreakEffect extends Effect {

  constructor(deckId = '') {
    super();
    this.deckId = deckId; // 'chest' or 'chance'
  }

  applyEffect(playerObj) {
    Modal.activate('TODO: Add jailbreak card (from ' + this.deckId + ' pile) to assets.', 'TO-DO');
    GameState.refreshBoard();
    GameState.showEndTurn();
    // Something like: playerObj.setJailbreakCard(this.deckId, TRUE);
  }

} // end class JailbreakEffect

// Effect for moving player token directly to Jail.
class BustedEffect extends Effect {

  constructor() {
    super();
  }

  applyEffect(playerObj) {
    Modal.activate('TODO: Move token directly to Jail, etc.', 'TO-DO');
    GameState.refreshBoard();
    GameState.showEndTurn();
    // Something like: playerObj.goToJail();
  }

} // end class BustedEffect

// Effect for assessing repairs on player's properties.
class RepairsEffect extends Effect {

  constructor(costPerHouse, costPerHotel) {
    super();
    this.costPerHouse = costPerHouse;
    this.costPerHotel = costPerHotel;
  }

  applyEffect(playerObj) {
    Modal.activate('TODO: Assess repairs at $' + this.costPerHouse + ' per house and $' + this.costPerHotel + ' per hotel.', 'TO-DO');
    GameState.refreshBoard();
    GameState.showEndTurn();
    // Something like: playerObj.payRepairs(this.costPerHouse, this.costPerHotel);
  }

} // end class RepairsEffect

// Base class for all cards in the Community Chest and Chance decks.
class Card {
  deckTitle = false; // populated in constructor

  constructor(title, message, effect) {
    this.deckTitle = title;
    this.message   = message;
    this.effect    = effect;
  }

  // Alias for Effect.applyEffect()
  applyEffect(playerObj) {
    this.getEffect().applyEffect(playerObj);
  }

  getMessage() {
    return this.message;
  }

  getEffect() {
    return this.effect;
  }

  getPlayer() {
    return GameState.getCurrentPlayerObject();
  }

  showToPlayer() {
    let _this = this;
    let $modal = $('#card-overlay');
    let currPlayer = this.getPlayer();

    // Populate modal with title and message.
    $modal.removeClass('from-chance from-chest').addClass('in-deck');
    $modal.addClass('from-' + DeckType[this.deckTitle]);
    $modal.find('.card-title').text(this.deckTitle);
    $modal.find('.card-content').html(this.message);
    // On OK click, hide modal, waiting for animation, then act.
    $modal.find('.card-action').off('click').on('click', e => {
      $modal.removeClass('show').addClass('hide');
      window.setTimeout(_ => {
        $modal.addClass('hidden');
        // Apply the effect once the card has finished hiding.
        _this.applyEffect(currPlayer);
        // Oh, and refresh the board, too.
        GameState.refreshBoard();
      }, 500);
    });
    // Now show the card modal.
    $modal.removeClass('hidden');
    window.setTimeout(_ => {
      $modal.removeClass('hide').addClass('show');
      // Wait a bit, then remove the in-deck class.
      window.setTimeout(_ => {
        $modal.removeClass('in-deck');
      }, 750);
    }, 50);
  }

} // end class Card

// Pseudo-enum of card deck types.
const DeckType  = Object.freeze({
  CommunityChest:    'chest',
  'Community Chest': 'chest',
  Chance:            'chance',
});
const DeckTitle = Object.freeze({
  CommunityChest: 'Community Chest',
  Chance:         'Chance',
});

// Aliases for deck types.
const chest  = DeckType.CommunityChest;
const chance = DeckType.Chance;

// Definition of all cards in the Community Chest and Chance decks.
const CardDeck = {
  // Community Chest (id 'chest').
  chest: [
    new Card(
      DeckTitle.CommunityChest,
      'Advance to Go. (Collect $200)',
      new MoveToSpaceEffect(0)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Bank error in your favor. Collect $200.',
      new MoneyEffect(200, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Doctor\'s fee. Pay $50.',
      new MoneyEffect(-50, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'From sale of stock you get $50.',
      new MoneyEffect(50, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Get Out of Jail Free. This card may be kept until needed or traded.',
      new JailbreakEffect(DeckType.CommunityChest)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Go to Jail. Go directly to Jail, do not pass Go, do not collect $200.',
      new BustedEffect()
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Holiday fund matures. Receive $100.',
      new MoneyEffect(100, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Income tax refund. Collect $20.',
      new MoneyEffect(20, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'It is your birthday. Collect $10 from every player.',
      new MoneyEffect(10, true)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Life insurance matures. Collect $100.',
      new MoneyEffect(100, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Pay hospital fees of $100.',
      new MoneyEffect(-100, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Pay school fees of $50.',
      new MoneyEffect(-50, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'Receive $25 consultancy fee.',
      new MoneyEffect(25, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'You are assessed for street repair. $40 per house. $115 per hotel.',
      new RepairsEffect(40, 115)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'You have won second prize in a beauty contest. Collect $10.',
      new MoneyEffect(10, false)
    ),
    new Card(
      DeckTitle.CommunityChest,
      'You inherit $100.',
      new MoneyEffect(100, false)
    ),
  ],
  // Chance (id 'chance').
  chance: [
    new Card(
      DeckTitle.Chance,
      'Advance to Boardwalk.',
      new MoveToSpaceEffect(39)
    ),
    new Card(
      DeckTitle.Chance,
      'Advance to Go. (Collect $200)',
      new MoveToSpaceEffect(0)
    ),
    new Card(
      DeckTitle.Chance,
      'Advance to Illinois Avenue. If you pass Go, collect $200.',
      new MoveToSpaceEffect(24)
    ),
    new Card(
      DeckTitle.Chance,
      'Advance to St. Charles Place. If you pass Go, collect $200.',
      new MoveToSpaceEffect(11)
    ),
    new Card(
      DeckTitle.Chance,
      'Advance to the nearest Railroad. if unowned, you may buy it from the Bank. If owned, pay owner twice the rental to which they are otherwise entitled.',
      new MoveToTypeEffect(SpaceType.Railroad, false, 2)
    ),
    new Card(
      DeckTitle.Chance,
      'Advance to the nearest Railroad. if unowned, you may buy it from the Bank. If owned, pay owner twice the rental to which they are otherwise entitled.',
      new MoveToTypeEffect(SpaceType.Railroad, false, 2)
    ),
    new Card(
      DeckTitle.Chance,
      'Advance token to nearest Utility. If unowned, you may buy it from the Bank. If owned, throw dice and pay owner a total ten times amount thrown.',
      new MoveToTypeEffect(SpaceType.Utility, true, 10)
    ),
    new Card(
      DeckTitle.Chance,
      'Bank pays you dividend of $50.',
      new MoneyEffect(50, false)
    ),
    new Card(
      DeckTitle.Chance,
      'Get Out of Jail Free. This card may be kept until needed or traded.',
      new JailbreakEffect(DeckType.Chance)
    ),
    new Card(
      DeckTitle.Chance,
      'Go Back 3 Spaces.',
      new MoveSpacesEffect(-3)
    ),
    new Card(
      DeckTitle.Chance,
      'Go to Jail. Go directly to Jail, do not pass Go, do not collect $200.',
      new BustedEffect()
    ),
    new Card(
      DeckTitle.Chance,
      'Make general repairs on all your properties. For each house pay $25. For each hotel pay $100.',
      new RepairsEffect(25, 100)
    ),
    new Card(
      DeckTitle.Chance,
      'Speeding fine $15.',
      new MoneyEffect(-15, false)
    ),
    new Card(
      DeckTitle.Chance,
      'Take a trip to Reading Railroad. If you pass Go, collect $200.',
      new MoveToSpaceEffect(5)
    ),
    new Card(
      DeckTitle.Chance,
      'You have been elected Chairman of the Board. Pay each player $50.',
      new MoneyEffect(-50, true)
    ),
    new Card(
      DeckTitle.Chance,
      'Your building loan matures. Collect $150.',
      new MoneyEffect(150, false)
    ),
  ],
};

// Pseudo-enum of Utility space sub-types.
const UtilityType = Object.freeze({
  Electric: Symbol('electric'),
  Water:    Symbol('water'),
});

// Pseudo-enum of Tax space sub-types.
const TaxType = Object.freeze({
  Income: Symbol('income'),
  Luxury: Symbol('luxury'),
});

// Psuedo-enum of individual Railroad spaces.
const Railroad = {
  Reading:      0,
  Pennsylvania: 1,
  BAndO:        2,
  ShortLine:    3,
};

// Pseudo-enum of Property groups.
const PropertyGroup = {
  Brown:  0,
  Aqua:   1,
  Pink:   2,
  Orange: 3,
  Red:    4,
  Yellow: 5,
  Green:  6,
  Blue:   7,
};

// List of all board spaces (each an instance of a class extending Effect).
const BoardSpaces = [
  // 0 - GO
  new GoSpace('GO'),
  // 1 - Property 0a: Mediterranean Avenue
  new PropertySpace(
    'Mediterranean Avenue', PropertyGroup.Brown, 'MEDIT', 60,
    [2, 4, 10, 30, 90, 160, 250], 50
  ),
  // 2 - Deck: Community Chest 1 of 3
  new DeckSpace('Community Chest', DeckType.CommunityChest),
  // 3 - Property 0b: Baltic Avenue
  new PropertySpace(
    'Baltic Avenue', PropertyGroup.Brown, 'BALT', 60,
    [4, 8, 20, 60, 180, 320, 450], 50
  ),
  // 4 - Tax 1 of 2: Income
  new TaxSpace('Income Tax', TaxType.Income),
  // 5 - Railroad 1 of 4: Reading R/R
  new RailroadSpace('Reading Railroad', Railroad.Reading, 'READ.', 'R / R'),
  // 6 - Property 1a: Oriental Avenue
  new PropertySpace(
    'Oriental Avenue', PropertyGroup.Aqua, 'ORIEN', 100,
    [6, 12, 30, 90, 270, 400, 550], 50
  ),
  // 7 - Deck: Chance 1 of 3
  new DeckSpace('Chance', DeckType.Chance),
  // 8 - Property 1b: Vermont Avenue
  new PropertySpace(
    'Vermont Avenue', PropertyGroup.Aqua, 'VERM', 100,
    [6, 12, 30, 90, 270, 400, 550], 50
  ),
  // 9 - Property 1c: Connecticut Avenue
  new PropertySpace(
    'Connecticut Avenue', PropertyGroup.Aqua, 'CONN', 120,
    [8, 16, 40, 100, 300, 450, 600], 50
  ),
  // 10 - Jail / Just Visiting
  new VisitingSpace('Just Visiting'),
  // 11 - Property 2a: St. Charles Place
  new PropertySpace(
    'St. Charles Place', PropertyGroup.Pink, 'ST.CH', 140,
    [10, 20, 50, 150, 450, 625, 750], 100
  ),
  // 12 - Utility 1 of 2: Electric Company
  new UtilitySpace('Electric Company', UtilityType.Electric, 'ELECT.', 'COMP.', 'lightbulb'),
  // 13 - Property 2b: States Avenue
  new PropertySpace(
    'States Avenue', PropertyGroup.Pink, 'STATE', 140,
    [10, 20, 50, 150, 450, 625, 750], 100
  ),
  // 14 - Property 2c: Virginia Avenue
  new PropertySpace(
    'Virginia Avenue', PropertyGroup.Pink, 'VIRG', 160,
    [12, 24, 60, 180, 500, 700, 900], 100
  ),
  // 15 - Railroad 2 of 4: Pennsylvania
  new RailroadSpace('Pennsylvania Railroad', Railroad.Pennsylvania, 'PENN.', 'R / R'),
  // 16 - Property 3a: St. James Place
  new PropertySpace(
    'St. James Place', PropertyGroup.Orange, 'ST.JM', 180,
    [14, 28, 70, 200, 550, 750, 950], 100
  ),
  // 17 - Deck: Community Chest 2 of 3
  new DeckSpace('Community Chest', DeckType.CommunityChest),
  // 18 - Property 3b: Tennessee Avenue
  new PropertySpace(
    'Tennessee Avenue', PropertyGroup.Orange, 'TENN', 180,
    [14, 28, 70, 200, 550, 750, 950], 100
  ),
  // 19 - Property 2c: New York Avenue
  new PropertySpace(
    'New York Avenue', PropertyGroup.Orange, 'NYRK', 200,
    [16, 32, 80, 220, 600, 800, 1000], 100
  ),
  // 20 - Free Parking
  new FreeSpace('Free Parking'),
  // 21 - Property 4a: Kentucky Avenue
  new PropertySpace(
    'Kentucky Avenue', PropertyGroup.Red, 'KENY', 220,
    [18, 36, 90, 250, 700, 875, 1050], 150
  ),
  // 22 - Property 4b: Indiana Avenue
  new PropertySpace(
    'Indiana Avenue', PropertyGroup.Red, 'INDA', 220,
    [18, 36, 90, 250, 700, 875, 1050], 150
  ),
  // 23 - Deck: Chance 2 of 3
  new DeckSpace('Chance', DeckType.Chance),
  // 24 - Property 4c: Illinois Avenue
  new PropertySpace(
    'Illinois Avenue', PropertyGroup.Red, 'ILLIN', 240,
    [20, 40, 100, 300, 750, 925, 1100], 150
  ),
  // 25 - Railroad 3 of 4: B. & O. R/R
  new RailroadSpace('B. & O. Railroad', Railroad.BAndO, 'B. & O.', 'R / R'),
  // 26 - Property 5a: Atlantic Avenue
  new PropertySpace(
    'Atlantic Avenue', PropertyGroup.Yellow, 'ATLN', 260,
    [22, 44, 110, 330, 800, 975, 1150], 150
  ),
  // 27 - Property 5b: Ventnor Avenue
  new PropertySpace(
    'Ventnor Avenue', PropertyGroup.Yellow, 'VENT', 260,
    [22, 44 ,110, 330, 800, 975, 1150], 150
  ),
  // 28 - Utility 2 of 2: Water Works
  new UtilitySpace('Water Works', UtilityType.Water, 'WATER', 'WORKS', 'tint'),
  // 29 - Property 5c: Marvin Gardens
  new PropertySpace(
    'Marvin Gardens', PropertyGroup.Yellow, 'MARV', 280,
    [24, 48, 120, 360, 850, 1025, 1200], 150
  ),
  // 30 - GO TO JAIL
  new BustedSpace('Go to Jail'),
  // 31 - Property 6a: Pacific Avenue
  new PropertySpace(
    'Pacific Avenue', PropertyGroup.Green, 'PACIF', 300,
    [26, 52, 130, 390, 900, 1100, 1275], 200
  ),
  // 32 - Property 6b: North Carolina Avenue
  new PropertySpace(
    'North Carolina Avenue', PropertyGroup.Green, 'N.CRL', 300,
    [26, 52, 130, 390, 900, 1100, 1275], 200
  ),
  // 33 - Deck: Community Chest 3 of 3
  new DeckSpace('Community Chest', DeckType.CommunityChest),
  // 34 - Property 6c: Pennsylvania Avenue
  new PropertySpace(
    'Pennsylvania Avenue', PropertyGroup.Green, 'PENN', 320,
    [28, 56, 150, 450, 1000, 1200, 1400], 200
  ),
  // 35 - Railroad 4 of 4: Short Line
  new RailroadSpace('Short Line', Railroad.ShortLine, 'SHORT', 'LINE'),
  // 36 - Deck: Chance 3 of 3
  new DeckSpace('Chance', DeckType.Chance),
  // 37 - Property 7a: Park Place
  new PropertySpace(
    'Park Place', PropertyGroup.Blue, 'PARK', 350,
    [35, 70, 175, 500, 1100, 1300, 1500], 200
  ),
  // 38 - Tax 2 of 2: Luxury
  new TaxSpace('Luxury Tax', TaxType.Luxury),
  // 39 - Property 7b: Boardwalk
  new PropertySpace(
    'Boardwalk', PropertyGroup.Blue, 'BDWK', 400,
    [50, 100, 200, 600, 1400, 1700, 2000], 200
  ),
];

// Singleton class for managing game state and player turns.
let GameState = {
  // Stores the singleton instance of this GameState.
  instance: false,
  // Each element of `players` is a Player instance.
  players: [],
  // Current player id (false = no players).
  currentPlayer: false,
  // Dice values.
  dice: [0, 0],
  // Milliseconds to wait between space-by-space moves of player token.
  tokenMoveDelay: 500,
  // Amount of money player pays to be freed from Jail.
  bailAmount: 50,
  // Number of in-jail dice rolls a player gets after being thrown in Jail before they are forced to pay the .
  numDoubleTries: 3,

  init(players = []) {
    // Set each player's playerNumber (0..) and add to list.
    players.forEach((player, index) => {
      player.playerNumber = index;
      this.players.push(player);
    });

    // Go through each BoardSpace and set its internal ID (0..39).
    BoardSpaces.forEach((space, index) => {
      space.setId(index);
    });

    // Show "Start Game" state.
    $('button.start-game').attr('disabled', false);
    $('.player-selection').remove();
    $('.tabletop > h1').text('Ready!');
    $('.tabletop .players-list h2').text('Click “Start Game” to begin.');
    this.showActionState('start');
  },

  getBailAmount() {
    return this.bailAmount;
  },

  getBoard() {
    return $('#board');
  },

  getCurrentPlayerDisplay() {
    // Player 0 is displayed as Player 1, and so on.
    return this.currentPlayer + 1;
  },

  getCurrentPlayerIndex() {
    return this.currentPlayer;
  },

  getCurrentPlayerObject() {
    return this.players[this.currentPlayer];
  },

  showActionState(state = 'start') {
    // Hide all actions.
    this.getBoard().find('.action').addClass('hidden');
    // Find specified action.
    let $action = this.getBoard().find('.action.' + state);
    // Check if action exists.
    if (!$action.length) {
      GameState.showModal({
        text:  `Action state "${state}" does not exist.`,
        title: 'ERROR',
      });
      return;
    }
    // Show specified action.
    $action.removeClass('hidden');
  },

  // Refresh game board and tabletop contents.
  refreshBoard() {
    /// BOARD ///

    // First, populate current player number and balance, then show them.
    const playerNbr = this.currentPlayer + 1;
    let B = this.getBoard();
    let currPlayer = this.getCurrentPlayerObject();
    B.find('.player-info .current .value').text(currPlayer.getName());
    B.find('.player-info .player-money .money').text(currPlayer.getMoney());
    B.find('.player-info').removeClass('hidden');
    // Place all tokens in their respective spaces.
    this.players.forEach(player => {
      player.updateToken();
    });

    /// TABLETOP ///

    // Change tabletop title to reflect current status of gameplay.
    $('.tabletop h1').text('Game Status');
    // Define a template we can re-use for each player's status line.
    const tpl =
      `<div class="player-line player-[index]">
        <div class="token color-[color]">[num]</div>
        <div class="name">[name]</div>
        <div class="indicator hidden">Current Turn</div>
      </div>`;
    let $list = $('.tabletop .players-list');
    // Reset player list contents.
    $list.html('');
    // Add each player's token, number, name, turn indicator (if applicable).
    this.players.forEach(player => {
      // Generate player line HTML from template above.
      let playerHtml = tpl
        .replaceAll('[color]', player.tokenColorNum)
        .replaceAll('[name]',  player.name)
        .replaceAll('[num]',   player.playerNumber + 1)
        .replaceAll('[index]', player.playerNumber);
      // Un-hide turn indicator if this is current player.
      if (this.currentPlayer == player.playerNumber) {
        playerHtml = playerHtml.replaceAll('hidden', '');
      }
      // Add player line HTML to player-list.
      $list.append(playerHtml);
    });
    // Now un-hide the player list.
    $list.removeClass('hidden');
    // Store reference to cards list where each stack will be placed.
    let $assets = $('.your-assets .cards');
    // Completely empty out list of asset cards before [re-]adding them in.
    $assets.html('');
    // Next, gather and show current player's assets, starting with Properties.
    for (let color in PropertyGroup) {
      // Go through each color group and check if player owns properties in any.
      if (PropertyGroup.hasOwnProperty(color)) {
        let groupId = PropertyGroup[color];
        let propsInGroup = this.getAllPropertiesInGroup(groupId);
        let ownedProps = [];
        // Go through each property in color group and check if player owns any.
        propsInGroup.forEach(thisProperty => {
          // Does player own this?
          if (thisProperty.owningPlayerId === currPlayer.playerNumber) {
            // Yes, add spaceId to the list.
            let spaceId = thisProperty.getId();
            ownedProps.push(thisProperty);
          }
        });
        // Does player own any spaces in this color group?
        if (ownedProps.length) {
          // They do, so start a new card stack.
          let $propStack = $('<div />').addClass('stack');
          // Populate stack with a minicard for each Property.
          ownedProps.forEach(thisProperty => {
            // Clone another minicard.
            let $propMinicard = $('#templates .minicard.property').clone();
            // Populate color group number and Property name.
            $propMinicard.find('.color-bar.name')
              .addClass('group-' + thisProperty.getGroupId())
              .text(thisProperty.getMiniName());
            // Populate space ID/index.
            $propMinicard.attr('data-space-id', thisProperty.getId());
            // Add the constructed minicard to the stack.
            $propStack.append($propMinicard);
            // Let's save some memory here.
            $propMinicard = null;
          });
          // Now that the stack's been...uh, stacked, we add it to the tabletop.
          $assets.append($propStack);
          $propStack = null;
        }
      }
    }
    // Next, gather Railroads.
    let railroadSpaces = this.getAllSpacesOfType(SpaceType.Railroad);
    if (railroadSpaces.length) {
      // Player owns at least one Railroad.
      let $rrStack = $('<div />').addClass('stack');
      let hasRailroads = false;
      // Populate stack with a minicard for each Railroad.
      railroadSpaces.forEach(thisSpace => {
        if (thisSpace.owningPlayerId === currPlayer.playerNumber) {
          hasRailroads = true;
          // Populate minicard with Railroad name.
          let $rrMinicard = $('#templates .minicard.railroad').clone();
          $rrMinicard.attr('data-space-id', thisSpace.getId());
          let rrNames = thisSpace.getNames();
          $rrMinicard.find('.name1').text(rrNames[0]);
          $rrMinicard.find('.name2').text(rrNames[1]);
          // Add the constructed minicard to the stack.
          $rrStack.append($rrMinicard);
        }
      });
      // Check if stack has anything.
      if (hasRailroads) {
        // Add stack to tabletop.
        $assets.append($rrStack);
      }
      // Clear from memory.
      $rrStack = null;
    }
    // Finally, gather Utilities.
    let utilitySpaces = this.getAllSpacesOfType(SpaceType.Utility);
    if (utilitySpaces.length) {
      // Player owns at least one Utility.
      let $utilStack = $('<div />').addClass('stack');
      let hasUtilities = false;
      // Populate stack with a minicard for each Utility.
      utilitySpaces.forEach(thisSpace => {
        if (thisSpace.owningPlayerId === currPlayer.playerNumber) {
          hasUtilities = true;
          // Populate minicard with Utility icon and name.
          let $utilMinicard = $('#templates .minicard.utility').clone();
          $utilMinicard.attr('data-space-id', thisSpace.getId());
          $utilMinicard.find('i.fa').addClass(thisSpace.getIcon());
          let utilNames = thisSpace.getNames();
          $utilMinicard.find('.name1').text(utilNames[0]);
          $utilMinicard.find('.name2').text(utilNames[1]);
          // Add the constructed minicard to the stack.
          $utilStack.append($utilMinicard);
        }
      });
      // Check if stack has anything.
      if (hasUtilities) {
        // Add stack to tabletop.
        $assets.append($utilStack);
      }
      // Clear from memory.
      $utilStack = null;
    }
    // Show the assets panel, even if empty.
    let $container = $assets.parents('.your-assets');
    $container.find('h3 .name').text(currPlayer.getName());
    $container.removeClass('hidden');
  },

  start() {
    // Randomize starting player index.
    this.currentPlayer = Math.floor(Math.random() * this.players.length);
    // Create a new token for each player.
    this.players.forEach((player, index) => {
      // Set player's token color number internally.
      let colorNum = index + 1;
      player.tokenColorNum = colorNum;
      // Determine player's token color (TODO: make this a user selection).
      let playerColor = 'color-' + colorNum;
      // Create token HTML object.
      let $token = $('<div />')
        .addClass('token ' + playerColor)
        .attr('data-player', index);
      // Add token HTML to the board.
      this.getBoard().append($token);
    });

    // Refresh board and tabletop with new data.
    this.refreshBoard();

    // Begin starting player's turn.
    let currPlayer = this.getCurrentPlayerObject();
    currPlayer.beginTurn();
  },

  rollDice() {
    let player = this.getCurrentPlayerObject();
    // Randomly generate two integers, 1..6
    let die1 = Math.floor(Math.random() * 6) + 1;
    let die2 = Math.floor(Math.random() * 6) + 1;
    // Show numberes on dice.
    $('#die-1').attr('data-value', die1);
    $('#die-2').attr('data-value', die2);
    // Store and show total value.
    this.dice = [die1, die2];
    const diceTotal = die1 + die2;
    this.getBoard().find('.dice-value').text(diceTotal).removeClass('hidden');
    this.getBoard().find('.dice-status').removeClass('hidden');
    // Check if player is rolling for doubles from within Jail.
    if (player.inJail) {
      if (die1 != die2) {
        // No doubles, no go. Decrement tries and check if player is out of tries.
        player.numDoubleTriesLeft--;
        if (player.numDoubleTriesLeft == 0) {
          // Out of tries, so show the "Pay Bail" action state.
          GameState.showActionState('pay-bail');
          // Relinquish control to player to pay bail.
          return;
        }
        this.showModal({
          title: 'Still In Jail',
          text:  'You failed to roll doubles! Number of tries left: ' + player.numDoubleTriesLeft + '. End of turn.',
          onClosed: _ => {
            // End turn.
            GameState.showEndTurn();
          },
        });
      }
      else {
        // Doubles! Free player and move.
        this.showModal({
          title: 'You are Free!',
          text:  'You rolled doubles, so you have been freed from Jail! You will now move the number of spaces shown on the dice.',
          onClosed: _ => {
            // Move player token to Visiting part of space.
            player.freeFromJail();
            // Hide "Roll Dice" to avoid premature re-roll.
            GameState.showActionState('moving');
            // Advance active player's token to space.
            let targetSpace = player.getCurrentSpaceId() + diceTotal;
            player.advanceTokenToSpace(targetSpace);
          },
        });
      }
    }
    else {
      // Hide "Roll Dice" action state to avoid premature re-roll.
      this.showActionState('moving');
      // Advance active player's token to space.
      let targetSpace = player.getCurrentSpaceId() + diceTotal;
      player.advanceTokenToSpace(targetSpace);
    }
  },

  clearDice() {
    // Clear individual dice values internally.
    this.dice = [0, 0];
    // Clear values of each die on board.
    $('#die-1').attr('data-value', '0');
    $('#die-2').attr('data-value', '0');
    // Hide dice status and total value.
    this.getBoard().find('.dice-value').text('');
    this.getBoard().find('.dice-status, .dice-value').addClass('hidden');
  },

  showDice() {
    this.getBoard().find('.dice').removeClass('hidden');
  },

  hideDice() {
    this.getBoard().find('.dice').addClass('hidden');
  },

  getDiceTotal() {
    return this.dice.reduce((sum, val) => sum + val, 0);
  },

  purchaseProperty() {
    // Get space ID and current player.
    let player = this.getCurrentPlayerObject();
    let spaceId = player.getCurrentSpaceId();
    let currSpace = BoardSpaces[spaceId];
    let cost = currSpace.cost;
    // Add space ID to player's list of owned properties.
    player.setPropertyOwned(spaceId);
    // Subtract cost from player's money.
    player.alterMoney(0 - cost);
    // Notify player.
    this.showModal({
      text:  `You have purchased ${currSpace.name} for $${cost}`,
      title: 'Purchase Property',
      onClosed: _ => {
        // End turn.
        GameState.showEndTurn();
      }
    });
  },

  getAllPropertiesInGroup(groupId) {
    let matches = [];
    // Go through each board space, checking for match.
    BoardSpaces.forEach(space => {
      if (
        space.hasTitleDeed() &&
        space.getType()    == SpaceType.Property &&
        space.getGroupId() == groupId
      ) {
        matches.push(space);
      }
    });
    return matches;
  },

  getAllSpacesOfType(spaceType) {
    let matches = [];
    // Go through each board space, checking for match.
    BoardSpaces.forEach(space => {
      if (space.getType() == spaceType) {
        matches.push(space);
      }
    });
    return matches;
  },

  showEndTurn() {
    this.clearDice();
    this.hideDice();
    this.showActionState('end');
  },

  getNumSpacesOfTypeOwned(spaceType, ownerId) {
    let numMatches = 0;
    // Go through each board space, checking for match.
    BoardSpaces.forEach(space => {
      if (
        space.hasTitleDeed() &&
        space.getType() == spaceType &&
        space.owningPlayerId === ownerId
      ) {
        numMatches++;
      }
    });
    return numMatches;
  },

  // Alias for Modal.activate()
  showModal(options = false) {
    if (!options || typeof options !== 'object') return;
    Modal.activate(
      options.text     || 'Unknown error.',
      options.title    || 'Attention',
      options.html     || false,
      options.buttons  || [],
      options.onClosed || false,
    );
  },

}; // end GameState

// Definition of the Player class.
class Player {
  // Whether player is in jail (obviously not true at start).
  inJail = false;

  // How many times a player has left to try rolling doubles to escape jail.
  numDoubleTriesLeft = 0;

  // Whether player has failed all attempts to roll doubles in jail.
  // Used to determine whether or not player must then roll dice before moving.
  failedAllDoublesAttempts = false;

  // How many doubles player has rolled in current turn (3 = Go to Jail).
  numDoublesThisTurn = 0;

  // JSON of `spaceId`s => # houses/hotels (5 = hotel).
  propertiesOwned = {};

  // Current turn position among all players.
  playerNumber = false; // set to int on GameState.init()

  // Current space that this player occupies.
  currentSpaceId = 0;

  // Array of booleans for each deck's "Get Out of Jail Free" card.
  jailbreakCards = {
    chest:  false,
    chance: false,
  };

  // Color number of player token (1..12).
  tokenColorNum = 0;

  constructor(name, money = 1500) {
    this.name  = name;
    this.money = money;
  }

  // Change player's money by given amount.
  alterMoney(amount) {
    this.money += amount;
    this.updateBoardMoneyShown();
    GameState.refreshBoard();
  }

  getName() {
    return this.name;
  }

  getMoney() {
    return this.money;
  }

  getCurrentSpaceId() {
    return this.currentSpaceId;
  }

  // Update money shown on board.
  updateBoardMoneyShown() {
    // Update money displayed.
    $('#board .player-money .money').val(this.money);
  }

  // Set given property as owned by player.
  setPropertyOwned(spaceId) {
    // Add space ID to list of properties owned by player.
    this.propertiesOwned[spaceId] = 0;
    // Set space's owning player to current player index (0..n)
    let space = BoardSpaces[spaceId];
    space.owningPlayerId = this.playerNumber;
  }

  // Check if player owns a property by space ID.
  checkIfOwnsProperty(spaceId) {
    return this.propertiedOwned.include(spaceId);
  }

  // Refresh player token position on board based on currently occupied space.
  updateToken() {
    // Find player token
    let $board = GameState.getBoard();
    let $token = $board.find('.token[data-player="' + this.playerNumber + '"]');
    // Get center of current space.
    let $space = $board.find('.space[data-pos="'  + this.currentSpaceId + '"]');
    let spaceCenter = {
      x: $space.offset().left + $space.width()  / 2 - $board.offset().left,
      y: $space.offset().top  + $space.height() / 2 - $board.offset().top,
    };
    // Update token position.
    $token.css({
      left: `${spaceCenter.x}px`,
      top:  `${spaceCenter.y}px`,
    });
  }

  // Advance token to a target space, one space at a time.
  advanceTokenToSpace(targetSpaceId) {
    let _this = this;
    let moveDelay = GameState.tokenMoveDelay;
    let currSpaceId = this.currentSpaceId;
    // If this is the same space, do nothing.
    if (currSpaceId == targetSpaceId) return;
    // Move to target, one space at a time.
    let thisSpaceId = currSpaceId + 1;
    let didWePassGo = false;
    let intervalId = window.setInterval(_ => {
      let realSpaceId;
      // Are we at or past GO?
      if (thisSpaceId >= BoardSpaces.length) {
        // Check to see if we marked player as passing GO yet.
        if (thisSpaceId > BoardSpaces.length && !didWePassGo) {
          // To prevent multiple triggers, set flag to true.
          didWePassGo = true;
        }
        // Either way, get the REAL space index (0..39).
        realSpaceId = thisSpaceId % BoardSpaces.length;
      }
      else {
        // We're within range, this is already the real space index.
        realSpaceId = thisSpaceId;
      }
      // Move player to this new space and update token.
      this.currentSpaceId = realSpaceId;
      this.updateToken();
      // If we've arrived, clear interval and react to landing on space.
      if (thisSpaceId == targetSpaceId) {
        window.clearInterval(intervalId);
        window.setTimeout(_ => {
          _this.reactToLandOnSpace(didWePassGo);
        }, moveDelay);
      }
      thisSpaceId++;
    }, moveDelay);
  }

  // React to player landing on current space by triggering its onLand() logic.
  reactToLandOnSpace(passedGo) {
    // Run onLand() logic on this space.
    let spaceId = this.getCurrentSpaceId();
    let space = BoardSpaces[spaceId];
    if (passedGo) {
      // Trigger the GO space's onPass() functionality.
      BoardSpaces[0].onPass(_ => {
        // Trigger this space's onLand() functionality after "passed go" message.
        space.onLand();
      });
    }
    else {
      // Otherwise, just trigger this space's onLand() functionality.
      space.onLand();
    }
  }

  // Send player to jail and end turn.
  sendToJail() {
    let $board = GameState.getBoard();
    let $token = $board.find('.token[data-player="' + this.playerNumber + '"]');
    // Get center of "In Jail" portion of Visiting space.
    let jailXY = this.getJailCoords();
    // Set internal flag.
    this.inJail = true;
    // Reset flag for whether player failed all doubles attempts.
    this.failedAllDoublesAttempts = false;
    // Reset player counter for doubles attempts.
    this.numDoubleTriesLeft = GameState.numDoubleTries;
    // Update actual token element.
    $token.css({
      left: `${jailXY.x}px`,
      top:  `${jailXY.y}px`,
    });
    // Show end turn.
    this.showEndTurn();
  }

  // Get center coordinates of the "In Jail" portion of the Jail/Visiting space.
  getJailCoords() {
    let $jail = GameState.getBoard().find('#corner-visiting .subcorner');
    return {
      x: $jail.offset().left + $jail.width()  / 2 - $board.offset().left,
      y: $jail.offset().top  + $jail.height() / 2 - $board.offset().top,
    };
  }

  // Set player free from jail.
  freeFromJail() {
    // Set flag to false.
    this.inJail = false;
    // Reset number of doubles tries left.
    this.numDoubleTriesLeft = 0;
    // Reset flag for whether player failed all doubles attempts.
    this.failedAllDoublesAttempts = false;
    // Move player to actual space ID of Just Visiting space.
    this.currentSpaceId = 10; // TODO: codify this value
    // Update token so it appears in proper place.
    this.updateToken();
  }

  // Pay bail to get out of jail.
  payBail() {
    // Player has chosen to pay bail.
    let bail = GameState.getBailAmount();
    // So pay the bail to the bank.
    this.alterMoney(0 - bail);
    // Now, free the player!
    this.freeFromJail();
    if (this.failedAllDoublesAttempts) {
      // Player must now move number of spaces already shown on dice.
      this.advanceTokenToSpace(this.getCurrentSpaceId() + GameState.getDiceTotal());
    } else {
      // Let player roll dice since they opted to pay bail instead of rolling.
      GameState.showActiveState('roll');
    }
  }

  // Return the player's token element.
  getToken() {
    return $('#board .token[data-player="' + this.playerNumber + '"]');
  }

  // Begin player's turn.
  beginTurn() {
    // Set current player as, well, current.
    GameState.currentPlayer = this.playerNumber;
    // Refresh tabletop to show current player turn and info.
    GameState.refreshBoard();
    // Add active class to token.
    this.getToken().addClass('active');
    // Check if player is in jail.
    if (this.inJail) {
      // In jail. Show pay/roll action state.
      // TODO: Show game state with options to pay bail or roll dice.
    }
    // Not in jail. Let player roll dice.
    else {
      // Switch to "Roll Dice" action state.
      GameState.showActionState('roll');
      // Show blank dice for a fresh roll.
      GameState.clearDice();
      GameState.showDice();
    }
  }

  // Show the "End Turn" action state.
  showEndTurn() {
    GameState.clearDice();
    GameState.hideDice();
    GameState.showActionState('end');
  }

  // End player's turn and begin next player's turn.
  endTurn() {
    let nextPlayer;
    // Increment turn #, reseting to 0 after reaching max.
    let currPlayerId = GameState.getCurrentPlayerIndex()
    let nextPlayerId = (currPlayerId + 1) % GameState.players.length;
    // Clear active class for token.
    this.getToken().removeClass('active');
    // Begin turn for next player.
    nextPlayer = GameState.players[nextPlayerId];
    nextPlayer.beginTurn();
  }

} // end class Player

// Singleton class for managing modal dialogs and actions.
const Modal = {

  // Reference to modal element itself, for showing/hiding & finding child elements.
  getElement: () => $('#modal-overlay'),

  // MUST be run before doing anything with Modal object.
  init() {
    let _this = this;
    // Clear all contents.
    this.clearAll();
    // Assign functionality to close button click event.
    this.get('close').off('click').on('click', e => {
      _this.deactivate();
    });
  },

  // Returns element(s) within the modal matching the given class name.
  get: className => Modal.getElement().find(`.${className}`),

  // Shows modal. Should be run last after loading content & buttons.
  show() {
    let _this = this;
    let $elem = this.getElement();
    $elem.removeClass('hidden');
    // Wait a split-second to ensure animation plays.
    window.setTimeout(_ => {
      // Transition opacity via classes so that it animates.
      $elem.removeClass('hide').addClass('show');
    }, 20);
  },

  // Hides modal. Should be run last after any action btn click events.
  hide() {
    let $elem = this.getElement();
    $elem.removeClass('show').addClass('hide');
    // Wait for fade effect to finish.
    window.setTimeout(_ => {
      // Hide completely, so we can interact with the board again.
      $elem.addClass('hidden');
    }, 250);
  },

  // Loads given content (assumed to be HTML) into modal body.
  loadHtml(contentHtml, title = 'Modal dialog') {
    this.get('modal-content').html('').append(contentHtml);
    this.get('modal-title').text(title);
  },

  // Loads given content (assumed to be plaintext) into modal body.
  loadText(contentText, title = 'Modal dialog', contentElem = false) {
    // If content element given (e.g. 'p'), wrap this around text.
    if (contentElem) {
      let contentHtml = $(`<${contentElem} />`).text(contentText);
      this.loadHtml(contentHtml, title);
      return;
    }
    // No content element given, so just load plaintext as-is.
    this.get('modal-content').text(contentText);
    this.get('modal-title').text(title);
  },

  createButton(className = 'dismiss', btnText = 'Close', onClick = false, consume = false) {
    let _this = this;
    let $btn = $('<button />').text(btnText);
    $btn.addClass('modal-action ' + className);
    // Attach onClick functionality, if any.
    if (typeof onClick === 'function') {
      $btn.on('click', onClick);
    }
    // Check if explicitly TRUE for whether button consumes input.
    if (consume !== true) {
      // We're not consuming input, so trigger dismissal as well.
      $btn.on('click', e => {
        _this.deactivate();
      });
    }
    // Add button to footer.
    this.get('modal-footer').append($btn);
    // Return button itself.
    return $btn;
  },

  activate(content, title = '', isContentHtml = false, buttons = [], onModalClosed = false) {
    let _this = this;
    this.onModalClosed = (typeof onModalClosed === 'function')
      ? onModalClosed : false;
    // Load content and title.
    if (isContentHtml) {
      this.loadHtml(content, title);
    }
    else {
      this.loadText(content, title);
    }
    // Build all buttons.
    if (buttons.length) {
      // Example btnData: {type: 'confirm', text: 'OK', action: FUNC, consume: true}
      buttons.forEach(btnData => {
        // Pass a function, or false to omit.
        let onClickEvent = (typeof btnData.action === 'function')
          ? btnData.action : false;
        // Create button with given data (use defaults where data omitted).
        _this.createButton(
          btnData.type || null,
          btnData.text || null,
          onClickEvent,
          btnData.consume
        );
      });
    }
    else {
      // Create default dismissal "OK" button.
      this.createButton();
    }
    // Show modal.
    this.show();
  },

  // Clear all modal contents.
  clearAll() {
    this.get('modal-title').html('');
    this.get('modal-content').html('');
    this.get('modal-footer').html('');
  },

  deactivate() {
    let _this = this;
    // Hide the modal.
    this.hide();
    // Wait out animation, then clear modal and trigger onModalClosed (if any).
    let onClosed = this.onModalClosed;
    window.setTimeout(_ => {
      _this.clearAll();
      if (onClosed && typeof onClosed === 'function') {
        onClosed();
      }
      // Reset after running once.
      _this.onModalClosed = false;
    }, 250);
  },

}; // end Modal

// Click a title deed card to show a close-up below it.
$('body').on('click', '.minicard a.overlay', e => {
  e.preventDefault();
  let $closeup  = $('.card-closeup');
  let $minicard = $(e.target).parents('.minicard');

  // Remove active class from all other minicards first.
  $('.tabletop .cards .minicard').removeClass('active');
  // And hide all other card close-ups.
  $closeup.find('.card').addClass('hidden');

  // If minicard is already active, hide close-up and deactivate.
  if ($minicard.hasClass('active')) {
    // WHY DOES THIS NEVER GET TRIGGERED?!?!?!
    $closeup.addClass('hidden');
    $minicard.removeClass('active');
    return;
  }

  $minicard.addClass('active');

  const spaceId = $minicard.attr('data-space-id');
  const boardSpace = BoardSpaces[spaceId];

  // Sanity check.
  if (!boardSpace) return;

  const spaceType = boardSpace.getType();
  // If this is still false after switch, there's no deed to show.
  let $card = false;
  // Determine which title deed type we're showing.
  switch (spaceType) {
    case SpaceType.Property:
      $card = $closeup.find('.card.deed.property');
      break;
    case SpaceType.Railroad:
      $card = $closeup.find('.card.deed.railroad');
      break;
    case SpaceType.Utility:
      $card = $closeup.find('.card.deed.utility');
      break;
    default:
      console.error('No title deed to show!');
      return false;
  }

  // Populate appropriate card with property data.
  const propName = boardSpace.getName();
  $card.find('h2 span').text(propName);
  switch (spaceType) {
    case SpaceType.Property:
      // Populate rents.
      const propRents = boardSpace.rent;
      $card.find('.rent-default').text(propRents.default);
      $card.find('.rent-colorset').text(propRents.colorSet);
      $card.find('.rent-1house').text(propRents.houses[1]);
      $card.find('.rent-2house').text(propRents.houses[2]);
      $card.find('.rent-3house').text(propRents.houses[3]);
      $card.find('.rent-4house').text(propRents.houses[4]);
      $card.find('.rent-hotel').text(propRents.hotel);
      $card.find('.cost-building').text(boardSpace.bldgCost);
      // Set color group.
      $card.find('.color-bar')
        .removeClass() // wipe all classes (re-add default class next)
        .addClass('color-bar group-' + boardSpace.groupId);
      break;
    case SpaceType.Railroad:
      // We really don't need to do anything here as there's no data to transfer over.
      break;
    case SpaceType.Utility:
      let $icon = $card.find('i.fa');
      // Reset classes to just base .fa, and add utility icon.
      $icon.removeClass().addClass('fa fa-' + boardSpace.icon);
      break;
  }

  // Show close-up card.
  $card.removeClass('hidden');
  $closeup.removeClass('hidden');
});

// Click Close button in card close-up to close the close-up.
$('body').on('click', '.card-closeup button.close', e => {
  let $container = $(e.target).parents('.card-closeup');
  if (!$container.length) {
    return;
  }
  $('.tabletop .cards .minicard').removeClass('active');
  $container.find('.card').addClass('hidden');
  $container.addClass('hidden');
});

// Click "OK" to confirm # of players and determine how many rows show up.
$('body').on('click', 'button#set-num-players', e => {
  // The """+ converts the value to an int.
  let numPlayers = +$('#num-players').val();
  // Can't have fewer than 2 players, and no more than 12.
  if (numPlayers < 2 || numPlayers > 12) return;
  // Change the subtitle to instruct players to enter their names.
  $('.players-list h2').text('Please enter your names');
  // Set the template HTML for each player name input entry line.
  let $html = `<div class="line">
    <label for="player-name-[NBR]">Player <span class="player-idx">[NBR]</span>&rsquo;s name:</label>
    <input type="text" id="player-name-[NBR]" class="player-name-input" size="15" maxlength="20">
  </div>`;
  let $names = $('.player-selection .player-names');
  // Clear player name input entries.
  $names.find('.line').remove();
  for (let playerNbr = 1; playerNbr <= numPlayers; playerNbr++) {
    let $thisHtml = $html.replaceAll('[NBR]', playerNbr);
    $names.append($thisHtml);
  }
  // Get rid of the selection for number of players.
  $('.player-selection .player-qty').remove();
  // And show the confirm button (disabled at first).
  $('.player-selection .player-confirm').removeClass('hidden');
  // Finally, place focus on the first input element.
  $('.player-selection .player-name-input').first().focus();
});

// Update any player name input field to enable button if all names entered.
$('body').on('keyup', 'input.player-name-input', e => {
  // Start off true. If any field is empty, make false, break, return. Else, enable.
  let showButton = true;
  // Go through each player name input.
  $('input.player-name-input').each((idx, elm) => {
    let $this = $(elm);
    // Remove trailing whitespace, just in case.
    $this.val(
      $.trim( $this.val() )
    );
    // If this is empty, we need to get out of here.
    if ($this.val().length == 0) {
      showButton = false;
    }
  });
  // Set the disabled attribute to the opposite of whether or not we SHOW the button.
  $('button#confirm-players').attr('disabled', !showButton);
});

// Click "Confirm" to confirm players, which initializes GameState with data.
$('body').on('click', 'button#confirm-players', e => {
  // Build the player array first.
  let players = [];

  // Go through each player name input and compile the list of Player objects.
  $('.player-selection input.player-name-input').each((idx, elm) => {
    let $this = $(elm);
    let playerName = $.trim( $this.val() );
    // Create a new Player instance for each name entered.
    players.push( new Player(playerName) );
  });
  // Sanity check.
  if (players.length < 2) return;

  // Initialize game.
  GameState.init(players);
});

// Click "Start Game" to randomize first player & switch to Roll Dice state.
$('body').on('click', 'button.start-game', e => {
  GameState.start();
});

// Click "Roll Dice" to generate random dice values, then move to new space.
$('body').on('click', 'button.roll-dice', e => {
  GameState.rollDice();
});

// Click "Purchase [Property]" to add property to player's list & end turn.
$('body').on('click', 'button.purchase-property', e => {
  GameState.purchaseProperty();
});

// Click "Pay Bail" button to pay and release player from jail.
$('body').on('click', 'button.pay-bail', e => {
  let currPlayer = GameState.getCurrentPlayerObject();
  currPlayer.payBail();
});

// Click "Pass" to show "End Turn" button/state.
$('body').on('click', 'button.skip-property', e => {
  let currPlayer = GameState.getCurrentPlayerObject();
  currPlayer.showEndTurn();
});

// Click "End turn" to end player's turn and begin next one.
$('body').on('click', 'button.end-turn', e => {
  let currPlayer = GameState.getCurrentPlayerObject();
  currPlayer.endTurn();
});

/**
 * On page load...
 */
$(function() {

  // Initialize the Modal object.
  Modal.init();

}); // end docready

              
            
!
999px

Console