group-b/README.org

22 KiB
Raw Permalink Blame History

Group B Pace Notes

I've been thinking about a rally card-game, either a 52-card deck or a custom art deck, called Group B or pacenotes or something, two players, a co-driver will draw a hand of cards and play them with a driver, who will react to the pacenotes, managing speed, turbo, reaction energy and try to get to the end of a stage.

or maybe it's a roguelite deckbuilder… Let's try that:

Prototype Game Loop

I haven't actually beat this yet. That doesn't mean you can't? In theory? I don't know what happens when you run out of stage cards is what I am saying 😇

Some notes:

  • the longer the straight is, the more you'll accelerate. Probably worth making the longest straight 50 meters or so or injecting a braking-zone card between long straight and hard turns to make sure the player has the right resources
  • card values are all fucked up, this is just a first playtest..
from group_b.action_deck import ActionDeck, ActionCard
from group_b.player import PlayerState

import os

p = PlayerState()

os.system('cls' if os.name=='nt' else 'clear')
print("Stage start. Ready?")
while len(p._pace_note_queue) > 0:
  print(p.resources())
  print()
  print(', '.join([str(c) for c in p._pace_note_queue]))
  print("you can:", [f"{idx}: {str(c)}" for idx, c in enumerate(p.hand())])
  choice = int(input("action index: "))

  os.system('cls' if os.name=='nt' else 'clear')

  card = p.hand()[choice]
  p.perform_action(card)
  p.draw()

  p.check_damage()

print("You've beat the stage!")

Once it's possible to complete a stage the next logical step would be to add (speed*distance) timing to the stage so that you can see how fast you progress through and race against known seeds?

Dev Environment Setup and running the prototype

Naturally I'm using Nix for this because i have the brain worms.

{ pkgs ? import <nixpkgs> {}, ...}:

with pkgs;
let
  myPython = python3.withPackages( pp: with pp; []);
in
mkShell {
  packages = [
    myPython

    isort
    black
  ];
}

This is just a python thing. It only uses what's in stdlib. Clone this repo, set up a python or use the system one, and run python prototype.py. roam:Enjoy Your Gaming.

Player Actor

CLOCK: [2023-04-07 Fri 14:28]

Okay the logic of the loop and the deck management happens in this PlayerState class. It has an /rrix/group-b/src/branch/main/(ActionDeck) and a /rrix/group-b/src/branch/main/(StageDeck) where the cards are managed and it tracks the hand of the player. A player will draw four cards to start with based on the START_HAND const-ish and will play one per turn by calling perform_action and passing the card in.

There is, frankly, too much card logic in the perform_action function, it will be some work and diagramming to figure out how to design a state machine that'll behave better. It really does feel to me like an Elixir pattern matching model + a data struct will go a long way here? unsure.

But basically action cards are used to progress through stage cards until the stage card deck is empty.

A player can see NOTE_LOOKAHEAD cards in to the future. there is a difficulty lever here: having more future steps lets you plan better, but in "hardcore mode" you will only see the most-future prediction so having more steps actually could be harder since you have to internalize what the coming track looks like

from .action_deck import ActionDeck, ActionCard
from .stage_deck import StageDeck, StageDeckEmpty, StraightCard

class PlayerState():
    START_HAND = 4
    NOTE_LOOKAHEAD = 3
    MAX_HAND_SIZE = 5

    def __init__(self):
        self._action_deck = ActionDeck()
        self._action_deck.shuffle()

        self._stage = StageDeck()

        self._resources = PlayerResources()
        self._hand = [self._action_deck.draw() for _ in range(PlayerState.START_HAND)]
        self._pace_note_queue = [self._stage.draw() for _ in range(PlayerState.NOTE_LOOKAHEAD)]


    def hand(self):
        return self._hand

    def resources(self):
        return self._resources

    def draw(self):
        assert(len(self._hand) <= self.MAX_HAND_SIZE)
        new_action = self._action_deck.draw()
        self._hand.append(new_action)
        return new_action 

    def perform_action(self, action: ActionCard):
        # no cheating!
        assert(action in self.hand())

        current_stage_note = self._pace_note_queue.pop(0)
        self.apply_road_friction()

        for modifier, value in action.modifiers.items():
            if type(current_stage_note) == StraightCard:
              # TKTKTK partial value or the ability to play two cards
              # on long straight? hard to balance 70 meter straight in
              # to heavy zone
              if modifier != "focus":
                value = current_stage_note.modifier * value
            
            value += value * self._resources.momentum
            self.maybe_modify_resource(modifier, value)

        if not current_stage_note.can_pass_with(self, action):
            # maybe lol
            damage = int(self._resources.momentum + self._resources.speed)
            print(f"{self._resources.speed} is too fast for a {current_stage_note}! you've taken {damage*5} car and {damage} tire damage")
            self._resources.car_health -= damage * 5
            self._resources.tire_health -= damage

        current_stage_note.discard()
        action.discard()
        self.hand().remove(action)
        try:
            self._pace_note_queue.append(self._stage.draw())
        except StageDeckEmpty:
            pass

    def check_damage(self):
        if self._resources.car_health <= 0:
            raise Exception("you've crashed")

    def apply_road_friction(self):
        rf = self._resources.road_friction
        self._resources.speed -= self._resources.momentum * rf
        self._resources.momentum -= self._resources.momentum * rf


    # TKTKTK this will spend resources until one cannot be spent , need to check all before spendign any
    def maybe_modify_resource(self, modifier, value):
        if modifier == "focus" and self._resources[modifier] + value <= 0:
            print(f"{modifier} {value} {self._resources[modifier]}")
            raise Exception("you've lost focus and crashed!")

        if modifier == "speed" and self._resources[modifier] + value >= self._resources.top_speed:
            self._resources.speed = self._resources.top_speed
        elif self._resources[modifier] + value >= 0:
            self._resources[modifier] += value
        else:
            self._resources[modifier] = 0

