pnk-forever

Technical Integrations & Systems

Game Engine: text-engine

Source: Fork/customization of open-source text-engine by okaybenji Repository: https://github.com/okaybenji/text-engine Version: 2.0.0 License: GPL

Core Architecture

Engine File: index.js (26,081 bytes) Game Data: game-disks/p-n-k-forever.js (JavaScript object/JSON) Frontend: index.html (minimal HTML structure) Styling: styles/retro.css (terminal aesthetic)

Key Concepts

Disk Metaphor: The game uses a “disk” metaphor from floppy disk era. The entire game is represented as a single JavaScript object (the “disk”) that gets loaded into the engine.

const talesOfPhonixAndK = {
  roomId: "beach_rest",  // Starting location
  rooms: [...],           // All locations
  characters: [...],      // All NPCs
  inventory: [],          // Player's items
  // Custom properties can be added
};

loadDisk(talesOfPhonixAndK);

Command System

Built-in Commands

The engine provides a global commands array organized by argument count:

commands[0] - No arguments:

commands[1] - One argument:

commands[2] - Two arguments:

Custom Commands

P&K Forever adds the FLY command:

const fly = (name) => {
  if (name && name.length > 0) {
    flyTo([null, name]);
  } else {
    flyTo(name);
  }
};

const flyTo = (args) => {
  const [_, name] = args;
  const flyableRooms = getFlyableRooms();
  const flyableRoomsNames = flyableRooms.map((r) => r.id);

  if (flyableRoomsNames.includes(name)) {
    const destRoom = flyableRooms.find((r) => r.id === name);
    if (disk.roomId === destRoom.isFlyableFrom) {
      println(`You fly to ${name}!`);

      if (typeof destRoom.onFly === "function") {
        destRoom.onFly({ disk, println });
      }

      const k = getCharacter("k");
      if (k.agreedToTravel) {
        k.roomId = name;
      }
      enterRoom(name);
    } else {
      println(`You can't fly to ${name} from ${disk.roomId}!`);
    }
  } else {
    if (name) {
      println(`You don't know how to fly to ${name}.`);
    }
    println("You hover in the sky! 🐦");
  }
};

// Register with command system
commands[0].fly = fly;
commands[1].fly = fly;
commands[2] = Object.assign(commands[2], { fly: flyTo });

Help Menu Customization

const updateHelpCommand = (additionalCommands) => {
  help = () =>
    println(`
  The following commands are available:
  ========================================================

    LOOK :: repeat room description

    LOOK AT [OBJECT NAME] e.g. 'look at key'
    TAKE [OBJECT NAME] e.g. 'take book'
    TALK TO [CHARACTER NAME]  e.g. 'talk to mary'
    GO [DIRECTION] e.g. 'go north'
    USE [OBJECT NAME] e.g. 'use door'

    ${additionalCommands || ``}

    ITEMS:  list items in the room
    INV :: list inventory items
    SAVE:   save the current game
    LOAD:   load the last saved game
    HELP :: this help menu
  `);

  commands[0].help = help;
};

updateHelpCommand();  // Initial call
// Later, when FLY is unlocked:
updateHelpCommand(`FLY TO [ROOM NAME] e.g. 'fly to room'`);

Styling & Theme

CSS Themes

Retro Theme (styles/retro.css) - Used by P&K Forever:

Modern Theme (styles/modern.css) - Available but not used:

Dynamic Theme Loading

document.getElementById("styles").setAttribute("href", "styles/retro.css");

Font

Ultimate Apple II Font from KreativeKorp


Data Persistence

Save/Load System

Storage: Browser localStorage Format: JSON string with function serialization

Save Function:

let save = (name = 'save') => {
  const save = JSON.stringify(disk, (key, value) =>
    typeof value === 'function' ? value.toString() : value
  );
  localStorage.setItem(name, save);
  println(`Game saved.`)
};

Load Function:

let load = (name = 'save') => {
  const save = localStorage.getItem(name);

  if (!save) {
    println(`Save file not found.`);
    return;
  }

  disk = JSON.parse(save, (key, value) => {
    try {
      return eval(value);  // Deserialize functions
    } catch (error) {
      return value;
    }
  });
  println(`Game loaded.`)
  enterRoom(disk.roomId);
};

