how to structure game logic for user interfaces of different nature (gui, tui, cli)?

I would like to implement a simple game logic engine that is agnostic to the rendering system, but I don't know how to do that, really... so I would like to have advise and know the state of the art!

the idea is to have a "logic" class that contains the step to be done to advance the game, for instance "show this to the user" or "wait for user input on this choice": while it was easy to make it work in a textual user interface like this:

forever {
switch logic_get_next_action() {
// Writes a message to the user on the provided output
when MESSAGE: write(stdout, logic_get_message());
// Writes multiple choices, then waits for an input
when CHOICE: {
for c in logic_get_choices() {
write(stdout, c);
}
}
...
}

}


initially I thought that decoupling from stdin and stdout would be enough, and I could devise some sort of encoding that would allow me to use another kind of interface, but I realized this is very textual-centered, and relies on some synchronicity.

for instance it relies on the writes in choice to happen and be concluded before the read, and also blocks on read.

I suppose a more asynchronous solution is needed here but not really sure on how to proceed: don't want to be trapped in local choices! for instance after the textual prototype I am using an immediate mode GUI toolkit now, but what changes will I have to make if I ever switch to a retained mode?

You've solved the core of the problem by expressing your game logic so that it's a set of functions that the UI code can use in whatever structure makes sense.

for instance it relies on the writes in choice to happen and be concluded before the read, and also blocks on read.

Unless I misunderstand, this isn't a feature of your logic_*, it's a feature of the particular loop you showed, which is indeed only suitable for a text-based interface.

When you write a GUI your main loop (if any) will have a different structure, but one compatible with the same logic. It might be like:

forever {
// Draw frame
switch logic_get_next_action() {
when MESSAGE: draw_text(logic_get_message());
// Writes multiple choices, then waits for an input
when CHOICE: {
let x = 0, y = 0;
for c in logic_get_choices() {
draw_text(c, x, y);
y += line_height;
}
}
}
swap_buffers();

if (event_available()) {
if (event_is_a_button_click() && logic_get_next_action() == CHOICE) {
...
logic_update_state(choice_id_from_event);
} else ...other event handlers...
}
}


This is a non-blocking main loop using the same functions.

— wait, I think I might see the problem. Your MESSAGE action seems like it might be intended to display a message and then go on to do something else. Are you thinking that logic_get_next_action() will return a next thing all on its own when it's called the next time around the loop (e.g. perhaps two MESSAGEs followed by a CHOICE)?

If so, there's your problem. Here are two ideas for how to fix it:

1. Have a "get the current action" function, and handle MESSAGEs like CHOICEs: the game logic doesn't change any state until until a function is called to do it. In a text UI you can just let the text scroll up, but in a GUI you often wait for a button press to advance/dismiss the text, so that has to be an explicit action — it always needs to be possible to just fetch the current state again to redraw the screen.

2. Implement in your GUI code (or, if you're using retained mode, delegate) storing the messages that have been produced by the game logic so far. This makes sense if your game logic will frequently be emitting a sequence of event messages (e.g. narrating the moves in a fight) rather than describing the current state. It's also an approach that's closer to building a terminal inside your game, which makes sense if your game is particularly focused on textual interaction.

3. Same as option 2, but specifically create a helper data structure that keeps track of what text should currently be visible — it's not used in a text interface, because the existing terminal handles keeping text around, but is used in a GUI, and it can be forwarded some hints from the game logic to say when to e.g. clear the stored text, or specify which character is speaking to display their portrait, or whatever.

Exactly which architecture choices are best depend on the sort of game you're making — what kind of interactions between the user and the game logic are occurring, and how you want the GUI to work.

I went down this route.

Yes, isolating standard input and output is not enough. Sadly, they are blocking. You need an asynchronous solution.

I modeled it as an state machine. You are either running your logic core, or waiting for input. The UI was also an state machine. The logic core could request any state from the UI, and that state could result in some input from the user. To take this input, I made the logic core give a continuation to the UI. When the callback is executed the logic core updates its state, and next game loop cycle it decides what to do, which include calling the UI again.

But that is only good for turn-based-esque games.

I did the above for a visual novel. Next I moved to a 3d game. I followed the same pattern. The logic core would decide how to move the models, keeping a representation of the game state. Then a different subsystem would manipulate the 3D scene keeping it in sync with that representation. That worked good. Until I had to make it real-time.

To make it real-time, the logic core will call an avatar controller with the delta, and any new input, plus a continuation for when the action of the input is completed (if ever, it might not if another input interrupts it). The avatar controller will then update the state, according the last input taken and the elapsed time. If it finished, it calls the continuation. If a new input was passed, it might cancel the motion it was doing and pick a new one.

I no longer keep a pure representation of the world in the logic core. The reason being: physics. Thus, I let physics, animation, etc. To run independently, and they will report to the logic core when there is a collision. In fact, this is how I handle input. Another component will take input and report it to the logic core.

What is in the callback? It is what you could call the AI director (part of the logic core). It will spawn entities, despawn entities, and give goals to those entities. The entities know how to carry simple actions, and how to react to physics. However, the lack awareness of each other, so the AI director handles that, and also coordinates them according to player actions.