Jakke.fi logo
jakke.fi/blog

Blogging mostly about my free-time projects.

HomeBlogthe-gift-roleplaying-game

The Gift Roleplaying Game

@Type: Report

@Published:November 11, 2024 at 19:54

@Last Updated:November 12, 2024 at 17:16

@Author: Jakke Korpelainen

Intro

This is a bit older Next.js pages router project that I'm writing a study on now. I started this project somewhere in between the covid lockdowns and the general isolation. The initial idea was to replicate these older adventure style books that have a fairly linear story that may utilize recursion. Wanted to also revisit some of my favourite game mechanics from games such as the older Baldurs Gate's (this was developed before BG3 was released).

The initial plan was to of course use Contentful as the catalogue of all the fairly static game data that I can upkeep, such as items, races, places, and the story. It was an interesting challenge to use contentful GraphQL in these fairly complex relational structures.

For combat, I wanted to try to write a module with threejs that would support a loosely on Dungeons & Dragons 5th edition based simple turn based dice system.

For authentication, I picked Auth0 simply of it's familiarity to me and general suitability for this project.

The mutating data is in MongoDB, which holds the player characters and story progression etc.

The game communicates with an internal REST api.

The game has been programmed and produced by me, however most of the graphical assets are not usable for release and should be replaced, but they serve well as placeholders. Audio is mostly from acceptable licensing and AI was used in this for graphics, and as a storyteller in some of the story elements. I developed this mostly for myself as a hobby project to get this idea out of my head, not really to be played nor released as a product. Which is also why I am not releasing the source code for this.

There is quite an impressive amount of detail already in this project. However the project is unfinished and is on a hiatus.

Source & Demo

Demo: thegift.jakke.fi/

Source: N/A

the-gift-splash

Characters

The game supports character creation and setting the character attributes. Calculation is able to calculate also the racial bonuses. The characters track levels based on experience, but still lack leveling up and a class system.

Story

The story sets in this fantasy world that is based on some loose arabic desert concept. The main idea was to have this great unraveling mystery that would involve area politics, bloodlines, feuding factions, and a bit of religious mystery.

I'm not an experienced writer, this story was initially drafted by me for a Dungeons & Dragons roleplaying session with my friends which ended aruptly for life related reasons. However I became enticed by the idea of developing this world that I've created a bit more further.

Knowing that my best toolset lie within programming I set to work bringing the story into life.

The game begins in a stage where you're traveling on the desert into this mythical city which attracts travelers. After a while you arrive, but the city is in a bit of a turmoil since the sultan has died unexpectedly.

You can travel into the city and die in bloody combat in it's backalleys or the arena.

The city also offers an inn for resting (heal) and a few story points which have rules based on whether you've arrived or not arrived in some other story.

The game ends up in a story loop fairly quickly after you reach the city, meaning that the game would require more writing for it to continue. Most of the structure however exist to make a decent game out of these mechanics.

Dialogue

You can interact with NPCs in the world, they also can reward you with items.

Inventory

The characters have an inventory and a wallet (money broken down later). The items affect the player's combat performance in the form of dice rolls. Certain items give a better Armor Class (AC), which is tested against rolls. A better numerical AC fails more often to be hit against.

Similar for offensive, a better sword might have a better chance to hit or heftier dice. Say, a dagger would have 1d4 dice, which means 1 x 4 sided dice, and a greatsword has 2d6 (2 x 6 sided dice).

Also introduced items that only make sense for roleplaying and story. Examples for these would be a map, a waterskin, some goblin ears that could be sold for a minor monetary benefit, and perhaps also could be exchanged for a quest reward.

Money/Shop

Introduced also the concept of money and a shop for buying things. The shopkeep in the game is for personal amusement loosely based on a finnish auctioneer & media personality Aki Palsanmäki.

Combat

This was a fun challenge! I went away creating a couple of different environments, a fjord, a desert, a city, and some cliffs. All with their own soundscape (ambience, hdr) and some decorative 3d models.

Desert Encounter

City Encounter

Enemy AI

Given that we're working in turns, we're iterating all the enemies and their amount of actions etc.