Limitations & Considerations

  1. Function Serialization:
    • Functions are converted to strings
    • Closures are lost
    • Must use function expressions, not shorthand methods
    • Okay: onUse: () => { ... }
    • Not okay: onUse() { ... }
  2. Circular References:
    • JSON doesn’t support circular structures
    • Must avoid object properties that reference ancestors
  3. Reserved Keywords:
    • Can’t use JavaScript reserved words as object names
    • “key”, “window”, etc. must be avoided
    • Workaround: Use descriptive names (“silver key”, “tall window”)
  4. Version Compatibility:
    • Saves contain the entire disk
    • Loading overwrites current disk completely
    • Publishing updates breaks old saves
  5. Security:
    • eval() is used for function deserialization
    • Safe for personal use
    • Would need hardening for public deployment

External Integrations

IFTTT Webhooks

Service: IFTTT Maker Webhooks Purpose: Trigger real-world actions from game events

Implementation:

fetch(`https://maker.ifttt.com/trigger/{EVENT_NAME}/with/key/buN0S2VUtrVLjyoCLowl7X`)

Events Used:

IFTTT Applet Setup (hypothetical):

Each event would have corresponding IFTTT applet:

Example for pnk_chocolate:

IF Maker Event "pnk_chocolate" received
THEN
  - Send notification to phone
  - Or trigger smart home action
  - Or send email
  - Or add to shopping list
  - Or [any IFTTT integration]

Security Notes:


Deployment

Vercel

Status Badge:

