--- name: openrct2-plugin description: "Develop plugins for OpenRCT2 - JavaScript/TypeScript scripting, game actions, UI windows, hooks, multiplayer sync" metadata: author: mte90 version: "1.0.0" tags: - openrct2 - plugin - javascript - typescript - game-modding - rollercoaster-tycoon - scripting --- # OpenRCT2 Plugin Development Comprehensive guide for developing plugins (scripts) for OpenRCT2, the open-source re-implementation of RollerCoaster Tycoon 2. ## Overview OpenRCT2 allows custom scripts (plugins) written in JavaScript/TypeScript to extend the game with additional behavior - from extra windows to entire multiplayer game modes. ### Key Features - **JavaScript/TypeScript** - ES5 compatible, transpilers for ES6+ - **Game Actions** - Safe multiplayer-synchronized state mutations - **UI Windows** - Custom windows with widgets - **Hooks** - Subscribe to game events - **Network API** - TCP sockets for localhost communication - **Hot Reload** - Real-time plugin development ### Plugin Directory Place `.js` files in the `plugin` directory: - **Windows**: `C:\Users\YourName\Documents\OpenRCT2\plugin\` - **Mac**: `/Users/YourName/Library/Application Support/OpenRCT2/plugin/` - **Linux**: `$XDG_CONFIG_HOME/OpenRCT2/plugin/` or `$HOME/.config/OpenRCT2/plugin/` Access via game: **Red toolbox button → Open custom content folder** ## Plugin Template ### Basic Plugin Structure ```javascript function main() { console.log("Your plugin has started!"); // Your plugin code here } registerPlugin({ name: 'Your Plugin', version: '1.0', authors: ['Your Name'], type: 'remote', licence: 'MIT', targetApiVersion: 34, minApiVersion: 10, main: main }); ``` ### TypeScript Setup ```typescript // Install TypeScript and types // npm install typescript --save-dev // Copy openrct2.d.ts to your project /// function main() { console.log("TypeScript plugin loaded!"); } registerPlugin({ name: 'My TypeScript Plugin', version: '1.0', authors: ['Developer'], type: 'local', licence: 'MIT', targetApiVersion: 34, main: main }); ``` ### tsconfig.json ```json { "compilerOptions": { "target": "ES5", "module": "none", "outFile": "./dist/plugin.js", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` ## Plugin Types ### Local Plugins Load on any client with plugin installed. Cannot alter game state directly. ```javascript registerPlugin({ name: 'Local Info Plugin', version: '1.0', type: 'local', // Available to all players in multiplayer // ... main: function() { // Can only use game actions, not direct mutations // Good for: info windows, tools, dashboards } }); ``` ### Remote Plugins Load only on server, distributed to clients. Can mutate game state in execute context. ```javascript registerPlugin({ name: 'Remote Game Plugin', version: '1.0', type: 'remote', // Server-side, synced to clients // ... main: function() { // Can mutate game state in custom game action execute() } }); ``` ### Intransient Plugins Stay loaded across park changes and in title screen. ```javascript registerPlugin({ name: 'Global Plugin', version: '1.0', type: 'intransient', // Never unloaded // ... main: function() { // Active in title screen and across parks // Use context.sharedStorage for persistence } }); ``` ## Game Actions Game actions are the **recommended way** to mutate game state, ensuring multiplayer synchronization. ### Using Built-in Game Actions ```javascript // Place scenery using game action var action = { type: 'smallsceneryplace', args: { object: 0, // Scenery object ID x: 32 * 10, // X coordinate in map units y: 32 * 10, // Y coordinate in map units z: 0, // Z height direction: 0, // Rotation (0-3) quadrant: 0, // Quadrant for quarter tile scenery primaryColour: 0, // Primary color secondaryColour: 0 // Secondary color } }; context.executeAction(action, function(result) { if (result.error) { console.log("Failed to place scenery: " + result.error); } else { console.log("Scenery placed successfully"); } }); ``` ### Common Built-in Actions ```javascript // Set park cash context.executeAction({ type: 'parksetcash', args: { cash: 100000 } }, callback); // Set guest count context.executeAction({ type: 'parksetguestgenerationrate', args: { generationRate: 100 } }, callback); // Change land height context.executeAction({ type: 'landsetheight', args: { x: 32 * 10, y: 32 * 10, height: 10 } }, callback); // Build ride context.executeAction({ type: 'trackplace', args: { ride: 0, trackType: 1, x: 32 * 10, y: 32 * 10, z: 0, direction: 0 } }, callback); ``` ### Custom Game Actions ```javascript // Register custom action context.registerAction({ id: 'myplugin.award_cash', query: function(args) { // Validation - return error object if invalid if (args.amount < 0) { return { error: 'Amount must be positive' }; } if (args.amount > 100000) { return { error: 'Amount too large' }; } return {}; // Success }, execute: function(args) { // Actual game state mutation - only runs on server park.cash += args.amount; return {}; // Success } }); // Use custom action context.executeAction({ type: 'myplugin.award_cash', args: { amount: 5000 } }, function(result) { console.log(result.error || "Cash awarded!"); }); ``` ### Permission Checks ```javascript context.registerAction({ id: 'myplugin.admin_action', query: function(args) { // Check player permissions if (network.mode !== 'none') { var player = network.getPlayer(args.playerId); if (!player || !player.hasPermission('modify_park')) { return { error: 'No permission' }; } } return {}; }, execute: function(args) { // Perform action } }); ``` ## UI Development ### Check UI Availability ```javascript // Always check before using UI APIs (for headless servers) if (typeof ui !== 'undefined') { // UI is available ui.registerMenuItem('My Window', openWindow); } ``` ### Menu Integration ```javascript function main() { if (typeof ui === 'undefined') return; // Add menu item ui.registerMenuItem('My Plugin', function() { openMainWindow(); }); // Add to specific menu tab ui.registerMenuItem('Ride Stats', function() { openRideStatsWindow(); }, 'ride'); // 'map', 'park', 'ride', 'guest', etc. } ``` ### Window Creation ```javascript function openMainWindow() { var window = ui.openWindow({ classification: 'myplugin.main', title: 'My Plugin Window', width: 300, height: 200, widgets: [ // Label { type: 'label', x: 10, y: 10, width: 280, height: 20, text: 'Hello, OpenRCT2!' }, // Button { type: 'button', x: 10, y: 40, width: 120, height: 30, text: 'Click Me', onClick: function() { console.log('Button clicked!'); } }, // Checkbox { type: 'checkbox', x: 10, y: 80, width: 200, height: 20, text: 'Enable feature', isChecked: false, onChange: function(checked) { console.log('Checkbox: ' + checked); } }, // Dropdown { type: 'dropdown', x: 10, y: 110, width: 200, height: 20, items: ['Option 1', 'Option 2', 'Option 3'], selectedIndex: 0, onChange: function(index) { console.log('Selected: ' + index); } }, // Slider { type: 'slider', x: 10, y: 140, width: 200, height: 20, minValue: 0, maxValue: 100, value: 50, onChange: function(value) { console.log('Slider: ' + value); } }, // Spinner { type: 'spinner', x: 10, y: 170, width: 100, height: 20, text: '10', onDecrement: function() { // Handle decrement }, onIncrement: function() { // Handle increment } } ] }); return window; } ``` ### ListView Widget ```javascript { type: 'listview', x: 10, y: 10, width: 280, height: 150, scrollbars: 'vertical', isStriped: true, showColumnHeaders: true, columns: [ { header: 'Name', width: 150 }, { header: 'Value', width: 80 }, { header: 'Status', width: 50 } ], items: [ ['Ride 1', '$5000', 'OK'], ['Ride 2', '$3000', 'OK'], ['Ride 3', '$2000', 'Low'] ], onHighlight: function(item, column) { console.log('Highlighted: ' + item + ', ' + column); }, onClick: function(item, column) { console.log('Clicked: ' + item + ', ' + column); } } ``` ### GroupBox Widget ```javascript { type: 'groupbox', x: 10, y: 10, width: 280, height: 100, text: 'Settings', widgets: [ { type: 'checkbox', x: 10, y: 20, width: 260, height: 20, text: 'Enable notifications' }, { type: 'checkbox', x: 10, y: 45, width: 260, height: 20, text: 'Auto-save' } ] } ``` ### Tab Window ```javascript function openTabWindow() { var window = ui.openWindow({ classification: 'myplugin.tabs', title: 'Tabbed Window', width: 400, height: 300, tabs: [ { image: 5221, // Icon ID widgets: [ { type: 'label', x: 10, y: 10, width: 380, height: 20, text: 'Tab 1 Content' } ] }, { image: 5222, widgets: [ { type: 'label', x: 10, y: 10, width: 380, height: 20, text: 'Tab 2 Content' } ] } ] }); } ``` ### Window Events ```javascript var window = ui.openWindow({ /* ... */ }); window.onClose = function() { console.log('Window closed'); // Cleanup }; ``` ## Hooks (Events) ### Interval Hooks ```javascript // Every game tick (40ms) context.subscribe('interval.tick', function() { // Runs 25 times per second }); // Every game day context.subscribe('interval.day', function() { console.log('New day! Cash: ' + park.cash); // Award daily bonus park.cash += 1000; }); // Every in-game hour context.subscribe('interval.hour', function() { // Hourly updates }); ``` ### Ride Hooks ```javascript // Ride created context.subscribe('ride.ratings.calculate', function(e) { var ride = map.getRide(e.rideId); console.log('Ride ratings calculated: ' + ride.name); }); // Ride crashed context.subscribe('ride.crashed', function(e) { console.log('Ride ' + e.rideId + ' crashed!'); }); ``` ### Guest Hooks ```javascript // Guest entered park context.subscribe('guest.entered_park', function(e) { var guest = map.getEntity(e.entityId); console.log('Guest ' + guest.id + ' entered park'); }); // Guest left park context.subscribe('guest.left_park', function(e) { console.log('Guest left: ' + e.entityId); }); // Guest bought item context.subscribe('guest.bought_item', function(e) { console.log('Guest bought item: ' + e.item); }); ``` ### Network Hooks ```javascript // Chat message received context.subscribe('network.chat', function(e) { console.log(e.playerName + ': ' + e.message); // Anti-spam example if (e.message.length > 200) { network.kickPlayer(e.playerId); } }); // Player joined context.subscribe('network.join', function(e) { console.log('Player joined: ' + e.playerName); // Welcome message network.sendMessage('Welcome to the server, ' + e.playerName + '!'); }); // Player left context.subscribe('network.leave', function(e) { console.log('Player left: ' + e.playerName); }); ``` ### Action Hooks ```javascript // Before action executes context.subscribe('action.query', function(e) { if (e.action === 'ridesetstatus') { console.log('Ride status changing...'); } }); // After action executes context.subscribe('action.execute', function(e) { if (e.action === 'ridesetstatus') { console.log('Ride status changed'); } }); ``` ## Park and Map Access ### Park Information ```javascript // Park stats console.log('Park name: ' + park.name); console.log('Cash: $' + park.cash); console.log('Rating: ' + park.rating); console.log('Guests: ' + park.guests); console.log('Value: $' + park.value); console.log('Company value: $' + park.companyValue); // Park flags if (park.getFlag('noMoney')) { console.log('Park has no money'); } // Modify park (remote plugin only) park.cash = 100000; park.name = "My Awesome Park"; ``` ### Map Access ```javascript // Get map size var mapSize = map.size; console.log('Map size: ' + mapSize.x + 'x' + mapSize.y); // Iterate all tiles for (var x = 0; x < map.size.x; x++) { for (var y = 0; y < map.size.y; y++) { var tile = map.getTile(x, y); // Process tile } } // Get tile at coordinates var tile = map.getTile(10, 10); // Tile elements for (var i = 0; i < tile.numElements; i++) { var element = tile.getElement(i); if (element.type === 'surface') { console.log('Surface at ' + element.baseHeight); } else if (element.type === 'track') { console.log('Track element'); } else if (element.type === 'small_scenery') { console.log('Small scenery'); } } ``` ### Rides ```javascript // Get all rides var rides = map.rides; for (var i = 0; i < rides.length; i++) { var ride = rides[i]; console.log(ride.name + ' - Excitement: ' + ride.excitement); } // Get specific ride var ride = map.getRide(0); // Ride properties console.log('Type: ' + ride.type); console.log('Status: ' + ride.status); console.log('Excitement: ' + ride.excitement); console.log('Intensity: ' + ride.intensity); console.log('Nausea: ' + ride.nausea); console.log('Price: $' + ride.price); console.log('Customers: ' + ride.customers); // Modify ride (remote only) ride.price = 500; // $5.00 ride.name = "Super Coaster"; ``` ### Entities (Guests and Staff) ```javascript // Get all entities var entities = map.entities; // Iterate guests for (var i = 0; i < entities.length; i++) { var entity = entities[i]; if (entity.type === 'guest') { console.log('Guest ' + entity.id); console.log(' Cash: $' + entity.cash); console.log(' Happiness: ' + entity.happiness); console.log(' Energy: ' + entity.energy); console.log(' Hunger: ' + entity.hunger); console.log(' Thirst: ' + entity.thirst); } else if (entity.type === 'staff') { console.log('Staff ' + entity.id); console.log(' Type: ' + entity.staffType); } } // Get specific entity var guest = map.getEntity(entityId); // Modify guest (remote only) guest.happiness = 200; guest.energy = 150; guest.cash = 500; ``` ## Network API ### Network Mode ```javascript // Check network mode if (network.mode === 'server') { console.log('Running as server'); } else if (network.mode === 'client') { console.log('Running as client'); } else { console.log('Single player'); } ``` ### Player Management ```javascript // Get all players var players = network.players; for (var i = 0; i < players.length; i++) { var player = players[i]; console.log(player.name + ' (ID: ' + player.id + ')'); } // Get specific player var player = network.getPlayer(playerId); // Player properties console.log('Name: ' + player.name); console.log('Group: ' + player.group); console.log('Ping: ' + player.ping); // Kick player network.kickPlayer(playerId); // Send message network.sendMessage('Hello everyone!'); network.sendMessage('Private message', [playerId]); // To specific player ``` ### TCP Sockets ```javascript // Create server (localhost only) var server = network.createListener(); server.on('connection', function(conn) { console.log('Client connected'); conn.on('data', function(data) { console.log('Received: ' + data); conn.write('Echo: ' + data); }); conn.on('close', function() { console.log('Client disconnected'); }); }); server.listen(8080, function() { console.log('Server listening on port 8080'); }); // Create client var client = network.createSocket(); client.on('connect', function() { console.log('Connected to server'); client.write('Hello from OpenRCT2'); }); client.on('data', function(data) { console.log('Server says: ' + data); }); client.connect(8080, 'localhost'); ``` ## Data Persistence ### Shared Storage ```javascript // Persistent across all parks (plugin.store.json) var myData = context.sharedStorage.get('myplugin.data', { defaultValue: 0 }); context.sharedStorage.set('myplugin.data', myData + 1); // Namespaced keys recommended context.sharedStorage.set('MyPlugin.Settings.Enabled', true); context.sharedStorage.set('MyPlugin.Settings.Volume', 0.8); ``` ### Park Storage ```javascript // Saved with the park file var parkData = context.getParkStorage('myplugin'); var counter = parkData.get('counter', 0); parkData.set('counter', counter + 1); ``` ## Hot Reload ### Enable Hot Reload Edit `config.ini`: ```ini [plugin] enable_hot_reloading = true ``` ### Development Workflow ```javascript // Plugin with auto-reload support var window = null; function main() { console.log('Plugin loaded/reloaded!'); // Close old window on reload if (window) { window.close(); } // Create new window openWindow(); } function openWindow() { window = ui.openWindow({ classification: 'myplugin.dev', title: 'Dev Window', width: 200, height: 100, widgets: [ { type: 'label', x: 10, y: 10, width: 180, height: 80, text: 'Edit and save JS to reload!' } ] }); } ``` ## Best Practices ### 1. Check UI Availability ```javascript // Good: Check before UI operations function main() { if (typeof ui !== 'undefined') { ui.registerMenuItem('My Window', openWindow); } // Non-UI code runs everywhere context.subscribe('interval.day', onDay); } ``` ### 2. Use Game Actions for Mutations ```javascript // Good: Use game action context.executeAction({ type: 'parksetcash', args: { cash: 100000 } }, callback); // Bad: Direct mutation in local plugin // park.cash = 100000; // Will fail in multiplayer! ``` ### 3. Namespace Your Data ```javascript // Good: Use namespace prefix context.sharedStorage.set('MyPlugin.Key', value); // Bad: Generic key may conflict context.sharedStorage.set('data', value); ``` ### 4. Handle Errors ```javascript context.executeAction(action, function(result) { if (result.error) { console.log('Action failed: ' + result.error); return; } // Success handling }); ``` ### 5. Clean Up on Unload ```javascript var intervals = []; function main() { intervals.push(context.setInterval(update, 1000)); } // For remote/intransient plugins context.subscribe('map.changed', function() { // Clear intervals when map changes intervals.forEach(clearInterval); intervals = []; }); ``` ## ES5 Limitations OpenRCT2 uses Duktape (ES5). ES6+ features require transpilation: ### Not Supported ```javascript // Arrow functions var func = () => {}; // NO // Classes class MyClass {} // NO // let/const let x = 1; // NO // Template literals `Hello ${name}` // NO // Spread operator [...arr] // NO // Destructuring var { x } = obj; // NO // find/includes arr.find(x => x > 0); // NO arr.includes(5); // NO ``` ### ES5 Alternatives ```javascript // Function expressions var func = function() {}; // YES // Constructor functions function MyClass() {} // YES // var var x = 1; // YES // String concatenation 'Hello ' + name // YES // Array methods arr.filter(function(x) { return x > 0; })[0]; // YES arr.indexOf(5) !== -1; // YES ``` ## Debugging ### Console Logging ```javascript // Basic logging console.log('Debug message'); console.log('Value: ' + value); // Object inspection console.log(JSON.stringify(obj)); ``` ### REPL Console Run `openrct2.com` (Windows) or terminal version to access interactive console. ```javascript // In console, test expressions > park.cash > map.rides.length > context.sharedStorage.get('myplugin.data') ``` ## Distribution ### Publishing 1. **GitHub Releases** - Recommended, attach compiled `.js` 2. **openrct2plugins.org** - Community plugin repository ### Versioning ```javascript registerPlugin({ name: 'My Plugin', version: '1.2.3', // Semantic versioning minApiVersion: 34, // Minimum OpenRCT2 API version targetApiVersion: 34, // Target API for behavior // ... }); ``` ## References - **OpenRCT2 Scripting Docs**: https://github.com/OpenRCT2/OpenRCT2/blob/develop/distribution/scripting.md - **API Types**: https://github.com/OpenRCT2/OpenRCT2/blob/develop/distribution/openrct2.d.ts - **Plugin Samples**: https://github.com/OpenRCT2/plugin-samples - **Community Plugins**: https://openrct2plugins.org/ - **Duktape Engine**: https://duktape.org/