The Gift Roleplaying Game

## 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/](https://thegift.jakke.fi/) Source: N/A ![the-gift-splash](//images.ctfassets.net/z15dco8agirv/4XwriiAbxRzZfXV4byxiIT/9a15cf46c2ca3a8daef276f4596df4b0/the-gift-splash.png) ## Characters ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/xzdiRwW74g0?si=HrVRrujoNtVygxQ5" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` 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 ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/HCS0QWddgcE?si=Jjf-q_8gWdDMYmik" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` 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 ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/ovS4pbENlDA?si=5-wMaRgIonsEPIIZ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` You can interact with NPCs in the world, they also can reward you with items. ## Inventory ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/hA5shJdlvM8?si=L85AZZQDhsJfnD6x" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` 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 ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/XaBnjeyRTVs?si=uc0edxs87XE0l2jC" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` 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](https://fi.wikipedia.org/wiki/Aki_Palsanm%C3%A4ki). ## 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__ ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/w0-mSxqrZgM?si=o11DR8pzaN7Fcfqx" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` __City Encounter__ ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/HWVVVz3QBKg?si=f7o9bfyadwu6ILzv" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` __Enemy AI__ Given that we're working in turns, we're iterating all the enemies and their amount of actions etc. ```typescript import { Vector3 } from "three"; import { useEntityStore } from "./hooks/useEntityStore"; import { useEventStore } from "./hooks/useEventStore"; import MonsterEntity from "../types/MonsterEntity"; import { getShortestDirection } from "./pathfinding"; import { PLAYER_ID, findEntity, getPlayer } from "./entity"; import { EventOrigin } from "../constants/EventOrigin"; import { useGlobalsStore } from "./hooks/useGlobalsStore"; const ENEMY_ACTIONS = 1; // TODO: assign from entity const ENEMY_MOVEMENT_LEFT = 2; // TODO: assign from entity /** * Handle enemy movement * @param enemyEntityId */ function enemyMovementBehavior(enemyEntityId: string, canMove: boolean = true) { const entities = useEntityStore.getState().entities; const playerEntity = getPlayer(entities); const enemyEntity = findEntity( enemyEntityId, useEntityStore.getState().entities ) as MonsterEntity; const entityStore = useEntityStore.getState(); const playerPosition = playerEntity.position; console.debug(`AI: ${enemyEntity.name} notices player in ${playerPosition}.`); let enemyPosition = enemyEntity.position; const enemyVectorPosition = new Vector3( enemyPosition[0], enemyPosition[1], enemyPosition[2] ); const playerVectorPosition = new Vector3( playerPosition[0], playerPosition[1], playerPosition[2] ); // handle movement, if out of range, move closer to player const distance = enemyVectorPosition.distanceTo(playerVectorPosition); const isInRange = distance <= enemyEntity.range + 0.5; if (canMove && !isInRange) { const entities = useEntityStore.getState().entities; const occupiedLocations = entities .filter((e) => e.health > 0) .map((e) => e.position); const shortestDirection = getShortestDirection( enemyPosition, playerPosition, occupiedLocations ); console.debug("AI: Shortest direction to player is", shortestDirection); console.debug( `AI: ${enemyEntity.name} is ${Math.round( distance )} units away from player.` ); console.debug( `AI: ${enemyEntity.name} in ${enemyPosition} wants to move into ${shortestDirection.position}.` ); enemyPosition = shortestDirection.position; const existingEntities = useEntityStore .getState() .entities.filter((e) => e.id !== enemyEntity.id); const newEnemyEntity: MonsterEntity = { ...enemyEntity, position: enemyPosition, }; const newEntities = [...existingEntities, newEnemyEntity]; useEntityStore.setState({ ...entityStore, entities: newEntities, }); return false; } return isInRange; } const checkIsPlayerDefeated = () => useEntityStore.getState().entities.find((e) => e.id === PLAYER_ID)?.health === 0; /** * Handle enemy actions * @param enemyEntityId */ function enemyActionBehavior(enemyEntityId: string, canAction: boolean) { if (canAction) { const entities = useEntityStore.getState().entities; const enemyEntity = findEntity(enemyEntityId, entities); const playerEntity = getPlayer(entities); // dont beat a dead horse if (checkIsPlayerDefeated()) { const isInCapacitated = !useGlobalsStore.getState().isDeadly; const statusText = isInCapacitated ? "incapacitated" : "dead"; useEventStore.getState().addEvent({ origin: EventOrigin.COMBAT, content: `${enemyEntity.name} (${enemyEntity.id}) looms over ${playerEntity.name} (${playerEntity.id}) ${statusText} body.`, }); return false; } useEntityStore.getState().damageEntity(enemyEntity.id, playerEntity.id); } return !checkIsPlayerDefeated(); } type SimulationAmount = "ALL" | "ACTIONS" | "MOVEMENT"; /** * Handle enemy behavior, split into movement & actions * @param enemyEntityId */ async function enemyBehavior( enemyEntityId: string, simulationAmount: SimulationAmount = "ALL" ) { console.debug(`AI: ${enemyEntityId} is thinking.`); let isInRange = false; for (let index = 0; index < ENEMY_MOVEMENT_LEFT; index++) { isInRange = enemyMovementBehavior( enemyEntityId, ["MOVEMENT", "ALL"].includes(simulationAmount) ); if (isInRange) { break; } } if (isInRange) { console.debug(`AI: ${enemyEntityId} is in range for actions.`); for (let index = 0; index < ENEMY_ACTIONS; index++) { enemyActionBehavior( enemyEntityId, ["ACTIONS", "ALL"].includes(simulationAmount) ); } } } export async function simulateEnemy( entityId: string, simulationAmount: SimulationAmount = "ALL" ) { const entities = useEntityStore.getState().entities; const entity = findEntity(entityId, entities); if (entity.hostile && entity.health > 0) { enemyBehavior(entity.id, simulationAmount); } } /** Entry point for entity AI */ export async function simulateEnemies( simulationAmount: SimulationAmount = "ALL" ) { const entities = useEntityStore.getState().entities; const player = getPlayer(entities); if (player.health > 0) { const enemies = useEntityStore .getState() .entities.filter((e) => e.hostile && e.health > 0); for (const enemy of enemies) { await enemyBehavior(enemy.id, simulationAmount); } } } ``` __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. ```typescript import { Vector3, Vector3Tuple } from "three"; import { Box3 } from "three/src/Three"; import { Direction } from "../constants/Direction"; import { MOVEMENT_DIRECTIONS } from "../constants/MovementDirections"; import { Environment } from "../types/Environment"; import { PartialRecord } from "../types/PartialRecord"; import { useEntityStore } from "./hooks/useEntityStore"; import { useGlobalsStore } from "./hooks/useGlobalsStore"; import { generateRandom } from "./rng"; /** * Get new location based on originalPosition calculated the direction of movement (TODO: and speed!) * @param originalPosition * @param direction * @returns */ export function getPositionToDirection( originalPosition: Vector3Tuple, direction: Direction ) { const movement = MOVEMENT_DIRECTIONS[direction]; const newPosition = new Vector3(...originalPosition).add( new Vector3(...movement) ); return newPosition.toArray(); } /** * Creates bounding boxes from environment data * @param environment * @returns */ export function createPlayableBoundaries(environment: Environment) { const boundaries = environment.playableArea.map( (group) => new Box3(new Vector3(...group.from), new Vector3(...group.to)) ); return boundaries; } /** * Retrieves random position inside random playable area for given environment. * @param environment * @returns [random, 0, random] */ export function getRandomPositionInsidePlayableArea( environment: Environment ): Vector3Tuple { const playableAreas = environment?.playableArea ?? []; const randomPlayableArea = playableAreas[Math.floor(Math.random() * playableAreas.length)]; const randomX = generateRandom( randomPlayableArea?.from?.[0], randomPlayableArea?.to?.[0] ); const randomZ = generateRandom( randomPlayableArea?.from?.[2], randomPlayableArea?.to?.[2] ); return [randomX, 0, randomZ]; } /** * Helper to test if vector is inside playable area * @param playableArea * @param point * @returns */ export const isInsidePlayableArea = ( playableArea: Box3[], point: Vector3Tuple ) => playableArea.some((boundary) => boundary.containsPoint(new Vector3(...point)) ); /** * Pathfind the direction with shortest distance to target * @param from Start position * @param to Target position * @returns Shortest Direction, Distance, Position */ export function getShortestDirection( from: Vector3Tuple, to: Vector3Tuple, unallowedLocations: Vector3Tuple[] ) { let shortestMove: { direction: Direction; distance: number; position: Vector3Tuple; } | null = null; const environment = useGlobalsStore.getState().environment; const boundaries = createPlayableBoundaries(environment); Object.entries(MOVEMENT_DIRECTIONS).forEach( ([direction, location]: [ direction: Direction, location: [number, 0, number] ]) => { const newPositionInDirection = new Vector3( Math.round(from[0] + location[0]), Math.round(from[1] + location[1]), Math.round(from[2] + location[2]) ); const targetVectorPosition = new Vector3(to[0], to[1], to[2]); const possibleMove = { direction, position: newPositionInDirection.toArray(), distance: newPositionInDirection.distanceTo(targetVectorPosition), }; const isNewPositionOutsidePlayableArea = !isInsidePlayableArea( boundaries, possibleMove.position ); // skip location if not allowed (e.g. occupied by entity) const isNewPositionUnallowed = unallowedLocations.some((e) => e.every((pos, i) => pos === possibleMove.position[i]) ); if (isNewPositionOutsidePlayableArea) { console.debug( `PATHFINDING: Movement to ${direction} is outside of playable area` ); } if (isNewPositionUnallowed) { console.debug(`PATHFINDING: Movement to ${direction} is unallowed.`); } if ( !isNewPositionOutsidePlayableArea && !isNewPositionUnallowed && (!shortestMove || shortestMove.distance > possibleMove.distance) ) { shortestMove = possibleMove; } } ); return shortestMove; } /** * Get entities in range * @param targetEntityId Entity to inspect */ export function getEntitiesInRange( targetEntityId: string, overrideTargetPosition: Vector3Tuple = null ) { const entities = useEntityStore.getState().entities; const entitiesWithoutTarget = entities.filter( (f) => f.id !== targetEntityId && f.health > 0 ); const targetEntity = entities.find((f) => f.id === targetEntityId); const targetPosition = new Vector3( ...(overrideTargetPosition ?? targetEntity.position) ); let entitiesInRange: PartialRecord<Direction, string> = {}; entitiesWithoutTarget.forEach((entity) => { const entityPosition = new Vector3(...entity.position); const distance = targetPosition.distanceTo(entityPosition); const isInRange = distance <= targetEntity.range + 0.5; console.debug( `PATHFINDING: ${entity.id} is ${distance.toFixed(1)} away from ${ targetEntity.id }` ); if (isInRange) { console.debug(`PATHFINDING: ${entity.id} is in range.`); const diffPosition = [ (targetPosition.x - entityPosition.x) * -1, 0, (targetPosition.z - entityPosition.z) * -1, ]; const direction = Object.keys(MOVEMENT_DIRECTIONS).filter((dir) => MOVEMENT_DIRECTIONS[dir].every((pos, i) => pos === diffPosition[i]) )?.[0]; entitiesInRange = { ...entitiesInRange, [direction]: entity.id }; } }); console.debug("PATHFINDING: Entities in range", entitiesInRange); return entitiesInRange; } ``` ## 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** ```embed <iframe width="560" height="315" src="https://www.youtube.com/embed/WmrdVNIgcJw?si=zT79UdCD2xkJYwSr" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> ``` 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!