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
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!