A player has a few visible and invisible resources. The speed, focus and momentum are modified by the cards the player is able to play from the hand. The others are affected by the stage cards, and used to modify the others; for example road_friction will be set to a higher value on gravel stages causing player to lose more speed when coasting etc. This DictyDataClass is a useful little thing that lets me define a dataclass but still access it as though it is they are dictionaries.

from dataclasses import dataclass
class DictyDataClass():
    def __getitem__(self, item):
        return getattr(self, item)

    def __setitem__(self, item, val):
        return setattr(self, item, val)

@dataclass
class PlayerResources(DictyDataClass):
    speed: int = 0
    focus: int = 10
    momentum: int = 0
    car_health: int = 100
    tire_health: int = 100

    # only relevant in meta-progression
    soft_tires: int = 4
    hard_tires: int = 4

    # hidden values attached to the car, stage, etc
    tire_wear: int = 1
    road_friction: float = 0.1
    top_speed: int = 15

    def __str__(self):
        return "".join([
            "speed: ", str(self.speed), "\n",
            "focus: ", str(self.focus), "\n",
            "momentum: ", str(self.momentum), "\n",
            "car: ", str(self.car_health), "\n",
            "tire: ", str(self.tire_health), "\n",
            ])

Deck Abstractions

The Card and Deck exist to make some common interfaces for the different types of cards.

Base classes

A Card is pretty simple, basically it just exists to be extended by ActionCard and StageCard to embed metadata about how the card behaves, and to integrate with the Deck class below.

Higher-level classes should be able to load modifiers and actions from files on disk. discard is a silly little helper that I hope I don't regret adding since the self.deck makes this all feel kind of spaghetti-ish.

class Card():
    def __init__(self, name, deck):
        self.name = name
        self.deck = deck

    def discard(self):
        self.deck.discard(self)
        return None

    def __str__(self):
        return self.name

The deck base class is pretty simple, it really only exists to manage two lists of cards, a draw pile and a discard pile. A really simple Fisher-Yates shuffle is used to shuffle the cards in shuffle's /rrix/group-b/src/branch/main/(fisher-yates) below.

from random import randint
from typing import List

class Deck():
    def __init__(self):
        self._cards = self.load_cards()
        self._discard = []

    def load_cards(self) -> List[Card]:
        raise NotImplemented()

    def shuffle(self):
        self._cards.extend(self._discard)
        self._discard = []

        # (ref:fisher-yates)
        for old_slot, card in enumerate(reversed(self._cards)):
            new_slot = randint(0, len(self._cards)-1)
            self._cards[old_slot], self._cards[new_slot] = (
                self._cards[new_slot], self._cards[old_slot]
            )

    def draw(self) -> Card:
        if len(self._cards) == 0:
            print("shuffle...")
            self.shuffle()

        return self._cards.pop()

    def discard(self, card: Card):
        self._discard.append(card)

    def depth(self) -> int:
        return len(self._cards)

Action Deck

An ActionDeck's would be defined by the contents of the car that the player is working with. This would be used to abstract the sort of differences between an Audi Quattro, Rally1 and Mini Cooper, the spread and strength of the various cards. Players could also be granted specialty action cards in the service park or as part of meta-progression. By default a player has 10 cards spread between "go faster" and "slow down". I'll want to make this load from JSON or YAML files soon so that I can use Babel to generate them…. 😈