[![Deployed on Vercel](https://img.shields.io/badge/Deployed%20on-Vercel-black?logo=vercel&logoColor=white)](https://pnk-forever.vercel.app)

Configuration (vercel.json):

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "buildCommand": "cd v1-modern && npm install && npm run build",
  "outputDirectory": "v1-modern/dist",
  "framework": null,
  "cleanUrls": true,
  "redirects": [
    { "source": "/v0-original-text-engine", "destination": "/v0-original-text-engine/", "permanent": false },
    { "source": "/v0", "destination": "/v0-original-text-engine/", "permanent": false }
  ]
}

Deployment:

Site URLs:


Browser Compatibility

Requirements

Minimum:

Tested On:

Not Supported:

Polyfills

None used - assumes modern browser environment


Input System

Input Handling

Input Element:

<input id="input" autofocus spellcheck="false">

Event Listeners:

ENTER key - Submit command:

input.addEventListener('keypress', (e) => {
  const ENTER = 13;
  if (e.keyCode === ENTER) {
    applyInput();
  }
});

UP/DOWN arrows - Command history:

input.addEventListener('keydown', (e) => {
  const UP = 38;
  const DOWN = 40;
  const TAB = 9;

  if (e.keyCode === UP) {
    navigateHistory('prev');
  } else if (e.keyCode === DOWN) {
    navigateHistory('next');
  } else if (e.keyCode === TAB) {
    e.preventDefault();
    autocomplete();
  }
});

Focus management:

input.addEventListener('focusout', () => {
  input.focus({preventScroll: true});
});

Command History

let inputs = [''];  // History of commands
let inputsPos = 0;  // Current position in history

Autocomplete

TAB key triggers autocomplete:


Output System

Output Element

<div id="output"></div>

println Function

let println = (line, className) => {
  const output = document.querySelector('#output');
  const lineElement = document.createElement('div');

  if (className) {
    lineElement.classList.add(className);
  }

  // Handle arrays (random selection)
  if (Array.isArray(line)) {
    line = pickOne(line);
  }

  lineElement.innerHTML = line;
  output.appendChild(lineElement);

  // Auto-scroll to bottom
  output.scrollTop = output.scrollHeight;
};

Features:


Game Loop

Initialization

loadDisk(talesOfPhonixAndK);

Process:

  1. init(disk) - Add default values
  2. setup() - Register event listeners
  3. enterRoom(disk.roomId) - Enter starting room

Room Entry

enterRoom(id) {
  const room = getRoom(id);

  // Update disk state
  disk.roomId = id;

  // Increment visit counter
  room.visits++;

  // Display room
  if (room.img) println(room.img);
  println(room.name, 'room-name');
  println(room.desc);

  // List characters
  const characters = getCharactersInRoom(id);
  if (characters.length) {
    println(`Characters: ${characters.map(c => c.name[0]).join(', ')}`);
  }

  // List items
  if (room.items && room.items.length) {
    println(`Items: ${room.items.map(i => i.name[0]).join(', ')}`);
  }

  // Callback
  if (typeof room.onEnter === 'function') {
    room.onEnter();
  }
}

Command Processing

  1. User types command
  2. ENTER pressed
  3. applyInput() called
  4. Command parsed (split by spaces)
  5. Matched against commands[argumentCount]
  6. Corresponding function executed
  7. Result printed to output
  8. Input cleared, ready for next command

Helper Functions

Utility Functions

pickOne(arr) - Random array element:

let pickOne = (arr) => arr[Math.floor(Math.random() * arr.length)];

getRoom(id) - Find room by ID:

let getRoom = (id) => disk.rooms.find(room => room.id === id);

getCharacter(name) - Find character by name:

let getCharacter = (name, chars = disk.characters) => {
  return chars.find(char => {
    const names = Array.isArray(char.name) ? char.name : [char.name];
    return names.includes(name);
  });
};

getExit(dir, exits) - Find exit by direction:

let getExit = (dir, exits) => {
  return exits.find(exit => exit.dir === dir);
};

Custom Helpers (P&K Forever)

getFlyableRooms() - Get rooms that can be flown to:

const getFlyableRooms = () => {
  return disk.rooms.filter((r) => r.isFlyableFrom);
};

Special Mechanics

Visit Tracking

room.visits = 0;  // Initialized for each room

Incremented each time room is entered. Could be used for:

(Not heavily used in P&K Forever, but available)

Dynamic Room/Character Modification

Characters can move between rooms:

const k = getCharacter("k");
k.roomId = "beach_sunset";  // Move K to new location

Items can be added to rooms dynamically:

const street = getRoom("jaffa_street");
street.items.push({
  name: ["Kite Board"],
  desc: "Your kite equipment",
  // ...
});

Exits can be added/removed:

// Add exit
street.exits.push({
  dir: "north",
  id: "beach_sunset",
});

// Remove exit block
const exit = getExit("east", room.exits);
delete exit.block;

Topic System for Conversations

k.roomTopics = {};  // Storage for location-specific topics
k.roomTopics["beach"] = k.topics;  // Save current topics
k.topics = k.roomTopics["kitchen"] || [ /* new topics */ ];  // Switch

Allows characters to have different conversations in different locations while preserving history.


Debug Features

Debug Room Override

const debug_room = null;  // "japan"
const talesOfPhonixAndK = {
  roomId: debug_room || "beach_rest",
  // ...
};

Set debug_room to skip to specific location for testing.

Console Access

All game functions are global, allowing console debugging:

// In browser console:
disk.roomId = "japan";
enterRoom("japan");
getCharacter("k").roomId = "japan";
disk.inventory.push({name: "test item"});

Performance Considerations

Lightweight Architecture

Optimization Opportunities (for v2)

  1. Code splitting: Separate engine from game data
  2. Lazy loading: Load rooms/assets on demand
  3. Image sprites: If adding graphics
  4. Compression: Minify JS/CSS
  5. Caching: Service worker for offline play

Accessibility

Current State

Good:

Could Improve:

Recommendations for v2


Security Considerations

Current Implementation

Safe:

Potential Concerns:

For Public Version

Would need:

  1. Backend API for webhooks
  2. Authentication/authorization
  3. Rate limiting
  4. Input sanitization
  5. CSP headers
  6. HTTPS only
  7. Remove eval(), use safer deserialization

Future Integration Opportunities

Potential Additions for v2

  1. Analytics: Track which Easter eggs are discovered
  2. Achievements: Steam-like achievement system
  3. Social: Share progress, screenshots
  4. Multiplayer: Collaborative puzzle solving
  5. Voice: Voice commands/narration
  6. AR: Real-world object scanning
  7. Blockchain: NFT Easter eggs (if appropriate)
  8. AI: Dynamic conversation generation
  9. Haptics: Controller/phone vibration
  10. Music: Dynamic soundtrack

Maintaining Simplicity

Any additions should honor the original’s elegance:


Technical Philosophy

The original P&K Forever makes powerful choices:

  1. No dependencies = No breaking changes, eternal compatibility
  2. Simple architecture = Easy to understand, modify, preserve
  3. JSON-based = Human-readable, version-controllable
  4. Browser-native = No installation, universal access
  5. Lightweight = Fast, accessible, sustainable

When modernizing, we must ask: “Does this addition serve the story and the player, or just the technology?”

The goal isn’t to show off what’s possible - it’s to honor what’s meaningful.