1import { Vector3 } from "three";
2import { useEntityStore } from "./hooks/useEntityStore";
3import { useEventStore } from "./hooks/useEventStore";
4import MonsterEntity from "../types/MonsterEntity";
5import { getShortestDirection } from "./pathfinding";
6import { PLAYER_ID, findEntity, getPlayer } from "./entity";
7import { EventOrigin } from "../constants/EventOrigin";
8import { useGlobalsStore } from "./hooks/useGlobalsStore";
9
10const ENEMY_ACTIONS = 1; // TODO: assign from entity
11const ENEMY_MOVEMENT_LEFT = 2; // TODO: assign from entity
12
13/**
14 * Handle enemy movement
15 * @param enemyEntityId
16 */
17function enemyMovementBehavior(enemyEntityId: string, canMove: boolean = true) {
18  const entities = useEntityStore.getState().entities;
19  const playerEntity = getPlayer(entities);
20  const enemyEntity = findEntity(
21    enemyEntityId,
22    useEntityStore.getState().entities
23  ) as MonsterEntity;
24
25  const entityStore = useEntityStore.getState();
26  const playerPosition = playerEntity.position;
27
28  console.debug(`AI: ${enemyEntity.name} notices player in ${playerPosition}.`);
29
30  let enemyPosition = enemyEntity.position;
31
32  const enemyVectorPosition = new Vector3(
33    enemyPosition[0],
34    enemyPosition[1],
35    enemyPosition[2]
36  );
37
38  const playerVectorPosition = new Vector3(
39    playerPosition[0],
40    playerPosition[1],
41    playerPosition[2]
42  );
43
44  // handle movement, if out of range, move closer to player
45  const distance = enemyVectorPosition.distanceTo(playerVectorPosition);
46  const isInRange = distance <= enemyEntity.range + 0.5;
47
48  if (canMove && !isInRange) {
49    const entities = useEntityStore.getState().entities;
50
51    const occupiedLocations = entities
52      .filter((e) => e.health > 0)
53      .map((e) => e.position);
54
55    const shortestDirection = getShortestDirection(
56      enemyPosition,
57      playerPosition,
58      occupiedLocations
59    );
60
61    console.debug("AI: Shortest direction to player is", shortestDirection);
62    console.debug(
63      `AI: ${enemyEntity.name} is ${Math.round(
64        distance
65      )} units away from player.`
66    );
67    console.debug(
68      `AI: ${enemyEntity.name} in ${enemyPosition} wants to move into ${shortestDirection.position}.`
69    );
70
71    enemyPosition = shortestDirection.position;
72
73    const existingEntities = useEntityStore
74      .getState()
75      .entities.filter((e) => e.id !== enemyEntity.id);
76
77    const newEnemyEntity: MonsterEntity = {
78      ...enemyEntity,
79      position: enemyPosition,
80    };
81
82    const newEntities = [...existingEntities, newEnemyEntity];
83
84    useEntityStore.setState({
85      ...entityStore,
86      entities: newEntities,
87    });
88    return false;
89  }
90
91  return isInRange;
92}
93
94const checkIsPlayerDefeated = () =>
95  useEntityStore.getState().entities.find((e) => e.id === PLAYER_ID)?.health ===
96  0;
97
98/**
99 * Handle enemy actions
100 * @param enemyEntityId
101 */
102function enemyActionBehavior(enemyEntityId: string, canAction: boolean) {
103  if (canAction) {
104    const entities = useEntityStore.getState().entities;
105    const enemyEntity = findEntity(enemyEntityId, entities);
106    const playerEntity = getPlayer(entities);
107
108    // dont beat a dead horse
109    if (checkIsPlayerDefeated()) {
110      const isInCapacitated = !useGlobalsStore.getState().isDeadly;
111      const statusText = isInCapacitated ? "incapacitated" : "dead";
112
113      useEventStore.getState().addEvent({
114        origin: EventOrigin.COMBAT,
115        content: `${enemyEntity.name} (${enemyEntity.id}) looms over ${playerEntity.name} (${playerEntity.id}) ${statusText} body.`,
116      });
117      return false;
118    }
119
120    useEntityStore.getState().damageEntity(enemyEntity.id, playerEntity.id);
121  }
122
123  return !checkIsPlayerDefeated();
124}
125
126type SimulationAmount = "ALL" | "ACTIONS" | "MOVEMENT";
127
128/**
129 * Handle enemy behavior, split into movement & actions
130 * @param enemyEntityId
131 */
132async function enemyBehavior(
133  enemyEntityId: string,
134  simulationAmount: SimulationAmount = "ALL"
135) {
136  console.debug(`AI: ${enemyEntityId} is thinking.`);
137  let isInRange = false;
138
139  for (let index = 0; index < ENEMY_MOVEMENT_LEFT; index++) {
140    isInRange = enemyMovementBehavior(
141      enemyEntityId,
142      ["MOVEMENT", "ALL"].includes(simulationAmount)
143    );
144    if (isInRange) {
145      break;
146    }
147  }
148
149  if (isInRange) {
150    console.debug(`AI: ${enemyEntityId} is in range for actions.`);
151
152    for (let index = 0; index < ENEMY_ACTIONS; index++) {
153      enemyActionBehavior(
154        enemyEntityId,
155        ["ACTIONS", "ALL"].includes(simulationAmount)
156      );
157    }
158  }
159}
160
161export async function simulateEnemy(
162  entityId: string,
163  simulationAmount: SimulationAmount = "ALL"
164) {
165  const entities = useEntityStore.getState().entities;
166  const entity = findEntity(entityId, entities);
167
168  if (entity.hostile && entity.health > 0) {
169    enemyBehavior(entity.id, simulationAmount);
170  }
171}
172
173/** Entry point for entity AI */
174export async function simulateEnemies(
175  simulationAmount: SimulationAmount = "ALL"
176) {
177  const entities = useEntityStore.getState().entities;
178  const player = getPlayer(entities);
179
180  if (player.health > 0) {
181    const enemies = useEntityStore
182      .getState()
183      .entities.filter((e) => e.hostile && e.health > 0);
184
185    for (const enemy of enemies) {
186      await enemyBehavior(enemy.id, simulationAmount);
187    }
188  }
189}
190