from .deck import Deck, Card

class ActionDeck(Deck):
    def load_cards(self):
        cards = [ActionCard(name="accelerate", deck=self)] * 3
        cards.extend([ActionCard(name="gun it!", deck=self)] * 2)
        cards.extend([ActionCard(name="brake", deck=self)] * 5)
        cards.extend([ActionCard(name="coast", deck=self)] * 2)
        cards.extend([ActionCard(name="find flow", deck=self)] * 2)

        return cards

class ActionCard(Card):
    def __init__(self, name: str, deck: ActionDeck):
        super().__init__(name, deck)
        self.modifiers = ActionCard.load_modifiers(name)


    @classmethod
    def load_modifiers(cls, name):
        if name == "accelerate":
            return dict(
                speed=0.5,
                focus=-1,
                momentum=0.25,
                tire_wear=-0.25,
                car_health=0,
            )
        elif name == "brake":
            return dict(
                speed=-1,
                focus=0,
                momentum=-1,
                tire_wear=0,
                car_health=0,
            )
        elif name == "coast":
            return dict(
                speed=0,
                focus=1,
                momentum=-0.25,
                tire_wear=0,
                car_health=0,
            )
        elif name == "gun it!":
            return dict(
                speed=1,
                focus=-2,
                momentum=0.5,
                tire_wear=-0.5,
                car_health=-0.05,
            )
        elif name == "find flow":
            return dict(
                speed=0,
                focus=4,
                momentum=0,
                tire_wear=-0.5,
                car_health=-0.05,
            )

speed should not be set by the cards but calculated from momentum… The user is managing the momentum and focus to get the most speed with the least damage/wear.

Stage Deck

The Stage Deck is what defines a rally stage. This version of it is procedurally generated where there are 2x as many turns as straights /rrix/group-b/src/branch/main/(StageCardWeight), and the straights' lengths are weighted toward being shorter /rrix/group-b/src/branch/main/(StraightCardWeight). You could imagine a StageDeck class that pulls its list of cards from a rough take on famous stages and could be paired with art of them.

from .deck import Deck, Card
from .action_deck import ActionCard
# from .player import PlayerState

from dataclasses import dataclass
from enum import Enum
from random import randint, choice, choices

class StageDeck(Deck):
    DEFAULT_DECK_SIZE=25

    def load_cards(self):
        cards = list()
        cards.extend(StageCard.generate(self, cnt=self.DEFAULT_DECK_SIZE))

        return cards

    def draw(self) -> Card:
        if len(self._cards) == 0:
            raise StageDeckEmpty()
        return self._cards.pop()

@dataclass
class StageCard(Card):
    # turn w/ numeric modifier (max speed you can take the turn)
    # straight w/ distance modifier (would it scale the accelerate cards?)
    # hazards (jump, gate, narrow, don't cut, over crest, unseen)

    def __init__(self, name: str, deck: StageDeck):
        super().__init__(name, deck)

    def can_pass_with(self, player, action: ActionCard) -> bool:
        raise NotImplemented()

    @classmethod
    def generate(cls, deck: StageDeck, cnt=1):
        weights = [10, 5]                                                    # (ref:StageCardWeight)
        cls_list = choices([TurnCard, StraightCard], weights=weights, k=cnt)
        return [real_cls.generate(deck) for real_cls in cls_list]
        
class StageDeckEmpty(BaseException):
    pass

Card sub-classes are expected to implement can_pass_with which take a PlayerState and an ActionCard to evaluate whether it is safe for a Player to progress through. For example, the TurnCard compares the speed, health, etc of the user to ensure they aren't taken a corner too quickly.

The shape of the stages being procedurally generated, the names of the StageCard's are inspired by actual pace notes; eventually these will have hazard modifiers like "long", "narrow", "over crest" but for now this works out. You end up with some fun things like reading out "flat out right into hairpin left".

class CardShape(str, Enum):
    RIGHT    = "right"
    LEFT     = "left"
    ESSES    = "esses"

class TurnCard(StageCard):
    shape: CardShape
    modifier: int

    def __str__(self):
        if self.modifier == 1:
            return f"sharp hairpin {self.shape}"

        if self.modifier == 2:
            return f"hairpin {self.shape}"
        
        if self.modifier == 8:
            return f"fast {self.shape}"

        if self.modifier == 9:
            return f"flat out {self.shape}"

        return f"{self.shape}-{self.modifier}"

    def can_pass_with(self, player, action: ActionCard) -> bool:
        # TKTKTK use certain action cards as pass?
        if int(player._resources.speed) > self.modifier:
            return False
        if player._resources.focus <= 0:
            return False
        if player._resources.tire_health <= 0:
            return False
        if player._resources.car_health <= 0:
            return False

        return True

    @classmethod
    def generate(cls, deck: StageDeck):
        mod = randint(1,9)
        shape = choice(list(CardShape))
        card = cls("", deck) 
        card.modifier=mod
        card.shape=shape

        return card

