The key here is not just separation of concerns, but also the single responsibility principle. The two are basically different sides of the same coin: when I think SOC I think top-down (I have these concerns, how do I separate them?) while SRP is more bottom-up (I have this object, does it have a single concern? Should it be split? Are its concerns split too much already?).
In your example you have the following entities and their responsibilities:
- Game: this is the code that makes the program "go."
- GameBoard: maintains the state of the play area.
- Card: a single entity on the game board.
- Player: performs actions that change the state of the game board.
Once you think about each entity's single responsibility the lines become more clear.
In an app such as a game, there is a main class that runs the main
loop, such as Program or Game. My question is, do I maintain every
reference to every instance of a class in this class, and make that
the only way in which they interact?
There are really two issues here to keep in mind. The first thing to decide is what entities know about other entities? Which entities belong to other entities?
Look at the responsibilities I outlined above. Players perform actions that change the state of the game board. In other words, Players send messages to (call methods on) the game board. Those messages likely involve cards: for example, a player may place a card in its hand on the board, or change the state of an existing card (e.g. turn a card over or move it to a new location).
Clearly, a player must know about the game board which contradicts the assumption you made in your question. Otherwise, the player must send a message to the game, which then relays that message to the game board. Since players perform actions on the game board, players must know about the game board. This increases coupling: instead of the player sending the message directly, now two actors must know how to send that message. The Law of Demeter implies that if an object must act on another object, in this scenario, that other object should be passed in via parameter to reduce coupling.
Next, where do you store what state? The game is the driver here, it must crease all of the objects either directly or via proxy (e.g. a factory or in a constructor that game calls). The logical next question is what objects need which other objects? This is basically what I asked above, but a different way of asking it.
The way I would design it is like this:
Game creates all of the objects required for the game.
Game shuffles the cards and divides them up per whatever game it represents (poker, solitaire, etc).
Game places the cards in their initial locations: maybe some on the game board, others in players' hands.
Game then goes into its main loop representing a turn.
Each turn would look like this:
Game sends a message to (invokes a method on) the current player and provides a reference to the game board.
Player performs whatever internal logic (computer player) or user interaction necessary to determine what play to perform.
Player sends a message to the game board provided to it asking to change the game board state.
The game board decides whether the move is valid or not (it is responsible for maintaining valid game state).
Control returns to the game, which then decides what to do next. Check for win conditions? Draw? Next player? Next turn? Depends on the specific card game being played.
Should it be up to the Game class to place the cards on the board, or
does it make more sense that, because it is the player's action, it
should be within the Player class.
Both: Game is responsible for the initial setup, but Player performs actions on the board. GameBoard is responsible for guaranteeing a valid state. For example, in classic Solitaire only the top card on a pile may be face-up.
Back to my original point: you have the right separations of concerns. You identified the proper objects. What tripped you up was figuring out how messages flow through the system and which objects should keep references to other objects. I would design it like this, which is pseudocode:
class Game {
main();
}
class GameBoard {
// Data structures specific to the game being played. There is a
// lot of hand-waving here to give the general idea without
// getting bogged down in the implementation.
Map<Card, Location> cards;
GameBoard(Map<Card, Location>);
// Return false if the move is invalid.
bool flip(Card);
bool move(Card, Location);
}
class Card {
// Make Rank and Suit enums.
Suit suit;
Rank rank;
bool faceUp;
}
class Player {
Set<Card> hand;
Player(Set<Card>);
void takeTurn(GameBoard);
}