Pathfinding

Now.. I'm not an algorithmical expert, but I managed to wing something together that works fairly well. For shortest pathing calculation I wrote greedy best-first search algorithm, which isn't perfect since it doesn't calculate the full picture.

1import { Vector3, Vector3Tuple } from "three";
2import { Box3 } from "three/src/Three";
3import { Direction } from "../constants/Direction";
4import { MOVEMENT_DIRECTIONS } from "../constants/MovementDirections";
5import { Environment } from "../types/Environment";
6import { PartialRecord } from "../types/PartialRecord";
7import { useEntityStore } from "./hooks/useEntityStore";
8import { useGlobalsStore } from "./hooks/useGlobalsStore";
9import { generateRandom } from "./rng";
10
11/**
12 * Get new location based on originalPosition calculated the direction of movement (TODO: and speed!)
13 * @param originalPosition
14 * @param direction
15 * @returns
16 */
17export function getPositionToDirection(
18  originalPosition: Vector3Tuple,
19  direction: Direction
20) {
21  const movement = MOVEMENT_DIRECTIONS[direction];
22  const newPosition = new Vector3(...originalPosition).add(
23    new Vector3(...movement)
24  );
25
26  return newPosition.toArray();
27}
28
29/**
30 * Creates bounding boxes from environment data
31 * @param environment
32 * @returns
33 */
34export function createPlayableBoundaries(environment: Environment) {
35  const boundaries = environment.playableArea.map(
36    (group) => new Box3(new Vector3(...group.from), new Vector3(...group.to))
37  );
38
39  return boundaries;
40}
41
42/**
43 * Retrieves random position inside random playable area for given environment.
44 * @param environment
45 * @returns [random, 0, random]
46 */
47export function getRandomPositionInsidePlayableArea(
48  environment: Environment
49): Vector3Tuple {
50  const playableAreas = environment?.playableArea ?? [];
51  const randomPlayableArea =
52    playableAreas[Math.floor(Math.random() * playableAreas.length)];
53
54  const randomX = generateRandom(
55    randomPlayableArea?.from?.[0],
56    randomPlayableArea?.to?.[0]
57  );
58
59  const randomZ = generateRandom(
60    randomPlayableArea?.from?.[2],
61    randomPlayableArea?.to?.[2]
62  );
63
64  return [randomX, 0, randomZ];
65}
66
67/**
68 * Helper to test if vector is inside playable area
69 * @param playableArea
70 * @param point
71 * @returns
72 */
73export const isInsidePlayableArea = (
74  playableArea: Box3[],
75  point: Vector3Tuple
76) =>
77  playableArea.some((boundary) =>
78    boundary.containsPoint(new Vector3(...point))
79  );
80
81/**
82 * Pathfind the direction with shortest distance to target
83 * @param from Start position
84 * @param to Target position
85 * @returns Shortest Direction, Distance, Position
86 */
87export function getShortestDirection(
88  from: Vector3Tuple,
89  to: Vector3Tuple,
90  unallowedLocations: Vector3Tuple[]
91) {
92  let shortestMove: {
93    direction: Direction;
94    distance: number;
95    position: Vector3Tuple;
96  } | null = null;
97
98  const environment = useGlobalsStore.getState().environment;
99  const boundaries = createPlayableBoundaries(environment);
100
101  Object.entries(MOVEMENT_DIRECTIONS).forEach(
102    ([direction, location]: [
103      direction: Direction,
104      location: [number, 0, number]
105    ]) => {
106      const newPositionInDirection = new Vector3(
107        Math.round(from[0] + location[0]),
108        Math.round(from[1] + location[1]),
109        Math.round(from[2] + location[2])
110      );
111
112      const targetVectorPosition = new Vector3(to[0], to[1], to[2]);
113
114      const possibleMove = {
115        direction,
116        position: newPositionInDirection.toArray(),
117        distance: newPositionInDirection.distanceTo(targetVectorPosition),
118      };
119
120      const isNewPositionOutsidePlayableArea = !isInsidePlayableArea(
121        boundaries,
122        possibleMove.position
123      );
124
125      // skip location if not allowed (e.g. occupied by entity)
126      const isNewPositionUnallowed = unallowedLocations.some((e) =>
127        e.every((pos, i) => pos === possibleMove.position[i])
128      );
129
130      if (isNewPositionOutsidePlayableArea) {
131        console.debug(
132          `PATHFINDING: Movement to ${direction} is outside of playable area`
133        );
134      }
135
136      if (isNewPositionUnallowed) {
137        console.debug(`PATHFINDING: Movement to ${direction} is unallowed.`);
138      }
139
140      if (
141        !isNewPositionOutsidePlayableArea &&
142        !isNewPositionUnallowed &&
143        (!shortestMove || shortestMove.distance > possibleMove.distance)
144      ) {
145        shortestMove = possibleMove;
146      }
147    }
148  );
149
150  return shortestMove;
151}
152
153/**
154 * Get entities in range
155 * @param targetEntityId Entity to inspect
156 */
157export function getEntitiesInRange(
158  targetEntityId: string,
159  overrideTargetPosition: Vector3Tuple = null
160) {
161  const entities = useEntityStore.getState().entities;
162  const entitiesWithoutTarget = entities.filter(
163    (f) => f.id !== targetEntityId && f.health > 0
164  );
165  const targetEntity = entities.find((f) => f.id === targetEntityId);
166  const targetPosition = new Vector3(
167    ...(overrideTargetPosition ?? targetEntity.position)
168  );
169
170  let entitiesInRange: PartialRecord<Direction, string> = {};
171  entitiesWithoutTarget.forEach((entity) => {
172    const entityPosition = new Vector3(...entity.position);
173    const distance = targetPosition.distanceTo(entityPosition);
174    const isInRange = distance <= targetEntity.range + 0.5;
175
176    console.debug(
177      `PATHFINDING: ${entity.id} is ${distance.toFixed(1)} away from ${
178        targetEntity.id
179      }`
180    );
181
182    if (isInRange) {
183      console.debug(`PATHFINDING: ${entity.id} is in range.`);
184      const diffPosition = [
185        (targetPosition.x - entityPosition.x) * -1,
186        0,
187        (targetPosition.z - entityPosition.z) * -1,
188      ];
189
190      const direction = Object.keys(MOVEMENT_DIRECTIONS).filter((dir) =>
191        MOVEMENT_DIRECTIONS[dir].every((pos, i) => pos === diffPosition[i])
192      )?.[0];
193
194      entitiesInRange = { ...entitiesInRange, [direction]: entity.id };
195    }
196  });
197
198  console.debug("PATHFINDING: Entities in range", entitiesInRange);
199
200  return entitiesInRange;
201}
202