Well, the naming mostly works out. For straight cards, if there are multiple back-to-back it can be a little weird. "into and 100" could/should/coalesce or be displayed to the player in a better fashion than it is right now.

class StraightCard(StageCard):
    # decameters lol
    modifier: int
    LENGTH_WEIGHTS = [10, 10, 7, 5, 5, 2, 2, 2, 1, 1]

    def __str__(self):
        if self.modifier == 1:
            return f"into"

        if self.modifier == 2:
            return f"and"

        return f"{self.modifier*10}"

    @classmethod
    def generate(cls, deck: StageDeck):
        mod = randint(1,10)
        shape = choices(range(10), weights=cls.LENGTH_WEIGHTS, k=1)[0] # (ref:StraightCardWeights)
        card = cls("", deck)
        card.modifier=mod

        return card

    def can_pass_with(self, player, action):
        return True

Late Night Notes

But maybe it's a single-player deckbuilding roguelite: this game Death Roads: Tournament has me thinking about "roguelite deckbuilder" style take on stage Rally Racing

there's a realism version of this where you're trying to model an actual rally weekend, and there is a gonzo version of this where you're trying to build god's own rally team. prototype is just about getting to the point where I can play with systems and not about the energy of the game.

  • the goal is to compete in stage rallies, 10-15 levels for each rally with a season-long game-loop progression, and some sort of meta-progression built on top of that

    • meta-progression: upgrading your team, car, training mechanics, moving to a new car class?
    • in-rally-progression: getting more fine-tuned setup of the car, making tire choice and making sure you don't over-extend yourself, deck restructuring, random events between stages and on stages, etc
  • players manage a handful of competing resources like focus, speed, momentum, car health, tire wear to have the shortest time through each stage and survive the rally
  • manage a deck of cards containing actions that they use to transit the stage safely. simple cards like accelerate/break/turn in, specialized cards like cut the apex, flash of insight, etc

game loop is basically:

you start at time control, your codriver reads out the next 2-3 pace notes and you're off; this can be abstracted by having a stage deck and an action deck; the stage deck can have negative effects mixed in like gravel on the stage, surprise ice, spectator on stage, etc. in easy modes the pace notes would be listed on the side but in harder difficulties you'd have to remember them yourself as you move through the stage. pace notes are similar to the real notes where

so basically imagine you are going to high speed through some left-6, right-6, 30 meters straight, so you build up a lot of speed and momentum but you start to drain focus because you are going so fast on a narrow road. now the co-driver reads a "right hairpin" - the way this would work is that you would see "right hairpin" when you are progressing through the right-6 so you would say "oh i have three cards, a speed up card, a late-braking card, and 'do it like takumi' special card that lets me turn with less momentum loss than otherwise normal brake and turn cards would let me" so i would play late braking so that i go through the 30 meter straight without scrubbing too much speed. at this point, i am coming up to the hairpin and my codriver reads "in to left 4" signaling a quick medium-speed left hander, so i then play 'do it like takumi' to get through the hairpin with enough momentum to accelerate in to the left-4 with the proper amount of speed.

the part of that deckbuilding game that i linked to in the post above is that your deck's contents is determined by the parts you have attached to the car. in my system that could be as simple as fitting hard vs soft compound tires; conserve softs for later rounds but have cards that cost more focus to use or less grip (is grip a modifier?), fit a different turbocharger to have different speed/momentum tradeoffs, or you could get a whole car upgrade from a mini to an esky to a lancia delta etc that would have different stats and card spread entirely

NEXT how many game systems/resources are reasonable to balance?

there's like 5 or 6 in the stage and another couple in the rally progression…

NEXT when do draws happen?

NEXT how do we handle difficulty curve?

NEXT there is a whole motorsport manager that could be built as the meta-progression lol is this my racing dynasty simulator too?

NEXT consider bolstering/replacing the focus mechanic with an actual time pressure placed on the player

if you only had a few seconds to play each hand the stress of rallying could actually be represented pretty well here, heh. maybe a focus bonus/buff that would come in to play if you are playing against the clock

NEXT consider whether this idea could extend to rally raid events like dakar or baja 1000

multiple classes like unsupported motorcycles to trucks

maintenance and parts management

fuel water etc resources

NEXT [#C] Plan Project tasks