Death, Victory

I've really liked the idea of suffering consequence in these linear(ish) roleplaying games. In my game the game is always in ironman mode, which means that you will not be able to revert anything you've done in the world.

This creates an interesting paradigm, since the combat should therefore be fairly different from games. I liked the idea that losing doesn't really necessarily mean that you've lost, or in fact died. This should vary a lot based on how the enemy sees the world, even a bandit likely isn't out there to kill you, more likely they're just out there to survive.

In this short game there is an encounter if you go into the sidealleys of the city. There you get jumped for three concecutive story cards and should you succeed you're celebrated gloriously since you've singlehandedly vanguished the bandits and their leader. However this is fairly impossible and requires a certain amount of luck and min-maxing the game in it's current state.

Should you lose these first two fights however, the bandits sell you to the mines and you wake up there pondering will you spend the rest of your days in forced labour. However, should you lose on the final encounter with the boss, the bandits capture you and you wake up patched by them. The story then can continue where the writer (in this case; me) would please in each of these losing conditions. The game can end there, but should the story expanded, it could very well be written into a very immersive experience making your way out of the mess, and these story extensions could even can be published later and these characters in a "deadend" could be then continued - I really love this idea.

Basically encounter story has a victory and optional defeat story. Should the story be missing, you will die in the encounter losing condition, best applications for these would be e.g. beasts which can be expected to show no mercy.

Tools

Sculptor

I also attempted to write a story tree visualization tooling with threejs. This proved to be quite a programming challenge since it is very recursion and geometry themed problem to visualize.

Thank you for reading!